Compare commits

..
Author SHA1 Message Date
Sonarly Claude Code e6956ead47 fix(docker): add LOGIC_FUNCTION_TYPE env var to worker in docker-compose
https://sonarly.com/issue/15848?type=bug

Workflow execution of logic functions fails because the worker process doesn't receive the LOGIC_FUNCTION_TYPE environment variable, causing it to default to DISABLED in production while the server process correctly receives LOCAL.

Fix: Added `LOGIC_FUNCTION_TYPE` and `CODE_INTERPRETER_TYPE` environment variable passthrough to both server and worker services in `docker-compose.yml`.

**Problem:** After commit f0c83434a7 changed the default `LOGIC_FUNCTION_TYPE` from `LOCAL` to `DISABLED` in production, self-hosted users who set `LOGIC_FUNCTION_TYPE=LOCAL` in their `.env` file couldn't get the value passed into their Docker containers because the variable wasn't listed in the `environment:` section of docker-compose.yml. This caused the worker process (which runs workflows) to use the `DisabledDriver`, while direct testing through the server process might work if the user manually added the env var only to the server config.

**Fix:** Added both `LOGIC_FUNCTION_TYPE: ${LOGIC_FUNCTION_TYPE}` and `CODE_INTERPRETER_TYPE: ${CODE_INTERPRETER_TYPE}` to both the server and worker `environment:` sections, following the same pattern as `STORAGE_TYPE` and other existing passthrough variables. This ensures both variables from `.env` are propagated to both containers.

