diff --git a/packages/twenty-front/src/modules/object-record/record-field/__mocks__/fieldDefinitions.ts b/packages/twenty-front/src/modules/object-record/record-field/__mocks__/fieldDefinitions.ts index 7559a5790..8c1bb8638 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/__mocks__/fieldDefinitions.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/__mocks__/fieldDefinitions.ts @@ -1,11 +1,12 @@ import { formatFieldMetadataItemAsFieldDefinition } from '@/object-metadata/utils/formatFieldMetadataItemAsFieldDefinition'; import { FieldDefinition } from '@/object-record/record-field/types/FieldDefinition'; import { - FieldActorMetadata, - FieldFullNameMetadata, - FieldRatingMetadata, - FieldSelectMetadata, - FieldTextMetadata, + FieldActorMetadata, + FieldFullNameMetadata, + FieldLinksMetadata, + FieldRatingMetadata, + FieldSelectMetadata, + FieldTextMetadata, } from '@/object-record/record-field/types/FieldMetadata'; import { FieldMetadataType } from '~/generated-metadata/graphql'; import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems'; @@ -111,3 +112,19 @@ export const actorFieldDefinition: FieldDefinition = { objectMetadataNameSingular: 'person', }, }; + +export const linksFieldDefinition: FieldDefinition = { + fieldMetadataId, + label: 'Links', + iconName: 'IconLink', + type: FieldMetadataType.LINKS, + defaultValue: { + primaryLinkUrl: null, + primaryLinkLabel: null, + secondaryLinks: [], + }, + metadata: { + fieldName: 'links', + objectMetadataNameSingular: 'company', + }, +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/LinksFieldInput.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/LinksFieldInput.stories.tsx index 59b763b2b..ff7b3e0f8 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/LinksFieldInput.stories.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/LinksFieldInput.stories.tsx @@ -416,3 +416,31 @@ export const Cancel: Story = { expect(cancelJestFn).toHaveBeenCalledTimes(1); }, }; + +export const InvalidUrls: Story = { + args: { + value: { + primaryLinkUrl: 'lydia,com', + primaryLinkLabel: 'Invalid URL', + secondaryLinks: [ + { url: 'wikipedia', label: 'Missing Protocol' }, + { url: '\\invalid', label: 'Invalid Characters' }, + ], + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const input = await canvas.findByPlaceholderText('URL'); + expect(input).toBeVisible(); + expect(input).toHaveValue(''); + + await waitFor(() => { + expect(canvas.queryByRole('link')).toBeNull(); + }); + + expect(canvas.queryByText('Invalid URL')).not.toBeInTheDocument(); + expect(canvas.queryByText('Missing Protocol')).not.toBeInTheDocument(); + expect(canvas.queryByText('Invalid Characters')).not.toBeInTheDocument(); + }, +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/utils/__tests__/getFieldLinkDefinedLinks.test.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/utils/__tests__/getFieldLinkDefinedLinks.test.ts index 4147b388e..288bed4cb 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/utils/__tests__/getFieldLinkDefinedLinks.test.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/utils/__tests__/getFieldLinkDefinedLinks.test.ts @@ -158,5 +158,33 @@ describe('getFieldLinkDefinedLinks', () => { }), ).toEqual([]); }); + + it('should filter out secondary links and primary link with invalid URLs', () => { + expect( + getFieldLinkDefinedLinks({ + primaryLinkUrl: 'lydia,com', + primaryLinkLabel: 'Invalid Primary', + secondaryLinks: [ + { + url: 'lydia,com', + label: 'Invalid URL', + }, + { + url: 'wikipedia', + label: 'Missing Protocol', + }, + { + url: 'https://twenty.com', + label: 'Valid URL', + }, + ], + }), + ).toEqual([ + { + url: 'https://twenty.com', + label: 'Valid URL', + }, + ]); + }); }); }); diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/utils/getFieldLinkDefinedLinks.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/utils/getFieldLinkDefinedLinks.ts index d7c20cf45..7534e60c8 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/utils/getFieldLinkDefinedLinks.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/utils/getFieldLinkDefinedLinks.ts @@ -1,6 +1,6 @@ import { FieldLinksValue } from '@/object-record/record-field/types/FieldMetadata'; import { isNonEmptyString } from '@sniptt/guards'; -import { isDefined } from 'twenty-shared/utils'; +import { isDefined, isValidUrl } from 'twenty-shared/utils'; export const getFieldLinkDefinedLinks = (fieldValue: FieldLinksValue) => { return [ @@ -23,5 +23,6 @@ export const getFieldLinkDefinedLinks = (fieldValue: FieldLinksValue) => { label: link.label, }; }) - .filter(isDefined); + .filter(isDefined) + .filter(({ url }) => isValidUrl(url)); }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldLinksValue.ts b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldLinksValue.ts index 30f3219ed..93d12668f 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldLinksValue.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldLinksValue.ts @@ -1,15 +1,14 @@ -import { absoluteUrlSchema } from 'twenty-shared/utils'; import { z } from 'zod'; import { FieldLinksValue } from '../FieldMetadata'; export const linksSchema = z.object({ primaryLinkLabel: z.string().nullable(), - primaryLinkUrl: absoluteUrlSchema.or(z.string().length(0)).nullable(), + primaryLinkUrl: z.string().nullable(), secondaryLinks: z .array( z.object({ label: z.string().nullable(), - url: absoluteUrlSchema.nullable(), + url: z.string().nullable(), }), ) .nullable(), diff --git a/packages/twenty-front/src/modules/object-record/record-field/utils/__tests__/isFieldValueEmpty.test.ts b/packages/twenty-front/src/modules/object-record/record-field/utils/__tests__/isFieldValueEmpty.test.ts index 41171d50e..b62dc80b1 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/utils/__tests__/isFieldValueEmpty.test.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/utils/__tests__/isFieldValueEmpty.test.ts @@ -2,6 +2,7 @@ import { booleanFieldDefinition, fieldMetadataId, fullNameFieldDefinition, + linksFieldDefinition, relationFieldDefinition, selectFieldDefinition, } from '@/object-record/record-field/__mocks__/fieldDefinitions'; @@ -112,4 +113,104 @@ describe('isFieldValueEmpty', () => { }), ).toBe(false); }); + + it('should return correct value for links field', () => { + // Empty cases + expect( + isFieldValueEmpty({ + fieldDefinition: linksFieldDefinition, + fieldValue: { + primaryLinkUrl: null, + primaryLinkLabel: null, + secondaryLinks: [], + }, + }), + ).toBe(true); + + expect( + isFieldValueEmpty({ + fieldDefinition: linksFieldDefinition, + fieldValue: null, + }), + ).toBe(true); + + // Valid primary link only + expect( + isFieldValueEmpty({ + fieldDefinition: linksFieldDefinition, + fieldValue: { + primaryLinkUrl: 'https://www.twenty.com', + primaryLinkLabel: 'Twenty Website', + secondaryLinks: [], + }, + }), + ).toBe(false); + + // Valid secondary link only + expect( + isFieldValueEmpty({ + fieldDefinition: linksFieldDefinition, + fieldValue: { + primaryLinkUrl: null, + primaryLinkLabel: null, + secondaryLinks: [ + { url: 'https://docs.twenty.com', label: 'Documentation' }, + ], + }, + }), + ).toBe(false); + + // Invalid primary link but valid secondary link + expect( + isFieldValueEmpty({ + fieldDefinition: linksFieldDefinition, + fieldValue: { + primaryLinkUrl: 'lydia,com', + primaryLinkLabel: 'Invalid URL', + secondaryLinks: [ + { url: 'https://docs.twenty.com', label: 'Documentation' }, + ], + }, + }), + ).toBe(false); + + // Valid primary link but invalid secondary link + expect( + isFieldValueEmpty({ + fieldDefinition: linksFieldDefinition, + fieldValue: { + primaryLinkUrl: 'https://www.twenty.com', + primaryLinkLabel: 'Twenty Website', + secondaryLinks: [{ url: 'wikipedia', label: 'Invalid URL' }], + }, + }), + ).toBe(false); + + // All invalid links + expect( + isFieldValueEmpty({ + fieldDefinition: linksFieldDefinition, + fieldValue: { + primaryLinkUrl: 'lydia,com', + primaryLinkLabel: 'Invalid URL', + secondaryLinks: [{ url: 'wikipedia', label: 'Invalid URL' }], + }, + }), + ).toBe(true); + + // Multiple secondary links with mix of valid and invalid + expect( + isFieldValueEmpty({ + fieldDefinition: linksFieldDefinition, + fieldValue: { + primaryLinkUrl: null, + primaryLinkLabel: null, + secondaryLinks: [ + { url: 'wikipedia', label: 'Invalid URL' }, + { url: 'https://docs.twenty.com', label: 'Documentation' }, + ], + }, + }), + ).toBe(false); + }); }); diff --git a/packages/twenty-front/src/modules/object-record/record-field/utils/isFieldValueEmpty.ts b/packages/twenty-front/src/modules/object-record/record-field/utils/isFieldValueEmpty.ts index 06d3db8da..a10f241bc 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/utils/isFieldValueEmpty.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/utils/isFieldValueEmpty.ts @@ -1,5 +1,6 @@ import { isArray, isNonEmptyArray, isString } from '@sniptt/guards'; +import { getFieldLinkDefinedLinks } from '@/object-record/record-field/meta-types/input/utils/getFieldLinkDefinedLinks'; import { FieldDefinition } from '@/object-record/record-field/types/FieldDefinition'; import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'; import { isFieldActor } from '@/object-record/record-field/types/guards/isFieldActor'; @@ -116,9 +117,14 @@ export const isFieldValueEmpty = ({ } if (isFieldLinks(fieldDefinition)) { - return ( - !isFieldLinksValue(fieldValue) || isValueEmpty(fieldValue.primaryLinkUrl) - ); + if (!isFieldLinksValue(fieldValue)) { + return true; + } + + const definedLinks = getFieldLinkDefinedLinks(fieldValue); + const isFieldLinksEmpty = definedLinks.length === 0; + + return isFieldLinksEmpty; } if (isFieldActor(fieldDefinition)) { diff --git a/packages/twenty-front/src/modules/ui/field/display/components/__stories__/LinksDisplay.stories.tsx b/packages/twenty-front/src/modules/ui/field/display/components/__stories__/LinksDisplay.stories.tsx index 9364b5022..fe5cddad9 100644 --- a/packages/twenty-front/src/modules/ui/field/display/components/__stories__/LinksDisplay.stories.tsx +++ b/packages/twenty-front/src/modules/ui/field/display/components/__stories__/LinksDisplay.stories.tsx @@ -180,3 +180,25 @@ export const AutomaticLabelFromURL: Story = { expect(secondaryLink).toHaveAttribute('href', 'https://test.example.com'); }, }; + +export const InvalidLinks: Story = { + args: { + value: { + primaryLinkUrl: 'wikipedia', + primaryLinkLabel: 'Invalid URL', + secondaryLinks: [{ url: 'lydia,com', label: 'Invalid URL with comma' }], + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await waitFor(() => { + expect(canvas.queryByRole('link')).toBeNull(); + }); + + expect(canvas.queryByText('Invalid URL')).not.toBeInTheDocument(); + expect( + canvas.queryByText('Invalid URL with comma'), + ).not.toBeInTheDocument(); + }, +}; diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util.ts b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util.ts index a7636a29d..606a7f48d 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util.ts @@ -7,6 +7,8 @@ import { graphqlQueryRunnerExceptionHandler } from 'src/engine/api/graphql/works import { handleDuplicateKeyError } from 'src/engine/api/graphql/workspace-query-runner/utils/handle-duplicate-key-error.util'; import { workspaceExceptionHandler } from 'src/engine/api/graphql/workspace-query-runner/utils/workspace-exception-handler.util'; import { WorkspaceQueryRunnerException } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-runner.exception'; +import { RecordTransformerException } from 'src/engine/core-modules/record-transformer/record-transformer.exception'; +import { recordTransformerGraphqlApiExceptionHandler } from 'src/engine/core-modules/record-transformer/utils/record-transformer-graphql-api-exception-handler.util'; import { PermissionsException } from 'src/engine/metadata-modules/permissions/permissions.exception'; import { permissionGraphqlApiExceptionHandler } from 'src/engine/metadata-modules/permissions/utils/permission-graphql-api-exception-handler.util'; @@ -23,6 +25,8 @@ export const workspaceQueryRunnerGraphqlApiExceptionHandler = ( } throw error; } + case error instanceof RecordTransformerException: + return recordTransformerGraphqlApiExceptionHandler(error); case error instanceof PermissionsException: return permissionGraphqlApiExceptionHandler(error); case error instanceof WorkspaceQueryRunnerException: diff --git a/packages/twenty-server/src/engine/core-modules/record-transformer/record-transformer.exception.ts b/packages/twenty-server/src/engine/core-modules/record-transformer/record-transformer.exception.ts new file mode 100644 index 000000000..60bfbfe44 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/record-transformer/record-transformer.exception.ts @@ -0,0 +1,12 @@ +import { CustomException } from 'src/utils/custom-exception'; + +export class RecordTransformerException extends CustomException { + declare code: RecordTransformerExceptionCode; + constructor(message: string, code: RecordTransformerExceptionCode) { + super(message, code); + } +} + +export enum RecordTransformerExceptionCode { + INVALID_URL = 'INVALID_URL', +} diff --git a/packages/twenty-server/src/engine/core-modules/record-transformer/services/record-input-transformer.service.ts b/packages/twenty-server/src/engine/core-modules/record-transformer/services/record-input-transformer.service.ts index d089abaff..0ef3306a3 100644 --- a/packages/twenty-server/src/engine/core-modules/record-transformer/services/record-input-transformer.service.ts +++ b/packages/twenty-server/src/engine/core-modules/record-transformer/services/record-input-transformer.service.ts @@ -1,14 +1,16 @@ import { Injectable } from '@nestjs/common'; import { ServerBlockNoteEditor } from '@blocknote/server-util'; +import { isNonEmptyString } from '@sniptt/guards'; import { FieldMetadataType } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface'; import { lowercaseDomain } from 'src/engine/api/graphql/workspace-query-runner/utils/query-runner-links.util'; +import { removeEmptyLinks } from 'src/engine/core-modules/record-transformer/utils/remove-empty-links'; import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types'; -import { LinkMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/links.composite-type'; +import { LinkMetadataNullable } from 'src/engine/metadata-modules/field-metadata/composite-types/links.composite-type'; import { RichTextV2Metadata, richTextV2ValueSchema, @@ -129,38 +131,45 @@ export class RecordInputTransformerService { }; } - // eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any private transformLinksValue(value: any): any { if (!value) { return value; } - const newPrimaryLinkUrl = lowercaseDomain(value?.primaryLinkUrl); + const primaryLinkUrlRaw = value.primaryLinkUrl as string | null; + const primaryLinkLabelRaw = value.primaryLinkLabel as string | null; + const secondaryLinksRaw = value.secondaryLinks as string | null; - let secondaryLinks = value?.secondaryLinks; + let secondaryLinksArray: LinkMetadataNullable[] | null = null; - if (secondaryLinks) { + if (isNonEmptyString(secondaryLinksRaw)) { try { - const secondaryLinksArray = JSON.parse(secondaryLinks); - - secondaryLinks = JSON.stringify( - secondaryLinksArray.map((link: LinkMetadata) => { - return { - ...link, - url: lowercaseDomain(link.url), - }; - }), - ); + secondaryLinksArray = JSON.parse(secondaryLinksRaw); } catch { /* empty */ } } + const { primaryLinkLabel, primaryLinkUrl, secondaryLinks } = + removeEmptyLinks({ + primaryLinkUrl: primaryLinkUrlRaw, + primaryLinkLabel: primaryLinkLabelRaw, + secondaryLinks: secondaryLinksArray, + }); + return { ...value, - primaryLinkUrl: newPrimaryLinkUrl, - secondaryLinks, + primaryLinkUrl: isDefined(primaryLinkUrl) + ? lowercaseDomain(primaryLinkUrl) + : primaryLinkUrl, + primaryLinkLabel, + secondaryLinks: JSON.stringify( + secondaryLinks?.map((link) => ({ + ...link, + url: isDefined(link.url) ? lowercaseDomain(link.url) : link.url, + })), + ), }; } diff --git a/packages/twenty-server/src/engine/core-modules/record-transformer/utils/__tests__/remove-empty-links.spec.ts b/packages/twenty-server/src/engine/core-modules/record-transformer/utils/__tests__/remove-empty-links.spec.ts new file mode 100644 index 000000000..94001f19b --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/record-transformer/utils/__tests__/remove-empty-links.spec.ts @@ -0,0 +1,203 @@ +import { + RecordTransformerException, + RecordTransformerExceptionCode, +} from 'src/engine/core-modules/record-transformer/record-transformer.exception'; +import { removeEmptyLinks } from 'src/engine/core-modules/record-transformer/utils/remove-empty-links'; + +describe('removeEmptyLinks', () => { + it('should return null values when all inputs are empty', () => { + expect( + removeEmptyLinks({ + primaryLinkUrl: null, + primaryLinkLabel: null, + secondaryLinks: [], + }), + ).toEqual({ + primaryLinkUrl: null, + primaryLinkLabel: null, + secondaryLinks: [], + }); + + expect( + removeEmptyLinks({ + primaryLinkUrl: null, + primaryLinkLabel: null, + secondaryLinks: null, + }), + ).toEqual({ + primaryLinkUrl: null, + primaryLinkLabel: null, + secondaryLinks: [], + }); + }); + + it('should keep valid primary link and remove empty secondary links', () => { + expect( + removeEmptyLinks({ + primaryLinkUrl: 'https://www.twenty.com', + primaryLinkLabel: 'Twenty Website', + secondaryLinks: [], + }), + ).toEqual({ + primaryLinkUrl: 'https://www.twenty.com', + primaryLinkLabel: 'Twenty Website', + secondaryLinks: [], + }); + }); + + it('should promote first valid secondary link to primary when primary is empty', () => { + expect( + removeEmptyLinks({ + primaryLinkUrl: null, + primaryLinkLabel: null, + secondaryLinks: [ + { + url: 'https://docs.twenty.com', + label: 'Documentation', + }, + { + url: 'https://github.com/twentyhq/twenty', + label: 'GitHub', + }, + ], + }), + ).toEqual({ + primaryLinkUrl: 'https://docs.twenty.com', + primaryLinkLabel: 'Documentation', + secondaryLinks: [ + { + url: 'https://github.com/twentyhq/twenty', + label: 'GitHub', + }, + ], + }); + }); + + it('should throw RecordTransformerException when primary link URL is invalid', () => { + expect(() => + removeEmptyLinks({ + primaryLinkUrl: 'lydia,com', + primaryLinkLabel: 'Invalid URL', + secondaryLinks: [], + }), + ).toThrow( + expect.objectContaining({ + constructor: RecordTransformerException, + code: RecordTransformerExceptionCode.INVALID_URL, + message: 'The URL of the link is not valid', + }), + ); + }); + + it('should throw RecordTransformerException when any secondary link URL is invalid', () => { + expect(() => + removeEmptyLinks({ + primaryLinkUrl: 'https://www.twenty.com', + primaryLinkLabel: 'Twenty Website', + secondaryLinks: [ + { + url: 'wikipedia', + label: 'Invalid URL', + }, + ], + }), + ).toThrow( + expect.objectContaining({ + constructor: RecordTransformerException, + code: RecordTransformerExceptionCode.INVALID_URL, + message: 'The URL of the link is not valid', + }), + ); + }); + + it('should throw RecordTransformerException when both primary and secondary URLs are invalid', () => { + expect(() => + removeEmptyLinks({ + primaryLinkUrl: 'lydia,com', + primaryLinkLabel: 'Invalid URL', + secondaryLinks: [ + { + url: 'wikipedia', + label: 'Invalid URL', + }, + ], + }), + ).toThrow( + expect.objectContaining({ + constructor: RecordTransformerException, + code: RecordTransformerExceptionCode.INVALID_URL, + message: 'The URL of the link is not valid', + }), + ); + }); + + it('should handle empty or null secondary links', () => { + expect( + removeEmptyLinks({ + primaryLinkUrl: 'https://www.twenty.com', + primaryLinkLabel: 'Twenty Website', + secondaryLinks: [ + { + url: '', + label: 'Empty URL', + }, + { + url: null, + label: 'Null URL', + }, + ], + }), + ).toEqual({ + primaryLinkUrl: 'https://www.twenty.com', + primaryLinkLabel: 'Twenty Website', + secondaryLinks: [], + }); + }); + + it('should return empty state when there are no valid URLs', () => { + expect( + removeEmptyLinks({ + primaryLinkUrl: '', + primaryLinkLabel: 'Empty URL', + secondaryLinks: [ + { + url: null, + label: 'Null URL', + }, + { + url: '', + label: 'Empty URL', + }, + ], + }), + ).toEqual({ + primaryLinkUrl: null, + primaryLinkLabel: null, + secondaryLinks: [], + }); + }); + + it('should keep valid URLs with null labels', () => { + expect( + removeEmptyLinks({ + primaryLinkUrl: 'https://www.twenty.com', + primaryLinkLabel: null, + secondaryLinks: [ + { + url: 'https://docs.twenty.com', + label: null, + }, + ], + }), + ).toEqual({ + primaryLinkUrl: 'https://www.twenty.com', + primaryLinkLabel: null, + secondaryLinks: [ + { + url: 'https://docs.twenty.com', + label: null, + }, + ], + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/record-transformer/utils/record-transformer-graphql-api-exception-handler.util.ts b/packages/twenty-server/src/engine/core-modules/record-transformer/utils/record-transformer-graphql-api-exception-handler.util.ts new file mode 100644 index 000000000..88fb37a2e --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/record-transformer/utils/record-transformer-graphql-api-exception-handler.util.ts @@ -0,0 +1,19 @@ +import { assertUnreachable } from 'twenty-shared/utils'; + +import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; +import { + RecordTransformerException, + RecordTransformerExceptionCode, +} from 'src/engine/core-modules/record-transformer/record-transformer.exception'; + +export const recordTransformerGraphqlApiExceptionHandler = ( + error: RecordTransformerException, +) => { + switch (error.code) { + case RecordTransformerExceptionCode.INVALID_URL: + throw new UserInputError(error.message); + default: { + assertUnreachable(error.code); + } + } +}; diff --git a/packages/twenty-server/src/engine/core-modules/record-transformer/utils/remove-empty-links.ts b/packages/twenty-server/src/engine/core-modules/record-transformer/utils/remove-empty-links.ts new file mode 100644 index 000000000..8fc282c9c --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/record-transformer/utils/remove-empty-links.ts @@ -0,0 +1,58 @@ +import { isNonEmptyString } from '@sniptt/guards'; +import { isDefined, isValidUrl } from 'twenty-shared/utils'; + +import { + RecordTransformerException, + RecordTransformerExceptionCode, +} from 'src/engine/core-modules/record-transformer/record-transformer.exception'; +import { LinkMetadataNullable } from 'src/engine/metadata-modules/field-metadata/composite-types/links.composite-type'; + +export const removeEmptyLinks = ({ + primaryLinkUrl, + secondaryLinks, + primaryLinkLabel, +}: { + secondaryLinks: LinkMetadataNullable[] | null; + primaryLinkUrl: string | null; + primaryLinkLabel: string | null; +}) => { + const filteredLinks = [ + isNonEmptyString(primaryLinkUrl) + ? { + url: primaryLinkUrl, + label: primaryLinkLabel, + } + : null, + ...(secondaryLinks ?? []), + ] + .filter(isDefined) + .map((link) => { + if (!isNonEmptyString(link.url)) { + return undefined; + } + + return { + url: link.url, + label: link.label, + }; + }) + .filter(isDefined); + + for (const link of filteredLinks) { + if (!isValidUrl(link.url)) { + throw new RecordTransformerException( + 'The URL of the link is not valid', + RecordTransformerExceptionCode.INVALID_URL, + ); + } + } + + const firstLink = filteredLinks.at(0); + const otherLinks = filteredLinks.slice(1); + + return { + primaryLinkUrl: firstLink?.url ?? null, + primaryLinkLabel: firstLink?.label ?? null, + secondaryLinks: otherLinks, + }; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/composite-types/links.composite-type.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/composite-types/links.composite-type.ts index 5d9924036..6291a8cb1 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/composite-types/links.composite-type.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/composite-types/links.composite-type.ts @@ -37,3 +37,8 @@ export type LinksMetadata = { primaryLinkUrl: string; secondaryLinks: LinkMetadata[] | null; }; + +export type LinkMetadataNullable = { + label: string | null; + url: string | null; +}; diff --git a/packages/twenty-server/test/integration/constants/test-primary-link-url.constant.ts b/packages/twenty-server/test/integration/constants/test-primary-link-url.constant.ts index a577aefc0..0df663f06 100644 --- a/packages/twenty-server/test/integration/constants/test-primary-link-url.constant.ts +++ b/packages/twenty-server/test/integration/constants/test-primary-link-url.constant.ts @@ -1 +1 @@ -export const TEST_PRIMARY_LINK_URL = 'http://test/'; +export const TEST_PRIMARY_LINK_URL = 'https://test.com/'; diff --git a/packages/twenty-shared/src/utils/url/__tests__/isValidUrl.test.ts b/packages/twenty-shared/src/utils/url/__tests__/isValidUrl.test.ts index c82dafe40..95798acc5 100644 --- a/packages/twenty-shared/src/utils/url/__tests__/isValidUrl.test.ts +++ b/packages/twenty-shared/src/utils/url/__tests__/isValidUrl.test.ts @@ -23,6 +23,7 @@ describe('isValidUrl', () => { expect(isValidUrl('')).toBe(false); expect(isValidUrl('\\')).toBe(false); expect(isValidUrl('wwwexamplecom')).toBe(false); + expect(isValidUrl('lydia,com')).toBe(false); expect(isValidUrl('2/toto')).toBe(false); expect(isValidUrl('2')).toBe(false); });