From 4f8aaeaab013af1e7160a2f79929783b6f47bb1d Mon Sep 17 00:00:00 2001
From: Paul Rastoin <45004772+prastoin@users.noreply.github.com>
Date: Fri, 31 Jul 2026 18:35:16 +0200
Subject: [PATCH] refactor(navigation-menu-item): validate universal properties
instead of ids (#23566)
Follow-up to the discussion on #23485 and closes
https://github.com/twentyhq/twenty/issues/23484.
`FlatNavigationMenuItemValidatorService` receives
`UniversalFlatEntityValidationArgs<'navigationMenuItem'>`, so the entity
it validates is a `UniversalFlatNavigationMenuItem`: `viewId`,
`pageLayoutId` and `targetObjectMetadataId` do not exist at that scope.
The validator read the right universal keys but passed them through a
bag of booleans named after the ids (`hasViewId`, `hasPageLayoutId`,
...) and then reported the id names in its errors. Nothing tied a
message to the property it checked, so fixing one message string leaves
the other five wrong.
## Changes
- Replace the private `validateNavigationMenuItemType` boolean bag with
`validateNavigationMenuItemTypeRequiredProperties({
flatNavigationMenuItem })` under
`flat-navigation-menu-item/validators/utils/`, in line with
`validateAgentRequiredProperties` and
`validateNavigationMenuItemPageLayoutReferenceCrossEntity`. It takes the
universal entity, so a message can only name a property that exists at
that scope.
- The util is an explicit `switch` on `NavigationMenuItemType` closed by
`assertUnreachable`, so adding a type fails to compile until its
contract is declared.
- Each case validates its own properties instead of checking presence
generically:
- `FOLDER`: non blank `name`
- `OBJECT`, `VIEW`, `PAGE_LAYOUT`:
`targetObjectMetadataUniversalIdentifier` / `viewUniversalIdentifier` /
`pageLayoutUniversalIdentifier` must be valid uuids
- `RECORD`: `targetRecordId` and
`targetObjectMetadataUniversalIdentifier`, both uuids, reported
separately
- `LINK`: `link` must pass `isValidUrl`
- Both call sites spread the result; the update path passes the merged
`{ ...from, ...update }` entity, which removes the redundant `name`
re-merge.
`targetRecordId` stays an id: it points at workspace record data rather
than metadata, so it has no universal counterpart.
## Behaviour
- Errors name the universal property (`viewUniversalIdentifier`) instead
of the id (`viewId`).
- Blank strings are now uniformly treated as missing; creation
previously accepted `link: " "`.
- `RECORD` reports each missing property separately instead of one
merged error.
- Values that are present but malformed are now rejected: non uuid
identifiers and links that are not urls. Standard application
identifiers are all v4 uuids and the create/update inputs already carry
`@IsUUID`, so this only tightens the app manifest path.
## Verification
- Unit tests for the util cover each type valid and invalid, blank
names, non url links and non uuid identifiers (23 tests pass alongside
the sibling suite)
- `nx typecheck twenty-server` clean
- oxlint (type-aware) and oxfmt clean on the changed files
---
...item-type-required-properties.util.spec.ts | 184 ++++++++++
...menu-item-type-required-properties.util.ts | 130 +++++++
...-navigation-menu-item-validator.service.ts | 143 +-------
...u-item-validation.integration-spec.ts.snap | 334 ++++++++++++++++++
...n-menu-item-validation.integration-spec.ts | 166 +++++++++
...enu-item-creation.integration-spec.ts.snap | 10 +-
...rcular-dependency.integration-spec.ts.snap | 6 +-
.../src/types/AssertUnreachable.type.ts | 3 +
packages/twenty-shared/src/types/index.ts | 1 +
9 files changed, 846 insertions(+), 131 deletions(-)
create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/validators/utils/__tests__/validate-navigation-menu-item-type-required-properties.util.spec.ts
create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/validators/utils/validate-navigation-menu-item-type-required-properties.util.ts
create mode 100644 packages/twenty-server/test/integration/metadata/suites/application/__snapshots__/failing-sync-application-navigation-menu-item-validation.integration-spec.ts.snap
create mode 100644 packages/twenty-server/test/integration/metadata/suites/application/failing-sync-application-navigation-menu-item-validation.integration-spec.ts
create mode 100644 packages/twenty-shared/src/types/AssertUnreachable.type.ts
diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/validators/utils/__tests__/validate-navigation-menu-item-type-required-properties.util.spec.ts b/packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/validators/utils/__tests__/validate-navigation-menu-item-type-required-properties.util.spec.ts
new file mode 100644
index 0000000000..b74fcab6d5
--- /dev/null
+++ b/packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/validators/utils/__tests__/validate-navigation-menu-item-type-required-properties.util.spec.ts
@@ -0,0 +1,184 @@
+import { NavigationMenuItemType } from 'twenty-shared/types';
+
+import { validateNavigationMenuItemTypeRequiredProperties } from 'src/engine/metadata-modules/flat-navigation-menu-item/validators/utils/validate-navigation-menu-item-type-required-properties.util';
+import { NavigationMenuItemExceptionCode } from 'src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.exception';
+import { type UniversalFlatNavigationMenuItem } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-navigation-menu-item.type';
+
+const VALID_UUID = '20202020-b001-4b01-8b01-c0aba11c0001';
+const OTHER_VALID_UUID = '20202020-b002-4b02-8b02-c0aba11c0002';
+
+const buildFlatNavigationMenuItem = (
+ overrides: Partial,
+): UniversalFlatNavigationMenuItem =>
+ ({
+ universalIdentifier: VALID_UUID,
+ name: null,
+ link: null,
+ icon: null,
+ color: null,
+ position: 0,
+ targetRecordId: null,
+ userWorkspaceId: null,
+ folderUniversalIdentifier: null,
+ viewUniversalIdentifier: null,
+ pageLayoutUniversalIdentifier: null,
+ targetObjectMetadataUniversalIdentifier: null,
+ ...overrides,
+ }) as UniversalFlatNavigationMenuItem;
+
+describe('validateNavigationMenuItemTypeRequiredProperties', () => {
+ it('should return an error when type is not defined', () => {
+ const errors = validateNavigationMenuItemTypeRequiredProperties({
+ flatNavigationMenuItem: buildFlatNavigationMenuItem({
+ type: undefined,
+ }),
+ });
+
+ expect(errors).toHaveLength(1);
+ expect(errors[0]).toMatchObject({
+ code: NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT,
+ message: 'Navigation menu item type is required',
+ });
+ });
+
+ it.each([
+ {
+ type: NavigationMenuItemType.FOLDER,
+ expectedMessages: ['A name is required for FOLDER type'],
+ },
+ {
+ type: NavigationMenuItemType.OBJECT,
+ expectedMessages: [
+ 'A valid targetObjectMetadataUniversalIdentifier is required for OBJECT type',
+ ],
+ },
+ {
+ type: NavigationMenuItemType.VIEW,
+ expectedMessages: [
+ 'A valid viewUniversalIdentifier is required for VIEW type',
+ ],
+ },
+ {
+ type: NavigationMenuItemType.RECORD,
+ expectedMessages: [
+ 'A valid targetRecordId is required for RECORD type',
+ 'A valid targetObjectMetadataUniversalIdentifier is required for RECORD type',
+ ],
+ },
+ {
+ type: NavigationMenuItemType.LINK,
+ expectedMessages: ['A valid link is required for LINK type'],
+ },
+ {
+ type: NavigationMenuItemType.PAGE_LAYOUT,
+ expectedMessages: [
+ 'A valid pageLayoutUniversalIdentifier is required for PAGE_LAYOUT type',
+ ],
+ },
+ ])(
+ 'should report every missing property for $type type',
+ ({ type, expectedMessages }) => {
+ const errors = validateNavigationMenuItemTypeRequiredProperties({
+ flatNavigationMenuItem: buildFlatNavigationMenuItem({ type }),
+ });
+
+ expect(errors.map(({ message }) => message)).toEqual(expectedMessages);
+ },
+ );
+
+ it.each([
+ {
+ type: NavigationMenuItemType.FOLDER,
+ overrides: { name: 'My folder' },
+ },
+ {
+ type: NavigationMenuItemType.OBJECT,
+ overrides: { targetObjectMetadataUniversalIdentifier: VALID_UUID },
+ },
+ {
+ type: NavigationMenuItemType.VIEW,
+ overrides: { viewUniversalIdentifier: VALID_UUID },
+ },
+ {
+ type: NavigationMenuItemType.RECORD,
+ overrides: {
+ targetRecordId: VALID_UUID,
+ targetObjectMetadataUniversalIdentifier: OTHER_VALID_UUID,
+ },
+ },
+ {
+ type: NavigationMenuItemType.LINK,
+ overrides: { link: 'https://twenty.com' },
+ },
+ {
+ type: NavigationMenuItemType.PAGE_LAYOUT,
+ overrides: { pageLayoutUniversalIdentifier: VALID_UUID },
+ },
+ ])(
+ 'should not report any error when $type type properties are valid',
+ ({ type, overrides }) => {
+ const errors = validateNavigationMenuItemTypeRequiredProperties({
+ flatNavigationMenuItem: buildFlatNavigationMenuItem({
+ type,
+ ...overrides,
+ }),
+ });
+
+ expect(errors).toEqual([]);
+ },
+ );
+
+ it('should return an error when type is unknown', () => {
+ const errors = validateNavigationMenuItemTypeRequiredProperties({
+ flatNavigationMenuItem: buildFlatNavigationMenuItem({
+ type: 'UNKNOWN_NAVIGATION_MENU_ITEM_TYPE' as NavigationMenuItemType,
+ }),
+ });
+
+ expect(errors).toHaveLength(1);
+ expect(errors[0]).toMatchObject({
+ code: NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT,
+ message:
+ 'Unknown navigation menu item type UNKNOWN_NAVIGATION_MENU_ITEM_TYPE',
+ });
+ });
+
+ it('should treat blank folder names as missing', () => {
+ const errors = validateNavigationMenuItemTypeRequiredProperties({
+ flatNavigationMenuItem: buildFlatNavigationMenuItem({
+ type: NavigationMenuItemType.FOLDER,
+ name: ' ',
+ }),
+ });
+
+ expect(errors.map(({ message }) => message)).toEqual([
+ 'A name is required for FOLDER type',
+ ]);
+ });
+
+ it('should report an error when the link is not a valid url', () => {
+ const errors = validateNavigationMenuItemTypeRequiredProperties({
+ flatNavigationMenuItem: buildFlatNavigationMenuItem({
+ type: NavigationMenuItemType.LINK,
+ link: 'not a link',
+ }),
+ });
+
+ expect(errors.map(({ message }) => message)).toEqual([
+ 'A valid link is required for LINK type',
+ ]);
+ });
+
+ it('should report an error when a universal identifier is not a valid uuid', () => {
+ const errors = validateNavigationMenuItemTypeRequiredProperties({
+ flatNavigationMenuItem: buildFlatNavigationMenuItem({
+ type: NavigationMenuItemType.VIEW,
+ viewUniversalIdentifier: 'not-a-uuid',
+ }),
+ });
+
+ expect(errors.map(({ message }) => message)).toEqual([
+ 'A valid viewUniversalIdentifier is required for VIEW type',
+ ]);
+ });
+});
diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/validators/utils/validate-navigation-menu-item-type-required-properties.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/validators/utils/validate-navigation-menu-item-type-required-properties.util.ts
new file mode 100644
index 0000000000..380a1fd226
--- /dev/null
+++ b/packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/validators/utils/validate-navigation-menu-item-type-required-properties.util.ts
@@ -0,0 +1,130 @@
+import { type MessageDescriptor } from '@lingui/core';
+import { msg, t } from '@lingui/core/macro';
+import {
+ type AssertUnreachable,
+ NavigationMenuItemType,
+} from 'twenty-shared/types';
+import { isDefined, isValidUrl, isValidUuid } from 'twenty-shared/utils';
+
+import { NavigationMenuItemExceptionCode } from 'src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.exception';
+import { type UniversalFlatNavigationMenuItem } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-navigation-menu-item.type';
+import { type FlatEntityValidationError } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/types/failed-flat-entity-validation.type';
+
+type NavigationMenuItemValidationError =
+ FlatEntityValidationError;
+
+const buildInvalidInputError = (
+ message: string,
+ userFriendlyMessage: MessageDescriptor,
+): NavigationMenuItemValidationError => ({
+ code: NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT,
+ message,
+ userFriendlyMessage,
+});
+
+const validateUuidProperty = ({
+ value,
+ message,
+ userFriendlyMessage,
+}: {
+ value: string | null | undefined;
+ message: string;
+ userFriendlyMessage: MessageDescriptor;
+}): NavigationMenuItemValidationError[] =>
+ isDefined(value) && isValidUuid(value)
+ ? []
+ : [buildInvalidInputError(message, userFriendlyMessage)];
+
+export const validateNavigationMenuItemTypeRequiredProperties = ({
+ flatNavigationMenuItem,
+}: {
+ flatNavigationMenuItem: UniversalFlatNavigationMenuItem;
+}): NavigationMenuItemValidationError[] => {
+ const {
+ type,
+ name,
+ link,
+ targetRecordId,
+ targetObjectMetadataUniversalIdentifier,
+ viewUniversalIdentifier,
+ pageLayoutUniversalIdentifier,
+ } = flatNavigationMenuItem;
+
+ if (!isDefined(type)) {
+ return [
+ buildInvalidInputError(
+ t`Navigation menu item type is required`,
+ msg`Navigation menu item type is required`,
+ ),
+ ];
+ }
+
+ switch (type) {
+ case NavigationMenuItemType.FOLDER: {
+ return isDefined(name) && name.trim() !== ''
+ ? []
+ : [
+ buildInvalidInputError(
+ t`A name is required for FOLDER type`,
+ msg`A name is required for FOLDER type`,
+ ),
+ ];
+ }
+ case NavigationMenuItemType.OBJECT: {
+ return validateUuidProperty({
+ value: targetObjectMetadataUniversalIdentifier,
+ message: t`A valid targetObjectMetadataUniversalIdentifier is required for OBJECT type`,
+ userFriendlyMessage: msg`A valid targetObjectMetadataUniversalIdentifier is required for OBJECT type`,
+ });
+ }
+ case NavigationMenuItemType.VIEW: {
+ return validateUuidProperty({
+ value: viewUniversalIdentifier,
+ message: t`A valid viewUniversalIdentifier is required for VIEW type`,
+ userFriendlyMessage: msg`A valid viewUniversalIdentifier is required for VIEW type`,
+ });
+ }
+ case NavigationMenuItemType.RECORD: {
+ return [
+ ...validateUuidProperty({
+ value: targetRecordId,
+ message: t`A valid targetRecordId is required for RECORD type`,
+ userFriendlyMessage: msg`A valid targetRecordId is required for RECORD type`,
+ }),
+ ...validateUuidProperty({
+ value: targetObjectMetadataUniversalIdentifier,
+ message: t`A valid targetObjectMetadataUniversalIdentifier is required for RECORD type`,
+ userFriendlyMessage: msg`A valid targetObjectMetadataUniversalIdentifier is required for RECORD type`,
+ }),
+ ];
+ }
+ case NavigationMenuItemType.LINK: {
+ return isDefined(link) && isValidUrl(link)
+ ? []
+ : [
+ buildInvalidInputError(
+ t`A valid link is required for LINK type`,
+ msg`A valid link is required for LINK type`,
+ ),
+ ];
+ }
+ case NavigationMenuItemType.PAGE_LAYOUT: {
+ return validateUuidProperty({
+ value: pageLayoutUniversalIdentifier,
+ message: t`A valid pageLayoutUniversalIdentifier is required for PAGE_LAYOUT type`,
+ userFriendlyMessage: msg`A valid pageLayoutUniversalIdentifier is required for PAGE_LAYOUT type`,
+ });
+ }
+ default: {
+ // oxlint-disable-next-line unused-imports/no-unused-vars
+ type UnhandledNavigationMenuItemType = AssertUnreachable;
+
+ return [
+ buildInvalidInputError(
+ t`Unknown navigation menu item type ${type}`,
+ msg`Unknown navigation menu item type ${type}`,
+ ),
+ ];
+ }
+ }
+};
diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-navigation-menu-item-validator.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-navigation-menu-item-validator.service.ts
index 5972799eae..2b720fc08f 100644
--- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-navigation-menu-item-validator.service.ts
+++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-navigation-menu-item-validator.service.ts
@@ -1,12 +1,11 @@
import { Injectable } from '@nestjs/common';
import { msg, t } from '@lingui/core/macro';
-import { isNonEmptyString } from '@sniptt/guards';
import { ALL_METADATA_NAME } from 'twenty-shared/metadata';
-import { NavigationMenuItemType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util';
+import { validateNavigationMenuItemTypeRequiredProperties } from 'src/engine/metadata-modules/flat-navigation-menu-item/validators/utils/validate-navigation-menu-item-type-required-properties.util';
import { NavigationMenuItemExceptionCode } from 'src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.exception';
import { type MetadataUniversalFlatEntityMaps } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/metadata-universal-flat-entity-maps.type';
import { validateFlatEntityCircularDependency } from 'src/engine/workspace-manager/workspace-migration/utils/validate-flat-entity-circular-dependency.util';
@@ -22,96 +21,6 @@ const NAVIGATION_MENU_ITEM_MAX_DEPTH = 2;
@Injectable()
export class FlatNavigationMenuItemValidatorService {
- private validateNavigationMenuItemType({
- type,
- hasTargetRecordId,
- hasTargetObjectMetadataId,
- hasViewId,
- hasLink,
- hasPageLayoutId,
- name,
- }: {
- type: NavigationMenuItemType | null | undefined;
- hasTargetRecordId: boolean;
- hasTargetObjectMetadataId: boolean;
- hasViewId: boolean;
- hasLink: boolean;
- hasPageLayoutId: boolean;
- name: string | null | undefined;
- }): FlatEntityValidationError[] {
- const errors: FlatEntityValidationError[] =
- [];
-
- if (!isDefined(type)) {
- errors.push({
- code: NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT,
- message: t`Navigation menu item type is required`,
- userFriendlyMessage: msg`Navigation menu item type is required`,
- });
-
- return errors;
- }
-
- switch (type) {
- case NavigationMenuItemType.FOLDER:
- if (!isDefined(name) || name.trim() === '') {
- errors.push({
- code: NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT,
- message: t`Folder name is required`,
- userFriendlyMessage: msg`Folder name is required`,
- });
- }
- break;
- case NavigationMenuItemType.OBJECT:
- if (!hasTargetObjectMetadataId) {
- errors.push({
- code: NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT,
- message: t`targetObjectMetadataId is required for OBJECT type`,
- userFriendlyMessage: msg`targetObjectMetadataId is required for OBJECT type`,
- });
- }
- break;
- case NavigationMenuItemType.VIEW:
- if (!hasViewId) {
- errors.push({
- code: NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT,
- message: t`viewId is required for VIEW type`,
- userFriendlyMessage: msg`viewId is required for VIEW type`,
- });
- }
- break;
- case NavigationMenuItemType.RECORD:
- if (!hasTargetRecordId || !hasTargetObjectMetadataId) {
- errors.push({
- code: NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT,
- message: t`targetRecordId and targetObjectMetadataId are required for RECORD type`,
- userFriendlyMessage: msg`targetRecordId and targetObjectMetadataId are required for RECORD type`,
- });
- }
- break;
- case NavigationMenuItemType.LINK:
- if (!hasLink) {
- errors.push({
- code: NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT,
- message: t`link is required for LINK type`,
- userFriendlyMessage: msg`link is required for LINK type`,
- });
- }
- break;
- case NavigationMenuItemType.PAGE_LAYOUT:
- if (!hasPageLayoutId) {
- errors.push({
- code: NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT,
- message: t`pageLayoutId is required for PAGE_LAYOUT type`,
- userFriendlyMessage: msg`pageLayoutId is required for PAGE_LAYOUT type`,
- });
- }
- break;
- }
-
- return errors;
- }
-
private getCircularDependencyValidationErrors({
navigationMenuItemUniversalIdentifier,
folderUniversalIdentifier,
@@ -173,6 +82,8 @@ export class FlatNavigationMenuItemValidatorService {
const validationResult = getEmptyFlatEntityValidationError({
flatEntityMinimalInformation: {
universalIdentifier: flatNavigationMenuItem.universalIdentifier,
+ name: flatNavigationMenuItem.name,
+ type: flatNavigationMenuItem.type,
},
metadataName: 'navigationMenuItem',
type: 'create',
@@ -189,23 +100,11 @@ export class FlatNavigationMenuItemValidatorService {
});
}
- const typeValidationErrors = this.validateNavigationMenuItemType({
- type: flatNavigationMenuItem.type,
- hasTargetRecordId: isDefined(flatNavigationMenuItem.targetRecordId),
- hasTargetObjectMetadataId: isDefined(
- flatNavigationMenuItem.targetObjectMetadataUniversalIdentifier,
- ),
- hasViewId: isDefined(flatNavigationMenuItem.viewUniversalIdentifier),
- hasLink:
- isDefined(flatNavigationMenuItem.link) &&
- isNonEmptyString(flatNavigationMenuItem.link),
- hasPageLayoutId: isDefined(
- flatNavigationMenuItem.pageLayoutUniversalIdentifier,
- ),
- name: flatNavigationMenuItem.name,
- });
-
- validationResult.errors.push(...typeValidationErrors);
+ validationResult.errors.push(
+ ...validateNavigationMenuItemTypeRequiredProperties({
+ flatNavigationMenuItem,
+ }),
+ );
if (isDefined(flatNavigationMenuItem.folderUniversalIdentifier)) {
const circularDependencyErrors =
@@ -257,6 +156,8 @@ export class FlatNavigationMenuItemValidatorService {
const validationResult = getEmptyFlatEntityValidationError({
flatEntityMinimalInformation: {
universalIdentifier: flatEntityToValidate.universalIdentifier,
+ name: flatEntityToValidate.name,
+ type: flatEntityToValidate.type,
},
metadataName: 'navigationMenuItem',
type: 'delete',
@@ -295,6 +196,8 @@ export class FlatNavigationMenuItemValidatorService {
const validationResult = getEmptyFlatEntityValidationError({
flatEntityMinimalInformation: {
universalIdentifier,
+ name: flatEntityUpdate.name ?? fromFlatNavigationMenuItem?.name,
+ type: flatEntityUpdate.type ?? fromFlatNavigationMenuItem?.type,
},
metadataName: 'navigationMenuItem',
type: 'update',
@@ -325,23 +228,11 @@ export class FlatNavigationMenuItemValidatorService {
...flatEntityUpdate,
};
- const nameUpdate = flatEntityUpdate.name;
-
- const typeValidationErrors = this.validateNavigationMenuItemType({
- type: toFlatNavigationMenuItem.type,
- hasTargetRecordId: isDefined(toFlatNavigationMenuItem.targetRecordId),
- hasTargetObjectMetadataId: isDefined(
- toFlatNavigationMenuItem.targetObjectMetadataUniversalIdentifier,
- ),
- hasViewId: isDefined(toFlatNavigationMenuItem.viewUniversalIdentifier),
- hasLink: isNonEmptyString((toFlatNavigationMenuItem.link ?? '').trim()),
- hasPageLayoutId: isDefined(
- toFlatNavigationMenuItem.pageLayoutUniversalIdentifier,
- ),
- name: isDefined(nameUpdate) ? nameUpdate : toFlatNavigationMenuItem.name,
- });
-
- validationResult.errors.push(...typeValidationErrors);
+ validationResult.errors.push(
+ ...validateNavigationMenuItemTypeRequiredProperties({
+ flatNavigationMenuItem: toFlatNavigationMenuItem,
+ }),
+ );
const folderUniversalIdentifierUpdate =
flatEntityUpdate.folderUniversalIdentifier;
diff --git a/packages/twenty-server/test/integration/metadata/suites/application/__snapshots__/failing-sync-application-navigation-menu-item-validation.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/application/__snapshots__/failing-sync-application-navigation-menu-item-validation.integration-spec.ts.snap
new file mode 100644
index 0000000000..6f577a6437
--- /dev/null
+++ b/packages/twenty-server/test/integration/metadata/suites/application/__snapshots__/failing-sync-application-navigation-menu-item-validation.integration-spec.ts.snap
@@ -0,0 +1,334 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Sync application should fail on invalid navigation menu items when syncing a FOLDER item with a blank name 1`] = `
+{
+ "extensions": {
+ "code": "METADATA_VALIDATION_FAILED",
+ "errors": {
+ "navigationMenuItem": [
+ {
+ "errors": [
+ {
+ "code": "INVALID_NAVIGATION_MENU_ITEM_INPUT",
+ "message": "A name is required for FOLDER type",
+ "userFriendlyMessage": "A name is required for FOLDER type",
+ },
+ ],
+ "flatEntityMinimalInformation": {
+ "name": " ",
+ "type": "FOLDER",
+ "universalIdentifier": Any,
+ },
+ "metadataName": "navigationMenuItem",
+ "status": "fail",
+ "type": "create",
+ },
+ ],
+ },
+ "message": "Validation failed for 1 navigationMenuItem",
+ "summary": {
+ "navigationMenuItem": 1,
+ "totalErrors": 1,
+ },
+ "userFriendlyMessage": "A name is required for FOLDER type",
+ },
+ "message": "Validation errors occurred while syncing application manifest metadata",
+ "name": "GraphQLError",
+}
+`;
+
+exports[`Sync application should fail on invalid navigation menu items when syncing a FOLDER item without name 1`] = `
+{
+ "extensions": {
+ "code": "METADATA_VALIDATION_FAILED",
+ "errors": {
+ "navigationMenuItem": [
+ {
+ "errors": [
+ {
+ "code": "INVALID_NAVIGATION_MENU_ITEM_INPUT",
+ "message": "A name is required for FOLDER type",
+ "userFriendlyMessage": "A name is required for FOLDER type",
+ },
+ ],
+ "flatEntityMinimalInformation": {
+ "name": null,
+ "type": "FOLDER",
+ "universalIdentifier": Any,
+ },
+ "metadataName": "navigationMenuItem",
+ "status": "fail",
+ "type": "create",
+ },
+ ],
+ },
+ "message": "Validation failed for 1 navigationMenuItem",
+ "summary": {
+ "navigationMenuItem": 1,
+ "totalErrors": 1,
+ },
+ "userFriendlyMessage": "A name is required for FOLDER type",
+ },
+ "message": "Validation errors occurred while syncing application manifest metadata",
+ "name": "GraphQLError",
+}
+`;
+
+exports[`Sync application should fail on invalid navigation menu items when syncing a LINK item with a link that is not a valid url 1`] = `
+{
+ "extensions": {
+ "code": "METADATA_VALIDATION_FAILED",
+ "errors": {
+ "navigationMenuItem": [
+ {
+ "errors": [
+ {
+ "code": "INVALID_NAVIGATION_MENU_ITEM_INPUT",
+ "message": "A valid link is required for LINK type",
+ "userFriendlyMessage": "A valid link is required for LINK type",
+ },
+ ],
+ "flatEntityMinimalInformation": {
+ "name": "Link with invalid url",
+ "type": "LINK",
+ "universalIdentifier": Any,
+ },
+ "metadataName": "navigationMenuItem",
+ "status": "fail",
+ "type": "create",
+ },
+ ],
+ },
+ "message": "Validation failed for 1 navigationMenuItem",
+ "summary": {
+ "navigationMenuItem": 1,
+ "totalErrors": 1,
+ },
+ "userFriendlyMessage": "A valid link is required for LINK type",
+ },
+ "message": "Validation errors occurred while syncing application manifest metadata",
+ "name": "GraphQLError",
+}
+`;
+
+exports[`Sync application should fail on invalid navigation menu items when syncing a LINK item without link 1`] = `
+{
+ "extensions": {
+ "code": "METADATA_VALIDATION_FAILED",
+ "errors": {
+ "navigationMenuItem": [
+ {
+ "errors": [
+ {
+ "code": "INVALID_NAVIGATION_MENU_ITEM_INPUT",
+ "message": "A valid link is required for LINK type",
+ "userFriendlyMessage": "A valid link is required for LINK type",
+ },
+ ],
+ "flatEntityMinimalInformation": {
+ "name": "Link without url",
+ "type": "LINK",
+ "universalIdentifier": Any,
+ },
+ "metadataName": "navigationMenuItem",
+ "status": "fail",
+ "type": "create",
+ },
+ ],
+ },
+ "message": "Validation failed for 1 navigationMenuItem",
+ "summary": {
+ "navigationMenuItem": 1,
+ "totalErrors": 1,
+ },
+ "userFriendlyMessage": "A valid link is required for LINK type",
+ },
+ "message": "Validation errors occurred while syncing application manifest metadata",
+ "name": "GraphQLError",
+}
+`;
+
+exports[`Sync application should fail on invalid navigation menu items when syncing a PAGE_LAYOUT item with a pageLayoutUniversalIdentifier that is not a uuid 1`] = `
+{
+ "extensions": {
+ "code": "METADATA_VALIDATION_FAILED",
+ "errors": {
+ "navigationMenuItem": [
+ {
+ "errors": [
+ {
+ "code": "INVALID_NAVIGATION_MENU_ITEM_INPUT",
+ "message": "A valid pageLayoutUniversalIdentifier is required for PAGE_LAYOUT type",
+ "userFriendlyMessage": "A valid pageLayoutUniversalIdentifier is required for PAGE_LAYOUT type",
+ },
+ ],
+ "flatEntityMinimalInformation": {
+ "name": "Page layout with malformed identifier",
+ "type": "PAGE_LAYOUT",
+ "universalIdentifier": Any,
+ },
+ "metadataName": "navigationMenuItem",
+ "status": "fail",
+ "type": "create",
+ },
+ ],
+ },
+ "message": "Validation failed for 1 navigationMenuItem",
+ "summary": {
+ "navigationMenuItem": 1,
+ "totalErrors": 1,
+ },
+ "userFriendlyMessage": "A valid pageLayoutUniversalIdentifier is required for PAGE_LAYOUT type",
+ },
+ "message": "Validation errors occurred while syncing application manifest metadata",
+ "name": "GraphQLError",
+}
+`;
+
+exports[`Sync application should fail on invalid navigation menu items when syncing a VIEW item with a viewUniversalIdentifier that is not a uuid 1`] = `
+{
+ "extensions": {
+ "code": "METADATA_VALIDATION_FAILED",
+ "errors": {
+ "navigationMenuItem": [
+ {
+ "errors": [
+ {
+ "code": "INVALID_NAVIGATION_MENU_ITEM_INPUT",
+ "message": "A valid viewUniversalIdentifier is required for VIEW type",
+ "userFriendlyMessage": "A valid viewUniversalIdentifier is required for VIEW type",
+ },
+ ],
+ "flatEntityMinimalInformation": {
+ "name": "View with malformed identifier",
+ "type": "VIEW",
+ "universalIdentifier": Any,
+ },
+ "metadataName": "navigationMenuItem",
+ "status": "fail",
+ "type": "create",
+ },
+ ],
+ },
+ "message": "Validation failed for 1 navigationMenuItem",
+ "summary": {
+ "navigationMenuItem": 1,
+ "totalErrors": 1,
+ },
+ "userFriendlyMessage": "A valid viewUniversalIdentifier is required for VIEW type",
+ },
+ "message": "Validation errors occurred while syncing application manifest metadata",
+ "name": "GraphQLError",
+}
+`;
+
+exports[`Sync application should fail on invalid navigation menu items when syncing an OBJECT item with a targetObjectUniversalIdentifier that is not a uuid 1`] = `
+{
+ "extensions": {
+ "code": "METADATA_VALIDATION_FAILED",
+ "errors": {
+ "navigationMenuItem": [
+ {
+ "errors": [
+ {
+ "code": "INVALID_NAVIGATION_MENU_ITEM_INPUT",
+ "message": "A valid targetObjectMetadataUniversalIdentifier is required for OBJECT type",
+ "userFriendlyMessage": "A valid targetObjectMetadataUniversalIdentifier is required for OBJECT type",
+ },
+ ],
+ "flatEntityMinimalInformation": {
+ "name": "Object with malformed target",
+ "type": "OBJECT",
+ "universalIdentifier": Any,
+ },
+ "metadataName": "navigationMenuItem",
+ "status": "fail",
+ "type": "create",
+ },
+ ],
+ },
+ "message": "Validation failed for 1 navigationMenuItem",
+ "summary": {
+ "navigationMenuItem": 1,
+ "totalErrors": 1,
+ },
+ "userFriendlyMessage": "A valid targetObjectMetadataUniversalIdentifier is required for OBJECT type",
+ },
+ "message": "Validation errors occurred while syncing application manifest metadata",
+ "name": "GraphQLError",
+}
+`;
+
+exports[`Sync application should fail on invalid navigation menu items when syncing an OBJECT item without targetObjectUniversalIdentifier 1`] = `
+{
+ "extensions": {
+ "code": "METADATA_VALIDATION_FAILED",
+ "errors": {
+ "navigationMenuItem": [
+ {
+ "errors": [
+ {
+ "code": "INVALID_NAVIGATION_MENU_ITEM_INPUT",
+ "message": "A valid targetObjectMetadataUniversalIdentifier is required for OBJECT type",
+ "userFriendlyMessage": "A valid targetObjectMetadataUniversalIdentifier is required for OBJECT type",
+ },
+ ],
+ "flatEntityMinimalInformation": {
+ "name": "Object without target",
+ "type": "OBJECT",
+ "universalIdentifier": Any,
+ },
+ "metadataName": "navigationMenuItem",
+ "status": "fail",
+ "type": "create",
+ },
+ ],
+ },
+ "message": "Validation failed for 1 navigationMenuItem",
+ "summary": {
+ "navigationMenuItem": 1,
+ "totalErrors": 1,
+ },
+ "userFriendlyMessage": "A valid targetObjectMetadataUniversalIdentifier is required for OBJECT type",
+ },
+ "message": "Validation errors occurred while syncing application manifest metadata",
+ "name": "GraphQLError",
+}
+`;
+
+exports[`Sync application should fail on invalid navigation menu items when syncing an item with an unknown type 1`] = `
+{
+ "extensions": {
+ "code": "METADATA_VALIDATION_FAILED",
+ "errors": {
+ "navigationMenuItem": [
+ {
+ "errors": [
+ {
+ "code": "INVALID_NAVIGATION_MENU_ITEM_INPUT",
+ "message": "Unknown navigation menu item type UNKNOWN_NAVIGATION_MENU_ITEM_TYPE",
+ "userFriendlyMessage": "Unknown navigation menu item type UNKNOWN_NAVIGATION_MENU_ITEM_TYPE",
+ },
+ ],
+ "flatEntityMinimalInformation": {
+ "name": "Item with unknown type",
+ "type": "UNKNOWN_NAVIGATION_MENU_ITEM_TYPE",
+ "universalIdentifier": Any,
+ },
+ "metadataName": "navigationMenuItem",
+ "status": "fail",
+ "type": "create",
+ },
+ ],
+ },
+ "message": "Validation failed for 1 navigationMenuItem",
+ "summary": {
+ "navigationMenuItem": 1,
+ "totalErrors": 1,
+ },
+ "userFriendlyMessage": "Unknown navigation menu item type UNKNOWN_NAVIGATION_MENU_ITEM_TYPE",
+ },
+ "message": "Validation errors occurred while syncing application manifest metadata",
+ "name": "GraphQLError",
+}
+`;
diff --git a/packages/twenty-server/test/integration/metadata/suites/application/failing-sync-application-navigation-menu-item-validation.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/application/failing-sync-application-navigation-menu-item-validation.integration-spec.ts
new file mode 100644
index 0000000000..91c3ac2394
--- /dev/null
+++ b/packages/twenty-server/test/integration/metadata/suites/application/failing-sync-application-navigation-menu-item-validation.integration-spec.ts
@@ -0,0 +1,166 @@
+import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
+import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util';
+import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util';
+import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util';
+import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util';
+import { type NavigationMenuItemManifest } from 'twenty-shared/application';
+import {
+ eachTestingContextFilter,
+ type EachTestingContext,
+} from 'twenty-shared/testing';
+import { NavigationMenuItemType } from 'twenty-shared/types';
+
+const TEST_APP_ID = 'c1b2c3d4-0001-4000-a000-000000000001';
+const TEST_ROLE_ID = 'c1b2c3d4-0002-4000-a000-000000000002';
+const TEST_NAVIGATION_MENU_ITEM_ID = 'c1b2c3d4-0003-4000-a000-000000000003';
+
+type TestContext = {
+ navigationMenuItem: NavigationMenuItemManifest;
+};
+
+const failingNavigationMenuItemSyncTestCases: EachTestingContext[] =
+ [
+ {
+ title: 'when syncing a FOLDER item without name',
+ context: {
+ navigationMenuItem: {
+ universalIdentifier: TEST_NAVIGATION_MENU_ITEM_ID,
+ type: NavigationMenuItemType.FOLDER,
+ position: 0,
+ icon: 'IconFolder',
+ },
+ },
+ },
+ {
+ title: 'when syncing a FOLDER item with a blank name',
+ context: {
+ navigationMenuItem: {
+ universalIdentifier: TEST_NAVIGATION_MENU_ITEM_ID,
+ type: NavigationMenuItemType.FOLDER,
+ position: 0,
+ name: ' ',
+ },
+ },
+ },
+ {
+ title: 'when syncing a LINK item without link',
+ context: {
+ navigationMenuItem: {
+ universalIdentifier: TEST_NAVIGATION_MENU_ITEM_ID,
+ type: NavigationMenuItemType.LINK,
+ position: 0,
+ name: 'Link without url',
+ },
+ },
+ },
+ {
+ title: 'when syncing a LINK item with a link that is not a valid url',
+ context: {
+ navigationMenuItem: {
+ universalIdentifier: TEST_NAVIGATION_MENU_ITEM_ID,
+ type: NavigationMenuItemType.LINK,
+ position: 0,
+ name: 'Link with invalid url',
+ link: 'not a link',
+ },
+ },
+ },
+ {
+ title:
+ 'when syncing an OBJECT item without targetObjectUniversalIdentifier',
+ context: {
+ navigationMenuItem: {
+ universalIdentifier: TEST_NAVIGATION_MENU_ITEM_ID,
+ type: NavigationMenuItemType.OBJECT,
+ position: 0,
+ name: 'Object without target',
+ },
+ },
+ },
+ {
+ title:
+ 'when syncing an OBJECT item with a targetObjectUniversalIdentifier that is not a uuid',
+ context: {
+ navigationMenuItem: {
+ universalIdentifier: TEST_NAVIGATION_MENU_ITEM_ID,
+ type: NavigationMenuItemType.OBJECT,
+ position: 0,
+ name: 'Object with malformed target',
+ targetObjectUniversalIdentifier: 'not-a-uuid',
+ },
+ },
+ },
+ {
+ title:
+ 'when syncing a VIEW item with a viewUniversalIdentifier that is not a uuid',
+ context: {
+ navigationMenuItem: {
+ universalIdentifier: TEST_NAVIGATION_MENU_ITEM_ID,
+ type: NavigationMenuItemType.VIEW,
+ position: 0,
+ name: 'View with malformed identifier',
+ viewUniversalIdentifier: 'not-a-uuid',
+ },
+ },
+ },
+ {
+ title:
+ 'when syncing a PAGE_LAYOUT item with a pageLayoutUniversalIdentifier that is not a uuid',
+ context: {
+ navigationMenuItem: {
+ universalIdentifier: TEST_NAVIGATION_MENU_ITEM_ID,
+ type: NavigationMenuItemType.PAGE_LAYOUT,
+ position: 0,
+ name: 'Page layout with malformed identifier',
+ pageLayoutUniversalIdentifier: 'not-a-uuid',
+ },
+ },
+ },
+ {
+ title: 'when syncing an item with an unknown type',
+ context: {
+ navigationMenuItem: {
+ universalIdentifier: TEST_NAVIGATION_MENU_ITEM_ID,
+ type: 'UNKNOWN_NAVIGATION_MENU_ITEM_TYPE' as NavigationMenuItemType,
+ position: 0,
+ name: 'Item with unknown type',
+ },
+ },
+ },
+ ];
+
+describe('Sync application should fail on invalid navigation menu items', () => {
+ beforeAll(async () => {
+ await setupApplicationForSync({
+ applicationUniversalIdentifier: TEST_APP_ID,
+ name: 'Test Invalid Navigation Menu Item App',
+ description: 'App for testing navigation menu item manifest validation',
+ sourcePath: 'test-invalid-navigation-menu-item',
+ });
+ }, 60000);
+
+ afterAll(async () => {
+ await cleanupApplicationAndAppRegistration({
+ applicationUniversalIdentifier: TEST_APP_ID,
+ });
+ });
+
+ it.each(eachTestingContextFilter(failingNavigationMenuItemSyncTestCases))(
+ '$title',
+ async ({ context }) => {
+ const { errors } = await syncApplication({
+ manifest: buildBaseManifest({
+ appId: TEST_APP_ID,
+ roleId: TEST_ROLE_ID,
+ overrides: {
+ navigationMenuItems: [context.navigationMenuItem],
+ },
+ }),
+ expectToFail: true,
+ });
+
+ expectOneNotInternalServerErrorSnapshot({ errors });
+ },
+ 60000,
+ );
+});
diff --git a/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/__snapshots__/failing-navigation-menu-item-creation.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/__snapshots__/failing-navigation-menu-item-creation.integration-spec.ts.snap
index 7cac66f006..cbe9f7791a 100644
--- a/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/__snapshots__/failing-navigation-menu-item-creation.integration-spec.ts.snap
+++ b/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/__snapshots__/failing-navigation-menu-item-creation.integration-spec.ts.snap
@@ -1,4 +1,4 @@
-// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
+// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`NavigationMenuItem creation should fail when creating with empty targetObjectMetadataId 1`] = `
{
@@ -100,11 +100,13 @@ exports[`NavigationMenuItem creation should fail when creating with missing targ
"errors": [
{
"code": "INVALID_NAVIGATION_MENU_ITEM_INPUT",
- "message": "targetRecordId and targetObjectMetadataId are required for RECORD type",
- "userFriendlyMessage": "targetRecordId and targetObjectMetadataId are required for RECORD type",
+ "message": "A valid targetObjectMetadataUniversalIdentifier is required for RECORD type",
+ "userFriendlyMessage": "A valid targetObjectMetadataUniversalIdentifier is required for RECORD type",
},
],
"flatEntityMinimalInformation": {
+ "name": null,
+ "type": "RECORD",
"universalIdentifier": Any,
},
"metadataName": "navigationMenuItem",
@@ -118,7 +120,7 @@ exports[`NavigationMenuItem creation should fail when creating with missing targ
"navigationMenuItem": 1,
"totalErrors": 1,
},
- "userFriendlyMessage": "targetRecordId and targetObjectMetadataId are required for RECORD type",
+ "userFriendlyMessage": "A valid targetObjectMetadataUniversalIdentifier is required for RECORD type",
},
"message": "Multiple validation errors occurred while creating navigation menu items",
"name": "GraphQLError",
diff --git a/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/__snapshots__/failing-navigation-menu-item-update-circular-dependency.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/__snapshots__/failing-navigation-menu-item-update-circular-dependency.integration-spec.ts.snap
index 3c58e5d86a..8529db571b 100644
--- a/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/__snapshots__/failing-navigation-menu-item-update-circular-dependency.integration-spec.ts.snap
+++ b/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/__snapshots__/failing-navigation-menu-item-update-circular-dependency.integration-spec.ts.snap
@@ -1,4 +1,4 @@
-// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
+// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Navigation Menu Item update should fail with circular dependency when folderId equals id (self-reference) 1`] = `
{
@@ -15,6 +15,8 @@ exports[`Navigation Menu Item update should fail with circular dependency when f
},
],
"flatEntityMinimalInformation": {
+ "name": "Standalone Folder",
+ "type": "FOLDER",
"universalIdentifier": Any,
},
"metadataName": "navigationMenuItem",
@@ -50,6 +52,8 @@ exports[`Navigation Menu Item update should fail with circular dependency when u
},
],
"flatEntityMinimalInformation": {
+ "name": "Parent Folder",
+ "type": "FOLDER",
"universalIdentifier": Any,
},
"metadataName": "navigationMenuItem",
diff --git a/packages/twenty-shared/src/types/AssertUnreachable.type.ts b/packages/twenty-shared/src/types/AssertUnreachable.type.ts
new file mode 100644
index 0000000000..f9237cf178
--- /dev/null
+++ b/packages/twenty-shared/src/types/AssertUnreachable.type.ts
@@ -0,0 +1,3 @@
+// Type level counterpart of assertUnreachable: fails to compile when a switch
+// statement does not handle every case, without any runtime behavior
+export type AssertUnreachable = T;
diff --git a/packages/twenty-shared/src/types/index.ts b/packages/twenty-shared/src/types/index.ts
index 07564353b4..02307eb900 100644
--- a/packages/twenty-shared/src/types/index.ts
+++ b/packages/twenty-shared/src/types/index.ts
@@ -15,6 +15,7 @@ export { AppBasePath } from './AppBasePath';
export { AppPath } from './AppPath';
export type { Arrayable } from './Arrayable';
export type { ArraySortDirection } from './ArraySortDirection';
+export type { AssertUnreachable } from './AssertUnreachable.type';
export { CalendarChannelContactAutoCreationPolicy } from './CalendarChannelContactAutoCreationPolicy';
export { CalendarChannelSyncStage } from './CalendarChannelSyncStage';
export { CalendarChannelSyncStatus } from './CalendarChannelSyncStatus';