**Note for Kubernetes/Terraform users:** The Terraform deployment files (`deployment-server.tf` and `deployment-worker.tf`) have the same gap — they don't include `LOGIC_FUNCTION_TYPE` or `CODE_INTERPRETER_TYPE` in their env blocks. K8s users would need to add these env vars to their deployment configurations manually.
2026-03-18 09:03:34 +00:00
60 changed files with 332 additions and 1883 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "create-twenty-app",
"version": "0.8.0-canary.0",
"version": "0.7.0",
"description": "Command-line interface to create Twenty application",
"main": "dist/cli.cjs",
"bin": "dist/cli.cjs",
@@ -20,6 +20,9 @@ services:
STORAGE_S3_NAME: ${STORAGE_S3_NAME}
STORAGE_S3_ENDPOINT: ${STORAGE_S3_ENDPOINT}
LOGIC_FUNCTION_TYPE: ${LOGIC_FUNCTION_TYPE}
CODE_INTERPRETER_TYPE: ${CODE_INTERPRETER_TYPE}
APP_SECRET: ${APP_SECRET:-replace_me_with_a_random_string}
# MESSAGING_PROVIDER_GMAIL_ENABLED: ${MESSAGING_PROVIDER_GMAIL_ENABLED}
# CALENDAR_PROVIDER_GOOGLE_ENABLED: ${CALENDAR_PROVIDER_GOOGLE_ENABLED}
@@ -74,6 +77,9 @@ services:
STORAGE_S3_NAME: ${STORAGE_S3_NAME}
STORAGE_S3_ENDPOINT: ${STORAGE_S3_ENDPOINT}
LOGIC_FUNCTION_TYPE: ${LOGIC_FUNCTION_TYPE}
CODE_INTERPRETER_TYPE: ${CODE_INTERPRETER_TYPE}
APP_SECRET: ${APP_SECRET:-replace_me_with_a_random_string}
# MESSAGING_PROVIDER_GMAIL_ENABLED: ${MESSAGING_PROVIDER_GMAIL_ENABLED}
# CALENDAR_PROVIDER_GOOGLE_ENABLED: ${CALENDAR_PROVIDER_GOOGLE_ENABLED}
@@ -38,7 +38,7 @@ export const CommandListItem = ({
id={action.key}
Icon={action.Icon}
label={getCommandMenuItemLabel(action.label)}
description={getCommandMenuItemLabel(action.description)}
description={getCommandMenuItemLabel(action.description ?? '')}
to={to}
onClick={disabled ? undefined : onClick}
hotKeys={action.hotKeys}
@@ -13,7 +13,7 @@ import { type MenuItemAccent } from 'twenty-ui/navigation';
export type CommandMenuItemDisplayProps = {
key: string;
label: Nullable<MessageDescriptor | string>;
label: MessageDescriptor | string;
shortLabel?: Nullable<MessageDescriptor | string>;
description?: MessageDescriptor | string;
Icon: IconComponent;
@@ -42,7 +42,9 @@ export const Default: Story = {
const canvas = within(canvasElement);
await userEvent.click(
await canvas.findByText(
getCommandMenuItemLabel(addToFavoritesCommandMenuItem?.shortLabel),
getCommandMenuItemLabel(
addToFavoritesCommandMenuItem?.shortLabel ?? '',
),
),
);
expect(addToFavoritesMock).toHaveBeenCalled();
@@ -57,7 +59,7 @@ export const WithLink: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const menuItem = await canvas.findByText(
getCommandMenuItemLabel(goToPeopleCommandMenuItem?.shortLabel),
getCommandMenuItemLabel(goToPeopleCommandMenuItem?.shortLabel ?? ''),
);
expect(menuItem).toBeVisible();
expect(canvas.getByRole('link')).toHaveAttribute('href', '/objects/people');
@@ -60,7 +60,9 @@ export const Default: Story = {
expect(
await canvas.findByText(
getCommandMenuItemLabel(addToFavoritesCommandMenuItem?.shortLabel),
getCommandMenuItemLabel(
addToFavoritesCommandMenuItem?.shortLabel ?? '',
),
),
).toBeVisible();
},
@@ -65,7 +65,9 @@ export const AsButton: Story = {
const canvas = within(canvasElement);
await userEvent.click(
await canvas.findByText(
getCommandMenuItemLabel(addToFavoritesCommandMenuItem?.shortLabel),
getCommandMenuItemLabel(
addToFavoritesCommandMenuItem?.shortLabel ?? '',
),
),
);
expect(addToFavoritesMock).toHaveBeenCalled();
@@ -101,7 +103,7 @@ export const AsListItem: Story = {
const canvas = within(canvasElement);
await userEvent.click(
await canvas.findByText(
getCommandMenuItemLabel(addToFavoritesCommandMenuItem?.label),
getCommandMenuItemLabel(addToFavoritesCommandMenuItem?.label ?? ''),
),
);
expect(addToFavoritesMock).toHaveBeenCalled();
@@ -137,7 +139,7 @@ export const AsDropdownItem: Story = {
const canvas = within(canvasElement);
await userEvent.click(
await canvas.findByText(
getCommandMenuItemLabel(addToFavoritesCommandMenuItem?.label),
getCommandMenuItemLabel(addToFavoritesCommandMenuItem?.label ?? ''),
),
);
expect(addToFavoritesMock).toHaveBeenCalled();
@@ -53,7 +53,7 @@ export const Default: Story = {
const canvas = within(canvasElement);
await userEvent.click(
await canvas.findByText(
getCommandMenuItemLabel(addToFavoritesCommandMenuItem?.label),
getCommandMenuItemLabel(addToFavoritesCommandMenuItem?.label ?? ''),
),
);
expect(addToFavoritesMock).toHaveBeenCalled();
@@ -68,7 +68,7 @@ export const WithLink: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const dropdownItem = await canvas.findByText(
getCommandMenuItemLabel(goToPeopleCommandMenuItem?.label),
getCommandMenuItemLabel(goToPeopleCommandMenuItem?.label ?? ''),
);
expect(dropdownItem).toBeVisible();
},
@@ -1,7 +1,7 @@
import { CommandListItem } from '@/command-menu-item/display/components/CommandListItem';
import { createMockCommandMenuItems } from '@/command-menu-item/mock/command-menu-items.mock';
import { NoSelectionRecordCommandKeys } from '@/command-menu-item/record/no-selection/types/NoSelectionRecordCommandKeys';
import { SingleRecordCommandKeys } from '@/command-menu-item/record/single-record/types/SingleRecordCommandKeys';
import { createMockCommandMenuItems } from '@/command-menu-item/mock/command-menu-items.mock';
import { getCommandMenuItemLabel } from '@/command-menu-item/utils/getCommandMenuItemLabel';
import { SelectableListComponentInstanceContext } from '@/ui/layout/selectable-list/states/contexts/SelectableListComponentInstanceContext';
import { type Meta, type StoryObj } from '@storybook/react-vite';
@@ -53,7 +53,7 @@ export const Default: Story = {
const canvas = within(canvasElement);
await userEvent.click(
await canvas.findByText(
getCommandMenuItemLabel(addToFavoritesCommandMenuItem?.label),
getCommandMenuItemLabel(addToFavoritesCommandMenuItem?.label ?? ''),
),
);
expect(addToFavoritesMock).toHaveBeenCalled();
@@ -68,7 +68,7 @@ export const WithLink: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const listItem = await canvas.findByText(
getCommandMenuItemLabel(goToPeopleCommandMenuItem?.label),
getCommandMenuItemLabel(goToPeopleCommandMenuItem?.label ?? ''),
);
expect(listItem).toBeVisible();
},
@@ -1,5 +1,5 @@
import { CommandConfigContext } from '@/command-menu-item/contexts/CommandConfigContext';
import { Command } from '@/command-menu-item/display/components/Command';
import { CommandConfigContext } from '@/command-menu-item/contexts/CommandConfigContext';
import { computeProgressText } from '@/command-menu-item/utils/computeProgressText';
import { getCommandMenuItemLabel } from '@/command-menu-item/utils/getCommandMenuItemLabel';
import { contextStoreAnyFieldFilterValueComponentState } from '@/context-store/states/contextStoreAnyFieldFilterValueComponentState';
@@ -78,7 +78,9 @@ export const DeleteMultipleRecordsCommand = () => {
const originalLabel = getCommandMenuItemLabel(actionConfig.label);
const originalShortLabel = getCommandMenuItemLabel(actionConfig.shortLabel);
const originalShortLabel = getCommandMenuItemLabel(
actionConfig.shortLabel ?? '',
);
const progressText = computeProgressText(progress);
@@ -85,7 +85,9 @@ export const DestroyMultipleRecordsCommand = () => {
const originalLabel = getCommandMenuItemLabel(actionConfig.label);
const originalShortLabel = getCommandMenuItemLabel(actionConfig.shortLabel);
const originalShortLabel = getCommandMenuItemLabel(
actionConfig.shortLabel ?? '',
);
const progressText = computeProgressText(progress);
@@ -49,7 +49,9 @@ export const ExportMultipleRecordsCommand = () => {
const originalLabel = getCommandMenuItemLabel(actionConfig.label);
const originalShortLabel = getCommandMenuItemLabel(actionConfig.shortLabel);
const originalShortLabel = getCommandMenuItemLabel(
actionConfig.shortLabel ?? '',
);
const progressText = computeProgressText(exportProgress);
@@ -16,7 +16,6 @@ import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomState
import { type CommandMenuContextApi } from 'twenty-shared/types';
import {
evaluateConditionalAvailabilityExpression,
interpolateCommandMenuItemLabel,
isDefined,
} from 'twenty-shared/utils';
import { type IconComponent, useIcons } from 'twenty-ui/display';
@@ -74,15 +73,7 @@ const buildCommandMenuItemFromFrontComponent = ({
mountContext,
commandMenuContextApi,
}: BuildCommandMenuItemFromFrontComponentParams) => {
const displayLabel = interpolateCommandMenuItemLabel({
label: item.label,
context: commandMenuContextApi,
});
const displayShortLabel = interpolateCommandMenuItemLabel({
label: item.shortLabel,
context: commandMenuContextApi,
});
const displayLabel = item.label;
const Icon = getIcon(item.icon, COMMAND_MENU_DEFAULT_ICON);
@@ -94,7 +85,7 @@ const buildCommandMenuItemFromFrontComponent = ({
} else {
openFrontComponentInSidePanel({
frontComponentId: item.frontComponentId,
pageTitle: displayLabel ?? '',
pageTitle: displayLabel,
pageIcon: Icon,
recordContext: isDefined(mountContext)
? {
@@ -111,7 +102,7 @@ const buildCommandMenuItemFromFrontComponent = ({
key: `command-menu-item-front-component-${item.id}`,
scope,
label: displayLabel,
shortLabel: displayShortLabel,
shortLabel: item.shortLabel,
position: item.position,
isPinned,
Icon,
@@ -169,14 +160,8 @@ const buildCommandItemFromEngineKey = ({
type,
key: `command-menu-item-engine-${item.id}`,
scope,
label: interpolateCommandMenuItemLabel({
label: item.label,
context: commandMenuContextApi,
}),
shortLabel: interpolateCommandMenuItemLabel({
label: item.shortLabel,
context: commandMenuContextApi,
}),
label: item.label,
shortLabel: item.shortLabel,
position: item.position,
isPinned,
Icon,
@@ -14,7 +14,7 @@ export type CommandMenuItemConfig = {
type: CommandMenuItemType;
scope: CommandMenuItemScope;
key: string;
label: Nullable<MessageDescriptor | string>;
label: MessageDescriptor | string;
shortLabel?: Nullable<MessageDescriptor | string>;
description?: MessageDescriptor | string;
position: number;
@@ -1,14 +1,8 @@
import { i18n, type MessageDescriptor } from '@lingui/core';
import { isString } from '@sniptt/guards';
import { type Nullable } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
export const getCommandMenuItemLabel = (
label: Nullable<string | MessageDescriptor>,
label: string | MessageDescriptor,
): string => {
if (!isDefined(label)) {
return '';
}
return isString(label) ? label : i18n._(label);
};
@@ -1,6 +1,6 @@
import { getCommandMenuItemLabel } from '@/command-menu-item/utils/getCommandMenuItemLabel';
import { styled } from '@linaria/react';
import { type MessageDescriptor } from '@lingui/core';
import { i18n, type MessageDescriptor } from '@lingui/core';
import { isString } from '@sniptt/guards';
import { type MouseEvent } from 'react';
import { type Nullable } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
@@ -20,7 +20,7 @@ const StyledWrapper = styled.div`
export type CommandMenuButtonProps = {
command: {
key: string;
label: Nullable<string | MessageDescriptor>;
label: string | MessageDescriptor;
shortLabel?: Nullable<string | MessageDescriptor>;
Icon: IconComponent;
isPrimaryCTA?: boolean;
@@ -30,16 +30,22 @@ export type CommandMenuButtonProps = {
disabled?: boolean;
};
const getCommandMenuButtonLabel = (
label: string | MessageDescriptor,
): string => {
return isString(label) ? label : i18n._(label);
};
export const CommandMenuButton = ({
command,
onClick,
to,
disabled = false,
}: CommandMenuButtonProps) => {
const resolvedLabel = getCommandMenuItemLabel(command.label);
const resolvedLabel = getCommandMenuButtonLabel(command.label);
const resolvedShortLabel = isDefined(command.shortLabel)
? getCommandMenuItemLabel(command.shortLabel)
? getCommandMenuButtonLabel(command.shortLabel)
: undefined;
const buttonAccent = command.isPrimaryCTA ? 'blue' : 'default';
@@ -1,6 +1,5 @@
import { FIND_MINIMAL_METADATA } from '@/metadata-store/graphql/queries/findMinimalMetadata';
import {
ALL_METADATA_ENTITY_KEYS,
metadataStoreState,
type MetadataEntityKey,
} from '@/metadata-store/states/metadataStoreState';
@@ -32,8 +31,6 @@ export const useLoadMinimalMetadata = () => {
const staleEntityKeys: MetadataEntityKey[] = [];
const entityKeysWithServerHash = new Set<MetadataEntityKey>();
if (isDefined(collectionHashes)) {
for (const { collectionName, hash } of collectionHashes) {
const entityKey = mapAllMetadataNameToEntityKey(collectionName);
@@ -42,8 +39,6 @@ export const useLoadMinimalMetadata = () => {
continue;
}
entityKeysWithServerHash.add(entityKey);
const entry = store.get(metadataStoreState.atomFamily(entityKey));
if (entry.currentCollectionHash !== hash) {
@@ -57,15 +52,6 @@ export const useLoadMinimalMetadata = () => {
}
}
for (const entityKey of ALL_METADATA_ENTITY_KEYS) {
if (
!entityKeysWithServerHash.has(entityKey) &&
!staleEntityKeys.includes(entityKey)
) {
staleEntityKeys.push(entityKey);
}
}
const objectsEntry = store.get(
metadataStoreState.atomFamily('objectMetadataItems'),
);
@@ -54,7 +54,6 @@ export const useSaveNavigationMenuItemsDraft = () => {
if (!isDefined(objectMetadataItem)) {
continue;
}
if (objectMetadataItem.color === draftItem.color) {
continue;
}
@@ -144,10 +144,9 @@ export const WorkflowEditActionEmailBase = ({
(account) => account.id === formData.connectedAccountId,
);
const missingDraftScopes =
action.type === 'DRAFT_EMAIL' && isDefined(selectedAccount)
? getMissingDraftEmailScopes(selectedAccount)
: [];
const missingDraftScopes = isDefined(selectedAccount)
? getMissingDraftEmailScopes(selectedAccount)
: [];
const missingScopes =
isDefined(selectedAccount) &&
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "twenty-sdk",
"version": "0.8.0-canary.0",
"version": "0.7.0",
"main": "dist/index.cjs",
"module": "dist/index.mjs",
"types": "dist/sdk/index.d.ts",
@@ -5,7 +5,6 @@ import { CronRegisterAllCommand } from 'src/database/commands/cron-register-all.
import { DataSeedWorkspaceCommand } from 'src/database/commands/data-seed-dev-workspace.command';
import { ListOrphanedWorkspaceEntitiesCommand } from 'src/database/commands/list-and-delete-orphaned-workspace-entities.command';
import { ConfirmationQuestion } from 'src/database/commands/questions/confirmation.question';
import { WorkspaceExportModule } from 'src/database/commands/workspace-export/workspace-export.module';
import { UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/upgrade-version-command.module';
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module';
@@ -40,7 +39,6 @@ import { AutomatedTriggerModule } from 'src/modules/workflow/workflow-trigger/au
imports: [
UpgradeVersionCommandModule,
TypeOrmModule.forFeature([WorkspaceEntity]),
WorkspaceExportModule,
// Cron command dependencies
MessagingImportManagerModule,
CalendarEventImportManagerModule,
@@ -1,135 +0,0 @@
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { DataSource, Repository } from 'typeorm';
import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
import { makeNavigationMenuItemTypeNotNullQueries } from 'src/database/typeorm/core/migrations/utils/1773681736596-makeNavigationMenuItemTypeNotNull.util';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
@Command({
name: 'upgrade:1-20:backfill-navigation-menu-item-type',
description:
'Backfill navigation menu item type based on existing columns, then apply NOT NULL and CHECK constraints',
})
export class BackfillNavigationMenuItemTypeCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
private hasRunOnce = false;
constructor(
@InjectRepository(WorkspaceEntity)
protected readonly workspaceRepository: Repository<WorkspaceEntity>,
protected readonly twentyORMGlobalManager: GlobalWorkspaceOrmManager,
protected readonly dataSourceService: DataSourceService,
@InjectDataSource()
private readonly coreDataSource: DataSource,
) {
super(workspaceRepository, twentyORMGlobalManager, dataSourceService);
}
override async runOnWorkspace({
options,
}: RunOnWorkspaceArgs): Promise<void> {
if (this.hasRunOnce) {
this.logger.warn(
'Skipping has already been run once BackfillNavigationMenuItemTypeCommand',
);
return;
}
if (options.dryRun) {
return;
}
const queryRunner = this.coreDataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
await this.backfillType(queryRunner);
await this.cleanConflictingColumns(queryRunner);
await queryRunner.commitTransaction();
} catch (error) {
await queryRunner.rollbackTransaction();
this.logger.error(
`Rolling back BackfillNavigationMenuItemTypeCommand data backfill: ${error.message}`,
);
await queryRunner.release();
return;
}
await queryRunner.startTransaction();
try {
await makeNavigationMenuItemTypeNotNullQueries(queryRunner);
await queryRunner.commitTransaction();
this.logger.log('Successfully run BackfillNavigationMenuItemTypeCommand');
this.hasRunOnce = true;
} catch (error) {
await queryRunner.rollbackTransaction();
this.logger.error(
`Rolling back BackfillNavigationMenuItemTypeCommand schema changes: ${error.message}`,
);
} finally {
await queryRunner.release();
}
}
private async backfillType(
queryRunner: ReturnType<DataSource['createQueryRunner']>,
): Promise<void> {
await queryRunner.query(
`UPDATE "core"."navigationMenuItem" SET "type" = 'OBJECT' WHERE "type" = 'VIEW' AND "targetObjectMetadataId" IS NOT NULL AND "targetRecordId" IS NULL`,
);
await queryRunner.query(
`UPDATE "core"."navigationMenuItem" SET "type" = 'RECORD' WHERE "type" IS NULL AND "targetRecordId" IS NOT NULL AND "targetObjectMetadataId" IS NOT NULL`,
);
await queryRunner.query(
`UPDATE "core"."navigationMenuItem" SET "type" = 'OBJECT' WHERE "type" IS NULL AND "targetObjectMetadataId" IS NOT NULL AND "targetRecordId" IS NULL`,
);
await queryRunner.query(
`UPDATE "core"."navigationMenuItem" SET "type" = 'VIEW' WHERE "type" IS NULL AND "viewId" IS NOT NULL`,
);
await queryRunner.query(
`UPDATE "core"."navigationMenuItem" SET "type" = 'LINK' WHERE "type" IS NULL AND "link" IS NOT NULL`,
);
await queryRunner.query(
`UPDATE "core"."navigationMenuItem" SET "type" = 'FOLDER' WHERE "type" IS NULL`,
);
}
private async cleanConflictingColumns(
queryRunner: ReturnType<DataSource['createQueryRunner']>,
): Promise<void> {
await queryRunner.query(
`UPDATE "core"."navigationMenuItem" SET "targetRecordId" = NULL, "targetObjectMetadataId" = NULL, "link" = NULL WHERE "type" = 'VIEW'`,
);
await queryRunner.query(
`UPDATE "core"."navigationMenuItem" SET "viewId" = NULL, "link" = NULL WHERE "type" = 'RECORD'`,
);
await queryRunner.query(
`UPDATE "core"."navigationMenuItem" SET "viewId" = NULL, "targetRecordId" = NULL, "link" = NULL WHERE "type" = 'OBJECT'`,
);
await queryRunner.query(
`UPDATE "core"."navigationMenuItem" SET "viewId" = NULL, "targetRecordId" = NULL, "targetObjectMetadataId" = NULL WHERE "type" = 'LINK'`,
);
await queryRunner.query(
`UPDATE "core"."navigationMenuItem" SET "viewId" = NULL, "targetRecordId" = NULL, "targetObjectMetadataId" = NULL, "link" = NULL WHERE "type" = 'FOLDER'`,
);
}
}
@@ -2,7 +2,6 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BackfillCommandMenuItemsCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-backfill-command-menu-items.command';
import { BackfillNavigationMenuItemTypeCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-backfill-navigation-menu-item-type.command';
import { BackfillPageLayoutsCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-backfill-page-layouts.command';
import { SeedCliApplicationRegistrationCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-seed-cli-application-registration.command';
import { ApplicationRegistrationModule } from 'src/engine/core-modules/application/application-registration/application-registration.module';
@@ -32,14 +31,12 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
],
providers: [
BackfillCommandMenuItemsCommand,
BackfillNavigationMenuItemTypeCommand,
BackfillPageLayoutsCommand,
SeedCliApplicationRegistrationCommand,
MigrateRichTextToTextCommand,
],
exports: [
BackfillCommandMenuItemsCommand,
BackfillNavigationMenuItemTypeCommand,
BackfillPageLayoutsCommand,
SeedCliApplicationRegistrationCommand,
MigrateRichTextToTextCommand,
@@ -29,10 +29,9 @@ import { MigrateWorkflowSendEmailAttachmentsCommand } from 'src/database/command
import { MigrateWorkspacePicturesCommand } from 'src/database/commands/upgrade-version-command/1-18/1-18-migrate-workspace-pictures.command';
import { AddMissingSystemFieldsToStandardObjectsCommand } from 'src/database/commands/upgrade-version-command/1-19/1-19-add-missing-system-fields-to-standard-objects.command';
import { BackfillMessageChannelMessageAssociationMessageFolderCommand } from 'src/database/commands/upgrade-version-command/1-19/1-19-backfill-message-channel-message-association-message-folder.command';
import { BackfillMissingStandardViewsCommand } from 'src/database/commands/upgrade-version-command/1-19/1-19-backfill-missing-standard-views.command';
import { BackfillNavigationMenuItemTypeCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-backfill-navigation-menu-item-type.command';
import { BackfillSystemFieldsIsSystemCommand } from 'src/database/commands/upgrade-version-command/1-19/1-19-backfill-system-fields-is-system.command';
import { FixInvalidStandardUniversalIdentifiersCommand } from 'src/database/commands/upgrade-version-command/1-19/1-19-fix-invalid-standard-universal-identifiers.command';
import { BackfillMissingStandardViewsCommand } from 'src/database/commands/upgrade-version-command/1-19/1-19-backfill-missing-standard-views.command';
import { SeedServerIdCommand } from 'src/database/commands/upgrade-version-command/1-19/1-19-seed-server-id.command';
import { BackfillCommandMenuItemsCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-backfill-command-menu-items.command';
import { BackfillPageLayoutsCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-backfill-page-layouts.command';
@@ -88,7 +87,6 @@ export class UpgradeCommand extends UpgradeCommandRunner {
protected readonly seedServerIdCommand: SeedServerIdCommand,
// 1.20 Commands
protected readonly backfillNavigationMenuItemTypeCommand: BackfillNavigationMenuItemTypeCommand,
protected readonly backfillCommandMenuItemsCommand: BackfillCommandMenuItemsCommand,
protected readonly backfillPageLayoutsCommand: BackfillPageLayoutsCommand,
protected readonly seedCliApplicationRegistrationCommand: SeedCliApplicationRegistrationCommand,
@@ -139,7 +137,6 @@ export class UpgradeCommand extends UpgradeCommandRunner {
];
const commands_1200: VersionCommands = [
this.backfillNavigationMenuItemTypeCommand,
this.migrateRichTextToTextCommand,
this.backfillCommandMenuItemsCommand,
this.backfillPageLayoutsCommand,
@@ -1,84 +0,0 @@
import { formatSqlValue } from 'src/database/commands/workspace-export/utils/format-sql-value.util';
describe('formatSqlValue', () => {
it('should return NULL for null and undefined', () => {
expect(formatSqlValue(null)).toBe('NULL');
expect(formatSqlValue(undefined)).toBe('NULL');
});
it('should return unquoted TRUE/FALSE for booleans', () => {
expect(formatSqlValue(true)).toBe('TRUE');
expect(formatSqlValue(false)).toBe('FALSE');
});
it('should return unquoted numbers', () => {
expect(formatSqlValue(42)).toBe('42');
expect(formatSqlValue(3.14)).toBe('3.14');
expect(formatSqlValue(-1)).toBe('-1');
expect(formatSqlValue(0)).toBe('0');
});
it('should return NULL for NaN and Infinity', () => {
expect(formatSqlValue(NaN)).toBe('NULL');
expect(formatSqlValue(Infinity)).toBe('NULL');
expect(formatSqlValue(-Infinity)).toBe('NULL');
});
it('should return unquoted bigint', () => {
expect(formatSqlValue(BigInt(9007199254740991))).toBe('9007199254740991');
});
it('should escape single quotes in strings', () => {
expect(formatSqlValue("it's")).toBe("'it''s'");
});
it('should handle backslashes with E-string prefix', () => {
expect(formatSqlValue('path\\to\\file')).toBe("E'path\\\\to\\\\file'");
});
it('should format dates as escaped ISO strings', () => {
const date = new Date('2024-01-15T10:30:00.000Z');
expect(formatSqlValue(date)).toBe("'2024-01-15T10:30:00.000Z'");
});
it('should JSON-serialize objects when isJsonColumn is true', () => {
const value = { key: 'value' };
expect(formatSqlValue(value, true)).toBe('\'{"key":"value"}\'');
});
it('should JSON-serialize plain objects even when isJsonColumn is false', () => {
const value = { key: 'value' };
expect(formatSqlValue(value, false)).toBe('\'{"key":"value"}\'');
});
it('should return empty PostgreSQL array literal for empty arrays', () => {
expect(formatSqlValue([])).toBe("'{}'");
});
it('should format string arrays as PostgreSQL array literals', () => {
expect(formatSqlValue(['a', 'b', 'c'])).toBe('\'{"a","b","c"}\'');
});
it('should escape single quotes in array elements', () => {
expect(formatSqlValue(["O'Reilly"])).toBe("'{\"O''Reilly\"}'");
});
it('should format arrays with null elements as PostgreSQL array literals', () => {
expect(formatSqlValue([null, 'foo', 'bar'])).toBe('\'{NULL,"foo","bar"}\'');
});
it('should JSON-serialize arrays of objects', () => {
const value = [{ id: 1 }, { id: 2 }];
expect(formatSqlValue(value)).toBe('\'[{"id":1},{"id":2}]\'');
});
it('should throw on strings containing null bytes', () => {
expect(() => formatSqlValue('hello\0world')).toThrow(
'Null bytes are not allowed',
);
});
});
@@ -1,11 +0,0 @@
import { escapeIdentifier } from 'src/engine/workspace-manager/workspace-migration/utils/remove-sql-injection.util';
export const buildInsertPrefix = (
schemaName: string,
tableName: string,
columnNames: string[],
): string => {
const escapedColumnNames = columnNames.map(escapeIdentifier).join(', ');
return `INSERT INTO ${escapeIdentifier(schemaName)}.${escapeIdentifier(tableName)} (${escapedColumnNames}) VALUES `;
};
@@ -1,45 +0,0 @@
import { type FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { generateColumnDefinitions } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/generate-column-definitions.util';
const JSON_COLUMN_TYPES = new Set(['json', 'jsonb']);
type WorkspaceTableColumnSets = {
jsonColumns: Set<string>;
generatedColumns: Set<string>;
};
export const buildWorkspaceTableColumnSets = (
workspaceId: string,
objectMetadata: ObjectMetadataEntity,
fieldMetadatas: FieldMetadataEntity[],
): WorkspaceTableColumnSets => {
const jsonColumns = new Set<string>();
const generatedColumns = new Set<string>();
const flatObjectMetadata = objectMetadata as unknown as FlatObjectMetadata;
for (const fieldMetadata of fieldMetadatas) {
const flatFieldMetadata = fieldMetadata as unknown as FlatFieldMetadata;
const columnDefinitions = generateColumnDefinitions({
flatFieldMetadata,
flatObjectMetadata,
workspaceId,
});
for (const columnDefinition of columnDefinitions) {
if (JSON_COLUMN_TYPES.has(columnDefinition.type)) {
jsonColumns.add(columnDefinition.name);
}
if (columnDefinition.type === 'tsvector') {
generatedColumns.add(columnDefinition.name);
}
}
}
return { jsonColumns, generatedColumns };
};
@@ -1,55 +0,0 @@
import { isDefined } from 'twenty-shared/utils';
import { escapeLiteral } from 'src/engine/workspace-manager/workspace-migration/utils/remove-sql-injection.util';
export const formatSqlValue = (
value: unknown,
isJsonColumn = false,
): string => {
if (!isDefined(value)) return 'NULL';
if (isJsonColumn) {
return escapeLiteral(JSON.stringify(value));
}
if (typeof value === 'boolean') return value ? 'TRUE' : 'FALSE';
if (typeof value === 'number') {
if (!Number.isFinite(value)) return 'NULL';
return String(value);
}
if (typeof value === 'bigint') return String(value);
if (value instanceof Date) return escapeLiteral(value.toISOString());
if (Array.isArray(value)) {
if (value.length === 0) return "'{}'";
if (isDefined(value[0]) && typeof value[0] === 'object') {
return escapeLiteral(JSON.stringify(value));
}
const formattedElements = value.map((element) => {
if (!isDefined(element)) return 'NULL';
const stringElement = String(element);
const escapedElement = stringElement
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"');
return `"${escapedElement}"`;
});
const arrayLiteral = `{${formattedElements.join(',')}}`;
return `'${arrayLiteral.replace(/'/g, "''")}'`;
}
if (typeof value === 'object') {
return escapeLiteral(JSON.stringify(value));
}
return escapeLiteral(String(value));
};
@@ -1,4 +0,0 @@
export const generateInsertStatement = (
insertPrefix: string,
formattedValues: string[],
): string => `${insertPrefix}(${formattedValues.join(', ')});\n`;
@@ -1,75 +0,0 @@
import { type FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { buildSqlColumnDefinition } from 'src/engine/twenty-orm/workspace-schema-manager/utils/build-sql-column-definition.util';
import { computeTableName } from 'src/engine/utils/compute-table-name.util';
import {
escapeIdentifier,
escapeLiteral,
} from 'src/engine/workspace-manager/workspace-migration/utils/remove-sql-injection.util';
import { generateColumnDefinitions } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/generate-column-definitions.util';
import {
type CreateEnumOperationSpec,
EnumOperation,
collectEnumOperationsForObject,
} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/workspace-schema-enum-operations.util';
export const generateWorkspaceSchemaDdl = (
workspaceId: string,
schemaName: string,
objectMetadatas: ObjectMetadataEntity[],
fieldsByObjectId: Map<string, FieldMetadataEntity[]>,
): string[] => {
const statements: string[] = [];
for (const objectMetadata of objectMetadatas) {
if (!objectMetadata.isActive) continue;
const tableName = computeTableName(
objectMetadata.nameSingular,
objectMetadata.isCustom,
);
const fieldMetadatas = fieldsByObjectId.get(objectMetadata.id) ?? [];
const flatFieldMetadatas = fieldMetadatas as unknown as FlatFieldMetadata[];
const flatObjectMetadata = objectMetadata as unknown as FlatObjectMetadata;
const enumOperations = collectEnumOperationsForObject({
tableName,
operation: EnumOperation.CREATE,
flatFieldMetadatas,
});
for (const enumOperation of enumOperations) {
const createOp = enumOperation as CreateEnumOperationSpec;
const escapedValues = createOp.values.map(escapeLiteral).join(', ');
statements.push(
`CREATE TYPE ${escapeIdentifier(schemaName)}.${escapeIdentifier(createOp.enumName)} AS ENUM (${escapedValues});`,
);
}
const columnDefinitions = flatFieldMetadatas.flatMap((flatFieldMetadata) =>
generateColumnDefinitions({
flatFieldMetadata,
flatObjectMetadata,
workspaceId,
}),
);
if (columnDefinitions.length === 0) continue;
const columnsSql = columnDefinitions
.map(
(columnDefinition) => ` ${buildSqlColumnDefinition(columnDefinition)}`,
)
.join(',\n');
statements.push(
`CREATE TABLE ${escapeIdentifier(schemaName)}.${escapeIdentifier(tableName)} (\n${columnsSql}\n);`,
);
}
return statements;
};
@@ -1,11 +0,0 @@
import { DataSource } from 'typeorm';
export const getCoreEntityMetadatasWithWorkspaceId = (
dataSource: DataSource,
) => {
return dataSource.entityMetadatas.filter((entityMetadata) =>
entityMetadata.columns.some(
(column) => column.propertyName === 'workspaceId',
),
);
};
@@ -1,70 +0,0 @@
import { Logger } from '@nestjs/common';
import { Command, CommandRunner, Option } from 'nest-commander';
import { WorkspaceExportService } from 'src/database/commands/workspace-export/workspace-export.service';
type WorkspaceExportCommandOptions = {
workspaceId: string;
outputPath: string;
tables?: string;
};
@Command({
name: 'workspace:export',
description: 'Export a workspace as SQL INSERT statements',
})
export class WorkspaceExportCommand extends CommandRunner {
private readonly logger = new Logger(WorkspaceExportCommand.name);
constructor(private readonly workspaceExportService: WorkspaceExportService) {
super();
}
@Option({
flags: '--workspace-id <workspaceId>',
description: 'Workspace UUID to export',
required: true,
})
parseWorkspaceId(value: string): string {
return value;
}
@Option({
flags: '--output-path <outputPath>',
description: 'Directory to write the .sql file',
defaultValue: '/tmp/exports',
})
parseOutputPath(value: string): string {
return value;
}
@Option({
flags: '--tables <tables>',
description:
'Comma-separated workspace table names to export (uses nameSingular from ObjectMetadata)',
})
parseTables(value: string): string {
return value;
}
async run(
_passedParams: string[],
options: WorkspaceExportCommandOptions,
): Promise<void> {
const tableFilter = options.tables?.split(',').map((table) => table.trim());
try {
const filePath = await this.workspaceExportService.exportWorkspace({
workspaceId: options.workspaceId,
outputPath: options.outputPath,
tableFilter,
});
this.logger.log(`Export complete: ${filePath}`);
} catch (error) {
this.logger.error('Export failed', error);
throw error;
}
}
}
@@ -1,15 +0,0 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { WorkspaceExportCommand } from 'src/database/commands/workspace-export/workspace-export.command';
import { WorkspaceExportService } from 'src/database/commands/workspace-export/workspace-export.service';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
@Module({
imports: [
TypeOrmModule.forFeature([ObjectMetadataEntity, FieldMetadataEntity]),
],
providers: [WorkspaceExportCommand, WorkspaceExportService],
})
export class WorkspaceExportModule {}
@@ -1,325 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { once } from 'events';
import { type WriteStream, createWriteStream, mkdirSync } from 'fs';
import { finished } from 'stream/promises';
import {
DataSource,
type EntityMetadata,
type QueryRunner,
Repository,
} from 'typeorm';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { getWorkspaceSchemaName } from 'src/engine/workspace-datasource/utils/get-workspace-schema-name.util';
import { computeTableName } from 'src/engine/utils/compute-table-name.util';
import { escapeIdentifier } from 'src/engine/workspace-manager/workspace-migration/utils/remove-sql-injection.util';
import { getCoreEntityMetadatasWithWorkspaceId } from 'src/database/commands/workspace-export/utils/get-core-entity-metadatas-with-workspace-id.util';
import { generateWorkspaceSchemaDdl } from 'src/database/commands/workspace-export/utils/generate-workspace-schema-ddl.util';
import { buildInsertPrefix } from 'src/database/commands/workspace-export/utils/build-insert-prefix.util';
import { buildWorkspaceTableColumnSets } from 'src/database/commands/workspace-export/utils/build-workspace-table-column-sets.util';
import { formatSqlValue } from 'src/database/commands/workspace-export/utils/format-sql-value.util';
import { generateInsertStatement } from 'src/database/commands/workspace-export/utils/generate-insert-statement.util';
const BATCH_SIZE = 5000;
type WorkspaceExportParams = {
workspaceId: string;
outputPath: string;
tableFilter?: string[];
};
type RowFilter = {
filterColumn: string;
filterValue: string;
};
type WriteRowsOptions = {
schemaName: string;
tableName: string;
displayName: string;
queryRunner: QueryRunner;
stream: WriteStream;
rowFilter?: RowFilter;
jsonColumns?: Set<string>;
excludedColumns?: Set<string>;
};
@Injectable()
export class WorkspaceExportService {
private readonly logger = new Logger(WorkspaceExportService.name);
constructor(
@InjectDataSource()
private readonly dataSource: DataSource,
@InjectRepository(ObjectMetadataEntity)
private readonly objectMetadataRepository: Repository<ObjectMetadataEntity>,
@InjectRepository(FieldMetadataEntity)
private readonly fieldMetadataRepository: Repository<FieldMetadataEntity>,
) {}
async exportWorkspace({
workspaceId,
outputPath,
tableFilter,
}: WorkspaceExportParams): Promise<string> {
const workspace = await this.dataSource
.getRepository(WorkspaceEntity)
.findOne({ where: { id: workspaceId } });
if (!workspace) {
throw new Error(`Workspace ${workspaceId} not found`);
}
const schemaName = getWorkspaceSchemaName(workspaceId);
this.logger.log(`Exporting workspace ${workspaceId} (${schemaName})`);
const objectMetadatas = await this.objectMetadataRepository.find({
where: { workspaceId },
});
const fieldMetadatas = await this.fieldMetadataRepository.find({
where: { workspaceId },
});
const fieldsByObjectId = new Map<string, FieldMetadataEntity[]>();
for (const fieldMetadata of fieldMetadatas) {
const objectFields =
fieldsByObjectId.get(fieldMetadata.objectMetadataId) ?? [];
objectFields.push(fieldMetadata);
fieldsByObjectId.set(fieldMetadata.objectMetadataId, objectFields);
}
mkdirSync(outputPath, { recursive: true });
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const filePath = `${outputPath}/${workspaceId}-${timestamp}.sql`;
const stream = createWriteStream(filePath);
const queryRunner = this.dataSource.createQueryRunner();
try {
stream.write("SET session_replication_role = 'replica';\n\n");
await this.writeCoreEntityRows(workspaceId, queryRunner, stream);
stream.write(
`\nCREATE SCHEMA IF NOT EXISTS ${escapeIdentifier(schemaName)};\n\n`,
);
this.writeWorkspaceSchemaDdl(
workspaceId,
schemaName,
objectMetadatas,
fieldsByObjectId,
stream,
);
await this.writeWorkspaceDataRows(
workspaceId,
schemaName,
objectMetadatas,
fieldsByObjectId,
tableFilter,
queryRunner,
stream,
);
stream.write("\nSET session_replication_role = 'origin';\n");
} finally {
await queryRunner.release();
stream.end();
await finished(stream);
}
return filePath;
}
private async writeCoreEntityRows(
workspaceId: string,
queryRunner: QueryRunner,
stream: WriteStream,
): Promise<void> {
const workspaceEntityMetadata = this.dataSource.entityMetadatas.find(
(entityMetadata) => entityMetadata.tableName === 'workspace',
);
if (workspaceEntityMetadata) {
await this.writeRows({
schemaName: workspaceEntityMetadata.schema || 'core',
tableName: workspaceEntityMetadata.tableName,
displayName: workspaceEntityMetadata.tableName,
queryRunner,
stream,
rowFilter: { filterColumn: 'id', filterValue: workspaceId },
jsonColumns: this.buildJsonColumnSet(workspaceEntityMetadata),
});
}
const coreEntityMetadatas = getCoreEntityMetadatasWithWorkspaceId(
this.dataSource,
);
for (const entityMetadata of coreEntityMetadatas) {
try {
await this.writeRows({
schemaName: entityMetadata.schema || 'core',
tableName: entityMetadata.tableName,
displayName: entityMetadata.tableName,
queryRunner,
stream,
rowFilter: {
filterColumn: 'workspaceId',
filterValue: workspaceId,
},
jsonColumns: this.buildJsonColumnSet(entityMetadata),
});
} catch (error) {
this.logger.warn(`${entityMetadata.tableName}: skipped`, error);
}
}
}
private buildJsonColumnSet(entityMetadata: EntityMetadata): Set<string> {
return new Set(
entityMetadata.columns
.filter((column) => column.type === 'jsonb' || column.type === 'json')
.map((column) => column.databaseName),
);
}
private async writeRows({
schemaName,
tableName,
displayName,
queryRunner,
stream,
rowFilter,
jsonColumns,
excludedColumns,
}: WriteRowsOptions): Promise<void> {
const whereClause = rowFilter
? ` WHERE "${rowFilter.filterColumn}" = $1`
: '';
const queryParameters = rowFilter ? [rowFilter.filterValue] : [];
const [{ count: totalCount }] = await queryRunner.query(
`SELECT COUNT(*)::int as count FROM "${schemaName}"."${tableName}"${whereClause}`,
queryParameters,
);
if (totalCount === 0) return;
this.logger.log(` ${displayName}: ${totalCount} rows`);
let insertPrefix: string | undefined;
for (let offset = 0; offset < totalCount; offset += BATCH_SIZE) {
const rows: Record<string, unknown>[] = await queryRunner.query(
`SELECT * FROM "${schemaName}"."${tableName}"${whereClause} ORDER BY "id" LIMIT ${BATCH_SIZE} OFFSET ${offset}`,
queryParameters,
);
const batchStatements: string[] = [];
for (const row of rows) {
const columnNames = Object.keys(row).filter(
(columnName) => !excludedColumns?.has(columnName),
);
if (!insertPrefix) {
insertPrefix = buildInsertPrefix(schemaName, tableName, columnNames);
}
const formattedValues = columnNames.map((columnName) =>
formatSqlValue(row[columnName], jsonColumns?.has(columnName)),
);
batchStatements.push(
generateInsertStatement(insertPrefix, formattedValues),
);
}
if (!stream.write(batchStatements.join(''))) {
await once(stream, 'drain');
}
}
}
private writeWorkspaceSchemaDdl(
workspaceId: string,
schemaName: string,
objectMetadatas: ObjectMetadataEntity[],
fieldsByObjectId: Map<string, FieldMetadataEntity[]>,
stream: WriteStream,
): void {
this.logger.log('Generating workspace schema DDL from metadata...');
const ddlStatements = generateWorkspaceSchemaDdl(
workspaceId,
schemaName,
objectMetadatas,
fieldsByObjectId,
);
this.logger.log(` ${ddlStatements.length} DDL statements`);
for (const statement of ddlStatements) {
stream.write(statement + '\n');
}
stream.write('\n');
}
private async writeWorkspaceDataRows(
workspaceId: string,
schemaName: string,
objectMetadatas: ObjectMetadataEntity[],
fieldsByObjectId: Map<string, FieldMetadataEntity[]>,
tableFilter: string[] | undefined,
queryRunner: QueryRunner,
stream: WriteStream,
): Promise<void> {
for (const objectMetadata of objectMetadatas) {
if (!objectMetadata.isActive) continue;
const tableName = computeTableName(
objectMetadata.nameSingular,
objectMetadata.isCustom,
);
if (tableFilter && !tableFilter.includes(objectMetadata.nameSingular)) {
continue;
}
const objectFieldMetadatas =
fieldsByObjectId.get(objectMetadata.id) ?? [];
const { jsonColumns, generatedColumns } = buildWorkspaceTableColumnSets(
workspaceId,
objectMetadata,
objectFieldMetadatas,
);
try {
await this.writeRows({
schemaName,
tableName,
displayName: objectMetadata.nameSingular,
queryRunner,
stream,
jsonColumns,
excludedColumns: generatedColumns,
});
} catch (error) {
this.logger.warn(`${objectMetadata.nameSingular}: skipped`, error);
}
}
}
}
@@ -11,15 +11,29 @@ export class AddTypeToNavigationMenuItem1773681736596
);
await queryRunner.query(
`ALTER TABLE "core"."navigationMenuItem" ADD "type" "core"."navigationMenuItem_type_enum"`,
`ALTER TABLE "core"."navigationMenuItem" ADD "type" "core"."navigationMenuItem_type_enum" NOT NULL DEFAULT 'VIEW'`,
);
await queryRunner.query(
`ALTER TABLE "core"."navigationMenuItem" DROP CONSTRAINT "CHK_navigation_menu_item_target_fields"`,
);
await queryRunner.query(
`ALTER TABLE "core"."navigationMenuItem" ADD CONSTRAINT "CHK_navigation_menu_item_type_fields" CHECK (
("type" = 'FOLDER')
OR ("type" = 'OBJECT' AND "targetObjectMetadataId" IS NOT NULL)
OR ("type" = 'VIEW')
OR ("type" = 'RECORD' AND "targetRecordId" IS NOT NULL AND "targetObjectMetadataId" IS NOT NULL)
OR ("type" = 'LINK' AND "link" IS NOT NULL)
)`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."navigationMenuItem" DROP CONSTRAINT "CHK_navigation_menu_item_type_fields"`,
);
await queryRunner.query(
`ALTER TABLE "core"."navigationMenuItem" ADD CONSTRAINT "CHK_navigation_menu_item_target_fields" CHECK (("targetRecordId" IS NULL AND "targetObjectMetadataId" IS NULL) OR ("targetRecordId" IS NOT NULL AND "targetObjectMetadataId" IS NOT NULL))`,
);
@@ -1,49 +0,0 @@
import { type MigrationInterface, type QueryRunner } from 'typeorm';
import { makeNavigationMenuItemTypeNotNullQueries } from 'src/database/typeorm/core/migrations/utils/1773681736596-makeNavigationMenuItemTypeNotNull.util';
export class MakeNavigationMenuItemTypeNotNull1773822077682
implements MigrationInterface
{
name = 'MakeNavigationMenuItemTypeNotNull1773822077682';
public async up(queryRunner: QueryRunner): Promise<void> {
const savepointName = 'sp_make_navigation_menu_item_type_not_null';
try {
await queryRunner.query(`SAVEPOINT ${savepointName}`);
await makeNavigationMenuItemTypeNotNullQueries(queryRunner);
await queryRunner.query(`RELEASE SAVEPOINT ${savepointName}`);
} catch (e) {
try {
await queryRunner.query(`ROLLBACK TO SAVEPOINT ${savepointName}`);
await queryRunner.query(`RELEASE SAVEPOINT ${savepointName}`);
} catch (rollbackError) {
// oxlint-disable-next-line no-console
console.error(
'Failed to rollback to savepoint in MakeNavigationMenuItemTypeNotNull1773822077682',
rollbackError,
);
throw rollbackError;
}
// oxlint-disable-next-line no-console
console.error(
'Swallowing MakeNavigationMenuItemTypeNotNull1773822077682 error',
e,
);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."navigationMenuItem" DROP CONSTRAINT "CHK_navigation_menu_item_type_fields"`,
);
await queryRunner.query(
`ALTER TABLE "core"."navigationMenuItem" ALTER COLUMN "type" DROP NOT NULL`,
);
}
}
@@ -1,19 +0,0 @@
import { type QueryRunner } from 'typeorm';
export const makeNavigationMenuItemTypeNotNullQueries = async (
queryRunner: QueryRunner,
): Promise<void> => {
await queryRunner.query(
`ALTER TABLE "core"."navigationMenuItem" ALTER COLUMN "type" SET NOT NULL`,
);
await queryRunner.query(
`ALTER TABLE "core"."navigationMenuItem" ADD CONSTRAINT "CHK_navigation_menu_item_type_fields" CHECK (
("type" = 'FOLDER')
OR ("type" = 'OBJECT' AND "targetObjectMetadataId" IS NOT NULL)
OR ("type" = 'VIEW' AND "viewId" IS NOT NULL)
OR ("type" = 'RECORD' AND "targetRecordId" IS NOT NULL AND "targetObjectMetadataId" IS NOT NULL)
OR ("type" = 'LINK' AND "link" IS NOT NULL)
)`,
);
};
@@ -25,7 +25,7 @@ export const fromCommandMenuItemManifestToUniversalFlatCommandMenuItem = ({
universalIdentifier: commandMenuItemManifest.universalIdentifier,
applicationUniversalIdentifier,
label: commandMenuItemManifest.label,
shortLabel: commandMenuItemManifest.shortLabel ?? null,
shortLabel: null,
position: 0,
icon: commandMenuItemManifest.icon ?? null,
isPinned: commandMenuItemManifest.isPinned ?? false,
@@ -1,5 +1,4 @@
import { FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED } from 'twenty-shared/constants';
import { isDefined } from 'twenty-shared/utils';
import { type TimelineThreadDTO } from 'src/engine/core-modules/messaging/dtos/timeline-thread.dto';
import { extractParticipantSummary } from 'src/engine/core-modules/messaging/utils/extract-participant-summary.util';
@@ -22,25 +21,23 @@ export const formatThreads = (
[key: string]: MessageChannelVisibility;
},
): TimelineThreadDTO[] => {
return threads
.filter((thread) => isDefined(threadParticipantsByThreadId[thread.id]))
.map((thread) => {
const visibility = threadVisibilityByThreadId[thread.id];
return threads.map((thread) => {
const visibility = threadVisibilityByThreadId[thread.id];
return {
...thread,
subject:
visibility === MessageChannelVisibility.SHARE_EVERYTHING ||
visibility === MessageChannelVisibility.SUBJECT
? thread.subject
: FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED,
lastMessageBody:
visibility === MessageChannelVisibility.SHARE_EVERYTHING
? thread.lastMessageBody
: FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED,
...extractParticipantSummary(threadParticipantsByThreadId[thread.id]),
visibility,
read: true,
};
});
return {
...thread,
subject:
visibility === MessageChannelVisibility.SHARE_EVERYTHING ||
visibility === MessageChannelVisibility.SUBJECT
? thread.subject
: FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED,
lastMessageBody:
visibility === MessageChannelVisibility.SHARE_EVERYTHING
? thread.lastMessageBody
: FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED,
...extractParticipantSummary(threadParticipantsByThreadId[thread.id]),
visibility,
read: true,
};
});
};
@@ -39,7 +39,7 @@ import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-enti
'CHK_navigation_menu_item_type_fields',
`("type" = 'FOLDER')
OR ("type" = 'OBJECT' AND "targetObjectMetadataId" IS NOT NULL)
OR ("type" = 'VIEW' AND "viewId" IS NOT NULL)
OR ("type" = 'VIEW')
OR ("type" = 'RECORD' AND "targetRecordId" IS NOT NULL AND "targetObjectMetadataId" IS NOT NULL)
OR ("type" = 'LINK' AND "link" IS NOT NULL)`,
)
@@ -87,6 +87,7 @@ export class NavigationMenuItemEntity
nullable: false,
type: 'enum',
enum: NavigationMenuItemType,
default: NavigationMenuItemType.VIEW,
})
type: NavigationMenuItemType;
@@ -1,7 +1,6 @@
import { type MetadataUniversalFlatEntityPropertiesToCompare } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/metadata-universal-flat-entity-properties-to-compare.type';
export const OBJECT_METADATA_STANDARD_OVERRIDES_PROPERTIES = [
'color',
'labelSingular',
'labelPlural',
'description',
@@ -117,21 +117,6 @@ export class ObjectMetadataResolver {
);
}
@ResolveField(() => String, { nullable: true })
async color(
@Parent() objectMetadata: ObjectMetadataDTO,
@Context() context: I18nContext,
): Promise<string> {
const i18n = this.i18nService.getI18nInstance(context.req.locale);
return resolveObjectMetadataStandardOverride(
objectMetadata,
'color',
context.req.locale,
i18n,
);
}
@UseGuards(SettingsPermissionGuard(PermissionFlagType.DATA_MODEL))
@Mutation(() => ObjectMetadataDTO)
async createOneObject(
@@ -28,12 +28,10 @@ describe('resolveObjectMetadataStandardOverride', () => {
labelPlural: 'My Customs',
description: 'Custom Description',
icon: 'custom-icon',
color: 'blue',
isCustom: true,
standardOverrides: undefined,
} satisfies Pick<
ObjectMetadataDTO,
| 'color'
| 'labelPlural'
| 'labelSingular'
| 'description'
@@ -58,12 +56,10 @@ describe('resolveObjectMetadataStandardOverride', () => {
labelPlural: 'My Customs',
description: 'Custom Description',
icon: 'custom-icon',
color: 'blue',
isCustom: true,
standardOverrides: undefined,
} satisfies Pick<
ObjectMetadataDTO,
| 'color'
| 'labelPlural'
| 'labelSingular'
| 'description'
@@ -88,12 +84,10 @@ describe('resolveObjectMetadataStandardOverride', () => {
labelPlural: 'My Customs',
description: 'Custom Description',
icon: 'custom-icon',
color: 'blue',
isCustom: true,
standardOverrides: undefined,
} satisfies Pick<
ObjectMetadataDTO,
| 'color'
| 'labelPlural'
| 'labelSingular'
| 'description'
@@ -111,36 +105,6 @@ describe('resolveObjectMetadataStandardOverride', () => {
expect(result).toBe('custom-icon');
});
it('should return the object value for custom color object', () => {
const objectMetadata = {
labelSingular: 'My Custom',
labelPlural: 'My Customs',
description: 'Custom Description',
icon: 'custom-icon',
color: 'green',
isCustom: true,
standardOverrides: undefined,
} satisfies Pick<
ObjectMetadataDTO,
| 'color'
| 'labelPlural'
| 'labelSingular'
| 'description'
| 'icon'
| 'isCustom'
| 'standardOverrides'
>;
const result = resolveObjectMetadataStandardOverride(
objectMetadata,
'color',
SOURCE_LOCALE,
mockI18n,
);
expect(result).toBe('green');
});
});
describe('Standard objects - Icon overrides', () => {
@@ -167,55 +131,6 @@ describe('resolveObjectMetadataStandardOverride', () => {
});
});
describe('Standard objects - Color overrides', () => {
it('should return override color when available for standard object', () => {
const objectMetadata = {
labelSingular: 'Company',
labelPlural: 'Companies',
description: 'Standard Description',
icon: 'default-icon',
color: 'blue',
isCustom: false,
standardOverrides: {
color: 'red',
},
};
const result = resolveObjectMetadataStandardOverride(
objectMetadata,
'color',
'fr-FR',
mockI18n,
);
expect(result).toBe('red');
});
it('should return base color when no override exists for standard object', () => {
const objectMetadata = {
labelSingular: 'Company',
labelPlural: 'Companies',
description: 'Standard Description',
icon: 'default-icon',
color: 'blue',
isCustom: false,
standardOverrides: undefined,
};
mockGenerateMessageId.mockReturnValue('generated-message-id');
mockI18n._.mockReturnValue('generated-message-id');
const result = resolveObjectMetadataStandardOverride(
objectMetadata,
'color',
SOURCE_LOCALE,
mockI18n,
);
expect(result).toBe('blue');
});
});
describe('Standard objects - Translation overrides', () => {
it('should return translation override when available for non-icon objects', () => {
const objectMetadata = {
@@ -9,7 +9,6 @@ import { type ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metad
export const resolveObjectMetadataStandardOverride = (
objectMetadata: Pick<
ObjectMetadataDTO,
| 'color'
| 'labelPlural'
| 'labelSingular'
| 'description'
@@ -17,7 +16,7 @@ export const resolveObjectMetadataStandardOverride = (
| 'isCustom'
| 'standardOverrides'
>,
labelKey: 'color' | 'labelPlural' | 'labelSingular' | 'description' | 'icon',
labelKey: 'labelPlural' | 'labelSingular' | 'description' | 'icon',
locale: keyof typeof APP_LOCALES | undefined,
i18nInstance: I18n,
): string => {
@@ -28,16 +27,15 @@ export const resolveObjectMetadataStandardOverride = (
}
if (
(labelKey === 'icon' || labelKey === 'color') &&
isDefined(objectMetadata.standardOverrides?.[labelKey])
labelKey === 'icon' &&
isDefined(objectMetadata.standardOverrides?.icon)
) {
return objectMetadata.standardOverrides[labelKey];
return objectMetadata.standardOverrides.icon;
}
if (
isDefined(objectMetadata.standardOverrides?.translations) &&
labelKey !== 'icon' &&
labelKey !== 'color'
labelKey !== 'icon'
) {
const translationValue =
objectMetadata.standardOverrides.translations[safeLocale]?.[labelKey];
@@ -26,7 +26,6 @@ import { type SerializableAuthContext } from 'src/engine/core-modules/auth/types
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
import { type FlatWorkspaceMemberMaps } from 'src/engine/core-modules/user/types/flat-workspace-member-maps.type';
import { type MetadataEventBatch } from 'src/engine/metadata-event-emitter/types/metadata-event-batch.type';
import { OBJECT_METADATA_STANDARD_OVERRIDES_PROPERTIES } from 'src/engine/metadata-modules/object-metadata/constants/object-metadata-standard-overrides-properties.constant';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
@@ -137,13 +136,9 @@ export class WorkspaceEventEmitterService {
const enrichedMetadataEventBatch = isMetadata
? await this.enrichFieldMetadataEventsWithRelations(
eventBatch as MetadataEventBatch,
).then((batch) =>
this.enrichNavigationMenuItemEventsWithTargetRecordIdentifier(batch),
)
.then((batch) =>
this.enrichNavigationMenuItemEventsWithTargetRecordIdentifier(
batch,
),
)
.then((batch) => this.resolveObjectMetadataStandardOverrides(batch))
: undefined;
for (const [streamChannelId, streamData] of streamsData) {
@@ -313,65 +308,6 @@ export class WorkspaceEventEmitterService {
return { ...metadataEventBatch, events: enrichedEvents };
}
private resolveObjectMetadataStandardOverrides(
metadataEventBatch: MetadataEventBatch,
): MetadataEventBatch {
if (metadataEventBatch.metadataName !== 'objectMetadata') {
return metadataEventBatch;
}
const enrichedEvents = metadataEventBatch.events.map((event) => {
const enrichedProperties = { ...event.properties };
if (
'before' in enrichedProperties &&
isDefined(enrichedProperties.before)
) {
enrichedProperties.before =
this.applyStandardOverridesToObjectMetadataRecord(
enrichedProperties.before as Record<string, unknown>,
) as typeof enrichedProperties.before;
}
if (
'after' in enrichedProperties &&
isDefined(enrichedProperties.after)
) {
enrichedProperties.after =
this.applyStandardOverridesToObjectMetadataRecord(
enrichedProperties.after as Record<string, unknown>,
) as typeof enrichedProperties.after;
}
return { ...event, properties: enrichedProperties } as typeof event;
});
return { ...metadataEventBatch, events: enrichedEvents };
}
private applyStandardOverridesToObjectMetadataRecord(
record: Record<string, unknown>,
): Record<string, unknown> {
const standardOverrides = record.standardOverrides as
| Record<string, unknown>
| null
| undefined;
if (!isDefined(standardOverrides)) {
return record;
}
const resolved = { ...record };
for (const key of OBJECT_METADATA_STANDARD_OVERRIDES_PROPERTIES) {
if (isDefined(standardOverrides[key])) {
resolved[key] = standardOverrides[key];
}
}
return resolved;
}
private async processObjectRecordStreamEvents(
streamChannelId: string,
streamData: EventStreamData,
@@ -6,7 +6,7 @@ import { EngineComponentKey } from 'src/engine/metadata-modules/command-menu-ite
export const STANDARD_COMMAND_MENU_ITEMS = {
navigateToNextRecord: {
universalIdentifier: '3db2457d-8e96-4b8e-94c9-ed95d3f95738',
label: 'Navigate to next ${capitalize(objectMetadataItem.labelSingular)}',
label: 'Navigate to next record',
shortLabel: null,
icon: 'IconChevronDown',
position: 0,
@@ -21,8 +21,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
},
navigateToPreviousRecord: {
universalIdentifier: 'ec10f871-415b-420b-8150-7e09f6f04833',
label:
'Navigate to previous ${capitalize(objectMetadataItem.labelSingular)}',
label: 'Navigate to previous record',
shortLabel: null,
icon: 'IconChevronUp',
position: 1,
@@ -37,8 +36,8 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
},
createNewRecord: {
universalIdentifier: '08d255bf-58cd-47a5-bd82-78c5c58592f1',
label: 'Create new ${capitalize(objectMetadataItem.labelSingular)}',
shortLabel: 'New ${capitalize(objectMetadataItem.labelSingular)}',
label: 'Create new record',
shortLabel: 'New record',
icon: 'IconPlus',
position: 2,
isPinned: true,
@@ -67,7 +66,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
},
deleteMultipleRecords: {
universalIdentifier: 'cde86f1f-2c13-42b1-812b-f2b2b468cb83',
label: 'Delete ${capitalize(objectMetadataItem.labelPlural)}',
label: 'Delete records',
shortLabel: 'Delete',
icon: 'IconTrash',
position: 4,
@@ -82,7 +81,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
},
restoreSingleRecord: {
universalIdentifier: '8b3a1cae-3e4d-43c1-a71f-48592b2e47ff',
label: 'Restore ${capitalize(objectMetadataItem.labelSingular)}',
label: 'Restore record',
shortLabel: 'Restore',
icon: 'IconRefresh',
position: 5,
@@ -97,7 +96,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
},
restoreMultipleRecords: {
universalIdentifier: '8b740c9d-d99a-45a8-812f-809caaf420ac',
label: 'Restore ${capitalize(objectMetadataItem.labelPlural)}',
label: 'Restore records',
shortLabel: 'Restore',
icon: 'IconRefresh',
position: 6,
@@ -112,8 +111,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
},
destroySingleRecord: {
universalIdentifier: '44a78417-c394-4bc8-961f-98b503030ddb',
label:
'Permanently destroy ${capitalize(objectMetadataItem.labelSingular)}',
label: 'Permanently destroy record',
shortLabel: 'Destroy',
icon: 'IconTrashX',
position: 7,
@@ -128,7 +126,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
},
destroyMultipleRecords: {
universalIdentifier: 'c630b3fb-7920-40d1-9906-77d0aa797608',
label: 'Permanently destroy ${capitalize(objectMetadataItem.labelPlural)}',
label: 'Permanently destroy records',
shortLabel: 'Destroy',
icon: 'IconTrashX',
position: 8,
@@ -143,7 +141,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
},
addToFavorites: {
universalIdentifier: '38bf80c3-bd55-4753-80ba-38aa66429a03',
label: 'Add to Favorites',
label: 'Add to favorites',
shortLabel: null,
icon: 'IconHeart',
position: 9,
@@ -158,7 +156,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
},
removeFromFavorites: {
universalIdentifier: '3ea42507-44fa-4895-a36d-cbfef7355a50',
label: 'Remove from Favorites',
label: 'Remove from favorites',
shortLabel: null,
icon: 'IconHeartOff',
position: 10,
@@ -216,7 +214,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
},
updateMultipleRecords: {
universalIdentifier: '2e080651-f098-4a78-bea9-7a70002dc57c',
label: 'Update ${capitalize(objectMetadataItem.labelPlural)}',
label: 'Update records',
shortLabel: 'Update',
icon: 'IconEdit',
position: 14,
@@ -231,7 +229,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
},
mergeMultipleRecords: {
universalIdentifier: '6c14eb04-8e7e-4d47-93c0-8ec4834e2e60',
label: 'Merge ${capitalize(objectMetadataItem.labelPlural)}',
label: 'Merge records',
shortLabel: 'Merge',
icon: 'IconArrowMerge',
position: 15,
@@ -246,7 +244,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
},
exportMultipleRecords: {
universalIdentifier: 'f71f68e5-7b6e-4c03-8161-c48434d7777c',
label: 'Export ${capitalize(objectMetadataItem.labelPlural)}',
label: 'Export records',
shortLabel: 'Export',
icon: 'IconFileExport',
position: 16,
@@ -260,7 +258,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
},
importRecords: {
universalIdentifier: 'a2dc9de7-4798-422e-bb55-bfad7b9bdbe8',
label: 'Import ${capitalize(objectMetadataItem.labelPlural)}',
label: 'Import records',
shortLabel: 'Import',
icon: 'IconFileImport',
position: 17,
@@ -274,7 +272,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
},
exportView: {
universalIdentifier: '80680f2a-c426-48b3-a839-c63a6183dc4b',
label: 'Export View',
label: 'Export view',
shortLabel: 'Export',
icon: 'IconFileExport',
position: 18,
@@ -288,8 +286,8 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
},
seeDeletedRecords: {
universalIdentifier: 'd63c21c3-9785-4750-be87-5f36269b8e0d',
label: 'See deleted ${capitalize(objectMetadataItem.labelPlural)}',
shortLabel: 'Deleted ${capitalize(objectMetadataItem.labelPlural)}',
label: 'See deleted records',
shortLabel: 'Deleted records',
icon: 'IconRotate2',
position: 19,
isPinned: false,
@@ -316,7 +314,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
},
hideDeletedRecords: {
universalIdentifier: '1420db7f-0fba-49e2-b23e-4b7caa0fafa0',
label: 'Hide deleted ${capitalize(objectMetadataItem.labelPlural)}',
label: 'Hide deleted records',
shortLabel: 'Hide deleted',
icon: 'IconEyeOff',
position: 21,
@@ -586,8 +584,8 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
},
seeActiveVersionWorkflow: {
universalIdentifier: '31790508-75ff-4e4c-a768-83bd1b0718e0',
label: 'See Active Version',
shortLabel: 'See Active Version',
label: 'See active version',
shortLabel: 'See active version',
icon: 'IconVersions',
position: 46,
isPinned: false,
@@ -602,8 +600,8 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
},
seeRunsWorkflow: {
universalIdentifier: 'e57efc2d-00a2-493a-b76c-f2dabd23a5eb',
label: 'See Runs',
shortLabel: 'See Runs',
label: 'See runs',
shortLabel: 'See runs',
icon: 'IconHistoryToggle',
position: 47,
isPinned: true,
@@ -618,8 +616,8 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
},
seeVersionsWorkflow: {
universalIdentifier: '92781d24-b875-4282-8cdb-d127f04a5c7d',
label: 'See Versions History',
shortLabel: 'See Versions',
label: 'See versions history',
shortLabel: 'See versions',
icon: 'IconVersions',
position: 48,
isPinned: false,
@@ -634,8 +632,8 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
},
addNodeWorkflow: {
universalIdentifier: '818117fa-6cad-4ebc-83c1-40f4afc28d94',
label: 'Add a Node',
shortLabel: 'Add a Node',
label: 'Add a node',
shortLabel: 'Add a node',
icon: 'IconPlus',
position: 49,
isPinned: true,
@@ -650,7 +648,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
},
tidyUpWorkflow: {
universalIdentifier: '1f3a3cab-161a-4775-af47-11be4d0bf411',
label: 'Tidy up Workflow',
label: 'Tidy up workflow',
shortLabel: 'Tidy up',
icon: 'IconReorder',
position: 50,
@@ -682,8 +680,8 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
},
goToRuns: {
universalIdentifier: '1ba959da-ff49-4c1f-a517-2b78ee200508',
label: 'Go to Runs',
shortLabel: 'See Runs',
label: 'Go to runs',
shortLabel: 'See runs',
icon: 'IconHistoryToggle',
position: 52,
isPinned: false,
@@ -697,8 +695,8 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
},
seeVersionWorkflowRun: {
universalIdentifier: 'cc3a065c-c89e-40ac-9449-4272c55b1bb8',
label: 'See Version',
shortLabel: 'See Version',
label: 'See version',
shortLabel: 'See version',
icon: 'IconVersions',
position: 53,
isPinned: true,
@@ -712,8 +710,8 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
},
seeWorkflowWorkflowRun: {
universalIdentifier: '9d9cc62d-3543-45c3-93f3-23d2d8979f2b',
label: 'See Workflow',
shortLabel: 'See Workflow',
label: 'See workflow',
shortLabel: 'See workflow',
icon: 'IconSettingsAutomation',
position: 54,
isPinned: true,
@@ -743,8 +741,8 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
},
seeRunsWorkflowVersion: {
universalIdentifier: '44e305c7-4f0a-45ec-803f-6471b56455cb',
label: 'See Runs',
shortLabel: 'See Runs',
label: 'See runs',
shortLabel: 'See runs',
icon: 'IconHistoryToggle',
position: 56,
isPinned: true,
@@ -759,8 +757,8 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
},
seeWorkflowWorkflowVersion: {
universalIdentifier: 'b43052db-023e-4083-9b63-2c2dfbfd1320',
label: 'See Workflow',
shortLabel: 'See Workflow',
label: 'See workflow',
shortLabel: 'See workflow',
icon: 'IconSettingsAutomation',
position: 57,
isPinned: true,
@@ -775,8 +773,8 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
},
useAsDraftWorkflowVersion: {
universalIdentifier: '483c0c1d-ea4d-4a4d-8a59-2dcf9f8e38f6',
label: 'Use as Draft',
shortLabel: 'Use as Draft',
label: 'Use as draft',
shortLabel: 'Use as draft',
icon: 'IconPencil',
position: 58,
isPinned: true,
@@ -791,8 +789,8 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
},
seeVersionsWorkflowVersion: {
universalIdentifier: '1d4abeb7-2750-4af7-9a92-fbadd2a9e4ba',
label: 'See Versions History',
shortLabel: 'See Versions',
label: 'See versions history',
shortLabel: 'See versions',
icon: 'IconVersions',
position: 59,
isPinned: false,
@@ -807,7 +805,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
},
searchRecords: {
universalIdentifier: 'fa24e25e-68f8-4548-82ff-c7b5168b7c7d',
label: 'Search ${capitalize(objectMetadataItem.labelPlural)}',
label: 'Search records',
shortLabel: 'Search',
icon: 'IconSearch',
position: 60,
@@ -821,7 +819,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
},
searchRecordsFallback: {
universalIdentifier: 'c659890c-7266-46c9-bfe1-75cefff8b6d0',
label: 'Search ${capitalize(objectMetadataItem.labelPlural)}',
label: 'Search records',
shortLabel: 'Search',
icon: 'IconSearch',
position: 61,
@@ -61,7 +61,11 @@ export class FlatObjectMetadataValidatorService {
};
if (!buildOptions.isSystemBuild && existingFlatObjectMetadata.isSystem) {
const allowedOverrideKeys = new Set(['standardOverrides', 'isActive']);
const allowedOverrideKeys = new Set([
'standardOverrides',
'isActive',
'color',
]);
const disallowedProperties = Object.keys(flatEntityUpdate).filter(
(property) => !allowedOverrideKeys.has(property),
);
@@ -2,7 +2,7 @@ import { Scope } from '@nestjs/common';
import { MessageParticipantRole } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { And, Any, ILike, In, IsNull, Not, Or } from 'typeorm';
import { And, Any, ILike, In, Not, Or } from 'typeorm';
import { type ObjectRecordCreateEvent } from 'twenty-shared/database-events';
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
@@ -108,7 +108,6 @@ export class BlocklistItemDeleteMessagesJob {
where: {
connectedAccount: {
accountOwnerId: workspaceMemberId,
deletedAt: IsNull(),
},
},
relations: ['connectedAccount'],
@@ -117,7 +116,7 @@ export class BlocklistItemDeleteMessagesJob {
for (const messageChannel of messageChannels) {
const messageChannelHandles = [messageChannel.handle];
if (isDefined(messageChannel.connectedAccount.handleAliases)) {
if (messageChannel.connectedAccount.handleAliases) {
messageChannelHandles.push(
...messageChannel.connectedAccount.handleAliases.split(','),
);
@@ -2,7 +2,6 @@ import {
MessageImportDriverException,
MessageImportDriverExceptionCode,
} from 'src/modules/messaging/message-import-manager/drivers/exceptions/message-import-driver.exception';
import { isDefined } from 'twenty-shared/utils';
export const parseMicrosoftMessagesImportError = (
error: {
@@ -13,14 +12,6 @@ export const parseMicrosoftMessagesImportError = (
options?: { cause?: Error },
): MessageImportDriverException => {
if (error.statusCode === 400) {
if (!isDefined(error.message)) {
return new MessageImportDriverException(
`Microsoft Graph API returned 400 with empty error body`,
MessageImportDriverExceptionCode.TEMPORARY_ERROR,
{ cause: options?.cause },
);
}
return new MessageImportDriverException(
`Invalid request to Microsoft Graph API: ${error.message}`,
MessageImportDriverExceptionCode.UNKNOWN,
@@ -1,46 +1,7 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`Standard object metadata update should succeed when updating description 1`] = `
{
"color": "",
"description": "Updated test description for company",
"icon": "IconBuildingSkyscraper",
"id": Any<String>,
"isActive": true,
"labelPlural": "Companies",
"labelSingular": "Company",
"namePlural": "companies",
"nameSingular": "company",
"shortcut": "C",
"standardOverrides": {
"color": null,
"description": "Updated test description for company",
"icon": null,
"labelPlural": null,
"labelSingular": null,
},
}
`;
exports[`Standard object metadata update should succeed when updating icon 1`] = `
{
"color": "",
"description": "A company",
"icon": "IconBuildingSkyscraper",
"id": Any<String>,
"isActive": true,
"labelPlural": "Companies",
"labelSingular": "Company",
"namePlural": "companies",
"nameSingular": "company",
"shortcut": "C",
"standardOverrides": null,
}
`;
exports[`Standard object metadata update should succeed when setting isActive to false 1`] = `
{
"color": "",
"description": "A company",
"icon": "IconBuildingSkyscraper",
"id": Any<String>,
@@ -54,9 +15,43 @@ exports[`Standard object metadata update should succeed when setting isActive to
}
`;
exports[`Standard object metadata update should succeed when updating description 1`] = `
{
"description": "Updated test description for company",
"icon": "IconBuildingSkyscraper",
"id": Any<String>,
"isActive": true,
"labelPlural": "Companies",
"labelSingular": "Company",
"namePlural": "companies",
"nameSingular": "company",
"shortcut": "C",
"standardOverrides": {
"description": "Updated test description for company",
"icon": null,
"labelPlural": null,
"labelSingular": null,
},
}
`;
exports[`Standard object metadata update should succeed when updating icon 1`] = `
{
"description": "A company",
"icon": "IconBuildingSkyscraper",
"id": Any<String>,
"isActive": true,
"labelPlural": "Companies",
"labelSingular": "Company",
"namePlural": "companies",
"nameSingular": "company",
"shortcut": "C",
"standardOverrides": null,
}
`;
exports[`Standard object metadata update should succeed when updating labelSingular and labelPlural 1`] = `
{
"color": "",
"description": "A company",
"icon": "IconBuildingSkyscraper",
"id": Any<String>,
@@ -67,7 +62,6 @@ exports[`Standard object metadata update should succeed when updating labelSingu
"nameSingular": "company",
"shortcut": "C",
"standardOverrides": {
"color": null,
"description": null,
"icon": null,
"labelPlural": "Businesses",
@@ -75,25 +69,3 @@ exports[`Standard object metadata update should succeed when updating labelSingu
},
}
`;
exports[`Standard object metadata update should succeed when updating color 1`] = `
{
"color": "red",
"description": "A company",
"icon": "IconBuildingSkyscraper",
"id": Any<String>,
"isActive": true,
"labelPlural": "Companies",
"labelSingular": "Company",
"namePlural": "companies",
"nameSingular": "company",
"shortcut": "C",
"standardOverrides": {
"color": "red",
"description": null,
"icon": null,
"labelPlural": null,
"labelSingular": null,
},
}
`;
@@ -46,12 +46,6 @@ const successfulUpdateTestsUseCase: UpdateOneStandardObjectMetadataTestingContex
labelPlural: 'Businesses',
},
},
{
title: 'when updating color',
context: {
color: 'red',
},
},
];
const allTestsUseCases = [...successfulUpdateTestsUseCase];
@@ -73,7 +67,6 @@ describe('Standard object metadata update should succeed', () => {
namePlural
labelSingular
labelPlural
color
description
icon
isActive
@@ -128,7 +121,6 @@ describe('Standard object metadata update should succeed', () => {
namePlural
labelSingular
labelPlural
color
description
icon
isActive
@@ -136,7 +128,6 @@ describe('Standard object metadata update should succeed', () => {
standardOverrides {
labelSingular
labelPlural
color
description
icon
}
@@ -2,7 +2,6 @@ import { type SyncableEntityOptions } from '@/application/syncableEntityOptionsT
export type CommandMenuItemManifest = SyncableEntityOptions & {
label: string;
shortLabel?: string;
icon?: string;
isPinned?: boolean;
availabilityType?: 'GLOBAL' | 'RECORD_SELECTION' | 'FALLBACK';
@@ -1,316 +0,0 @@
import {
CommandMenuContextApiPageType,
type CommandMenuContextApi,
} from '@/types';
import { interpolateCommandMenuItemLabel } from '../interpolateCommandMenuItemLabel';
const buildContext = (
overrides: Partial<CommandMenuContextApi> = {},
): CommandMenuContextApi => ({
pageType: CommandMenuContextApiPageType.INDEX_PAGE,
isInSidePanel: false,
isPageInEditMode: false,
favoriteRecordIds: [],
isSelectAll: false,
hasAnySoftDeleteFilterOnView: false,
numberOfSelectedRecords: 0,
objectPermissions: {
objectMetadataId: '',
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: true,
canDestroyObjectRecords: false,
restrictedFields: {},
rowLevelPermissionPredicates: [],
rowLevelPermissionPredicateGroups: [],
},
selectedRecords: [],
featureFlags: {},
targetObjectReadPermissions: {},
targetObjectWritePermissions: {},
objectMetadataItem: {},
...overrides,
});
describe('interpolateCommandMenuItemLabel', () => {
describe('sequential invocations (global regex lastIndex)', () => {
it('should interpolate correctly when called multiple times in sequence', () => {
const context = buildContext({
numberOfSelectedRecords: 2,
objectMetadataItem: { labelPlural: 'people' },
});
expect(
interpolateCommandMenuItemLabel({
label: 'First: ${numberOfSelectedRecords}',
context,
}),
).toBe('First: 2');
expect(
interpolateCommandMenuItemLabel({
label: 'Second: ${objectMetadataItem.labelPlural}',
context,
}),
).toBe('Second: people');
});
});
describe('plain strings without template variables', () => {
it('should return the label unchanged when no template variables are present', () => {
const context = buildContext();
expect(
interpolateCommandMenuItemLabel({ label: 'Delete', context }),
).toBe('Delete');
});
it('should return null when label is null', () => {
const context = buildContext();
expect(
interpolateCommandMenuItemLabel({ label: null, context }),
).toBeNull();
});
it('should return null when label is undefined', () => {
const context = buildContext();
expect(
interpolateCommandMenuItemLabel({ label: undefined, context }),
).toBeNull();
});
it('should return an empty string for an empty label', () => {
const context = buildContext();
expect(interpolateCommandMenuItemLabel({ label: '', context })).toBe('');
});
});
describe('simple template variable interpolation', () => {
it('should interpolate a top-level context property', () => {
const context = buildContext({ numberOfSelectedRecords: 5 });
expect(
interpolateCommandMenuItemLabel({
label: 'Selected: ${numberOfSelectedRecords}',
context,
}),
).toBe('Selected: 5');
});
it('should interpolate objectMetadataItem.labelSingular', () => {
const context = buildContext({
objectMetadataItem: { labelSingular: 'person' },
});
expect(
interpolateCommandMenuItemLabel({
label: 'Create new ${objectMetadataItem.labelSingular}',
context,
}),
).toBe('Create new person');
});
it('should interpolate objectMetadataItem.labelPlural', () => {
const context = buildContext({
objectMetadataItem: { labelPlural: 'companies' },
});
expect(
interpolateCommandMenuItemLabel({
label: 'Delete ${objectMetadataItem.labelPlural}',
context,
}),
).toBe('Delete companies');
});
});
describe('multiple template variables', () => {
it('should interpolate multiple variables in one label', () => {
const context = buildContext({
numberOfSelectedRecords: 3,
objectMetadataItem: { labelPlural: 'people' },
});
expect(
interpolateCommandMenuItemLabel({
label:
'${numberOfSelectedRecords} ${objectMetadataItem.labelPlural} selected',
context,
}),
).toBe('3 people selected');
});
});
describe('nested property access', () => {
it('should resolve deeply nested properties', () => {
const context = buildContext({
objectMetadataItem: {
labelSingular: 'opportunity',
nameSingular: 'opportunity',
},
});
expect(
interpolateCommandMenuItemLabel({
label: 'New ${objectMetadataItem.nameSingular}',
context,
}),
).toBe('New opportunity');
});
});
describe('missing context values', () => {
it('should return empty string for undefined nested property', () => {
const context = buildContext({
objectMetadataItem: {},
});
expect(
interpolateCommandMenuItemLabel({
label: 'New ${objectMetadataItem.labelSingular}',
context,
}),
).toBe('New ');
});
it('should return empty string for empty objectMetadataItem', () => {
const context = buildContext();
expect(
interpolateCommandMenuItemLabel({
label: 'Create new ${objectMetadataItem.labelSingular}',
context,
}),
).toBe('Create new ');
});
});
describe('unresolvable property paths', () => {
it('should return empty string for a path that cannot be resolved', () => {
const context = buildContext();
expect(
interpolateCommandMenuItemLabel({
label: 'Label ${nonExistent.deep.path}',
context,
}),
).toBe('Label ');
});
it('should not resolve inherited Object.prototype members', () => {
const context = buildContext();
expect(
interpolateCommandMenuItemLabel({
label: 'Val: ${toString}',
context,
}),
).toBe('Val: ');
expect(
interpolateCommandMenuItemLabel({
label: 'Val: ${objectMetadataItem.hasOwnProperty}',
context,
}),
).toBe('Val: ');
});
});
describe('boolean context values', () => {
it('should stringify boolean values', () => {
const context = buildContext({ isInSidePanel: true });
expect(
interpolateCommandMenuItemLabel({
label: 'Side panel: ${isInSidePanel}',
context,
}),
).toBe('Side panel: true');
});
it('should pass through string values as-is without forced lowercasing', () => {
const context = buildContext({
objectMetadataItem: { labelSingular: 'Person' },
});
expect(
interpolateCommandMenuItemLabel({
label: 'Create new ${objectMetadataItem.labelSingular}',
context,
}),
).toBe('Create new Person');
});
});
describe('capitalize transform', () => {
it('should capitalize the first character of a resolved value', () => {
const context = buildContext({
objectMetadataItem: { labelSingular: 'person' },
});
expect(
interpolateCommandMenuItemLabel({
label: '${capitalize(objectMetadataItem.labelSingular)} details',
context,
}),
).toBe('Person details');
});
it('should capitalize a mixed-case value', () => {
const context = buildContext({
objectMetadataItem: { labelPlural: 'companyRecords' },
});
expect(
interpolateCommandMenuItemLabel({
label: '${capitalize(objectMetadataItem.labelPlural)} selected',
context,
}),
).toBe('CompanyRecords selected');
});
it('should return empty string when the nested property is undefined', () => {
const context = buildContext({
objectMetadataItem: {},
});
expect(
interpolateCommandMenuItemLabel({
label: '${capitalize(objectMetadataItem.labelSingular)} details',
context,
}),
).toBe(' details');
});
});
describe('lowercase transform', () => {
it('should lowercase the resolved value', () => {
const context = buildContext({
objectMetadataItem: { labelSingular: 'Person' },
});
expect(
interpolateCommandMenuItemLabel({
label: 'Create ${lowercase(objectMetadataItem.labelSingular)}',
context,
}),
).toBe('Create person');
});
it('should lowercase all characters', () => {
const context = buildContext({
objectMetadataItem: { labelPlural: 'People' },
});
expect(
interpolateCommandMenuItemLabel({
label: 'Delete ${lowercase(objectMetadataItem.labelPlural)}',
context,
}),
).toBe('Delete people');
});
});
});
@@ -1,120 +0,0 @@
import { isNonEmptyArray, isNonEmptyString } from '@sniptt/guards';
import { Parser } from 'expr-eval-fork';
import { isDefined } from '../validation/isDefined';
import { safeGetNestedProperty } from './safeGetNestedProperty';
type ArrayMethod = 'every' | 'some';
const createArrayPropCheck = (
method: ArrayMethod,
predicate: (value: unknown) => boolean,
) => {
return (array: unknown, prop: string) => {
if (!isNonEmptyArray(array)) {
return false;
}
return array[method]((item) =>
predicate(safeGetNestedProperty(item, prop)),
);
};
};
const createArrayPropValueCheck = (
method: ArrayMethod,
predicate: (value: unknown, target: unknown) => boolean,
) => {
return (array: unknown, prop: string, value: unknown) => {
if (!isNonEmptyArray(array)) {
return false;
}
return array[method]((item) =>
predicate(safeGetNestedProperty(item, prop), value),
);
};
};
export const conditionalAvailabilityParser = new Parser();
conditionalAvailabilityParser.functions.isDefined = (value: unknown) =>
isDefined(value);
conditionalAvailabilityParser.functions.isNonEmptyString = (value: unknown) =>
isNonEmptyString(value);
conditionalAvailabilityParser.functions.includes = (
array: unknown,
value: unknown,
) => Array.isArray(array) && array.includes(value);
conditionalAvailabilityParser.functions.arrayLength = (value: unknown) =>
Array.isArray(value) ? value.length : 0;
conditionalAvailabilityParser.functions.every = createArrayPropCheck(
'every',
Boolean,
);
conditionalAvailabilityParser.functions.everyDefined = createArrayPropCheck(
'every',
isDefined,
);
conditionalAvailabilityParser.functions.some = createArrayPropCheck(
'some',
Boolean,
);
conditionalAvailabilityParser.functions.someDefined = createArrayPropCheck(
'some',
isDefined,
);
conditionalAvailabilityParser.functions.someNonEmptyString =
createArrayPropCheck('some', isNonEmptyString);
conditionalAvailabilityParser.functions.none = createArrayPropCheck(
'every',
(value) => !Boolean(value),
);
conditionalAvailabilityParser.functions.noneDefined = createArrayPropCheck(
'every',
(value) => !isDefined(value),
);
conditionalAvailabilityParser.functions.everyEquals = createArrayPropValueCheck(
'every',
(a, b) => a === b,
);
conditionalAvailabilityParser.functions.someEquals = createArrayPropValueCheck(
'some',
(a, b) => a === b,
);
conditionalAvailabilityParser.functions.noneEquals = createArrayPropValueCheck(
'every',
(a, b) => a !== b,
);
conditionalAvailabilityParser.functions.includesEvery =
createArrayPropValueCheck(
'every',
(array, value) => Array.isArray(array) && array.includes(value),
);
conditionalAvailabilityParser.functions.includesSome =
createArrayPropValueCheck(
'some',
(array, value) => Array.isArray(array) && array.includes(value),
);
conditionalAvailabilityParser.functions.includesNone =
createArrayPropValueCheck(
'every',
(array, value) => Array.isArray(array) && !array.includes(value),
);
@@ -1,21 +0,0 @@
import { isNonEmptyString } from '@sniptt/guards';
import { type EvaluationContext } from 'expr-eval-fork';
import { conditionalAvailabilityParser } from './conditionalAvailabilityParser';
export const evaluateConditionalAvailabilityExpression = (
expression: string | null | undefined,
context: EvaluationContext,
): boolean => {
if (!isNonEmptyString(expression)) {
return true;
}
try {
const parsed = conditionalAvailabilityParser.parse(expression);
return parsed.evaluate(context) === true;
} catch {
return false;
}
};
@@ -1,75 +0,0 @@
import { type Nullable } from '@/types';
import { capitalize } from '../strings/capitalize';
import { isDefined } from '../validation/isDefined';
import { safeGetNestedProperty } from './safeGetNestedProperty';
const TEMPLATE_VARIABLE_REGEX = /\$\{([^{}]+)\}/g;
const HAS_TEMPLATE_VARIABLE_REGEX = /\$\{[^{}]+\}/;
const TRANSFORM_FUNCTION_CALL_REGEX = /^(\w+)\((.+)\)$/;
const LABEL_TRANSFORM_FUNCTIONS: Record<string, (value: string) => string> = {
capitalize,
lowercase: (value: string) => value.toLowerCase(),
};
const resolveTemplateExpression = ({
expression,
context,
}: {
expression: string;
context: Record<string, unknown>;
}): string => {
const trimmedExpression = expression.trim();
const transformFunctionMatch = trimmedExpression.match(
TRANSFORM_FUNCTION_CALL_REGEX,
);
const expressionToEvaluate = transformFunctionMatch
? transformFunctionMatch[2].trim()
: trimmedExpression;
const transformFunction = transformFunctionMatch
? LABEL_TRANSFORM_FUNCTIONS[transformFunctionMatch[1]]
: undefined;
const resolvedPropertyValue = safeGetNestedProperty(
context,
expressionToEvaluate,
);
if (!isDefined(resolvedPropertyValue)) {
return '';
}
const stringValue = String(resolvedPropertyValue);
return isDefined(transformFunction)
? transformFunction(stringValue)
: stringValue;
};
export const interpolateCommandMenuItemLabel = ({
label,
context,
}: {
label: Nullable<string>;
context: Record<string, unknown>;
}): Nullable<string> => {
if (!isDefined(label)) {
return null;
}
if (!HAS_TEMPLATE_VARIABLE_REGEX.test(label)) {
return label;
}
return label.replace(TEMPLATE_VARIABLE_REGEX, (match, expression: string) => {
try {
return resolveTemplateExpression({ expression, context });
} catch {
return match;
}
});
};
@@ -1,39 +0,0 @@
import { isObject, isString } from '@sniptt/guards';
import { isDefined } from '../validation/isDefined';
const BLOCKED_PROPERTY_NAMES = new Set([
'__proto__',
'constructor',
'prototype',
]);
export const safeGetNestedProperty = (
objectToEvaluate: unknown,
path: string,
): unknown => {
if (!isString(path)) {
return undefined;
}
const parts = path.split('.');
let currentObject: unknown = objectToEvaluate;
for (const part of parts) {
if (!isDefined(currentObject) || !isObject(currentObject)) {
return undefined;
}
if (
BLOCKED_PROPERTY_NAMES.has(part) ||
!Object.prototype.hasOwnProperty.call(currentObject, part)
) {
return undefined;
}
currentObject = (currentObject as Record<string, unknown>)[part];
}
return currentObject;
};
@@ -0,0 +1,143 @@
import {
isNonEmptyArray,
isNonEmptyString,
isObject,
isString,
} from '@sniptt/guards';
import { type EvaluationContext, Parser } from 'expr-eval-fork';
import { isDefined } from '../validation/isDefined';
const BLOCKED_PROPERTY_NAMES = new Set([
'__proto__',
'constructor',
'prototype',
]);
const safeGetNestedProperty = (
objectToEvaluate: unknown,
path: string,
): unknown => {
if (!isString(path)) {
return undefined;
}
const parts = path.split('.');
let currentObject: unknown = objectToEvaluate;
for (const part of parts) {
if (!isDefined(currentObject) || !isObject(currentObject)) {
return undefined;
}
if (BLOCKED_PROPERTY_NAMES.has(part)) {
return undefined;
}
currentObject = (currentObject as Record<string, unknown>)[part];
}
return currentObject;
};
type ArrayMethod = 'every' | 'some';
const createArrayPropCheck = (
method: ArrayMethod,
predicate: (value: unknown) => boolean,
) => {
return (array: unknown, prop: string) => {
if (!isNonEmptyArray(array)) {
return false;
}
return array[method]((item) =>
predicate(safeGetNestedProperty(item, prop)),
);
};
};
const createArrayPropValueCheck = (
method: ArrayMethod,
predicate: (value: unknown, target: unknown) => boolean,
) => {
return (array: unknown, prop: string, value: unknown) => {
if (!isNonEmptyArray(array)) {
return false;
}
return array[method]((item) =>
predicate(safeGetNestedProperty(item, prop), value),
);
};
};
const parser = new Parser();
parser.functions.isDefined = (value: unknown) => isDefined(value);
parser.functions.isNonEmptyString = (value: unknown) => isNonEmptyString(value);
parser.functions.includes = (array: unknown, value: unknown) =>
Array.isArray(array) && array.includes(value);
parser.functions.arrayLength = (value: unknown) =>
Array.isArray(value) ? value.length : 0;
parser.functions.every = createArrayPropCheck('every', Boolean);
parser.functions.everyDefined = createArrayPropCheck('every', isDefined);
parser.functions.some = createArrayPropCheck('some', Boolean);
parser.functions.someDefined = createArrayPropCheck('some', isDefined);
parser.functions.someNonEmptyString = createArrayPropCheck(
'some',
isNonEmptyString,
);
parser.functions.none = createArrayPropCheck(
'every',
(value) => !Boolean(value),
);
parser.functions.noneDefined = createArrayPropCheck(
'every',
(value) => !isDefined(value),
);
parser.functions.everyEquals = createArrayPropValueCheck(
'every',
(a, b) => a === b,
);
parser.functions.someEquals = createArrayPropValueCheck(
'some',
(a, b) => a === b,
);
parser.functions.noneEquals = createArrayPropValueCheck(
'every',
(a, b) => a !== b,
);
parser.functions.includesEvery = createArrayPropValueCheck(
'every',
(array, value) => Array.isArray(array) && array.includes(value),
);
parser.functions.includesSome = createArrayPropValueCheck(
'some',
(array, value) => Array.isArray(array) && array.includes(value),
);
parser.functions.includesNone = createArrayPropValueCheck(
'every',
(array, value) => Array.isArray(array) && !array.includes(value),
);
export const evaluateConditionalAvailabilityExpression = (
expression: string | null | undefined,
context: EvaluationContext,
): boolean => {
if (!isNonEmptyString(expression)) {
return true;
}
try {
const parsed = parser.parse(expression);
return parsed.evaluate(context) === true;
} catch {
return false;
}
};
+1 -4
View File
@@ -23,11 +23,8 @@ export { upsertIntoArrayOfObjectsComparingId } from './array/upsertIntoArrayOfOb
export { upsertPropertiesOfItemIntoArrayOfObjectsComparingId } from './array/upsertPropertiesOfItemIntoArrayOfObjectsComparingId';
export { assertUnreachable } from './assertUnreachable';
export { base64UrlEncode } from './base64UrlEncode';
export { conditionalAvailabilityParser } from './command-menu-items/conditionalAvailabilityParser';
export { evaluateConditionalAvailabilityExpression } from './command-menu-items/evaluateConditionalAvailabilityExpression';
export { interpolateCommandMenuItemLabel } from './command-menu-items/interpolateCommandMenuItemLabel';
export { safeGetNestedProperty } from './command-menu-items/safeGetNestedProperty';
export { computeDiffBetweenObjects } from './compute-diff-between-objects';
export { evaluateConditionalAvailabilityExpression } from './conditional-availability/evaluateConditionalAvailabilityExpression';
export { isPlainDateAfter } from './date/isPlainDateAfter';
export { isPlainDateBefore } from './date/isPlainDateBefore';
export { isPlainDateBeforeOrEqual } from './date/isPlainDateBeforeOrEqual';