From 644a4c8496f324bfb47b71fc03d0cf5e3e07343b Mon Sep 17 00:00:00 2001 From: hoeppner-dataport Date: Wed, 27 Sep 2023 12:21:05 +0200 Subject: [PATCH 01/31] initial commit --- .../update-element-content.body.params.ts | 14 ++++++++ .../board/repo/board-do.builder-impl.ts | 15 +++++++++ .../board/repo/recursive-delete.vistor.ts | 7 ++++ .../board/repo/recursive-save.visitor.ts | 16 ++++++++++ .../recursive-copy.visitor.ts | 19 +++++++++++ .../service/content-element-update.visitor.ts | 8 +++++ .../modules/copy-helper/types/copy.types.ts | 1 + .../board/content-element.factory.ts | 15 +++++++++ .../shared/domain/domainobject/board/index.ts | 3 +- .../domainobject/board/link-element.do.ts | 32 +++++++++++++++++++ .../board/types/any-content-element-do.ts | 8 ++++- .../board/types/board-composite-visitor.ts | 3 ++ .../board/types/content-elements.enum.ts | 1 + .../shared/domain/entity/boardnode/index.ts | 3 +- .../boardnode/link-element-node.entity.ts | 26 +++++++++++++++ .../boardnode/types/board-do.builder.ts | 3 ++ .../entity/boardnode/types/board-node-type.ts | 1 + 17 files changed, 172 insertions(+), 3 deletions(-) create mode 100644 apps/server/src/shared/domain/domainobject/board/link-element.do.ts create mode 100644 apps/server/src/shared/domain/entity/boardnode/link-element-node.entity.ts 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 05856e9ef5f..7c4b6450388 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() 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 af58280b33f..9fa34e29518 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, @@ -105,6 +107,19 @@ 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, + 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.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.ts b/apps/server/src/modules/board/repo/recursive-save.visitor.ts index 5561e636267..b62df44eb3f 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,20 @@ 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, + 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/recursive-copy.visitor.ts b/apps/server/src/modules/board/service/board-do-copy-service/recursive-copy.visitor.ts index 4d1bf55f5ae..621d012b1c5 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,24 @@ 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, + 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/content-element-update.visitor.ts b/apps/server/src/modules/board/service/content-element-update.visitor.ts index dfd430aa250..dedf632a330 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 @@ -12,10 +12,12 @@ 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'; @@ -48,6 +50,12 @@ export class ContentElementUpdateVisitor implements BoardCompositeVisitor { } } + visitLinkElement(linkElement: LinkElement): void { + if (!(this.content instanceof LinkContentBody)) { + this.throwNotHandled(linkElement); + } + } + visitRichTextElement(richTextElement: RichTextElement): void { if (this.content instanceof RichTextContentBody) { richTextElement.text = sanitizeRichText(this.content.text, this.content.inputFormat); 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/content-element.factory.ts b/apps/server/src/shared/domain/domainobject/board/content-element.factory.ts index fb476d2dbd0..450ac4c7448 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,17 @@ export class ContentElementFactory { return element; } + private buildLink() { + const element = new LinkElement({ + id: new ObjectId().toHexString(), + url: '', + 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.ts b/apps/server/src/shared/domain/domainobject/board/link-element.do.ts new file mode 100644 index 00000000000..899a84f3323 --- /dev/null +++ b/apps/server/src/shared/domain/domainobject/board/link-element.do.ts @@ -0,0 +1,32 @@ +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; + } + + 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; +} + +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..7fddb4e9811 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,10 +1,16 @@ 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 = + | FileElement + | LinkElement + | RichTextElement + | SubmissionContainerElement + | ExternalToolElement; export const isAnyContentElement = (element: AnyBoardDo): element is AnyContentElementDo => { const 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..15fbe9ae417 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 @@ -3,6 +3,7 @@ import type { ColumnBoard } from '../column-board.do'; import type { Column } from '../column.do'; import { ExternalToolElement } from '../external-tool-element.do'; import type { 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 { SubmissionItem } from '../submission-item.do'; @@ -12,6 +13,7 @@ export interface BoardCompositeVisitor { 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/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.ts b/apps/server/src/shared/domain/entity/boardnode/link-element-node.entity.ts new file mode 100644 index 00000000000..44ebdb18fa1 --- /dev/null +++ b/apps/server/src/shared/domain/entity/boardnode/link-element-node.entity.ts @@ -0,0 +1,26 @@ +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; + + constructor(props: LinkElementNodeProps) { + super(props); + this.type = BoardNodeType.FILE_ELEMENT; + this.url = props.url; + } + + useDoBuilder(builder: BoardDoBuilder): AnyBoardDo { + const domainObject = builder.buildLinkElement(this); + + return domainObject; + } +} + +export interface LinkElementNodeProps extends BoardNodeProps { + url: 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..592728f717f 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,3 +1,4 @@ +import { LinkElement } from '@shared/domain/domainobject/board/link-element.do'; import { SubmissionItem } from '@shared/domain/domainobject/board/submission-item.do'; import type { Card, @@ -13,6 +14,7 @@ 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', From 8f45f02f5757b5abb3b623e94d1ddf9ccf4bec7c Mon Sep 17 00:00:00 2001 From: hoeppner-dataport Date: Wed, 27 Sep 2023 12:48:46 +0200 Subject: [PATCH 02/31] chore: implement response mapping for link element --- .../board/controller/card.controller.ts | 2 +- .../element/any-content-element.response.ts | 2 ++ .../board/controller/dto/element/index.ts | 6 ++-- .../dto/element/link-element.response.ts | 33 +++++++++++++++++++ .../content-element-response.factory.ts | 2 ++ .../modules/board/controller/mapper/index.ts | 4 ++- .../mapper/link-element-response.mapper.ts | 30 +++++++++++++++++ .../board/repo/board-do.builder-impl.ts | 1 + .../domain/domainobject/board/card.do.ts | 2 ++ .../src/shared/domain/entity/all-entities.ts | 2 ++ 10 files changed, 80 insertions(+), 4 deletions(-) create mode 100644 apps/server/src/modules/board/controller/dto/element/link-element.response.ts create mode 100644 apps/server/src/modules/board/controller/mapper/link-element-response.mapper.ts diff --git a/apps/server/src/modules/board/controller/card.controller.ts b/apps/server/src/modules/board/controller/card.controller.ts index e76bdbe088c..00f0cd31bf4 100644 --- a/apps/server/src/modules/board/controller/card.controller.ts +++ b/apps/server/src/modules/board/controller/card.controller.ts @@ -137,7 +137,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/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..c7557ac9ecf 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,9 @@ 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..7f6869eefa2 --- /dev/null +++ b/apps/server/src/modules/board/controller/dto/element/link-element.response.ts @@ -0,0 +1,33 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { ContentElementType } from '@shared/domain'; +import { TimestampsResponse } from '../timestamps.response'; + +export class LinkElementContent { + constructor({ url }: LinkElementContent) { + this.url = url; + } + + @ApiProperty() + url: 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/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..0b4ed7af1a4 --- /dev/null +++ b/apps/server/src/modules/board/controller/mapper/link-element-response.mapper.ts @@ -0,0 +1,30 @@ +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 }), + }); + + return result; + } + + canMap(element: LinkElement): boolean { + return element instanceof LinkElement; + } +} 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 9fa34e29518..636eab48115 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 @@ -75,6 +75,7 @@ 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, 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/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, From 0b1c514868bfab1e91d7e1e3eca922419f4bfc33 Mon Sep 17 00:00:00 2001 From: hoeppner-dataport Date: Wed, 27 Sep 2023 13:40:31 +0200 Subject: [PATCH 03/31] chore: add test for buildLinkElement of board-do.builder --- .../board/repo/board-do.builder-impl.spec.ts | 22 ++++++++++++++++++- .../shared/testing/factory/boardnode/index.ts | 3 ++- .../boardnode/link-element-node.factory.ts | 12 ++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 apps/server/src/shared/testing/factory/boardnode/link-element-node.factory.ts 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..6818713e79d 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 @@ -5,6 +5,7 @@ import { 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.build(); + + const domainObject = new BoardDoBuilderImpl().buildLinkElement(linkElementNode); + + expect(domainObject.constructor.name).toBe(ExternalToolElement.name); + }); + + it('should throw error if linkElement is not a leaf', () => { + const linkElementNode = linkElementNodeFactory.build(); + 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/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..65774eec3fa --- /dev/null +++ b/apps/server/src/shared/testing/factory/boardnode/link-element-node.factory.ts @@ -0,0 +1,12 @@ +/* istanbul ignore file */ +import { LinkElementNode, LinkElementNodeProps } from '@shared/domain'; +import { BaseFactory } from '../base.factory'; + +export const linkElementNodeFactory = BaseFactory.define( + LinkElementNode, + ({ sequence }) => { + return { + url: `https://www.example.com/link/${sequence}`, + }; + } +); From 74192aec5b6998f7817a2db6eacaaf29d2a39d99 Mon Sep 17 00:00:00 2001 From: hoeppner-dataport Date: Wed, 27 Sep 2023 13:46:49 +0200 Subject: [PATCH 04/31] chore: add link element to anyContentBody definition --- apps/server/src/modules/board/controller/dto/element/index.ts | 1 - .../dto/element/update-element-content.body.params.ts | 4 ++++ 2 files changed, 4 insertions(+), 1 deletion(-) 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 c7557ac9ecf..6787c007c1b 100644 --- a/apps/server/src/modules/board/controller/dto/element/index.ts +++ b/apps/server/src/modules/board/controller/dto/element/index.ts @@ -6,4 +6,3 @@ export * from './link-element.response'; export * from './rich-text-element.response'; export * from './submission-container-element.response'; export * from './update-element-content.body.params'; - 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 7c4b6450388..715abe16692 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 @@ -102,6 +102,7 @@ export class ExternalToolElementContentBody extends ElementContentBody { export type AnyElementContentBody = | FileContentBody + | LinkContentBody | RichTextContentBody | SubmissionContainerContentBody | ExternalToolContentBody; @@ -113,6 +114,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 }, @@ -123,6 +125,7 @@ export class UpdateElementContentBodyParams { @ApiProperty({ oneOf: [ { $ref: getSchemaPath(FileElementContentBody) }, + { $ref: getSchemaPath(LinkElementContentBody) }, { $ref: getSchemaPath(RichTextElementContentBody) }, { $ref: getSchemaPath(SubmissionContainerElementContentBody) }, { $ref: getSchemaPath(ExternalToolElementContentBody) }, @@ -130,6 +133,7 @@ export class UpdateElementContentBodyParams { }) data!: | FileElementContentBody + | LinkElementContentBody | RichTextElementContentBody | SubmissionContainerElementContentBody | ExternalToolElementContentBody; From a1a8114b58c18efbae803c05efd1da25659a7136 Mon Sep 17 00:00:00 2001 From: hoeppner-dataport Date: Wed, 27 Sep 2023 13:58:12 +0200 Subject: [PATCH 05/31] chore: add tests for content-element-update.visitor --- .../content-element-update.visitor.spec.ts | 18 ++++++++++++++++++ .../factory/domainobject/board/index.ts | 4 +++- .../board/link-element.do.factory.ts | 14 ++++++++++++++ 3 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/shared/testing/factory/domainobject/board/link-element.do.factory.ts 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..7385d17a232 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,6 +6,7 @@ import { columnFactory, externalToolElementFactory, fileElementFactory, + linkElementFactory, richTextElementFactory, submissionContainerElementFactory, submissionItemFactory, @@ -75,6 +76,23 @@ describe(ContentElementUpdateVisitor.name, () => { }); }); + 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 updater = new ContentElementUpdateVisitor(content); + + return { linkElement, updater }; + }; + + it('should throw an error', () => { + const { linkElement, updater } = setup(); + + expect(() => updater.visitLinkElement(linkElement)).toThrow(); + }); + }); + describe('when visiting a rich text element using the wrong content', () => { const setup = () => { const richTextElement = richTextElementFactory.build(); 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..59b4115f584 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,10 @@ 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..64243d8e8d1 --- /dev/null +++ b/apps/server/src/shared/testing/factory/domainobject/board/link-element.do.factory.ts @@ -0,0 +1,14 @@ +/* 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}`, + children: [], + createdAt: new Date(), + updatedAt: new Date(), + }; +}); From cbf47770df2f0a75b959ad4cd02a4d2a70f925b9 Mon Sep 17 00:00:00 2001 From: hoeppner-dataport Date: Wed, 27 Sep 2023 14:06:49 +0200 Subject: [PATCH 06/31] chore: add test for recursive delete visitor --- .../repo/recursive-delete.visitor.spec.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) 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(); From ecea6c961dcafce563e2b463f3b6ff83d6f92ace Mon Sep 17 00:00:00 2001 From: Oliver Happe Date: Wed, 27 Sep 2023 15:12:54 +0200 Subject: [PATCH 07/31] adds missing type for linkelementresponse --- .../modules/board/controller/dto/card/card.response.ts | 8 +++++++- .../src/modules/board/controller/dto/element/index.ts | 1 - 2 files changed, 7 insertions(+), 2 deletions(-) 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..05be11c863e 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,6 +1,11 @@ import { ApiExtraModels, ApiProperty, ApiPropertyOptional, getSchemaPath } from '@nestjs/swagger'; import { DecodeHtmlEntities } from '@shared/controller'; -import { AnyContentElementResponse, FileElementResponse, SubmissionContainerElementResponse } from '../element'; +import { + AnyContentElementResponse, + FileElementResponse, + LinkElementResponse, + SubmissionContainerElementResponse, +} from '../element'; import { RichTextElementResponse } from '../element/rich-text-element.response'; import { TimestampsResponse } from '../timestamps.response'; import { VisibilitySettingsResponse } from './visibility-settings.response'; @@ -35,6 +40,7 @@ export class CardResponse { { $ref: getSchemaPath(RichTextElementResponse) }, { $ref: getSchemaPath(FileElementResponse) }, { $ref: getSchemaPath(SubmissionContainerElementResponse) }, + { $ref: getSchemaPath(LinkElementResponse) }, ], }, }) 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 c7557ac9ecf..6787c007c1b 100644 --- a/apps/server/src/modules/board/controller/dto/element/index.ts +++ b/apps/server/src/modules/board/controller/dto/element/index.ts @@ -6,4 +6,3 @@ export * from './link-element.response'; export * from './rich-text-element.response'; export * from './submission-container-element.response'; export * from './update-element-content.body.params'; - From a90bb04258dae72d479c21a09dc39a1bca7ad80b Mon Sep 17 00:00:00 2001 From: hoeppner-dataport Date: Wed, 27 Sep 2023 15:44:43 +0200 Subject: [PATCH 08/31] chore: fix api generation issue by using @ApiExtraModels --- .../board/controller/dto/card/card.response.ts | 16 ++++++++++++---- .../modules/board/repo/board-do.builder-impl.ts | 4 +++- .../board/types/any-content-element-do.ts | 9 +++++---- .../board/types/board-composite-visitor.ts | 10 +++++----- .../entity/boardnode/types/board-do.builder.ts | 4 ++-- 5 files changed, 27 insertions(+), 16 deletions(-) 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..510210ec3bf 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,17 @@ 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(LinkElementResponse, RichTextElementResponse) export class CardResponse { constructor({ id, title, height, elements, visibilitySettings, timestamps }: CardResponse) { this.id = id; @@ -32,8 +38,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/repo/board-do.builder-impl.ts b/apps/server/src/modules/board/repo/board-do.builder-impl.ts index 636eab48115..4d8cf29f9ca 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 @@ -81,7 +81,9 @@ export class BoardDoBuilderImpl implements BoardDoBuilder { 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, 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 7fddb4e9811..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 @@ -6,18 +6,19 @@ import { SubmissionContainerElement } from '../submission-container-element.do'; import type { AnyBoardDo } from './any-board-do'; export type AnyContentElementDo = + | ExternalToolElement | FileElement | LinkElement | RichTextElement - | SubmissionContainerElement - | ExternalToolElement; + | 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 15fbe9ae417..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,12 +1,12 @@ 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 { LinkElement } from '../link-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; 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 592728f717f..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,13 +1,13 @@ -import { LinkElement } from '@shared/domain/domainobject/board/link-element.do'; -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'; From b9834c94bb58bf915a0de98fc9040740e9f40d68 Mon Sep 17 00:00:00 2001 From: hoeppner-dataport Date: Wed, 27 Sep 2023 16:09:53 +0200 Subject: [PATCH 09/31] chore: add text for recursive-save.visitor --- .../board/repo/recursive-save.visitor.spec.ts | 18 ++++++++++++++++++ .../boardnode/link-element-node.entity.ts | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) 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/shared/domain/entity/boardnode/link-element-node.entity.ts b/apps/server/src/shared/domain/entity/boardnode/link-element-node.entity.ts index 44ebdb18fa1..073a68cbb42 100644 --- 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 @@ -10,7 +10,7 @@ export class LinkElementNode extends BoardNode { constructor(props: LinkElementNodeProps) { super(props); - this.type = BoardNodeType.FILE_ELEMENT; + this.type = BoardNodeType.LINK_ELEMENT; this.url = props.url; } From d9dd3706aa6a4aba45255ab6511b57fa37285b92 Mon Sep 17 00:00:00 2001 From: hoeppner-dataport Date: Wed, 27 Sep 2023 16:14:49 +0200 Subject: [PATCH 10/31] chore: add test for linkElementDo --- .../board/link-element.do.spec.ts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 apps/server/src/shared/domain/domainobject/board/link-element.do.spec.ts 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); + }); + }); +}); From ddb8c43b68369ee99678e375c8c647346d90063a Mon Sep 17 00:00:00 2001 From: hoeppner-dataport Date: Wed, 27 Sep 2023 16:24:01 +0200 Subject: [PATCH 11/31] chore: add test for link-element-node.entity --- .../link-element-node.entity.spec.ts | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 apps/server/src/shared/domain/entity/boardnode/link-element-node.entity.spec.ts 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..aeaeaae4ce4 --- /dev/null +++ b/apps/server/src/shared/domain/entity/boardnode/link-element-node.entity.spec.ts @@ -0,0 +1,51 @@ +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' }; + 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' }); + 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); + }); + }); +}); From 46dd4aa211b44af7d01a378c905b1a0ec8a79ac1 Mon Sep 17 00:00:00 2001 From: hoeppner-dataport Date: Wed, 27 Sep 2023 16:29:57 +0200 Subject: [PATCH 12/31] chore: add test for link element response --- .../board/controller/api-test/card-create.api.spec.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) 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(); From 3b12a0815ecbb89cc4fe8318d5434a0c2eef173f Mon Sep 17 00:00:00 2001 From: hoeppner-dataport Date: Wed, 27 Sep 2023 16:36:49 +0200 Subject: [PATCH 13/31] chore: add test for linkElementResponseFactory --- .../content-element-response.factory.spec.ts | 35 ++++++++++++------- 1 file changed, 22 insertions(+), 13 deletions(-) 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); From b3aa5bc364e6929ce8c2290f00d17ad2e158afaf Mon Sep 17 00:00:00 2001 From: hoeppner-dataport Date: Thu, 28 Sep 2023 15:51:55 +0200 Subject: [PATCH 14/31] gather open graph data for linkElements --- apps/server/src/modules/board/board.module.ts | 2 + .../dto/element/link-element.response.ts | 9 +- .../mapper/link-element-response.mapper.ts | 2 +- .../src/modules/board/service/card.service.ts | 39 +- .../server/src/modules/board/service/index.ts | 1 + .../board/service/open-graph-proxy.service.ts | 54 ++ .../domainobject/board/link-element.do.ts | 10 + package-lock.json | 585 +++++++++++++++++- package.json | 1 + 9 files changed, 682 insertions(+), 21 deletions(-) create mode 100644 apps/server/src/modules/board/service/open-graph-proxy.service.ts 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/dto/element/link-element.response.ts b/apps/server/src/modules/board/controller/dto/element/link-element.response.ts index 7f6869eefa2..327fad89c32 100644 --- 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 @@ -1,14 +1,19 @@ -import { ApiProperty } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ContentElementType } from '@shared/domain'; +import { OpenGraphData } from '@src/modules/board/service'; import { TimestampsResponse } from '../timestamps.response'; export class LinkElementContent { - constructor({ url }: LinkElementContent) { + constructor({ url, openGraphData }: LinkElementContent) { this.url = url; + this.openGraphData = openGraphData; } @ApiProperty() url: string; + + @ApiPropertyOptional({ type: 'OpenGraphData' }) + openGraphData?: OpenGraphData; } export class LinkElementResponse { diff --git a/apps/server/src/modules/board/controller/mapper/link-element-response.mapper.ts b/apps/server/src/modules/board/controller/mapper/link-element-response.mapper.ts index 0b4ed7af1a4..f93a6fe0a56 100644 --- a/apps/server/src/modules/board/controller/mapper/link-element-response.mapper.ts +++ b/apps/server/src/modules/board/controller/mapper/link-element-response.mapper.ts @@ -18,7 +18,7 @@ export class LinkElementResponseMapper implements BaseResponseMapper { id: element.id, timestamps: new TimestampsResponse({ lastUpdatedAt: element.updatedAt, createdAt: element.createdAt }), type: ContentElementType.LINK, - content: new LinkElementContent({ url: element.url }), + content: new LinkElementContent({ url: element.url, openGraphData: element.openGraphData }), }); return result; diff --git a/apps/server/src/modules/board/service/card.service.ts b/apps/server/src/modules/board/service/card.service.ts index 3ef34806397..d46071b3d59 100644 --- a/apps/server/src/modules/board/service/card.service.ts +++ b/apps/server/src/modules/board/service/card.service.ts @@ -1,29 +1,56 @@ import { Injectable, NotFoundException } from '@nestjs/common'; -import { Card, Column, ContentElementType, EntityId } from '@shared/domain'; +import { Card, Column, ContentElementType, EntityId, LinkElement } from '@shared/domain'; import { ObjectId } from 'bson'; import { BoardDoRepo } from '../repo'; import { BoardDoService } from './board-do.service'; import { ContentElementService } from './content-element.service'; +import { OpenGraphProxyService } from './open-graph-proxy.service'; @Injectable() export class CardService { constructor( private readonly boardDoRepo: BoardDoRepo, private readonly boardDoService: BoardDoService, - private readonly contentElementService: ContentElementService + private readonly contentElementService: ContentElementService, + private readonly openGraphProxyService: OpenGraphProxyService ) {} async findById(cardId: EntityId): Promise { const card = await this.boardDoRepo.findByClassAndId(Card, cardId); - return card; + const extendedCard = await this.extendLinkElements(card); + return extendedCard; } 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'); + } + + const promises: Promise[] = []; + for (const card of cards) { + const extendedCardPromise = this.extendLinkElements(card as Card); + promises.push(extendedCardPromise); } - throw new NotFoundException('some ids do not belong to a card'); + + const extendedCards = Promise.all(promises); + return extendedCards; + } + + private async extendLinkElements(card: Card): Promise { + const linkElements: LinkElement[] = card.children.filter((c) => c instanceof LinkElement) as LinkElement[]; + const promises: Promise[] = []; + for (const linkElement of linkElements) { + promises.push(this.extendLinkElement(linkElement)); + } + (await Promise.all(promises)) as LinkElement[]; + + return card; + } + + private async extendLinkElement(linkElement: LinkElement): Promise { + linkElement.openGraphData = await this.openGraphProxyService.fetchOpenGraphData(linkElement.url); + return Promise.resolve(); } async create(parent: Column, requiredEmptyElements?: ContentElementType[]): Promise { 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.ts b/apps/server/src/modules/board/service/open-graph-proxy.service.ts new file mode 100644 index 00000000000..36a8de7cf2a --- /dev/null +++ b/apps/server/src/modules/board/service/open-graph-proxy.service.ts @@ -0,0 +1,54 @@ +import { Injectable } from '@nestjs/common'; +import { sortBy } from 'lodash'; +import ogs from 'open-graph-scraper'; +import { ImageObject } from 'open-graph-scraper/dist/lib/types'; + +export type OpenGraphData = { + title: string; + description: string; + image?: OpenGraphImageData; + url: string; +}; + +export type OpenGraphImageData = { + url: string; + type?: string; + width?: number; + height?: number; +}; + +@Injectable() +export class OpenGraphProxyService { + async fetchOpenGraphData(url: string): Promise { + const data = await ogs({ url }); + + const title = data.result.ogTitle ?? ''; + const description = data.result.ogDescription ?? ''; + const image = data.result.ogImage ? this.pickImage(data.result.ogImage) : undefined; + + const result = { + title, + description, + image, + url, + }; + + return result; + } + + private pickImage(images: ImageObject[], minWidth = 400, maxWidth = 800) { + const imagesWithCorrectDimensions = images.map((i) => this.fixDimensionTypes(i)); + const sortedImages = sortBy(imagesWithCorrectDimensions, ['width', 'height']); + const biggestSmallEnoughImage = [...sortedImages].reverse().find((i) => i.width && i.width <= maxWidth); + const smallestBigEnoughImage = sortedImages.find((i) => i.width && i.width >= minWidth); + return biggestSmallEnoughImage ?? smallestBigEnoughImage ?? sortedImages[0]; + } + + private fixDimensionTypes(image: { width?: number | string; height?: number | string; url: string; type?: string }) { + return { + ...image, + width: image.width ? +image.width : undefined, + height: image.height ? +image.height : undefined, + }; + } +} 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 index 899a84f3323..d810ee5b5a8 100644 --- a/apps/server/src/shared/domain/domainobject/board/link-element.do.ts +++ b/apps/server/src/shared/domain/domainobject/board/link-element.do.ts @@ -1,3 +1,4 @@ +import { OpenGraphData } from '@src/modules/board/service'; import { BoardComposite, BoardCompositeProps } from './board-composite.do'; import type { BoardCompositeVisitor, BoardCompositeVisitorAsync } from './types'; @@ -10,6 +11,14 @@ export class LinkElement extends BoardComposite { this.props.url = value; } + get openGraphData(): OpenGraphData { + return { url: this.props.url, title: '', description: '', ...this.props.openGraphData }; + } + + set openGraphData(value: OpenGraphData) { + this.props.openGraphData = value; + } + isAllowedAsChild(): boolean { return false; } @@ -25,6 +34,7 @@ export class LinkElement extends BoardComposite { export interface LinkElementProps extends BoardCompositeProps { url: string; + openGraphData?: OpenGraphData; } export function isLinkElement(reference: unknown): reference is LinkElement { diff --git a/package-lock.json b/package-lock.json index c19ca347f18..b75cdfe5a65 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", @@ -7410,6 +7411,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", @@ -7885,6 +7891,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", @@ -8865,6 +9027,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", @@ -9137,9 +9376,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", @@ -18012,6 +18251,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", @@ -18435,6 +18685,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", @@ -18752,6 +19021,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", @@ -23495,6 +23812,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", @@ -23709,9 +24037,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" } @@ -30041,6 +30369,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", @@ -30408,6 +30741,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", @@ -31206,6 +31647,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", @@ -31404,9 +31897,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", @@ -38115,6 +38608,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", @@ -38441,6 +38942,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", @@ -38673,6 +39192,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", @@ -42249,6 +42802,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", @@ -42439,9 +43000,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 99a99b681d4..345d398b2e7 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", From bff9a03fba15f7597f4a3f4b07bd9479cadaaaba Mon Sep 17 00:00:00 2001 From: Oliver Happe Date: Fri, 29 Sep 2023 11:44:57 +0200 Subject: [PATCH 15/31] trying to fix swagger --- .../dto/element/link-element.response.ts | 3 +- .../update-element-content.body.params.ts | 2 +- .../board/controller/element.controller.ts | 4 +- .../board/service/open-graph-proxy.service.ts | 62 +++++++++++++------ 4 files changed, 48 insertions(+), 23 deletions(-) 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 index 327fad89c32..ee4fed36474 100644 --- 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 @@ -1,8 +1,9 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ApiExtraModels, ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ContentElementType } from '@shared/domain'; import { OpenGraphData } from '@src/modules/board/service'; import { TimestampsResponse } from '../timestamps.response'; +@ApiExtraModels(OpenGraphData) export class LinkElementContent { constructor({ url, openGraphData }: LinkElementContent) { this.url = url; 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 715abe16692..8373ed72dd2 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 @@ -42,7 +42,7 @@ export class LinkElementContentBody extends ElementContentBody { type!: ContentElementType.LINK; @ValidateNested() - @ApiProperty() + @ApiProperty({}) content!: LinkContentBody; } diff --git a/apps/server/src/modules/board/controller/element.controller.ts b/apps/server/src/modules/board/controller/element.controller.ts index 361e59bf6b6..c0f3f0df29b 100644 --- a/apps/server/src/modules/board/controller/element.controller.ts +++ b/apps/server/src/modules/board/controller/element.controller.ts @@ -21,6 +21,7 @@ import { CreateSubmissionItemBodyParams, ExternalToolElementContentBody, FileElementContentBody, + LinkElementContentBody, MoveContentElementBody, RichTextElementContentBody, SubmissionContainerElementContentBody, @@ -60,7 +61,8 @@ export class ElementController { FileElementContentBody, RichTextElementContentBody, SubmissionContainerElementContentBody, - ExternalToolElementContentBody + ExternalToolElementContentBody, + LinkElementContentBody ) @ApiResponse({ status: 204 }) @ApiResponse({ status: 400, type: ApiValidationError }) 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 index 36a8de7cf2a..5cc1b9b7ea8 100644 --- a/apps/server/src/modules/board/service/open-graph-proxy.service.ts +++ b/apps/server/src/modules/board/service/open-graph-proxy.service.ts @@ -1,21 +1,50 @@ import { Injectable } from '@nestjs/common'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { sortBy } from 'lodash'; import ogs from 'open-graph-scraper'; import { ImageObject } from 'open-graph-scraper/dist/lib/types'; -export type OpenGraphData = { - title: string; - description: string; - image?: OpenGraphImageData; - url: string; -}; +export class OpenGraphImageData { + constructor({ url, type, width, height }: OpenGraphImageData) { + this.url = url; + this.type = type; + this.width = width ? +width : undefined; + this.height = height ? +height : undefined; + } -export type OpenGraphImageData = { + @ApiProperty() url: string; + + @ApiPropertyOptional() type?: string; + + @ApiPropertyOptional() width?: number; + + @ApiPropertyOptional() height?: number; -}; +} + +export class OpenGraphData { + constructor({ title, description, image, url }: OpenGraphData) { + this.title = title; + this.description = description; + this.image = image; + this.url = url; + } + + @ApiProperty() + title: string; + + @ApiProperty() + description: string; + + @ApiPropertyOptional() + image?: OpenGraphImageData; + + @ApiProperty() + url: string; +} @Injectable() export class OpenGraphProxyService { @@ -26,29 +55,22 @@ export class OpenGraphProxyService { const description = data.result.ogDescription ?? ''; const image = data.result.ogImage ? this.pickImage(data.result.ogImage) : undefined; - const result = { + const result = new OpenGraphData({ title, description, image, url, - }; + }); return result; } - private pickImage(images: ImageObject[], minWidth = 400, maxWidth = 800) { - const imagesWithCorrectDimensions = images.map((i) => this.fixDimensionTypes(i)); + private pickImage(images: ImageObject[], minWidth = 400, maxWidth = 800): OpenGraphImageData { + const imagesWithCorrectDimensions = images.map((i) => new OpenGraphImageData(i)); const sortedImages = sortBy(imagesWithCorrectDimensions, ['width', 'height']); const biggestSmallEnoughImage = [...sortedImages].reverse().find((i) => i.width && i.width <= maxWidth); const smallestBigEnoughImage = sortedImages.find((i) => i.width && i.width >= minWidth); + // return imagesWithCorrectDimensions[0]; return biggestSmallEnoughImage ?? smallestBigEnoughImage ?? sortedImages[0]; } - - private fixDimensionTypes(image: { width?: number | string; height?: number | string; url: string; type?: string }) { - return { - ...image, - width: image.width ? +image.width : undefined, - height: image.height ? +image.height : undefined, - }; - } } From 743ed53bc12913ec4fb45711eb399d97a5184e1c Mon Sep 17 00:00:00 2001 From: hoeppner-dataport Date: Fri, 29 Sep 2023 12:24:53 +0200 Subject: [PATCH 16/31] chore: fix problem with api generator --- .../board/controller/card.controller.ts | 13 +++-- .../controller/dto/card/card.response.ts | 10 +++- .../dto/element/link-element.response.ts | 47 +++++++++++++++++-- .../board/service/open-graph-proxy.service.ts | 45 +----------------- .../domainobject/board/link-element.do.ts | 2 +- 5 files changed, 61 insertions(+), 56 deletions(-) diff --git a/apps/server/src/modules/board/controller/card.controller.ts b/apps/server/src/modules/board/controller/card.controller.ts index 00f0cd31bf4..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) }, ], }, }) 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 66846387040..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 @@ -11,7 +11,13 @@ import { import { TimestampsResponse } from '../timestamps.response'; import { VisibilitySettingsResponse } from './visibility-settings.response'; -@ApiExtraModels(LinkElementResponse, RichTextElementResponse) +@ApiExtraModels( + ExternalToolElementResponse, + FileElementResponse, + LinkElementResponse, + RichTextElementResponse, + SubmissionContainerElementResponse +) export class CardResponse { constructor({ id, title, height, elements, visibilitySettings, timestamps }: CardResponse) { this.id = id; @@ -40,9 +46,9 @@ export class CardResponse { oneOf: [ { $ref: getSchemaPath(ExternalToolElementResponse) }, { $ref: getSchemaPath(FileElementResponse) }, + { $ref: getSchemaPath(LinkElementResponse) }, { $ref: getSchemaPath(RichTextElementResponse) }, { $ref: getSchemaPath(SubmissionContainerElementResponse) }, - { $ref: getSchemaPath(LinkElementResponse) }, ], }, }) 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 index ee4fed36474..28e347d27eb 100644 --- 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 @@ -1,9 +1,48 @@ -import { ApiExtraModels, ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ContentElementType } from '@shared/domain'; -import { OpenGraphData } from '@src/modules/board/service'; import { TimestampsResponse } from '../timestamps.response'; -@ApiExtraModels(OpenGraphData) +export class OpenGraphImageData { + constructor({ url, type, width, height }: OpenGraphImageData) { + this.url = url; + this.type = type; + this.width = width ? +width : undefined; + this.height = height ? +height : undefined; + } + + @ApiProperty() + url: string; + + @ApiPropertyOptional() + type?: string; + + @ApiPropertyOptional() + width?: number; + + @ApiPropertyOptional() + height?: number; +} + +export class OpenGraphData { + constructor({ title, description, image, url }: OpenGraphData) { + this.title = title; + this.description = description; + this.image = image; + this.url = url; + } + + @ApiProperty() + title: string; + + @ApiProperty() + description: string; + + @ApiPropertyOptional({ type: OpenGraphImageData }) + image?: OpenGraphImageData; + + @ApiProperty() + url: string; +} export class LinkElementContent { constructor({ url, openGraphData }: LinkElementContent) { this.url = url; @@ -13,7 +52,7 @@ export class LinkElementContent { @ApiProperty() url: string; - @ApiPropertyOptional({ type: 'OpenGraphData' }) + @ApiPropertyOptional({ type: OpenGraphData }) openGraphData?: OpenGraphData; } 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 index 5cc1b9b7ea8..ee6338ede67 100644 --- a/apps/server/src/modules/board/service/open-graph-proxy.service.ts +++ b/apps/server/src/modules/board/service/open-graph-proxy.service.ts @@ -1,50 +1,8 @@ import { Injectable } from '@nestjs/common'; -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { sortBy } from 'lodash'; import ogs from 'open-graph-scraper'; import { ImageObject } from 'open-graph-scraper/dist/lib/types'; - -export class OpenGraphImageData { - constructor({ url, type, width, height }: OpenGraphImageData) { - this.url = url; - this.type = type; - this.width = width ? +width : undefined; - this.height = height ? +height : undefined; - } - - @ApiProperty() - url: string; - - @ApiPropertyOptional() - type?: string; - - @ApiPropertyOptional() - width?: number; - - @ApiPropertyOptional() - height?: number; -} - -export class OpenGraphData { - constructor({ title, description, image, url }: OpenGraphData) { - this.title = title; - this.description = description; - this.image = image; - this.url = url; - } - - @ApiProperty() - title: string; - - @ApiProperty() - description: string; - - @ApiPropertyOptional() - image?: OpenGraphImageData; - - @ApiProperty() - url: string; -} +import { OpenGraphData, OpenGraphImageData } from '../controller/dto'; @Injectable() export class OpenGraphProxyService { @@ -70,7 +28,6 @@ export class OpenGraphProxyService { const sortedImages = sortBy(imagesWithCorrectDimensions, ['width', 'height']); const biggestSmallEnoughImage = [...sortedImages].reverse().find((i) => i.width && i.width <= maxWidth); const smallestBigEnoughImage = sortedImages.find((i) => i.width && i.width >= minWidth); - // return imagesWithCorrectDimensions[0]; return biggestSmallEnoughImage ?? smallestBigEnoughImage ?? sortedImages[0]; } } 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 index d810ee5b5a8..37fddeac453 100644 --- a/apps/server/src/shared/domain/domainobject/board/link-element.do.ts +++ b/apps/server/src/shared/domain/domainobject/board/link-element.do.ts @@ -1,4 +1,4 @@ -import { OpenGraphData } from '@src/modules/board/service'; +import { OpenGraphData } from '@src/modules/board/controller/dto'; import { BoardComposite, BoardCompositeProps } from './board-composite.do'; import type { BoardCompositeVisitor, BoardCompositeVisitorAsync } from './types'; From 08258394a9f16fd24adf7ac108510e0acdd9790c Mon Sep 17 00:00:00 2001 From: Oliver Happe Date: Fri, 29 Sep 2023 15:32:54 +0200 Subject: [PATCH 17/31] handle invalid urls in open-graph-proxy --- apps/server/src/modules/board/service/card.service.ts | 6 +++++- .../src/modules/board/service/open-graph-proxy.service.ts | 3 +++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/server/src/modules/board/service/card.service.ts b/apps/server/src/modules/board/service/card.service.ts index d46071b3d59..a61eab1bd16 100644 --- a/apps/server/src/modules/board/service/card.service.ts +++ b/apps/server/src/modules/board/service/card.service.ts @@ -49,7 +49,11 @@ export class CardService { } private async extendLinkElement(linkElement: LinkElement): Promise { - linkElement.openGraphData = await this.openGraphProxyService.fetchOpenGraphData(linkElement.url); + try { + linkElement.openGraphData = await this.openGraphProxyService.fetchOpenGraphData(linkElement.url); + } catch (e) { + return Promise.resolve(); + } return Promise.resolve(); } 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 index ee6338ede67..080ad8e0851 100644 --- a/apps/server/src/modules/board/service/open-graph-proxy.service.ts +++ b/apps/server/src/modules/board/service/open-graph-proxy.service.ts @@ -7,6 +7,9 @@ import { OpenGraphData, OpenGraphImageData } from '../controller/dto'; @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 }); const title = data.result.ogTitle ?? ''; From 7afd6bbcb944f950ad4010fdd0b67bd8219f4651 Mon Sep 17 00:00:00 2001 From: hoeppner-dataport Date: Fri, 29 Sep 2023 16:06:28 +0200 Subject: [PATCH 18/31] fix: update visitor for linkelements --- .../modules/board/service/content-element-update.visitor.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 dedf632a330..d8a65338ced 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 @@ -51,7 +51,10 @@ export class ContentElementUpdateVisitor implements BoardCompositeVisitor { } visitLinkElement(linkElement: LinkElement): void { - if (!(this.content instanceof LinkContentBody)) { + if (this.content instanceof LinkContentBody) { + const urlWithProtocol = this.content.url.match(/:\/\//) ? this.content.url : `https://${this.content.url}`; + linkElement.url = new URL(urlWithProtocol).toString(); + } else { this.throwNotHandled(linkElement); } } From 23b3ddf9574da0f382680f1d56ed94df0489ceb7 Mon Sep 17 00:00:00 2001 From: hoeppner-dataport Date: Wed, 4 Oct 2023 11:34:41 +0200 Subject: [PATCH 19/31] refactoring of open data proxy usage (find => save) --- .../dto/element/link-element.response.ts | 51 +++------------ .../board/controller/element.controller.ts | 35 +++++++++-- .../mapper/link-element-response.mapper.ts | 7 ++- .../board/repo/board-do.builder-impl.ts | 2 + .../board/repo/recursive-save.visitor.ts | 2 + .../recursive-copy.visitor.ts | 2 + .../src/modules/board/service/card.service.ts | 39 ++---------- .../service/content-element-update.visitor.ts | 63 ++++++++++--------- .../board/service/content-element.service.ts | 13 ++-- .../board/service/open-graph-proxy.service.ts | 27 ++++---- .../server/src/modules/board/uc/element.uc.ts | 5 +- .../board/content-element.factory.ts | 1 + .../domainobject/board/link-element.do.ts | 31 ++++++--- .../boardnode/link-element-node.entity.ts | 10 +++ .../boardnode/link-element-node.factory.ts | 4 +- .../board/link-element.do.factory.ts | 1 + 16 files changed, 154 insertions(+), 139 deletions(-) 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 index 28e347d27eb..d6c4a7e7080 100644 --- 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 @@ -2,58 +2,25 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ContentElementType } from '@shared/domain'; import { TimestampsResponse } from '../timestamps.response'; -export class OpenGraphImageData { - constructor({ url, type, width, height }: OpenGraphImageData) { +export class LinkElementContent { + constructor({ url, title, description, imageUrl }: LinkElementContent) { this.url = url; - this.type = type; - this.width = width ? +width : undefined; - this.height = height ? +height : undefined; - } - - @ApiProperty() - url: string; - - @ApiPropertyOptional() - type?: string; - - @ApiPropertyOptional() - width?: number; - - @ApiPropertyOptional() - height?: number; -} - -export class OpenGraphData { - constructor({ title, description, image, url }: OpenGraphData) { this.title = title; this.description = description; - this.image = image; - this.url = url; + this.imageUrl = imageUrl; } - @ApiProperty() - title: string; - - @ApiProperty() - description: string; - - @ApiPropertyOptional({ type: OpenGraphImageData }) - image?: OpenGraphImageData; - @ApiProperty() url: string; -} -export class LinkElementContent { - constructor({ url, openGraphData }: LinkElementContent) { - this.url = url; - this.openGraphData = openGraphData; - } @ApiProperty() - url: string; + title: string; - @ApiPropertyOptional({ type: OpenGraphData }) - openGraphData?: OpenGraphData; + @ApiPropertyOptional() + description?: string; + + @ApiPropertyOptional() + imageUrl?: string; } export class LinkElementResponse { diff --git a/apps/server/src/modules/board/controller/element.controller.ts b/apps/server/src/modules/board/controller/element.controller.ts index c0f3f0df29b..2dacd2cf539 100644 --- a/apps/server/src/modules/board/controller/element.controller.ts +++ b/apps/server/src/modules/board/controller/element.controller.ts @@ -10,25 +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') @@ -64,18 +70,35 @@ export class ElementController { 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/link-element-response.mapper.ts b/apps/server/src/modules/board/controller/mapper/link-element-response.mapper.ts index f93a6fe0a56..e6a31b2c07a 100644 --- a/apps/server/src/modules/board/controller/mapper/link-element-response.mapper.ts +++ b/apps/server/src/modules/board/controller/mapper/link-element-response.mapper.ts @@ -18,7 +18,12 @@ export class LinkElementResponseMapper implements BaseResponseMapper { id: element.id, timestamps: new TimestampsResponse({ lastUpdatedAt: element.updatedAt, createdAt: element.createdAt }), type: ContentElementType.LINK, - content: new LinkElementContent({ url: element.url, openGraphData: element.openGraphData }), + content: new LinkElementContent({ + url: element.url, + title: element.title, + description: element.description, + imageUrl: element.imageUrl, + }), }); return result; 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 4d8cf29f9ca..1172e58af69 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 @@ -116,6 +116,8 @@ export class BoardDoBuilderImpl implements BoardDoBuilder { const element = new LinkElement({ id: boardNode.id, url: boardNode.url, + title: boardNode.title, + imageUrl: boardNode.imageUrl, children: [], createdAt: boardNode.createdAt, updatedAt: boardNode.updatedAt, 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 b62df44eb3f..165499ad672 100644 --- a/apps/server/src/modules/board/repo/recursive-save.visitor.ts +++ b/apps/server/src/modules/board/repo/recursive-save.visitor.ts @@ -116,6 +116,8 @@ export class RecursiveSaveVisitor implements BoardCompositeVisitor { const boardNode = new LinkElementNode({ id: linkElement.id, url: linkElement.url, + title: linkElement.title, + imageUrl: linkElement.imageUrl, parent: parentData?.boardNode, position: parentData?.position, }); 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 621d012b1c5..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 @@ -127,6 +127,8 @@ export class RecursiveCopyVisitor implements BoardCompositeVisitorAsync { const copy = new LinkElement({ id: new ObjectId().toHexString(), url: original.url, + title: original.title, + imageUrl: original.imageUrl, children: [], createdAt: new Date(), updatedAt: new Date(), diff --git a/apps/server/src/modules/board/service/card.service.ts b/apps/server/src/modules/board/service/card.service.ts index a61eab1bd16..b7fee25c94f 100644 --- a/apps/server/src/modules/board/service/card.service.ts +++ b/apps/server/src/modules/board/service/card.service.ts @@ -1,24 +1,20 @@ import { Injectable, NotFoundException } from '@nestjs/common'; -import { Card, Column, ContentElementType, EntityId, LinkElement } from '@shared/domain'; +import { Card, Column, ContentElementType, EntityId } from '@shared/domain'; import { ObjectId } from 'bson'; import { BoardDoRepo } from '../repo'; import { BoardDoService } from './board-do.service'; import { ContentElementService } from './content-element.service'; -import { OpenGraphProxyService } from './open-graph-proxy.service'; @Injectable() export class CardService { constructor( private readonly boardDoRepo: BoardDoRepo, private readonly boardDoService: BoardDoService, - private readonly contentElementService: ContentElementService, - private readonly openGraphProxyService: OpenGraphProxyService + private readonly contentElementService: ContentElementService ) {} async findById(cardId: EntityId): Promise { - const card = await this.boardDoRepo.findByClassAndId(Card, cardId); - const extendedCard = await this.extendLinkElements(card); - return extendedCard; + return this.boardDoRepo.findByClassAndId(Card, cardId); } async findByIds(cardIds: EntityId[]): Promise { @@ -27,34 +23,7 @@ export class CardService { throw new NotFoundException('some ids do not belong to a card'); } - const promises: Promise[] = []; - for (const card of cards) { - const extendedCardPromise = this.extendLinkElements(card as Card); - promises.push(extendedCardPromise); - } - - const extendedCards = Promise.all(promises); - return extendedCards; - } - - private async extendLinkElements(card: Card): Promise { - const linkElements: LinkElement[] = card.children.filter((c) => c instanceof LinkElement) as LinkElement[]; - const promises: Promise[] = []; - for (const linkElement of linkElements) { - promises.push(this.extendLinkElement(linkElement)); - } - (await Promise.all(promises)) as LinkElement[]; - - return card; - } - - private async extendLinkElement(linkElement: LinkElement): Promise { - try { - linkElement.openGraphData = await this.openGraphProxyService.fetchOpenGraphData(linkElement.url); - } catch (e) { - return Promise.resolve(); - } - return Promise.resolve(); + return cards as Card[]; } async create(parent: Column, requiredEmptyElements?: ContentElementType[]): Promise { 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 d8a65338ced..1e986bc2d1a 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, @@ -21,75 +22,81 @@ import { 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); } - visitLinkElement(linkElement: LinkElement): void { + async visitLinkElementAsync(linkElement: LinkElement): Promise { if (this.content instanceof LinkContentBody) { const urlWithProtocol = this.content.url.match(/:\/\//) ? this.content.url : `https://${this.content.url}`; linkElement.url = new URL(urlWithProtocol).toString(); - } else { - this.throwNotHandled(linkElement); + 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); } - visitRichTextElement(richTextElement: RichTextElement): void { + 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) { submissionContainerElement.dueDate = this.content.dueDate ?? undefined; - } else { - this.throwNotHandled(submissionContainerElement); } + 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 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.ts b/apps/server/src/modules/board/service/content-element.service.ts index bef5d076fc6..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,13 +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/open-graph-proxy.service.ts b/apps/server/src/modules/board/service/open-graph-proxy.service.ts index 080ad8e0851..6b2e9de3c7a 100644 --- a/apps/server/src/modules/board/service/open-graph-proxy.service.ts +++ b/apps/server/src/modules/board/service/open-graph-proxy.service.ts @@ -1,8 +1,13 @@ import { Injectable } from '@nestjs/common'; -import { sortBy } from 'lodash'; import ogs from 'open-graph-scraper'; import { ImageObject } from 'open-graph-scraper/dist/lib/types'; -import { OpenGraphData, OpenGraphImageData } from '../controller/dto'; + +type OpenGraphData = { + title: string; + description: string; + url: string; + image?: ImageObject; +}; @Injectable() export class OpenGraphProxyService { @@ -11,26 +16,24 @@ export class OpenGraphProxyService { throw new Error(`OpenGraphProxyService requires a valid URL. Given URL: ${url}`); } const data = await ogs({ url }); - + console.log('fetchOpenGraphData', data.result); const title = data.result.ogTitle ?? ''; const description = data.result.ogDescription ?? ''; const image = data.result.ogImage ? this.pickImage(data.result.ogImage) : undefined; - const result = new OpenGraphData({ + return { title, description, image, url, - }); - - return result; + }; } - private pickImage(images: ImageObject[], minWidth = 400, maxWidth = 800): OpenGraphImageData { - const imagesWithCorrectDimensions = images.map((i) => new OpenGraphImageData(i)); - const sortedImages = sortBy(imagesWithCorrectDimensions, ['width', 'height']); - const biggestSmallEnoughImage = [...sortedImages].reverse().find((i) => i.width && i.width <= maxWidth); + 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); - return biggestSmallEnoughImage ?? smallestBigEnoughImage ?? sortedImages[0]; + 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 0dafd9eb98f..e5dc039168c 100644 --- a/apps/server/src/modules/board/uc/element.uc.ts +++ b/apps/server/src/modules/board/uc/element.uc.ts @@ -28,11 +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/shared/domain/domainobject/board/content-element.factory.ts b/apps/server/src/shared/domain/domainobject/board/content-element.factory.ts index 450ac4c7448..7882047e538 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 @@ -57,6 +57,7 @@ export class ContentElementFactory { const element = new LinkElement({ id: new ObjectId().toHexString(), url: '', + title: '', createdAt: new Date(), updatedAt: new Date(), }); 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 index 37fddeac453..7b38cbd938e 100644 --- a/apps/server/src/shared/domain/domainobject/board/link-element.do.ts +++ b/apps/server/src/shared/domain/domainobject/board/link-element.do.ts @@ -1,22 +1,37 @@ -import { OpenGraphData } from '@src/modules/board/controller/dto'; 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 || ''; + return this.props.url ?? ''; } set url(value: string) { this.props.url = value; } - get openGraphData(): OpenGraphData { - return { url: this.props.url, title: '', description: '', ...this.props.openGraphData }; + get title(): string { + return this.props.title ?? ''; } - set openGraphData(value: OpenGraphData) { - this.props.openGraphData = value; + 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 { @@ -34,7 +49,9 @@ export class LinkElement extends BoardComposite { export interface LinkElementProps extends BoardCompositeProps { url: string; - openGraphData?: OpenGraphData; + title: string; + description?: string; + imageUrl?: string; } export function isLinkElement(reference: unknown): reference is LinkElement { 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 index 073a68cbb42..0102821d97b 100644 --- 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 @@ -8,10 +8,18 @@ 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 { @@ -23,4 +31,6 @@ export class LinkElementNode extends BoardNode { export interface LinkElementNodeProps extends BoardNodeProps { url: string; + title: string; + imageUrl?: string; } 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 index 65774eec3fa..1725634705f 100644 --- 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 @@ -5,8 +5,10 @@ import { BaseFactory } from '../base.factory'; export const linkElementNodeFactory = BaseFactory.define( LinkElementNode, ({ sequence }) => { + const url = `https://www.example.com/link/${sequence}`; return { - url: `https://www.example.com/link/${sequence}`, + url, + title: `The example page ${sequence}`, }; } ); 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 index 64243d8e8d1..af0e55a1912 100644 --- 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 @@ -7,6 +7,7 @@ export const linkElementFactory = BaseFactory.define Date: Thu, 5 Oct 2023 16:10:48 +0200 Subject: [PATCH 20/31] chore: fix test --- .../entity/boardnode/link-element-node.entity.spec.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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 index aeaeaae4ce4..1093e57922e 100644 --- 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 @@ -6,7 +6,7 @@ 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' }; + const elementProps = { url: 'https://www.any-fake.url/that-is-linked.html', title: 'A Great WebPage' }; const builder: DeepMocked = createMock(); return { elementProps, builder }; @@ -23,7 +23,10 @@ describe(LinkElementNode.name, () => { describe('useDoBuilder()', () => { const setup = () => { - const element = new LinkElementNode({ url: 'https://www.any-fake.url/that-is-linked.html' }); + 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(); From ae98800594addd173732f8ed9f1f2f7588a9485d Mon Sep 17 00:00:00 2001 From: hoeppner-dataport Date: Thu, 5 Oct 2023 16:16:18 +0200 Subject: [PATCH 21/31] chore: fix test --- .../src/modules/board/repo/board-do.builder-impl.spec.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 6818713e79d..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,4 +1,4 @@ -import { BoardNodeType, ExternalToolElement } from '@shared/domain'; +import { BoardNodeType, ExternalToolElement, LinkElement } from '@shared/domain'; import { cardNodeFactory, columnBoardNodeFactory, @@ -208,15 +208,15 @@ describe(BoardDoBuilderImpl.name, () => { describe('when building a link element', () => { it('should work without descendants', () => { - const linkElementNode = linkElementNodeFactory.build(); + const linkElementNode = linkElementNodeFactory.buildWithId(); const domainObject = new BoardDoBuilderImpl().buildLinkElement(linkElementNode); - expect(domainObject.constructor.name).toBe(ExternalToolElement.name); + expect(domainObject.constructor.name).toBe(LinkElement.name); }); it('should throw error if linkElement is not a leaf', () => { - const linkElementNode = linkElementNodeFactory.build(); + const linkElementNode = linkElementNodeFactory.buildWithId(); const columnNode = columnNodeFactory.buildWithId({ parent: linkElementNode }); expect(() => { From 1a0ece1bea3b24ea080efa0ca85abd47f4896da3 Mon Sep 17 00:00:00 2001 From: hoeppner-dataport Date: Fri, 6 Oct 2023 12:05:04 +0200 Subject: [PATCH 22/31] chore: remove console.log --- .../src/modules/board/service/open-graph-proxy.service.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 index 6b2e9de3c7a..2b54d75ee82 100644 --- a/apps/server/src/modules/board/service/open-graph-proxy.service.ts +++ b/apps/server/src/modules/board/service/open-graph-proxy.service.ts @@ -15,8 +15,10 @@ export class OpenGraphProxyService { if (url.length === 0) { throw new Error(`OpenGraphProxyService requires a valid URL. Given URL: ${url}`); } + const data = await ogs({ url }); - console.log('fetchOpenGraphData', data.result); + // 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; From 3fbfd18685f11778255e83978befbce50b1feb1e Mon Sep 17 00:00:00 2001 From: hoeppner-dataport Date: Fri, 6 Oct 2023 17:17:09 +0200 Subject: [PATCH 23/31] chore: fix tests --- .../content-element-update.visitor.spec.ts | 69 ++++++++++-------- .../service/content-element-update.visitor.ts | 4 +- .../service/content-element.service.spec.ts | 70 +++++++++++++++++-- 3 files changed, 106 insertions(+), 37 deletions(-) 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 7385d17a232..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 @@ -13,6 +13,7 @@ import { } 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', () => { @@ -24,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(); }); }); }); @@ -64,15 +66,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 { 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(); }); }); @@ -81,15 +84,16 @@ describe(ContentElementUpdateVisitor.name, () => { const linkElement = linkElementFactory.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 { linkElement, updater }; }; - it('should throw an error', () => { + it('should throw an error', async () => { const { linkElement, updater } = setup(); - expect(() => updater.visitLinkElement(linkElement)).toThrow(); + await expect(() => updater.visitLinkElementAsync(linkElement)).rejects.toThrow(); }); }); @@ -98,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(); }); }); @@ -116,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(); }); }); @@ -134,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); }); @@ -154,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(); }); }); @@ -170,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 1e986bc2d1a..b1f242d8685 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 @@ -55,7 +55,7 @@ export class ContentElementUpdateVisitor implements BoardCompositeVisitorAsync { async visitLinkElementAsync(linkElement: LinkElement): Promise { if (this.content instanceof LinkContentBody) { - const urlWithProtocol = this.content.url.match(/:\/\//) ? this.content.url : `https://${this.content.url}`; + 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; @@ -80,6 +80,7 @@ export class ContentElementUpdateVisitor implements BoardCompositeVisitorAsync { async visitSubmissionContainerElementAsync(submissionContainerElement: SubmissionContainerElement): Promise { if (this.content instanceof SubmissionContainerContentBody) { submissionContainerElement.dueDate = this.content.dueDate ?? undefined; + return Promise.resolve(); } return this.rejectNotHandled(submissionContainerElement); } @@ -92,6 +93,7 @@ export class ContentElementUpdateVisitor implements BoardCompositeVisitorAsync { 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; + return Promise.resolve(); } return this.rejectNotHandled(externalToolElement); } 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); }); }); }); From 2e5c63d732b8a081ebc546c6366d8ac6b3bfb58b Mon Sep 17 00:00:00 2001 From: hoeppner-dataport Date: Fri, 6 Oct 2023 17:27:30 +0200 Subject: [PATCH 24/31] add feature toogle to schemas --- config/default.schema.json | 5 +++++ config/development.json | 1 + 2 files changed, 6 insertions(+) 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 } From 1915269de0230f114056c409e907acb2452be3e3 Mon Sep 17 00:00:00 2001 From: hoeppner-dataport Date: Fri, 6 Oct 2023 17:41:11 +0200 Subject: [PATCH 25/31] chore: fix non related test (scout rule) --- apps/server/src/modules/board/service/card.service.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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); From 5885b3c13c94c71b8082cbc2ad9d5adbd736b6fd Mon Sep 17 00:00:00 2001 From: hoeppner-dataport Date: Fri, 6 Oct 2023 19:46:39 +0200 Subject: [PATCH 26/31] chore: fix test status 204 => 201 --- .../api-test/content-element-update-content.spec.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 bee1ad63f0f..ee16edb51a2 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 @@ -95,7 +95,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`, { @@ -105,7 +105,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 () => { @@ -164,7 +164,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`, { @@ -174,7 +174,7 @@ describe(`content element update content (api)`, () => { }, }); - expect(response.statusCode).toEqual(204); + expect(response.statusCode).toEqual(201); }); it('should not change dueDate value without dueDate parameter for submission container element', async () => { From 80b7c57b75558959c848d3e459890a8b02becbed Mon Sep 17 00:00:00 2001 From: hoeppner-dataport Date: Mon, 9 Oct 2023 13:19:32 +0200 Subject: [PATCH 27/31] chore: added new feature toggle to config-endpoint --- src/services/config/publicAppConfigService.js | 1 + 1 file changed, 1 insertion(+) 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', From fbf545d9c89fdd81f804f4f123df4ecc6cc41ce3 Mon Sep 17 00:00:00 2001 From: hoeppner-dataport Date: Mon, 9 Oct 2023 13:28:27 +0200 Subject: [PATCH 28/31] conar: sonar fix - remove second empty line at eof --- .../src/shared/testing/factory/domainobject/board/index.ts | 1 - 1 file changed, 1 deletion(-) 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 59b4115f584..9a6cdf84839 100644 --- a/apps/server/src/shared/testing/factory/domainobject/board/index.ts +++ b/apps/server/src/shared/testing/factory/domainobject/board/index.ts @@ -7,4 +7,3 @@ 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'; - From a9b0ac78865326472dfbbdf78d0bafa3f7beebb2 Mon Sep 17 00:00:00 2001 From: hoeppner-dataport Date: Mon, 9 Oct 2023 17:25:27 +0200 Subject: [PATCH 29/31] chore: add test for open graph proxy service --- .../service/open-graph-proxy.service.spec.ts | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 apps/server/src/modules/board/service/open-graph-proxy.service.spec.ts 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] })); + }); + }); +}); From 47500522a292323f36af489475d61b2a87e8ea00 Mon Sep 17 00:00:00 2001 From: hoeppner-dataport Date: Mon, 9 Oct 2023 17:32:05 +0200 Subject: [PATCH 30/31] chore: add test copying link elements --- .../board-do-copy.service.spec.ts | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) 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); + }); + }); }); From c7e87769b3411931fc4dea483a3c22d0b7591487 Mon Sep 17 00:00:00 2001 From: hoeppner-dataport Date: Tue, 10 Oct 2023 09:07:47 +0200 Subject: [PATCH 31/31] chore: add trailing comma --- .../server/src/modules/board/service/content-element.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 52ab52569ab..a7c957173f3 100644 --- a/apps/server/src/modules/board/service/content-element.service.ts +++ b/apps/server/src/modules/board/service/content-element.service.ts @@ -5,7 +5,7 @@ import { ContentElementFactory, ContentElementType, EntityId, - isAnyContentElement + isAnyContentElement, } from '@shared/domain'; import { AnyElementContentBody } from '../controller/dto'; import { BoardDoRepo } from '../repo';