Compare commits

...

11 Commits

Author SHA1 Message Date
3830ad7b24 first commit
Some checks failed
CI Front / front-sb-test (2, modules) (push) Has been cancelled
CI Front / front-sb-test (2, pages) (push) Has been cancelled
CI Front / front-sb-test (2, performance) (push) Has been cancelled
CI Front / front-sb-test (3, modules) (push) Has been cancelled
CI Front / front-sb-test (3, pages) (push) Has been cancelled
CI Front / front-sb-test (3, performance) (push) Has been cancelled
CI Front / front-sb-test (4, modules) (push) Has been cancelled
CI Front / front-sb-test (4, pages) (push) Has been cancelled
CI Front / front-sb-test (4, performance) (push) Has been cancelled
CI Front / merge-reports-and-check-coverage (modules) (push) Has been cancelled
CI Front / merge-reports-and-check-coverage (pages) (push) Has been cancelled
CI Front / merge-reports-and-check-coverage (performance) (push) Has been cancelled
CI Front / front-chromatic-deployment (push) Has been cancelled
CI Front / front-task (lint) (push) Has been cancelled
CI Front / front-task (test) (push) Has been cancelled
CI Front / front-task (typecheck) (push) Has been cancelled
CI Front / ci-front-status-check (push) Has been cancelled
CI Server / server-setup (push) Has been cancelled
CI Server / server-test (push) Has been cancelled
CI Server / server-integration-test (1) (push) Has been cancelled
CI Server / server-integration-test (2) (push) Has been cancelled
CI Server / server-integration-test (3) (push) Has been cancelled
CI Server / server-integration-test (4) (push) Has been cancelled
CI Server / ci-server-status-check (push) Has been cancelled
CI Shared / shared-test (lint) (push) Has been cancelled
CI Shared / shared-test (test) (push) Has been cancelled
CI Shared / shared-test (typecheck) (push) Has been cancelled
CI Shared / ci-shared-status-check (push) Has been cancelled
CI Website / website-build (push) Has been cancelled
CI Website / ci-website-status-check (push) Has been cancelled
2025-07-29 13:39:41 +05:30
34ae24b7db first commit
Some checks failed
CI Front / front-sb-test (3, performance) (push) Has been cancelled
CI Front / front-sb-test (4, modules) (push) Has been cancelled
CI Front / front-sb-test (4, pages) (push) Has been cancelled
CI Front / front-sb-test (4, performance) (push) Has been cancelled
CI Front / merge-reports-and-check-coverage (modules) (push) Has been cancelled
CI Front / merge-reports-and-check-coverage (pages) (push) Has been cancelled
CI Front / merge-reports-and-check-coverage (performance) (push) Has been cancelled
CI Front / front-chromatic-deployment (push) Has been cancelled
CI Front / front-task (lint) (push) Has been cancelled
CI Front / front-task (test) (push) Has been cancelled
CI Front / front-task (typecheck) (push) Has been cancelled
CI Front / ci-front-status-check (push) Has been cancelled
CI Server / changed-files-check (push) Has been cancelled
CI Server / server-setup (push) Has been cancelled
CI Server / server-test (push) Has been cancelled
CI Server / server-integration-test (1) (push) Has been cancelled
CI Server / server-integration-test (2) (push) Has been cancelled
CI Server / server-integration-test (3) (push) Has been cancelled
CI Server / server-integration-test (4) (push) Has been cancelled
CI Server / ci-server-status-check (push) Has been cancelled
CI Shared / changed-files-check (push) Has been cancelled
CI Shared / shared-test (lint) (push) Has been cancelled
CI Shared / shared-test (test) (push) Has been cancelled
CI Shared / shared-test (typecheck) (push) Has been cancelled
CI Shared / ci-shared-status-check (push) Has been cancelled
CI Website / changed-files-check (push) Has been cancelled
CI Website / website-build (push) Has been cancelled
CI Website / ci-website-status-check (push) Has been cancelled
Push translations to Crowdin / Extract and upload translations (push) Has been cancelled
CI Demo check / test (push) Has been cancelled
2025-07-29 12:23:29 +05:30
7cf778b579 Synchronization between Core Views and Workspace Views (#13461)
Closes https://github.com/twentyhq/core-team-issues/issues/1248

- Create listeners on each CRUD operation for all view related objects
and update the core views accordingly

Some fields have to be parsed since we changed the data model a little
bit when switching to core views.
2025-07-28 14:41:27 +02:00
400dd6d969 bug fix (#13466)
We should not enqueue jobs "unmatch participants" with empty email.

This was done because of deleting persons while persons may have empty
emails

Fixes https://github.com/twentyhq/twenty/issues/13462
2025-07-28 13:56:20 +02:00
ae47157818 Fix empty emails critical bug (#13465)
Fixes https://github.com/twentyhq/twenty/issues/13398

This bug was introduced by https://github.com/twentyhq/twenty/pull/13215

The emails were empty in scenarios where the user wasn't authenticated
(reset passwords for instance) because in `hydrateGraphqlRequest` the
information about the locale was added only if the user was
authenticated `!this.isTokenPresent(request)`. So the locale was
undefined making ` i18n.activate(undefined) ` fail silently resulting in
an empty email.
2025-07-28 12:56:29 +02:00
c59eb20886 Fix metadata playground not accessible (#13446)
as title


## Issue
<img width="982" height="487" alt="image"
src="https://github.com/user-attachments/assets/536567a3-ec36-45ed-b928-5ba4fd98ea70"
/>
2025-07-25 17:26:29 +02:00
8ea816b7ef morph relation : renaming an object (#13404)
# Why
If we have a Morph Relation, like :
Opportunity <-> Company & People

Let's say it's a MANY_TO_ONE on Opportunity side

Then we have two joinColumnNames looking like
- ownerPersonId
- ownerCompanyId

Let's say someone renames the obejct Person (assume we can even though
standard obejcts cannot be renames per say at the moment in the API)

We need to update the joinColumnName and create the associated
migrations
2025-07-25 16:40:51 +02:00
9380a1386a Fix missing components.schema for webhooks (#13433)
Webhooks are still documented in core playground to detail Webhooks of
core objects.
We moved webhooks to metadata playground and forgot to keep computation
of WebhookForResponse

This PR adds it back
2025-07-25 16:36:11 +02:00
d1b11bafe6 remove search any field feature flag (#13442)
<img width="1189" height="368" alt="Screenshot 2025-07-25 at 15 18 39"
src="https://github.com/user-attachments/assets/22879517-60bc-4884-8386-35a0b06a6636"
/>
2025-07-25 15:32:24 +02:00
523d0ac17c Revert "Added public lab feature flag for search any field" (#13441)
Reverts twentyhq/twenty#13430

We will release this in the 1.2
2025-07-25 15:10:58 +02:00
13bed8e4d2 fix createMany resolver when updating softdeleted record (#13425)
Context: updateOne/Many works on soft deleted records but the update of
the upsertMany throws error

fixes https://github.com/twentyhq/twenty/issues/13195
2025-07-25 14:35:04 +02:00
62 changed files with 1980 additions and 131 deletions

View File

@ -1,5 +1,5 @@
VITE_SERVER_BASE_URL=https://api.twenty.com VITE_SERVER_BASE_URL=https://crm.rootxwire.com
VITE_FRONT_BASE_URL=https://app.twenty.com VITE_FRONT_BASE_URL=https://crm.rootxwire.com
VITE_MODE=production VITE_MODE=production
# Used to generate packages/twenty-chrome-extension/src/generated/graphql.tsx # Used to generate packages/twenty-chrome-extension/src/generated/graphql.tsx

View File

@ -6,7 +6,7 @@ services:
volumes: volumes:
- server-local-data:/app/packages/twenty-server/.local-storage - server-local-data:/app/packages/twenty-server/.local-storage
ports: ports:
- "3000:3000" - "9026:3000"
environment: environment:
NODE_PORT: 3000 NODE_PORT: 3000
PG_DATABASE_URL: postgres://${PG_DATABASE_USER:-postgres}:${PG_DATABASE_PASSWORD:-postgres}@${PG_DATABASE_HOST:-db}:${PG_DATABASE_PORT:-5432}/default PG_DATABASE_URL: postgres://${PG_DATABASE_USER:-postgres}:${PG_DATABASE_PASSWORD:-postgres}@${PG_DATABASE_HOST:-db}:${PG_DATABASE_PORT:-5432}/default

View File

@ -6,10 +6,10 @@
<link <link
rel="icon" rel="icon"
type="image/x-icon" type="image/x-icon"
href="/images/icons/android/android-launchericon-48-48.png" href="/images/icons/android/48B.png"
data-rh="true" data-rh="true"
/> />
<link rel="apple-touch-icon" href="/images/icons/ios/192.png" /> <link rel="apple-touch-icon" href="/images/icons/android/192B" />
<link rel="manifest" href="/manifest.json" /> <link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#000000" /> <meta name="theme-color" content="#000000" />
@ -19,7 +19,7 @@
content="https://raw.githubusercontent.com/twentyhq/twenty/main/docs/static/img/social-card.png" content="https://raw.githubusercontent.com/twentyhq/twenty/main/docs/static/img/social-card.png"
/> />
<meta property="og:description" content="A modern open-source CRM" /> <meta property="og:description" content="A modern open-source CRM" />
<meta property="og:title" content="Twenty" /> <meta property="og:title" content="MessageKnot" />
<meta name="twitter:card" content="summary_large_image" /> <meta name="twitter:card" content="summary_large_image" />
<meta <meta
name="twitter:image" name="twitter:image"
@ -27,13 +27,13 @@
/> />
<meta name="twitter:description" content="A modern open-source CRM" /> <meta name="twitter:description" content="A modern open-source CRM" />
<meta name="twitter:title" content="Twenty" /> <meta name="twitter:title" content="MessageKnot" />
<link <link
rel="stylesheet" rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap"
/> />
<title>Twenty</title> <title>MessageKnot</title>
<!-- BEGIN: Twenty Config --> <!-- BEGIN: Twenty Config -->
<script id="twenty-env-config"> <script id="twenty-env-config">
window._env_ = { window._env_ = {

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@ -1,6 +1,6 @@
{ {
"short_name": "Twenty", "short_name": "MessageKnot",
"name": "Twenty", "name": "MessageKnot",
"start_url": ".", "start_url": ".",
"display": "standalone", "display": "standalone",
"theme_color": "#000000", "theme_color": "#000000",

View File

@ -740,7 +740,6 @@ export type FeatureFlagDto = {
export enum FeatureFlagKey { export enum FeatureFlagKey {
IS_AIRTABLE_INTEGRATION_ENABLED = 'IS_AIRTABLE_INTEGRATION_ENABLED', IS_AIRTABLE_INTEGRATION_ENABLED = 'IS_AIRTABLE_INTEGRATION_ENABLED',
IS_AI_ENABLED = 'IS_AI_ENABLED', IS_AI_ENABLED = 'IS_AI_ENABLED',
IS_ANY_FIELD_SEARCH_ENABLED = 'IS_ANY_FIELD_SEARCH_ENABLED',
IS_CORE_VIEW_SYNCING_ENABLED = 'IS_CORE_VIEW_SYNCING_ENABLED', IS_CORE_VIEW_SYNCING_ENABLED = 'IS_CORE_VIEW_SYNCING_ENABLED',
IS_FIELDS_PERMISSIONS_ENABLED = 'IS_FIELDS_PERMISSIONS_ENABLED', IS_FIELDS_PERMISSIONS_ENABLED = 'IS_FIELDS_PERMISSIONS_ENABLED',
IS_IMAP_SMTP_CALDAV_ENABLED = 'IS_IMAP_SMTP_CALDAV_ENABLED', IS_IMAP_SMTP_CALDAV_ENABLED = 'IS_IMAP_SMTP_CALDAV_ENABLED',

View File

@ -704,7 +704,6 @@ export type FeatureFlagDto = {
export enum FeatureFlagKey { export enum FeatureFlagKey {
IS_AIRTABLE_INTEGRATION_ENABLED = 'IS_AIRTABLE_INTEGRATION_ENABLED', IS_AIRTABLE_INTEGRATION_ENABLED = 'IS_AIRTABLE_INTEGRATION_ENABLED',
IS_AI_ENABLED = 'IS_AI_ENABLED', IS_AI_ENABLED = 'IS_AI_ENABLED',
IS_ANY_FIELD_SEARCH_ENABLED = 'IS_ANY_FIELD_SEARCH_ENABLED',
IS_CORE_VIEW_SYNCING_ENABLED = 'IS_CORE_VIEW_SYNCING_ENABLED', IS_CORE_VIEW_SYNCING_ENABLED = 'IS_CORE_VIEW_SYNCING_ENABLED',
IS_FIELDS_PERMISSIONS_ENABLED = 'IS_FIELDS_PERMISSIONS_ENABLED', IS_FIELDS_PERMISSIONS_ENABLED = 'IS_FIELDS_PERMISSIONS_ENABLED',
IS_IMAP_SMTP_CALDAV_ENABLED = 'IS_IMAP_SMTP_CALDAV_ENABLED', IS_IMAP_SMTP_CALDAV_ENABLED = 'IS_IMAP_SMTP_CALDAV_ENABLED',

View File

@ -57,7 +57,7 @@ export const Logo = ({
onClick, onClick,
}: LogoProps) => { }: LogoProps) => {
const { redirectToDefaultDomain } = useRedirectToDefaultDomain(); const { redirectToDefaultDomain } = useRedirectToDefaultDomain();
const defaultPrimaryLogoUrl = `${window.location.origin}/images/icons/android/android-launchericon-192-192.png`; const defaultPrimaryLogoUrl = `${window.location.origin}/images/icons/android/192B.png`;
const primaryLogoUrl = getImageAbsoluteURI({ const primaryLogoUrl = getImageAbsoluteURI({
imageUrl: primaryLogo ?? defaultPrimaryLogoUrl, imageUrl: primaryLogo ?? defaultPrimaryLogoUrl,

View File

@ -20,7 +20,7 @@ const StyledContainer = styled.div`
export const FooterNote = () => ( export const FooterNote = () => (
<StyledContainer> <StyledContainer>
<Trans>By using Twenty, you agree to the</Trans>{' '} <Trans>By using MessageKnot, you agree to the</Trans>{' '}
<a <a
href="https://twenty.com/legal/terms" href="https://twenty.com/legal/terms"
target="_blank" target="_blank"

View File

@ -10,7 +10,6 @@ import { t } from '@lingui/core/macro';
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
import { import {
IconApi, IconApi,
IconApps,
IconAt, IconAt,
IconCalendarEvent, IconCalendarEvent,
IconColorSwatch, IconColorSwatch,
@ -23,12 +22,11 @@ import {
IconKey, IconKey,
IconLock, IconLock,
IconMail, IconMail,
IconRocket,
IconServer, IconServer,
IconSettings, IconSettings,
IconUserCircle, IconUserCircle,
IconUsers, IconUsers,
IconWebhook, IconWebhook
} from 'twenty-ui/display'; } from 'twenty-ui/display';
import { PermissionFlagType } from '~/generated/graphql'; import { PermissionFlagType } from '~/generated/graphql';
@ -134,12 +132,12 @@ const useSettingsNavigationItems = (): SettingsNavigationSection[] => {
Icon: IconHierarchy2, Icon: IconHierarchy2,
isHidden: !permissionMap[PermissionFlagType.DATA_MODEL], isHidden: !permissionMap[PermissionFlagType.DATA_MODEL],
}, },
{ // {
label: t`Integrations`, // label: t`Integrations`,
path: SettingsPath.Integrations, // path: SettingsPath.Integrations,
Icon: IconApps, // Icon: IconApps,
isHidden: !permissionMap[PermissionFlagType.API_KEYS_AND_WEBHOOKS], // isHidden: !permissionMap[PermissionFlagType.API_KEYS_AND_WEBHOOKS],
}, // },
{ {
label: t`Security`, label: t`Security`,
path: SettingsPath.Security, path: SettingsPath.Security,
@ -193,11 +191,11 @@ const useSettingsNavigationItems = (): SettingsNavigationSection[] => {
!labPublicFeatureFlags.length || !labPublicFeatureFlags.length ||
!permissionMap[PermissionFlagType.WORKSPACE], !permissionMap[PermissionFlagType.WORKSPACE],
}, },
{ // {
label: t`Releases`, // label: t`Releases`,
path: SettingsPath.Releases, // path: SettingsPath.Releases,
Icon: IconRocket, // Icon: IconRocket,
}, // },
{ {
label: t`Logout`, label: t`Logout`,
onClick: signOut, onClick: signOut,

View File

@ -81,6 +81,7 @@ export const RestPlayground = ({ onError, schema }: RestPlaygroundProps) => {
forceDarkModeState: theme.name === 'dark' ? 'dark' : 'light', forceDarkModeState: theme.name === 'dark' ? 'dark' : 'light',
hideClientButton: true, hideClientButton: true,
hideDarkModeToggle: true, hideDarkModeToggle: true,
hideModels: schema === 'metadata',
pathRouting: { pathRouting: {
basePath: getSettingsPath(SettingsPath.RestPlayground, { basePath: getSettingsPath(SettingsPath.RestPlayground, {
schema, schema,

View File

@ -62,7 +62,7 @@ export const Default: Story = {
that I expect you to, anyways. :) that I expect you to, anyways. :)
</Modal.Content> </Modal.Content>
<Modal.Footer> <Modal.Footer>
By using Twenty, you're opting for the finest CRM experience you'll By using MessageKnot, you're opting for the finest CRM experience you'll
ever encounter. ever encounter.
</Modal.Footer> </Modal.Footer>
</> </>

View File

@ -1,9 +1,6 @@
import { ViewBarFilterDropdownAdvancedFilterButton } from '@/views/components/ViewBarFilterDropdownAdvancedFilterButton'; import { ViewBarFilterDropdownAdvancedFilterButton } from '@/views/components/ViewBarFilterDropdownAdvancedFilterButton';
import { ViewBarFilterDropdownAnyFieldSearchButton } from '@/views/components/ViewBarFilterDropdownAnyFieldSearchButton'; import { ViewBarFilterDropdownAnyFieldSearchButton } from '@/views/components/ViewBarFilterDropdownAnyFieldSearchButton';
import { ViewBarFilterDropdownVectorSearchButton } from '@/views/components/ViewBarFilterDropdownVectorSearchButton';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
const StyledContainer = styled.div` const StyledContainer = styled.div`
display: flex; display: flex;
@ -14,17 +11,9 @@ const StyledContainer = styled.div`
`; `;
export const ViewBarFilterDropdownBottomMenu = () => { export const ViewBarFilterDropdownBottomMenu = () => {
const isAnyFieldSearchEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_ANY_FIELD_SEARCH_ENABLED,
);
return ( return (
<StyledContainer> <StyledContainer>
{isAnyFieldSearchEnabled ? (
<ViewBarFilterDropdownAnyFieldSearchButton /> <ViewBarFilterDropdownAnyFieldSearchButton />
) : (
<ViewBarFilterDropdownVectorSearchButton />
)}
<ViewBarFilterDropdownAdvancedFilterButton /> <ViewBarFilterDropdownAdvancedFilterButton />
</StyledContainer> </StyledContainer>
); );

View File

@ -26,6 +26,7 @@ import { ViewFilterWorkspaceEntity } from 'src/modules/view/standard-objects/vie
import { ViewGroupWorkspaceEntity } from 'src/modules/view/standard-objects/view-group.workspace-entity'; import { ViewGroupWorkspaceEntity } from 'src/modules/view/standard-objects/view-group.workspace-entity';
import { ViewSortWorkspaceEntity } from 'src/modules/view/standard-objects/view-sort.workspace-entity'; import { ViewSortWorkspaceEntity } from 'src/modules/view/standard-objects/view-sort.workspace-entity';
import { ViewWorkspaceEntity } from 'src/modules/view/standard-objects/view.workspace-entity'; import { ViewWorkspaceEntity } from 'src/modules/view/standard-objects/view.workspace-entity';
import { transformViewFilterWorkspaceValueToCoreValue } from 'src/modules/view/utils/transform-view-filter-workspace-value-to-core-value';
@Command({ @Command({
name: 'migrate:views-to-core', name: 'migrate:views-to-core',
@ -298,22 +299,12 @@ export class MigrateViewsToCoreCommand extends ActiveOrSuspendedWorkspacesMigrat
continue; continue;
} }
let parsedValue: JSON;
try {
parsedValue = JSON.parse(filter.value);
} catch {
throw new Error(
`Could not parse value to JSON for view filter ${filter.id} for workspace ${workspaceId}`,
);
}
const coreViewFilter: Partial<ViewFilter> = { const coreViewFilter: Partial<ViewFilter> = {
id: filter.id, id: filter.id,
fieldMetadataId: filter.fieldMetadataId, fieldMetadataId: filter.fieldMetadataId,
viewId: filter.viewId, viewId: filter.viewId,
operand: filter.operand, operand: filter.operand,
value: parsedValue, value: transformViewFilterWorkspaceValueToCoreValue(filter.value),
viewFilterGroupId: filter.viewFilterGroupId, viewFilterGroupId: filter.viewFilterGroupId,
workspaceId, workspaceId,
createdAt: new Date(filter.createdAt), createdAt: new Date(filter.createdAt),

View File

@ -210,6 +210,7 @@ export class GraphqlQueryCreateManyResolverService extends GraphqlQueryBaseResol
.setFindOptions({ .setFindOptions({
select: selectedColumns, select: selectedColumns,
}) })
.withDeleted()
.getMany(); .getMany();
} }

View File

@ -30,15 +30,6 @@ export const PUBLIC_FEATURE_FLAGS: PublicFeatureFlag[] = [
imagePath: '', imagePath: '',
}, },
}, },
{
key: FeatureFlagKey.IS_ANY_FIELD_SEARCH_ENABLED,
metadata: {
label: 'Any field filter',
description:
'Search multiple fields at the same time with the new "Search any field" feature on tables and kanbans',
imagePath: '',
},
},
...(process.env.CLOUDFLARE_API_KEY ...(process.env.CLOUDFLARE_API_KEY
? [ ? [
// { // {

View File

@ -11,7 +11,6 @@ export enum FeatureFlagKey {
IS_RELATION_CONNECT_ENABLED = 'IS_RELATION_CONNECT_ENABLED', IS_RELATION_CONNECT_ENABLED = 'IS_RELATION_CONNECT_ENABLED',
IS_WORKSPACE_API_KEY_WEBHOOK_GRAPHQL_ENABLED = 'IS_WORKSPACE_API_KEY_WEBHOOK_GRAPHQL_ENABLED', IS_WORKSPACE_API_KEY_WEBHOOK_GRAPHQL_ENABLED = 'IS_WORKSPACE_API_KEY_WEBHOOK_GRAPHQL_ENABLED',
IS_FIELDS_PERMISSIONS_ENABLED = 'IS_FIELDS_PERMISSIONS_ENABLED', IS_FIELDS_PERMISSIONS_ENABLED = 'IS_FIELDS_PERMISSIONS_ENABLED',
IS_ANY_FIELD_SEARCH_ENABLED = 'IS_ANY_FIELD_SEARCH_ENABLED',
IS_CORE_VIEW_SYNCING_ENABLED = 'IS_CORE_VIEW_SYNCING_ENABLED', IS_CORE_VIEW_SYNCING_ENABLED = 'IS_CORE_VIEW_SYNCING_ENABLED',
IS_TWO_FACTOR_AUTHENTICATION_ENABLED = 'IS_TWO_FACTOR_AUTHENTICATION_ENABLED', IS_TWO_FACTOR_AUTHENTICATION_ENABLED = 'IS_TWO_FACTOR_AUTHENTICATION_ENABLED',
} }

View File

@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
import { Request } from 'express'; import { Request } from 'express';
import { OpenAPIV3_1 } from 'openapi-types'; import { OpenAPIV3_1 } from 'openapi-types';
import { capitalize } from 'twenty-shared/utils'; import { capitalize, isDefined } from 'twenty-shared/utils';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action'; import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
@ -42,6 +42,7 @@ import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metada
import { standardObjectMetadataDefinitions } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-objects'; import { standardObjectMetadataDefinitions } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-objects';
import { shouldExcludeFromWorkspaceApi } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/should-exclude-from-workspace-api.util'; import { shouldExcludeFromWorkspaceApi } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/should-exclude-from-workspace-api.util';
import { getServerUrl } from 'src/utils/get-server-url'; import { getServerUrl } from 'src/utils/get-server-url';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
@Injectable() @Injectable()
export class OpenApiService { export class OpenApiService {
@ -52,6 +53,30 @@ export class OpenApiService {
private readonly featureFlagService: FeatureFlagService, private readonly featureFlagService: FeatureFlagService,
) {} ) {}
private async getWorkspaceFromRequest(request: Request) {
try {
const { workspace } =
await this.accessTokenService.validateTokenByRequest(request);
workspaceValidator.assertIsDefinedOrThrow(workspace);
return workspace;
} catch (e) {
return null;
}
}
private async getObjectMetadataItems(workspace: Workspace) {
return await this.objectMetadataService.findManyWithinWorkspace(
workspace.id,
{
order: {
namePlural: 'ASC',
},
},
);
}
async generateCoreSchema(request: Request): Promise<OpenAPIV3_1.Document> { async generateCoreSchema(request: Request): Promise<OpenAPIV3_1.Document> {
const baseUrl = getServerUrl( const baseUrl = getServerUrl(
this.twentyConfigService.get('SERVER_URL'), this.twentyConfigService.get('SERVER_URL'),
@ -60,26 +85,14 @@ export class OpenApiService {
const schema = baseSchema('core', baseUrl); const schema = baseSchema('core', baseUrl);
let objectMetadataItems; const workspace = await this.getWorkspaceFromRequest(request);
let workspace;
try { if (!isDefined(workspace)) {
const authResult =
await this.accessTokenService.validateTokenByRequest(request);
workspace = authResult.workspace;
workspaceValidator.assertIsDefinedOrThrow(workspace);
objectMetadataItems =
await this.objectMetadataService.findManyWithinWorkspace(workspace.id, {
order: {
namePlural: 'ASC',
},
});
} catch (err) {
return schema; return schema;
} }
const objectMetadataItems = await this.getObjectMetadataItems(workspace);
if (!objectMetadataItems.length) { if (!objectMetadataItems.length) {
return schema; return schema;
} }
@ -105,7 +118,7 @@ export class OpenApiService {
return paths; return paths;
}, schema.paths as OpenAPIV3_1.PathsObject); }, schema.paths as OpenAPIV3_1.PathsObject);
schema.webhooks = objectMetadataItems.reduce( schema.webhooks = filteredObjectMetadataItems.reduce(
(paths, item) => { (paths, item) => {
paths[ paths[
this.createWebhookEventName( this.createWebhookEventName(
@ -134,8 +147,6 @@ export class OpenApiService {
>, >,
); );
schema.tags = computeSchemaTags(objectMetadataItems);
schema.components = { schema.components = {
...schema.components, // components.securitySchemes is defined in base Schema ...schema.components, // components.securitySchemes is defined in base Schema
schemas: computeSchemaComponents(filteredObjectMetadataItems), schemas: computeSchemaComponents(filteredObjectMetadataItems),
@ -161,7 +172,11 @@ export class OpenApiService {
const schema = baseSchema('metadata', baseUrl); const schema = baseSchema('metadata', baseUrl);
schema.tags = [{ name: 'placeholder' }]; const workspace = await this.getWorkspaceFromRequest(request);
if (!isDefined(workspace)) {
return schema;
}
const metadata = [ const metadata = [
{ {
@ -177,7 +192,7 @@ export class OpenApiService {
namePlural: 'webhooks', namePlural: 'webhooks',
}, },
{ {
nameSingular: 'apikey', nameSingular: 'apiKey',
namePlural: 'apiKeys', namePlural: 'apiKeys',
}, },
]; ];
@ -249,9 +264,18 @@ export class OpenApiService {
return path; return path;
}, schema.paths as OpenAPIV3_1.PathsObject); }, schema.paths as OpenAPIV3_1.PathsObject);
const objectMetadataItems = await this.getObjectMetadataItems(workspace);
const webhookAndApiKeyObjectMetadataItems = objectMetadataItems.filter(
({ nameSingular }) => ['webhook', 'apiKey'].includes(nameSingular),
);
schema.components = { schema.components = {
...schema.components, // components.securitySchemes is defined in base Schema ...schema.components, // components.securitySchemes is defined in base Schema
schemas: computeMetadataSchemaComponents(metadata), schemas: {
...computeMetadataSchemaComponents(metadata),
...computeSchemaComponents(webhookAndApiKeyObjectMetadataItems),
},
parameters: computeParameterComponents(true), parameters: computeParameterComponents(true),
responses: { responses: {
'400': get400ErrorResponses(), '400': get400ErrorResponses(),
@ -259,6 +283,8 @@ export class OpenApiService {
}, },
}; };
schema.tags = computeSchemaTags(webhookAndApiKeyObjectMetadataItems);
return schema; return schema;
} }

View File

@ -239,10 +239,13 @@ export const getJsonResponse = () => {
servers: { servers: {
type: 'array', type: 'array',
items: { items: {
type: 'object',
properties: {
url: { type: 'string' }, url: { type: 'string' },
description: { type: 'string' }, description: { type: 'string' },
}, },
}, },
},
components: { components: {
type: 'object', type: 'object',
properties: { properties: {

View File

@ -17,4 +17,5 @@ export enum ObjectMetadataExceptionCode {
OBJECT_MUTATION_NOT_ALLOWED = 'OBJECT_MUTATION_NOT_ALLOWED', OBJECT_MUTATION_NOT_ALLOWED = 'OBJECT_MUTATION_NOT_ALLOWED',
OBJECT_ALREADY_EXISTS = 'OBJECT_ALREADY_EXISTS', OBJECT_ALREADY_EXISTS = 'OBJECT_ALREADY_EXISTS',
MISSING_CUSTOM_OBJECT_DEFAULT_LABEL_IDENTIFIER_FIELD = 'MISSING_CUSTOM_OBJECT_DEFAULT_LABEL_IDENTIFIER_FIELD', MISSING_CUSTOM_OBJECT_DEFAULT_LABEL_IDENTIFIER_FIELD = 'MISSING_CUSTOM_OBJECT_DEFAULT_LABEL_IDENTIFIER_FIELD',
INVALID_ORM_OUTPUT = 'INVALID_ORM_OUTPUT',
} }

View File

@ -606,6 +606,21 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
queryRunner, queryRunner,
); );
const morphRelationFieldMetadataToUpdate =
await this.objectMetadataFieldRelationService.updateMorphRelationsJoinColumnName(
{
existingObjectMetadata,
objectMetadataForUpdate,
queryRunner,
},
);
await this.objectMetadataMigrationService.updateMorphRelationMigrations({
workspaceId: objectMetadataForUpdate.workspaceId,
morphRelationFieldMetadataToUpdate: morphRelationFieldMetadataToUpdate,
queryRunner,
});
await this.objectMetadataMigrationService.recomputeEnumNames( await this.objectMetadataMigrationService.recomputeEnumNames(
objectMetadataForUpdate, objectMetadataForUpdate,
objectMetadataForUpdate.workspaceId, objectMetadataForUpdate.workspaceId,

View File

@ -9,7 +9,12 @@ import { v4 as uuidV4 } from 'uuid';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface'; import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { computeMorphRelationFieldJoinColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-morph-relation-field-join-column-name.util';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import {
ObjectMetadataException,
ObjectMetadataExceptionCode,
} from 'src/engine/metadata-modules/object-metadata/object-metadata.exception';
import { buildDescriptionForRelationFieldMetadataOnFromField } from 'src/engine/metadata-modules/object-metadata/utils/build-description-for-relation-field-on-from-field.util'; import { buildDescriptionForRelationFieldMetadataOnFromField } from 'src/engine/metadata-modules/object-metadata/utils/build-description-for-relation-field-on-from-field.util';
import { buildDescriptionForRelationFieldMetadataOnToField } from 'src/engine/metadata-modules/object-metadata/utils/build-description-for-relation-field-on-to-field.util'; import { buildDescriptionForRelationFieldMetadataOnToField } from 'src/engine/metadata-modules/object-metadata/utils/build-description-for-relation-field-on-to-field.util';
import { RelationOnDeleteAction } from 'src/engine/metadata-modules/relation-metadata/relation-on-delete-action.type'; import { RelationOnDeleteAction } from 'src/engine/metadata-modules/relation-metadata/relation-on-delete-action.type';
@ -412,4 +417,108 @@ export class ObjectMetadataFieldRelationService {
description, description,
}; };
} }
private validateFieldMetadataTypeIsMorphRelation = (
fieldMetadatas: FieldMetadataEntity[],
): fieldMetadatas is Array<
FieldMetadataEntity & FieldMetadataEntity<FieldMetadataType.MORPH_RELATION>
> => {
return fieldMetadatas.every(
(fieldMetadata) =>
fieldMetadata.type === FieldMetadataType.MORPH_RELATION,
);
};
public async findTargetMorphRelationFieldMetadatas(
objectMetadataId: string,
): Promise<FieldMetadataEntity<FieldMetadataType.MORPH_RELATION>[]> {
const fieldMetadatas = await this.fieldMetadataRepository.find({
where: {
relationTargetObjectMetadataId: objectMetadataId,
type: FieldMetadataType.MORPH_RELATION,
},
relations: {
relationTargetObjectMetadata: true,
object: true,
},
});
if (!this.validateFieldMetadataTypeIsMorphRelation(fieldMetadatas)) {
throw new ObjectMetadataException(
'Invalid field metadata type. Expected MORPH_RELATION only',
ObjectMetadataExceptionCode.INVALID_ORM_OUTPUT,
);
}
return fieldMetadatas;
}
public async updateMorphRelationsJoinColumnName({
existingObjectMetadata,
objectMetadataForUpdate,
queryRunner,
}: {
existingObjectMetadata: Pick<
ObjectMetadataItemWithFieldMaps,
'nameSingular' | 'isCustom' | 'id' | 'labelPlural' | 'icon' | 'fieldsById'
>;
objectMetadataForUpdate: Pick<
ObjectMetadataItemWithFieldMaps,
| 'nameSingular'
| 'isCustom'
| 'workspaceId'
| 'id'
| 'labelSingular'
| 'labelPlural'
| 'icon'
| 'fieldsById'
>;
queryRunner: QueryRunner;
}): Promise<
{
fieldMetadata: FieldMetadataEntity<FieldMetadataType.MORPH_RELATION>;
newJoinColumnName: string;
}[]
> {
const fieldMetadataRepository =
queryRunner.manager.getRepository(FieldMetadataEntity);
const morphRelationFieldMetadataTargets =
await this.findTargetMorphRelationFieldMetadatas(
existingObjectMetadata.id,
);
const morphRelationFieldMetadataToUpdate =
morphRelationFieldMetadataTargets.filter(
(morphRelationFieldMetadata) =>
morphRelationFieldMetadata.settings?.relationType ===
RelationType.MANY_TO_ONE,
);
const morphRelationFieldMetadataToUpdateWithNewJoinColumnName = [];
if (morphRelationFieldMetadataToUpdate.length > 0) {
for (const morphRelationFieldMetadata of morphRelationFieldMetadataToUpdate) {
const newJoinColumnName = computeMorphRelationFieldJoinColumnName({
name: morphRelationFieldMetadata.name,
targetObjectMetadataNameSingular:
objectMetadataForUpdate.nameSingular,
});
await fieldMetadataRepository.save({
...morphRelationFieldMetadata,
settings: {
...morphRelationFieldMetadata.settings,
joinColumnName: newJoinColumnName,
},
});
morphRelationFieldMetadataToUpdateWithNewJoinColumnName.push({
fieldMetadata: morphRelationFieldMetadata,
newJoinColumnName,
});
}
}
return morphRelationFieldMetadataToUpdateWithNewJoinColumnName;
}
} }

View File

@ -8,6 +8,10 @@ import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfa
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; 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 { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import {
ObjectMetadataException,
ObjectMetadataExceptionCode,
} from 'src/engine/metadata-modules/object-metadata/object-metadata.exception';
import { buildMigrationsForCustomObjectRelations } from 'src/engine/metadata-modules/object-metadata/utils/build-migrations-for-custom-object-relations.util'; import { buildMigrationsForCustomObjectRelations } from 'src/engine/metadata-modules/object-metadata/utils/build-migrations-for-custom-object-relations.util';
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps'; import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { fieldMetadataTypeToColumnType } from 'src/engine/metadata-modules/workspace-migration/utils/field-metadata-type-to-column-type.util'; import { fieldMetadataTypeToColumnType } from 'src/engine/metadata-modules/workspace-migration/utils/field-metadata-type-to-column-type.util';
@ -360,4 +364,61 @@ export class ObjectMetadataMigrationService {
); );
} }
} }
public async updateMorphRelationMigrations({
workspaceId,
morphRelationFieldMetadataToUpdate,
queryRunner,
}: {
workspaceId: string;
morphRelationFieldMetadataToUpdate: {
fieldMetadata: FieldMetadataEntity<FieldMetadataType.MORPH_RELATION>;
newJoinColumnName: string;
}[];
queryRunner?: QueryRunner;
}) {
for (const morphRelationFieldMetadata of morphRelationFieldMetadataToUpdate) {
if (!morphRelationFieldMetadata.fieldMetadata.settings?.joinColumnName) {
throw new ObjectMetadataException(
`Settings for morph relation field should be defined ${morphRelationFieldMetadata.fieldMetadata.name}`,
ObjectMetadataExceptionCode.INVALID_ORM_OUTPUT,
);
}
await this.workspaceMigrationService.createCustomMigration(
generateMigrationName(
`rename-join-column-name-${morphRelationFieldMetadata.fieldMetadata.name}-to-${morphRelationFieldMetadata.newJoinColumnName}-in-${morphRelationFieldMetadata.fieldMetadata.object.nameSingular}`,
),
workspaceId,
[
{
name: computeObjectTargetTable(
morphRelationFieldMetadata.fieldMetadata.object,
),
action: WorkspaceMigrationTableActionType.ALTER,
columns: [
{
action: WorkspaceMigrationColumnActionType.ALTER,
currentColumnDefinition: {
columnName:
morphRelationFieldMetadata.fieldMetadata.settings
?.joinColumnName,
columnType: 'uuid',
isNullable: true,
defaultValue: null,
},
alteredColumnDefinition: {
columnName: `${morphRelationFieldMetadata.newJoinColumnName}`,
columnType: 'uuid',
isNullable: true,
defaultValue: null,
},
},
],
},
],
queryRunner,
);
}
}
} }

View File

@ -478,4 +478,53 @@ describe('resolveObjectMetadataStandardOverride', () => {
expect(mockI18n._).toHaveBeenCalledWith('auto.translation.id'); expect(mockI18n._).toHaveBeenCalledWith('auto.translation.id');
}); });
}); });
describe('Undefined locale handling', () => {
it('should use SOURCE_LOCALE fallback when locale is undefined for standard object', () => {
const objectMetadata = {
labelSingular: 'Standard Label',
labelPlural: 'Standard Labels',
description: 'Standard Description',
icon: 'default-icon',
isCustom: false,
standardOverrides: {
labelSingular: 'Source Override',
},
};
const result = resolveObjectMetadataStandardOverride(
objectMetadata,
'labelSingular',
undefined,
);
expect(result).toBe('Source Override');
expect(mockGenerateMessageId).not.toHaveBeenCalled();
expect(mockI18n._).not.toHaveBeenCalled();
});
it('should fall back to auto translation when locale is undefined and no SOURCE_LOCALE override exists', () => {
mockI18n._.mockReturnValue('Auto Translated Label');
mockGenerateMessageId.mockReturnValue('auto.translation.id');
const objectMetadata = {
labelSingular: 'Standard Label',
labelPlural: 'Standard Labels',
description: 'Standard Description',
icon: 'default-icon',
isCustom: false,
standardOverrides: undefined,
};
const result = resolveObjectMetadataStandardOverride(
objectMetadata,
'labelSingular',
undefined,
);
expect(result).toBe('Auto Translated Label');
expect(mockGenerateMessageId).toHaveBeenCalledWith('Standard Label');
expect(mockI18n._).toHaveBeenCalledWith('auto.translation.id');
});
});
}); });

View File

@ -1,6 +1,7 @@
import { import {
ConflictError, ConflictError,
ForbiddenError, ForbiddenError,
InternalServerError,
NotFoundError, NotFoundError,
UserInputError, UserInputError,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
@ -25,6 +26,8 @@ export const objectMetadataGraphqlApiExceptionHandler = (error: Error) => {
throw new ForbiddenError(error); throw new ForbiddenError(error);
case ObjectMetadataExceptionCode.OBJECT_ALREADY_EXISTS: case ObjectMetadataExceptionCode.OBJECT_ALREADY_EXISTS:
throw new ConflictError(error); throw new ConflictError(error);
case ObjectMetadataExceptionCode.INVALID_ORM_OUTPUT:
throw new InternalServerError(error);
case ObjectMetadataExceptionCode.MISSING_CUSTOM_OBJECT_DEFAULT_LABEL_IDENTIFIER_FIELD: case ObjectMetadataExceptionCode.MISSING_CUSTOM_OBJECT_DEFAULT_LABEL_IDENTIFIER_FIELD:
throw error; throw error;
default: { default: {

View File

@ -19,6 +19,8 @@ export const resolveObjectMetadataStandardOverride = (
labelKey: 'labelPlural' | 'labelSingular' | 'description' | 'icon', labelKey: 'labelPlural' | 'labelSingular' | 'description' | 'icon',
locale: keyof typeof APP_LOCALES | undefined, locale: keyof typeof APP_LOCALES | undefined,
): string => { ): string => {
const safeLocale = locale ?? SOURCE_LOCALE;
if (objectMetadata.isCustom) { if (objectMetadata.isCustom) {
return objectMetadata[labelKey] ?? ''; return objectMetadata[labelKey] ?? '';
} }
@ -32,11 +34,10 @@ export const resolveObjectMetadataStandardOverride = (
if ( if (
isDefined(objectMetadata.standardOverrides?.translations) && isDefined(objectMetadata.standardOverrides?.translations) &&
isDefined(locale) &&
labelKey !== 'icon' labelKey !== 'icon'
) { ) {
const translationValue = const translationValue =
objectMetadata.standardOverrides.translations[locale]?.[labelKey]; objectMetadata.standardOverrides.translations[safeLocale]?.[labelKey];
if (isDefined(translationValue)) { if (isDefined(translationValue)) {
return translationValue; return translationValue;
@ -44,7 +45,7 @@ export const resolveObjectMetadataStandardOverride = (
} }
if ( if (
locale === SOURCE_LOCALE && safeLocale === SOURCE_LOCALE &&
isNonEmptyString(objectMetadata.standardOverrides?.[labelKey]) isNonEmptyString(objectMetadata.standardOverrides?.[labelKey])
) { ) {
return objectMetadata.standardOverrides[labelKey] ?? ''; return objectMetadata.standardOverrides[labelKey] ?? '';

View File

@ -0,0 +1,12 @@
export type RelationFilterValue = {
isCurrentWorkspaceMemberSelected?: boolean;
selectedRecordIds: string[];
};
export type ViewFilterValue =
| string
| string[]
| RelationFilterValue
| Record<string, unknown>
| null
| undefined;

View File

@ -14,6 +14,7 @@ import {
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { ViewFilterValue } from 'src/engine/metadata-modules/view/types/view-filter-value.type';
import { View } from 'src/engine/metadata-modules/view/view.entity'; import { View } from 'src/engine/metadata-modules/view/view.entity';
@Entity({ name: 'viewFilter', schema: 'core' }) @Entity({ name: 'viewFilter', schema: 'core' })
@ -31,7 +32,7 @@ export class ViewFilter {
operand: string; operand: string;
@Column({ nullable: false, type: 'jsonb' }) @Column({ nullable: false, type: 'jsonb' })
value: JSON; value: ViewFilterValue;
@Column({ nullable: true, type: 'uuid' }) @Column({ nullable: true, type: 'uuid' })
viewFilterGroupId?: string | null; viewFilterGroupId?: string | null;

View File

@ -128,6 +128,10 @@ export class MiddlewareService {
public async hydrateGraphqlRequest(request: Request) { public async hydrateGraphqlRequest(request: Request) {
if (!this.isTokenPresent(request)) { if (!this.isTokenPresent(request)) {
request.locale =
(request.headers['x-locale'] as keyof typeof APP_LOCALES) ??
SOURCE_LOCALE;
return; return;
} }

View File

@ -110,7 +110,6 @@ describe('WorkspaceEntityManager', () => {
IS_RELATION_CONNECT_ENABLED: false, IS_RELATION_CONNECT_ENABLED: false,
IS_WORKSPACE_API_KEY_WEBHOOK_GRAPHQL_ENABLED: false, IS_WORKSPACE_API_KEY_WEBHOOK_GRAPHQL_ENABLED: false,
IS_FIELDS_PERMISSIONS_ENABLED: false, IS_FIELDS_PERMISSIONS_ENABLED: false,
IS_ANY_FIELD_SEARCH_ENABLED: false,
IS_CORE_VIEW_SYNCING_ENABLED: false, IS_CORE_VIEW_SYNCING_ENABLED: false,
IS_TWO_FACTOR_AUTHENTICATION_ENABLED: false, IS_TWO_FACTOR_AUTHENTICATION_ENABLED: false,
}, },

View File

@ -55,11 +55,6 @@ export const seedFeatureFlags = async (
workspaceId: workspaceId, workspaceId: workspaceId,
value: true, value: true,
}, },
{
key: FeatureFlagKey.IS_ANY_FIELD_SEARCH_ENABLED,
workspaceId: workspaceId,
value: false,
},
{ {
key: FeatureFlagKey.IS_TWO_FACTOR_AUTHENTICATION_ENABLED, key: FeatureFlagKey.IS_TWO_FACTOR_AUTHENTICATION_ENABLED,
workspaceId: workspaceId, workspaceId: workspaceId,

View File

@ -129,7 +129,9 @@ export class CalendarEventParticipantPersonListener {
const { addedAdditionalEmails, removedAdditionalEmails } = const { addedAdditionalEmails, removedAdditionalEmails } =
computeChangedAdditionalEmails(eventPayload.properties.diff); computeChangedAdditionalEmails(eventPayload.properties.diff);
const removedEmailPromises = removedAdditionalEmails.map((email) => const removedEmailPromises = removedAdditionalEmails
?.filter((email: string) => isDefined(email))
.map((email) =>
this.messageQueueService.add<CalendarEventParticipantUnmatchParticipantJobData>( this.messageQueueService.add<CalendarEventParticipantUnmatchParticipantJobData>(
CalendarEventParticipantUnmatchParticipantJob.name, CalendarEventParticipantUnmatchParticipantJob.name,
{ {
@ -166,6 +168,7 @@ export class CalendarEventParticipantPersonListener {
>, >,
) { ) {
for (const eventPayload of payload.events) { for (const eventPayload of payload.events) {
if (isDefined(eventPayload.properties.before.emails?.primaryEmail)) {
await this.messageQueueService.add<CalendarEventParticipantUnmatchParticipantJobData>( await this.messageQueueService.add<CalendarEventParticipantUnmatchParticipantJobData>(
CalendarEventParticipantUnmatchParticipantJob.name, CalendarEventParticipantUnmatchParticipantJob.name,
{ {
@ -174,12 +177,15 @@ export class CalendarEventParticipantPersonListener {
personId: eventPayload.recordId, personId: eventPayload.recordId,
}, },
); );
}
const additionalEmails = const additionalEmails =
eventPayload.properties.before.emails?.additionalEmails; eventPayload.properties.before.emails?.additionalEmails;
if (Array.isArray(additionalEmails)) { if (Array.isArray(additionalEmails)) {
const additionalEmailPromises = additionalEmails.map((email) => const additionalEmailPromises = additionalEmails
?.filter((email: string) => isDefined(email))
.map((email) =>
this.messageQueueService.add<CalendarEventParticipantUnmatchParticipantJobData>( this.messageQueueService.add<CalendarEventParticipantUnmatchParticipantJobData>(
CalendarEventParticipantUnmatchParticipantJob.name, CalendarEventParticipantUnmatchParticipantJob.name,
{ {

View File

@ -0,0 +1,209 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { ObjectRecordCreateEvent } from 'src/engine/core-modules/event-emitter/types/object-record-create.event';
import { ObjectRecordDeleteEvent } from 'src/engine/core-modules/event-emitter/types/object-record-delete.event';
import { ObjectRecordDestroyEvent } from 'src/engine/core-modules/event-emitter/types/object-record-destroy.event';
import { ObjectRecordDiff } from 'src/engine/core-modules/event-emitter/types/object-record-diff';
import { ObjectRecordRestoreEvent } from 'src/engine/core-modules/event-emitter/types/object-record-restore.event';
import { ObjectRecordUpdateEvent } from 'src/engine/core-modules/event-emitter/types/object-record-update.event';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event.type';
import {
ViewException,
ViewExceptionCode,
} from 'src/modules/view/views.exception';
type EntityWithId = { id: string };
type SyncOperations<T extends EntityWithId> = {
create: (workspaceId: string, entity: T) => Promise<void>;
update: (
workspaceId: string,
entity: T,
diff?: Partial<ObjectRecordDiff<T>>,
) => Promise<void>;
delete: (workspaceId: string, entity: Pick<T, 'id'>) => Promise<void>;
destroy: (workspaceId: string, entity: Pick<T, 'id'>) => Promise<void>;
restore: (workspaceId: string, entity: Pick<T, 'id'>) => Promise<void>;
};
@Injectable()
export abstract class BaseViewSyncListener<T extends EntityWithId> {
@Inject(FeatureFlagService)
protected readonly featureFlagService: FeatureFlagService;
@Inject(ExceptionHandlerService)
protected readonly exceptionHandlerService: ExceptionHandlerService;
protected readonly logger: Logger;
constructor(
protected readonly syncOperations: SyncOperations<T>,
loggerName: string,
protected readonly entityTypeName: string,
) {
this.logger = new Logger(loggerName);
}
protected async handleCreated(
batchEvent: WorkspaceEventBatch<ObjectRecordCreateEvent<T>>,
): Promise<void> {
const isEnabled = await this.isFeatureFlagEnabled(batchEvent.workspaceId);
if (!isEnabled) {
return;
}
for (const event of batchEvent.events) {
try {
await this.syncOperations.create(
batchEvent.workspaceId,
event.properties.after,
);
} catch (error) {
this.captureException(
error,
batchEvent.workspaceId,
'create',
event.properties.after.id,
);
}
}
}
protected async handleUpdated(
batchEvent: WorkspaceEventBatch<ObjectRecordUpdateEvent<T>>,
): Promise<void> {
const isEnabled = await this.isFeatureFlagEnabled(batchEvent.workspaceId);
if (!isEnabled) {
return;
}
for (const event of batchEvent.events) {
try {
await this.syncOperations.update(
batchEvent.workspaceId,
event.properties.after,
event.properties.diff,
);
} catch (error) {
this.captureException(
error,
batchEvent.workspaceId,
'update',
event.properties.after.id,
);
}
}
}
protected async handleDeleted(
batchEvent: WorkspaceEventBatch<ObjectRecordDeleteEvent<T>>,
): Promise<void> {
const isEnabled = await this.isFeatureFlagEnabled(batchEvent.workspaceId);
if (!isEnabled) {
return;
}
for (const event of batchEvent.events) {
try {
await this.syncOperations.delete(
batchEvent.workspaceId,
event.properties.before,
);
} catch (error) {
this.captureException(
error,
batchEvent.workspaceId,
'delete',
event.properties.before.id,
);
}
}
}
protected async handleDestroyed(
batchEvent: WorkspaceEventBatch<ObjectRecordDestroyEvent<T>>,
): Promise<void> {
const isEnabled = await this.isFeatureFlagEnabled(batchEvent.workspaceId);
if (!isEnabled) {
return;
}
for (const event of batchEvent.events) {
try {
await this.syncOperations.destroy(
batchEvent.workspaceId,
event.properties.before,
);
} catch (error) {
this.captureException(
error,
batchEvent.workspaceId,
'destroy',
event.properties.before.id,
);
}
}
}
protected async handleRestored(
batchEvent: WorkspaceEventBatch<ObjectRecordRestoreEvent<T>>,
): Promise<void> {
const isEnabled = await this.isFeatureFlagEnabled(batchEvent.workspaceId);
if (!isEnabled) {
return;
}
for (const event of batchEvent.events) {
try {
await this.syncOperations.restore(
batchEvent.workspaceId,
event.properties.after,
);
} catch (error) {
this.captureException(
error,
batchEvent.workspaceId,
'restore',
event.properties.after.id,
);
}
}
}
private async isFeatureFlagEnabled(workspaceId: string): Promise<boolean> {
const featureFlags =
await this.featureFlagService.getWorkspaceFeatureFlagsMap(workspaceId);
return featureFlags.IS_CORE_VIEW_SYNCING_ENABLED;
}
private captureException(
error: Error,
workspaceId: string,
operation: string,
entityId: string,
) {
const viewException = new ViewException(
`Failed to sync ${this.entityTypeName} ${entityId} to core: ${error.message}`,
ViewExceptionCode.CORE_VIEW_SYNC_ERROR,
);
this.exceptionHandlerService.captureExceptions([viewException], {
workspace: {
id: workspaceId,
},
additionalData: {
entityId: entityId,
entityType: this.entityTypeName,
operation: operation,
},
});
}
}

View File

@ -0,0 +1,81 @@
import { Injectable } from '@nestjs/common';
import { OnDatabaseBatchEvent } from 'src/engine/api/graphql/graphql-query-runner/decorators/on-database-batch-event.decorator';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
import { ObjectRecordCreateEvent } from 'src/engine/core-modules/event-emitter/types/object-record-create.event';
import { ObjectRecordDeleteEvent } from 'src/engine/core-modules/event-emitter/types/object-record-delete.event';
import { ObjectRecordDestroyEvent } from 'src/engine/core-modules/event-emitter/types/object-record-destroy.event';
import { ObjectRecordRestoreEvent } from 'src/engine/core-modules/event-emitter/types/object-record-restore.event';
import { ObjectRecordUpdateEvent } from 'src/engine/core-modules/event-emitter/types/object-record-update.event';
import { WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event.type';
import { ViewFieldSyncService } from 'src/modules/view/services/view-field-sync.service';
import { ViewFieldWorkspaceEntity } from 'src/modules/view/standard-objects/view-field.workspace-entity';
import { BaseViewSyncListener } from './base-view-sync.listener';
@Injectable()
export class ViewFieldListener extends BaseViewSyncListener<ViewFieldWorkspaceEntity> {
constructor(viewFieldSyncService: ViewFieldSyncService) {
super(
{
create:
viewFieldSyncService.createCoreViewField.bind(viewFieldSyncService),
update:
viewFieldSyncService.updateCoreViewField.bind(viewFieldSyncService),
delete:
viewFieldSyncService.deleteCoreViewField.bind(viewFieldSyncService),
destroy:
viewFieldSyncService.destroyCoreViewField.bind(viewFieldSyncService),
restore:
viewFieldSyncService.restoreCoreViewField.bind(viewFieldSyncService),
},
ViewFieldListener.name,
'view field',
);
}
@OnDatabaseBatchEvent('viewField', DatabaseEventAction.CREATED)
async handleViewFieldCreated(
batchEvent: WorkspaceEventBatch<
ObjectRecordCreateEvent<ViewFieldWorkspaceEntity>
>,
) {
return this.handleCreated(batchEvent);
}
@OnDatabaseBatchEvent('viewField', DatabaseEventAction.UPDATED)
async handleViewFieldUpdated(
batchEvent: WorkspaceEventBatch<
ObjectRecordUpdateEvent<ViewFieldWorkspaceEntity>
>,
) {
return this.handleUpdated(batchEvent);
}
@OnDatabaseBatchEvent('viewField', DatabaseEventAction.DELETED)
async handleViewFieldDeleted(
batchEvent: WorkspaceEventBatch<
ObjectRecordDeleteEvent<ViewFieldWorkspaceEntity>
>,
) {
return this.handleDeleted(batchEvent);
}
@OnDatabaseBatchEvent('viewField', DatabaseEventAction.DESTROYED)
async handleViewFieldDestroyed(
batchEvent: WorkspaceEventBatch<
ObjectRecordDestroyEvent<ViewFieldWorkspaceEntity>
>,
) {
return this.handleDestroyed(batchEvent);
}
@OnDatabaseBatchEvent('viewField', DatabaseEventAction.RESTORED)
async handleViewFieldRestored(
batchEvent: WorkspaceEventBatch<
ObjectRecordRestoreEvent<ViewFieldWorkspaceEntity>
>,
) {
return this.handleRestored(batchEvent);
}
}

View File

@ -0,0 +1,86 @@
import { Injectable } from '@nestjs/common';
import { OnDatabaseBatchEvent } from 'src/engine/api/graphql/graphql-query-runner/decorators/on-database-batch-event.decorator';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
import { ObjectRecordCreateEvent } from 'src/engine/core-modules/event-emitter/types/object-record-create.event';
import { ObjectRecordDeleteEvent } from 'src/engine/core-modules/event-emitter/types/object-record-delete.event';
import { ObjectRecordDestroyEvent } from 'src/engine/core-modules/event-emitter/types/object-record-destroy.event';
import { ObjectRecordRestoreEvent } from 'src/engine/core-modules/event-emitter/types/object-record-restore.event';
import { ObjectRecordUpdateEvent } from 'src/engine/core-modules/event-emitter/types/object-record-update.event';
import { WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event.type';
import { ViewFilterGroupSyncService } from 'src/modules/view/services/view-filter-group-sync.service';
import { ViewFilterGroupWorkspaceEntity } from 'src/modules/view/standard-objects/view-filter-group.workspace-entity';
import { BaseViewSyncListener } from './base-view-sync.listener';
@Injectable()
export class ViewFilterGroupListener extends BaseViewSyncListener<ViewFilterGroupWorkspaceEntity> {
constructor(viewFilterGroupSyncService: ViewFilterGroupSyncService) {
super(
{
create: viewFilterGroupSyncService.createCoreViewFilterGroup.bind(
viewFilterGroupSyncService,
),
update: viewFilterGroupSyncService.updateCoreViewFilterGroup.bind(
viewFilterGroupSyncService,
),
delete: viewFilterGroupSyncService.deleteCoreViewFilterGroup.bind(
viewFilterGroupSyncService,
),
destroy: viewFilterGroupSyncService.destroyCoreViewFilterGroup.bind(
viewFilterGroupSyncService,
),
restore: viewFilterGroupSyncService.restoreCoreViewFilterGroup.bind(
viewFilterGroupSyncService,
),
},
ViewFilterGroupListener.name,
'view filter group',
);
}
@OnDatabaseBatchEvent('viewFilterGroup', DatabaseEventAction.CREATED)
async handleViewFilterGroupCreated(
batchEvent: WorkspaceEventBatch<
ObjectRecordCreateEvent<ViewFilterGroupWorkspaceEntity>
>,
) {
return this.handleCreated(batchEvent);
}
@OnDatabaseBatchEvent('viewFilterGroup', DatabaseEventAction.UPDATED)
async handleViewFilterGroupUpdated(
batchEvent: WorkspaceEventBatch<
ObjectRecordUpdateEvent<ViewFilterGroupWorkspaceEntity>
>,
) {
return this.handleUpdated(batchEvent);
}
@OnDatabaseBatchEvent('viewFilterGroup', DatabaseEventAction.DELETED)
async handleViewFilterGroupDeleted(
batchEvent: WorkspaceEventBatch<
ObjectRecordDeleteEvent<ViewFilterGroupWorkspaceEntity>
>,
) {
return this.handleDeleted(batchEvent);
}
@OnDatabaseBatchEvent('viewFilterGroup', DatabaseEventAction.DESTROYED)
async handleViewFilterGroupDestroyed(
batchEvent: WorkspaceEventBatch<
ObjectRecordDestroyEvent<ViewFilterGroupWorkspaceEntity>
>,
) {
return this.handleDestroyed(batchEvent);
}
@OnDatabaseBatchEvent('viewFilterGroup', DatabaseEventAction.RESTORED)
async handleViewFilterGroupRestored(
batchEvent: WorkspaceEventBatch<
ObjectRecordRestoreEvent<ViewFilterGroupWorkspaceEntity>
>,
) {
return this.handleRestored(batchEvent);
}
}

View File

@ -0,0 +1,86 @@
import { Injectable } from '@nestjs/common';
import { OnDatabaseBatchEvent } from 'src/engine/api/graphql/graphql-query-runner/decorators/on-database-batch-event.decorator';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
import { ObjectRecordCreateEvent } from 'src/engine/core-modules/event-emitter/types/object-record-create.event';
import { ObjectRecordDeleteEvent } from 'src/engine/core-modules/event-emitter/types/object-record-delete.event';
import { ObjectRecordDestroyEvent } from 'src/engine/core-modules/event-emitter/types/object-record-destroy.event';
import { ObjectRecordRestoreEvent } from 'src/engine/core-modules/event-emitter/types/object-record-restore.event';
import { ObjectRecordUpdateEvent } from 'src/engine/core-modules/event-emitter/types/object-record-update.event';
import { WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event.type';
import { ViewFilterSyncService } from 'src/modules/view/services/view-filter-sync.service';
import { ViewFilterWorkspaceEntity } from 'src/modules/view/standard-objects/view-filter.workspace-entity';
import { BaseViewSyncListener } from './base-view-sync.listener';
@Injectable()
export class ViewFilterListener extends BaseViewSyncListener<ViewFilterWorkspaceEntity> {
constructor(viewFilterSyncService: ViewFilterSyncService) {
super(
{
create: viewFilterSyncService.createCoreViewFilter.bind(
viewFilterSyncService,
),
update: viewFilterSyncService.updateCoreViewFilter.bind(
viewFilterSyncService,
),
delete: viewFilterSyncService.deleteCoreViewFilter.bind(
viewFilterSyncService,
),
destroy: viewFilterSyncService.destroyCoreViewFilter.bind(
viewFilterSyncService,
),
restore: viewFilterSyncService.restoreCoreViewFilter.bind(
viewFilterSyncService,
),
},
ViewFilterListener.name,
'view filter',
);
}
@OnDatabaseBatchEvent('viewFilter', DatabaseEventAction.CREATED)
async handleViewFilterCreated(
batchEvent: WorkspaceEventBatch<
ObjectRecordCreateEvent<ViewFilterWorkspaceEntity>
>,
) {
return this.handleCreated(batchEvent);
}
@OnDatabaseBatchEvent('viewFilter', DatabaseEventAction.UPDATED)
async handleViewFilterUpdated(
batchEvent: WorkspaceEventBatch<
ObjectRecordUpdateEvent<ViewFilterWorkspaceEntity>
>,
) {
return this.handleUpdated(batchEvent);
}
@OnDatabaseBatchEvent('viewFilter', DatabaseEventAction.DELETED)
async handleViewFilterDeleted(
batchEvent: WorkspaceEventBatch<
ObjectRecordDeleteEvent<ViewFilterWorkspaceEntity>
>,
) {
return this.handleDeleted(batchEvent);
}
@OnDatabaseBatchEvent('viewFilter', DatabaseEventAction.DESTROYED)
async handleViewFilterDestroyed(
batchEvent: WorkspaceEventBatch<
ObjectRecordDestroyEvent<ViewFilterWorkspaceEntity>
>,
) {
return this.handleDestroyed(batchEvent);
}
@OnDatabaseBatchEvent('viewFilter', DatabaseEventAction.RESTORED)
async handleViewFilterRestored(
batchEvent: WorkspaceEventBatch<
ObjectRecordRestoreEvent<ViewFilterWorkspaceEntity>
>,
) {
return this.handleRestored(batchEvent);
}
}

View File

@ -0,0 +1,81 @@
import { Injectable } from '@nestjs/common';
import { OnDatabaseBatchEvent } from 'src/engine/api/graphql/graphql-query-runner/decorators/on-database-batch-event.decorator';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
import { ObjectRecordCreateEvent } from 'src/engine/core-modules/event-emitter/types/object-record-create.event';
import { ObjectRecordDeleteEvent } from 'src/engine/core-modules/event-emitter/types/object-record-delete.event';
import { ObjectRecordDestroyEvent } from 'src/engine/core-modules/event-emitter/types/object-record-destroy.event';
import { ObjectRecordRestoreEvent } from 'src/engine/core-modules/event-emitter/types/object-record-restore.event';
import { ObjectRecordUpdateEvent } from 'src/engine/core-modules/event-emitter/types/object-record-update.event';
import { WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event.type';
import { ViewGroupSyncService } from 'src/modules/view/services/view-group-sync.service';
import { ViewGroupWorkspaceEntity } from 'src/modules/view/standard-objects/view-group.workspace-entity';
import { BaseViewSyncListener } from './base-view-sync.listener';
@Injectable()
export class ViewGroupListener extends BaseViewSyncListener<ViewGroupWorkspaceEntity> {
constructor(viewGroupSyncService: ViewGroupSyncService) {
super(
{
create:
viewGroupSyncService.createCoreViewGroup.bind(viewGroupSyncService),
update:
viewGroupSyncService.updateCoreViewGroup.bind(viewGroupSyncService),
delete:
viewGroupSyncService.deleteCoreViewGroup.bind(viewGroupSyncService),
destroy:
viewGroupSyncService.destroyCoreViewGroup.bind(viewGroupSyncService),
restore:
viewGroupSyncService.restoreCoreViewGroup.bind(viewGroupSyncService),
},
ViewGroupListener.name,
'view group',
);
}
@OnDatabaseBatchEvent('viewGroup', DatabaseEventAction.CREATED)
async handleViewGroupCreated(
batchEvent: WorkspaceEventBatch<
ObjectRecordCreateEvent<ViewGroupWorkspaceEntity>
>,
) {
return this.handleCreated(batchEvent);
}
@OnDatabaseBatchEvent('viewGroup', DatabaseEventAction.UPDATED)
async handleViewGroupUpdated(
batchEvent: WorkspaceEventBatch<
ObjectRecordUpdateEvent<ViewGroupWorkspaceEntity>
>,
) {
return this.handleUpdated(batchEvent);
}
@OnDatabaseBatchEvent('viewGroup', DatabaseEventAction.DELETED)
async handleViewGroupDeleted(
batchEvent: WorkspaceEventBatch<
ObjectRecordDeleteEvent<ViewGroupWorkspaceEntity>
>,
) {
return this.handleDeleted(batchEvent);
}
@OnDatabaseBatchEvent('viewGroup', DatabaseEventAction.DESTROYED)
async handleViewGroupDestroyed(
batchEvent: WorkspaceEventBatch<
ObjectRecordDestroyEvent<ViewGroupWorkspaceEntity>
>,
) {
return this.handleDestroyed(batchEvent);
}
@OnDatabaseBatchEvent('viewGroup', DatabaseEventAction.RESTORED)
async handleViewGroupRestored(
batchEvent: WorkspaceEventBatch<
ObjectRecordRestoreEvent<ViewGroupWorkspaceEntity>
>,
) {
return this.handleRestored(batchEvent);
}
}

View File

@ -0,0 +1,81 @@
import { Injectable } from '@nestjs/common';
import { OnDatabaseBatchEvent } from 'src/engine/api/graphql/graphql-query-runner/decorators/on-database-batch-event.decorator';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
import { ObjectRecordCreateEvent } from 'src/engine/core-modules/event-emitter/types/object-record-create.event';
import { ObjectRecordDeleteEvent } from 'src/engine/core-modules/event-emitter/types/object-record-delete.event';
import { ObjectRecordDestroyEvent } from 'src/engine/core-modules/event-emitter/types/object-record-destroy.event';
import { ObjectRecordRestoreEvent } from 'src/engine/core-modules/event-emitter/types/object-record-restore.event';
import { ObjectRecordUpdateEvent } from 'src/engine/core-modules/event-emitter/types/object-record-update.event';
import { WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event.type';
import { ViewSortSyncService } from 'src/modules/view/services/view-sort-sync.service';
import { ViewSortWorkspaceEntity } from 'src/modules/view/standard-objects/view-sort.workspace-entity';
import { BaseViewSyncListener } from './base-view-sync.listener';
@Injectable()
export class ViewSortListener extends BaseViewSyncListener<ViewSortWorkspaceEntity> {
constructor(viewSortSyncService: ViewSortSyncService) {
super(
{
create:
viewSortSyncService.createCoreViewSort.bind(viewSortSyncService),
update:
viewSortSyncService.updateCoreViewSort.bind(viewSortSyncService),
delete:
viewSortSyncService.deleteCoreViewSort.bind(viewSortSyncService),
destroy:
viewSortSyncService.destroyCoreViewSort.bind(viewSortSyncService),
restore:
viewSortSyncService.restoreCoreViewSort.bind(viewSortSyncService),
},
ViewSortListener.name,
'view sort',
);
}
@OnDatabaseBatchEvent('viewSort', DatabaseEventAction.CREATED)
async handleViewSortCreated(
batchEvent: WorkspaceEventBatch<
ObjectRecordCreateEvent<ViewSortWorkspaceEntity>
>,
) {
return this.handleCreated(batchEvent);
}
@OnDatabaseBatchEvent('viewSort', DatabaseEventAction.UPDATED)
async handleViewSortUpdated(
batchEvent: WorkspaceEventBatch<
ObjectRecordUpdateEvent<ViewSortWorkspaceEntity>
>,
) {
return this.handleUpdated(batchEvent);
}
@OnDatabaseBatchEvent('viewSort', DatabaseEventAction.DELETED)
async handleViewSortDeleted(
batchEvent: WorkspaceEventBatch<
ObjectRecordDeleteEvent<ViewSortWorkspaceEntity>
>,
) {
return this.handleDeleted(batchEvent);
}
@OnDatabaseBatchEvent('viewSort', DatabaseEventAction.DESTROYED)
async handleViewSortDestroyed(
batchEvent: WorkspaceEventBatch<
ObjectRecordDestroyEvent<ViewSortWorkspaceEntity>
>,
) {
return this.handleDestroyed(batchEvent);
}
@OnDatabaseBatchEvent('viewSort', DatabaseEventAction.RESTORED)
async handleViewSortRestored(
batchEvent: WorkspaceEventBatch<
ObjectRecordRestoreEvent<ViewSortWorkspaceEntity>
>,
) {
return this.handleRestored(batchEvent);
}
}

View File

@ -0,0 +1,76 @@
import { Injectable } from '@nestjs/common';
import { OnDatabaseBatchEvent } from 'src/engine/api/graphql/graphql-query-runner/decorators/on-database-batch-event.decorator';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
import { ObjectRecordCreateEvent } from 'src/engine/core-modules/event-emitter/types/object-record-create.event';
import { ObjectRecordDeleteEvent } from 'src/engine/core-modules/event-emitter/types/object-record-delete.event';
import { ObjectRecordDestroyEvent } from 'src/engine/core-modules/event-emitter/types/object-record-destroy.event';
import { ObjectRecordRestoreEvent } from 'src/engine/core-modules/event-emitter/types/object-record-restore.event';
import { ObjectRecordUpdateEvent } from 'src/engine/core-modules/event-emitter/types/object-record-update.event';
import { WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event.type';
import { ViewSyncService } from 'src/modules/view/services/view-sync.service';
import { ViewWorkspaceEntity } from 'src/modules/view/standard-objects/view.workspace-entity';
import { BaseViewSyncListener } from './base-view-sync.listener';
@Injectable()
export class ViewListener extends BaseViewSyncListener<ViewWorkspaceEntity> {
constructor(viewSyncService: ViewSyncService) {
super(
{
create: viewSyncService.createCoreView.bind(viewSyncService),
update: viewSyncService.updateCoreView.bind(viewSyncService),
delete: viewSyncService.deleteCoreView.bind(viewSyncService),
destroy: viewSyncService.destroyCoreView.bind(viewSyncService),
restore: viewSyncService.restoreCoreView.bind(viewSyncService),
},
ViewListener.name,
'view',
);
}
@OnDatabaseBatchEvent('view', DatabaseEventAction.CREATED)
async handleViewCreated(
batchEvent: WorkspaceEventBatch<
ObjectRecordCreateEvent<ViewWorkspaceEntity>
>,
) {
return this.handleCreated(batchEvent);
}
@OnDatabaseBatchEvent('view', DatabaseEventAction.UPDATED)
async handleViewUpdated(
batchEvent: WorkspaceEventBatch<
ObjectRecordUpdateEvent<ViewWorkspaceEntity>
>,
) {
return this.handleUpdated(batchEvent);
}
@OnDatabaseBatchEvent('view', DatabaseEventAction.DELETED)
async handleViewDeleted(
batchEvent: WorkspaceEventBatch<
ObjectRecordDeleteEvent<ViewWorkspaceEntity>
>,
) {
return this.handleDeleted(batchEvent);
}
@OnDatabaseBatchEvent('view', DatabaseEventAction.DESTROYED)
async handleViewDestroyed(
batchEvent: WorkspaceEventBatch<
ObjectRecordDestroyEvent<ViewWorkspaceEntity>
>,
) {
return this.handleDestroyed(batchEvent);
}
@OnDatabaseBatchEvent('view', DatabaseEventAction.RESTORED)
async handleViewRestored(
batchEvent: WorkspaceEventBatch<
ObjectRecordRestoreEvent<ViewWorkspaceEntity>
>,
) {
return this.handleRestored(batchEvent);
}
}

View File

@ -0,0 +1,104 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { ObjectRecordDiff } from 'src/engine/core-modules/event-emitter/types/object-record-diff';
import { ViewField } from 'src/engine/metadata-modules/view/view-field.entity';
import { ViewFieldWorkspaceEntity } from 'src/modules/view/standard-objects/view-field.workspace-entity';
@Injectable()
export class ViewFieldSyncService {
constructor(
@InjectRepository(ViewField, 'core')
private readonly coreViewFieldRepository: Repository<ViewField>,
) {}
private parseUpdateDataFromDiff(
diff: Partial<ObjectRecordDiff<ViewFieldWorkspaceEntity>>,
): Partial<ViewField> {
const updateData: Record<string, unknown> = {};
for (const key of Object.keys(diff)) {
const diffValue = diff[key as keyof ViewFieldWorkspaceEntity];
if (isDefined(diffValue)) {
updateData[key] = diffValue.after;
}
}
return updateData as Partial<ViewField>;
}
public async createCoreViewField(
workspaceId: string,
workspaceViewField: ViewFieldWorkspaceEntity,
): Promise<void> {
const coreViewField: Partial<ViewField> = {
id: workspaceViewField.id,
fieldMetadataId: workspaceViewField.fieldMetadataId,
viewId: workspaceViewField.viewId,
position: workspaceViewField.position,
isVisible: workspaceViewField.isVisible,
size: workspaceViewField.size,
workspaceId,
createdAt: new Date(workspaceViewField.createdAt),
updatedAt: new Date(workspaceViewField.updatedAt),
deletedAt: workspaceViewField.deletedAt
? new Date(workspaceViewField.deletedAt)
: null,
};
await this.coreViewFieldRepository.save(coreViewField);
}
public async updateCoreViewField(
workspaceId: string,
workspaceViewField: Pick<ViewFieldWorkspaceEntity, 'id'>,
diff?: Partial<ObjectRecordDiff<ViewFieldWorkspaceEntity>>,
): Promise<void> {
if (!diff || Object.keys(diff).length === 0) {
return;
}
const updateData = this.parseUpdateDataFromDiff(diff);
if (Object.keys(updateData).length > 0) {
await this.coreViewFieldRepository.update(
{ id: workspaceViewField.id, workspaceId },
updateData,
);
}
}
public async deleteCoreViewField(
workspaceId: string,
workspaceViewField: Pick<ViewFieldWorkspaceEntity, 'id'>,
): Promise<void> {
await this.coreViewFieldRepository.softDelete({
id: workspaceViewField.id,
workspaceId,
});
}
public async destroyCoreViewField(
workspaceId: string,
workspaceViewField: Pick<ViewFieldWorkspaceEntity, 'id'>,
): Promise<void> {
await this.coreViewFieldRepository.delete({
id: workspaceViewField.id,
workspaceId,
});
}
public async restoreCoreViewField(
workspaceId: string,
workspaceViewField: Pick<ViewFieldWorkspaceEntity, 'id'>,
): Promise<void> {
await this.coreViewFieldRepository.restore({
id: workspaceViewField.id,
workspaceId,
});
}
}

View File

@ -0,0 +1,110 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { ObjectRecordDiff } from 'src/engine/core-modules/event-emitter/types/object-record-diff';
import { ViewFilterGroupLogicalOperator } from 'src/engine/metadata-modules/view/enums/view-filter-group-logical-operator';
import { ViewFilterGroup } from 'src/engine/metadata-modules/view/view-filter-group.entity';
import { ViewFilterGroupWorkspaceEntity } from 'src/modules/view/standard-objects/view-filter-group.workspace-entity';
@Injectable()
export class ViewFilterGroupSyncService {
constructor(
@InjectRepository(ViewFilterGroup, 'core')
private readonly coreViewFilterGroupRepository: Repository<ViewFilterGroup>,
) {}
private parseUpdateDataFromDiff(
diff: Partial<ObjectRecordDiff<ViewFilterGroupWorkspaceEntity>>,
): Partial<ViewFilterGroup> {
const updateData: Record<string, unknown> = {};
for (const key of Object.keys(diff)) {
const diffValue = diff[key as keyof ViewFilterGroupWorkspaceEntity];
if (isDefined(diffValue)) {
if (key === 'logicalOperator') {
updateData[key] = diffValue.after as ViewFilterGroupLogicalOperator;
} else {
updateData[key] = diffValue.after;
}
}
}
return updateData as Partial<ViewFilterGroup>;
}
public async createCoreViewFilterGroup(
workspaceId: string,
workspaceViewFilterGroup: ViewFilterGroupWorkspaceEntity,
): Promise<void> {
const coreViewFilterGroup: Partial<ViewFilterGroup> = {
id: workspaceViewFilterGroup.id,
viewId: workspaceViewFilterGroup.viewId,
logicalOperator:
workspaceViewFilterGroup.logicalOperator as ViewFilterGroupLogicalOperator,
parentViewFilterGroupId: workspaceViewFilterGroup.parentViewFilterGroupId,
positionInViewFilterGroup:
workspaceViewFilterGroup.positionInViewFilterGroup,
workspaceId,
createdAt: new Date(workspaceViewFilterGroup.createdAt),
updatedAt: new Date(workspaceViewFilterGroup.updatedAt),
deletedAt: workspaceViewFilterGroup.deletedAt
? new Date(workspaceViewFilterGroup.deletedAt)
: null,
};
await this.coreViewFilterGroupRepository.save(coreViewFilterGroup);
}
public async updateCoreViewFilterGroup(
workspaceId: string,
workspaceViewFilterGroup: Pick<ViewFilterGroupWorkspaceEntity, 'id'>,
diff?: Partial<ObjectRecordDiff<ViewFilterGroupWorkspaceEntity>>,
): Promise<void> {
if (!diff || Object.keys(diff).length === 0) {
return;
}
const updateData = this.parseUpdateDataFromDiff(diff);
if (Object.keys(updateData).length > 0) {
await this.coreViewFilterGroupRepository.update(
{ id: workspaceViewFilterGroup.id, workspaceId },
updateData,
);
}
}
public async deleteCoreViewFilterGroup(
workspaceId: string,
workspaceViewFilterGroup: Pick<ViewFilterGroupWorkspaceEntity, 'id'>,
): Promise<void> {
await this.coreViewFilterGroupRepository.softDelete({
id: workspaceViewFilterGroup.id,
workspaceId,
});
}
public async destroyCoreViewFilterGroup(
workspaceId: string,
workspaceViewFilterGroup: Pick<ViewFilterGroupWorkspaceEntity, 'id'>,
): Promise<void> {
await this.coreViewFilterGroupRepository.delete({
id: workspaceViewFilterGroup.id,
workspaceId,
});
}
public async restoreCoreViewFilterGroup(
workspaceId: string,
workspaceViewFilterGroup: ViewFilterGroupWorkspaceEntity,
): Promise<void> {
await this.coreViewFilterGroupRepository.restore({
id: workspaceViewFilterGroup.id,
workspaceId,
});
}
}

View File

@ -0,0 +1,121 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { ObjectRecordDiff } from 'src/engine/core-modules/event-emitter/types/object-record-diff';
import { ViewFilter } from 'src/engine/metadata-modules/view/view-filter.entity';
import { ViewFilterWorkspaceEntity } from 'src/modules/view/standard-objects/view-filter.workspace-entity';
import { transformViewFilterWorkspaceValueToCoreValue } from 'src/modules/view/utils/transform-view-filter-workspace-value-to-core-value';
@Injectable()
export class ViewFilterSyncService {
constructor(
@InjectRepository(ViewFilter, 'core')
private readonly coreViewFilterRepository: Repository<ViewFilter>,
) {}
private parseUpdateDataFromDiff(
diff: Partial<ObjectRecordDiff<ViewFilterWorkspaceEntity>>,
): Partial<ViewFilter> {
const updateData: Record<string, unknown> = {};
for (const key of Object.keys(diff)) {
const diffValue = diff[key as keyof ViewFilterWorkspaceEntity];
if (isDefined(diffValue)) {
if (key === 'value' && typeof diffValue.after === 'string') {
updateData[key] = transformViewFilterWorkspaceValueToCoreValue(
diffValue.after,
);
} else {
updateData[key] = diffValue.after;
}
}
}
return updateData as Partial<ViewFilter>;
}
public async createCoreViewFilter(
workspaceId: string,
workspaceViewFilter: ViewFilterWorkspaceEntity,
): Promise<void> {
if (!workspaceViewFilter.viewId) {
return;
}
const coreViewFilter: Partial<ViewFilter> = {
id: workspaceViewFilter.id,
fieldMetadataId: workspaceViewFilter.fieldMetadataId,
viewId: workspaceViewFilter.viewId,
operand: workspaceViewFilter.operand,
value: transformViewFilterWorkspaceValueToCoreValue(
workspaceViewFilter.value,
),
viewFilterGroupId: workspaceViewFilter.viewFilterGroupId,
workspaceId,
createdAt: new Date(workspaceViewFilter.createdAt),
updatedAt: new Date(workspaceViewFilter.updatedAt),
deletedAt: workspaceViewFilter.deletedAt
? new Date(workspaceViewFilter.deletedAt)
: null,
};
await this.coreViewFilterRepository.save(coreViewFilter);
}
public async updateCoreViewFilter(
workspaceId: string,
workspaceViewFilter: ViewFilterWorkspaceEntity,
diff?: Partial<ObjectRecordDiff<ViewFilterWorkspaceEntity>>,
): Promise<void> {
if (!workspaceViewFilter.viewId) {
return;
}
if (!diff || Object.keys(diff).length === 0) {
return;
}
const updateData = this.parseUpdateDataFromDiff(diff);
if (Object.keys(updateData).length > 0) {
await this.coreViewFilterRepository.update(
{ id: workspaceViewFilter.id, workspaceId },
updateData,
);
}
}
public async deleteCoreViewFilter(
workspaceId: string,
workspaceViewFilter: Pick<ViewFilterWorkspaceEntity, 'id'>,
): Promise<void> {
await this.coreViewFilterRepository.softDelete({
id: workspaceViewFilter.id,
workspaceId,
});
}
public async destroyCoreViewFilter(
workspaceId: string,
workspaceViewFilter: Pick<ViewFilterWorkspaceEntity, 'id'>,
): Promise<void> {
await this.coreViewFilterRepository.delete({
id: workspaceViewFilter.id,
workspaceId,
});
}
public async restoreCoreViewFilter(
workspaceId: string,
workspaceViewFilter: Pick<ViewFilterWorkspaceEntity, 'id'>,
): Promise<void> {
await this.coreViewFilterRepository.restore({
id: workspaceViewFilter.id,
workspaceId,
});
}
}

View File

@ -0,0 +1,112 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { ObjectRecordDiff } from 'src/engine/core-modules/event-emitter/types/object-record-diff';
import { ViewGroup } from 'src/engine/metadata-modules/view/view-group.entity';
import { ViewGroupWorkspaceEntity } from 'src/modules/view/standard-objects/view-group.workspace-entity';
@Injectable()
export class ViewGroupSyncService {
constructor(
@InjectRepository(ViewGroup, 'core')
private readonly coreViewGroupRepository: Repository<ViewGroup>,
) {}
private parseUpdateDataFromDiff(
diff: Partial<ObjectRecordDiff<ViewGroupWorkspaceEntity>>,
): Partial<ViewGroup> {
const updateData: Record<string, unknown> = {};
for (const key of Object.keys(diff)) {
const diffValue = diff[key as keyof ViewGroupWorkspaceEntity];
if (isDefined(diffValue)) {
updateData[key] = diffValue.after;
}
}
return updateData as Partial<ViewGroup>;
}
public async createCoreViewGroup(
workspaceId: string,
workspaceViewGroup: ViewGroupWorkspaceEntity,
): Promise<void> {
if (!workspaceViewGroup.viewId) {
return;
}
const coreViewGroup: Partial<ViewGroup> = {
id: workspaceViewGroup.id,
fieldMetadataId: workspaceViewGroup.fieldMetadataId,
viewId: workspaceViewGroup.viewId,
fieldValue: workspaceViewGroup.fieldValue,
isVisible: workspaceViewGroup.isVisible,
position: workspaceViewGroup.position,
workspaceId,
createdAt: new Date(workspaceViewGroup.createdAt),
updatedAt: new Date(workspaceViewGroup.updatedAt),
deletedAt: workspaceViewGroup.deletedAt
? new Date(workspaceViewGroup.deletedAt)
: null,
};
await this.coreViewGroupRepository.save(coreViewGroup);
}
public async updateCoreViewGroup(
workspaceId: string,
workspaceViewGroup: ViewGroupWorkspaceEntity,
diff?: Partial<ObjectRecordDiff<ViewGroupWorkspaceEntity>>,
): Promise<void> {
if (!workspaceViewGroup.viewId) {
return;
}
if (!diff || Object.keys(diff).length === 0) {
return;
}
const updateData = this.parseUpdateDataFromDiff(diff);
if (Object.keys(updateData).length > 0) {
await this.coreViewGroupRepository.update(
{ id: workspaceViewGroup.id, workspaceId },
updateData,
);
}
}
public async deleteCoreViewGroup(
workspaceId: string,
workspaceViewGroup: Pick<ViewGroupWorkspaceEntity, 'id'>,
): Promise<void> {
await this.coreViewGroupRepository.softDelete({
id: workspaceViewGroup.id,
workspaceId,
});
}
public async destroyCoreViewGroup(
workspaceId: string,
workspaceViewGroup: Pick<ViewGroupWorkspaceEntity, 'id'>,
): Promise<void> {
await this.coreViewGroupRepository.delete({
id: workspaceViewGroup.id,
workspaceId,
});
}
public async restoreCoreViewGroup(
workspaceId: string,
workspaceViewGroup: Pick<ViewGroupWorkspaceEntity, 'id'>,
): Promise<void> {
await this.coreViewGroupRepository.restore({
id: workspaceViewGroup.id,
workspaceId,
});
}
}

View File

@ -0,0 +1,120 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { ObjectRecordDiff } from 'src/engine/core-modules/event-emitter/types/object-record-diff';
import { ViewSortDirection } from 'src/engine/metadata-modules/view/enums/view-sort-direction';
import { ViewSort } from 'src/engine/metadata-modules/view/view-sort.entity';
import { ViewSortWorkspaceEntity } from 'src/modules/view/standard-objects/view-sort.workspace-entity';
@Injectable()
export class ViewSortSyncService {
constructor(
@InjectRepository(ViewSort, 'core')
private readonly coreViewSortRepository: Repository<ViewSort>,
) {}
private parseUpdateDataFromDiff(
diff: Partial<ObjectRecordDiff<ViewSortWorkspaceEntity>>,
): Partial<ViewSort> {
const updateData: Record<string, unknown> = {};
for (const key of Object.keys(diff)) {
const diffValue = diff[key as keyof ViewSortWorkspaceEntity];
if (isDefined(diffValue)) {
if (key === 'direction') {
updateData[key] = (
diffValue.after as string
).toUpperCase() as ViewSortDirection;
} else {
updateData[key] = diffValue.after;
}
}
}
return updateData as Partial<ViewSort>;
}
public async createCoreViewSort(
workspaceId: string,
workspaceViewSort: ViewSortWorkspaceEntity,
): Promise<void> {
if (!workspaceViewSort.viewId) {
return;
}
const direction =
workspaceViewSort.direction.toUpperCase() as ViewSortDirection;
const coreViewSort: Partial<ViewSort> = {
id: workspaceViewSort.id,
fieldMetadataId: workspaceViewSort.fieldMetadataId,
viewId: workspaceViewSort.viewId,
direction: direction,
workspaceId,
createdAt: new Date(workspaceViewSort.createdAt),
updatedAt: new Date(workspaceViewSort.updatedAt),
deletedAt: workspaceViewSort.deletedAt
? new Date(workspaceViewSort.deletedAt)
: null,
};
await this.coreViewSortRepository.save(coreViewSort);
}
public async updateCoreViewSort(
workspaceId: string,
workspaceViewSort: ViewSortWorkspaceEntity,
diff?: Partial<ObjectRecordDiff<ViewSortWorkspaceEntity>>,
): Promise<void> {
if (!workspaceViewSort.viewId) {
return;
}
if (!diff || Object.keys(diff).length === 0) {
return;
}
const updateData = this.parseUpdateDataFromDiff(diff);
if (Object.keys(updateData).length > 0) {
await this.coreViewSortRepository.update(
{ id: workspaceViewSort.id, workspaceId },
updateData,
);
}
}
public async deleteCoreViewSort(
workspaceId: string,
workspaceViewSort: Pick<ViewSortWorkspaceEntity, 'id'>,
): Promise<void> {
await this.coreViewSortRepository.softDelete({
id: workspaceViewSort.id,
workspaceId,
});
}
public async destroyCoreViewSort(
workspaceId: string,
workspaceViewSort: Pick<ViewSortWorkspaceEntity, 'id'>,
): Promise<void> {
await this.coreViewSortRepository.delete({
id: workspaceViewSort.id,
workspaceId,
});
}
public async restoreCoreViewSort(
workspaceId: string,
workspaceViewSort: Pick<ViewSortWorkspaceEntity, 'id'>,
): Promise<void> {
await this.coreViewSortRepository.restore({
id: workspaceViewSort.id,
workspaceId,
});
}
}

View File

@ -0,0 +1,121 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { ObjectRecordDiff } from 'src/engine/core-modules/event-emitter/types/object-record-diff';
import { ViewOpenRecordIn } from 'src/engine/metadata-modules/view/enums/view-open-record-in';
import { View } from 'src/engine/metadata-modules/view/view.entity';
import { ViewWorkspaceEntity } from 'src/modules/view/standard-objects/view.workspace-entity';
@Injectable()
export class ViewSyncService {
constructor(
@InjectRepository(View, 'core')
private readonly coreViewRepository: Repository<View>,
) {}
private parseUpdateDataFromDiff(
diff: Partial<ObjectRecordDiff<ViewWorkspaceEntity>>,
): Partial<View> {
const updateData: Record<string, unknown> = {};
for (const key of Object.keys(diff)) {
const diffValue = diff[key as keyof ViewWorkspaceEntity];
if (isDefined(diffValue)) {
if (key === 'openRecordIn') {
updateData[key] =
diffValue.after === 'SIDE_PANEL'
? ViewOpenRecordIn.SIDE_PANEL
: ViewOpenRecordIn.RECORD_PAGE;
} else {
updateData[key] = diffValue.after;
}
}
}
return updateData as Partial<View>;
}
public async createCoreView(
workspaceId: string,
workspaceView: ViewWorkspaceEntity,
): Promise<void> {
const coreView: Partial<View> = {
id: workspaceView.id,
name: workspaceView.name,
objectMetadataId: workspaceView.objectMetadataId,
type: workspaceView.type,
key: workspaceView.key,
icon: workspaceView.icon,
position: workspaceView.position,
isCompact: workspaceView.isCompact,
openRecordIn:
workspaceView.openRecordIn === 'SIDE_PANEL'
? ViewOpenRecordIn.SIDE_PANEL
: ViewOpenRecordIn.RECORD_PAGE,
kanbanAggregateOperation: workspaceView.kanbanAggregateOperation,
kanbanAggregateOperationFieldMetadataId:
workspaceView.kanbanAggregateOperationFieldMetadataId,
workspaceId,
createdAt: new Date(workspaceView.createdAt),
updatedAt: new Date(workspaceView.updatedAt),
deletedAt: workspaceView.deletedAt
? new Date(workspaceView.deletedAt)
: null,
};
await this.coreViewRepository.save(coreView);
}
public async updateCoreView(
workspaceId: string,
workspaceView: ViewWorkspaceEntity,
diff?: Partial<ObjectRecordDiff<ViewWorkspaceEntity>>,
): Promise<void> {
if (!diff || Object.keys(diff).length === 0) {
return;
}
const updateData = this.parseUpdateDataFromDiff(diff);
if (Object.keys(updateData).length > 0) {
await this.coreViewRepository.update(
{ id: workspaceView.id, workspaceId },
updateData,
);
}
}
public async deleteCoreView(
workspaceId: string,
workspaceView: ViewWorkspaceEntity,
): Promise<void> {
await this.coreViewRepository.softDelete({
id: workspaceView.id,
workspaceId,
});
}
public async destroyCoreView(
workspaceId: string,
workspaceView: ViewWorkspaceEntity,
): Promise<void> {
await this.coreViewRepository.delete({
id: workspaceView.id,
workspaceId,
});
}
public async restoreCoreView(
workspaceId: string,
workspaceView: ViewWorkspaceEntity,
): Promise<void> {
await this.coreViewRepository.restore({
id: workspaceView.id,
workspaceId,
});
}
}

View File

@ -0,0 +1,11 @@
import { ViewFilterValue } from 'src/engine/metadata-modules/view/types/view-filter-value.type';
export const transformViewFilterWorkspaceValueToCoreValue = (
value: string,
): ViewFilterValue => {
try {
return JSON.parse(value);
} catch {
return value;
}
};

View File

@ -1,11 +1,52 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { ViewField } from 'src/engine/metadata-modules/view/view-field.entity';
import { ViewFilterGroup } from 'src/engine/metadata-modules/view/view-filter-group.entity';
import { ViewFilter } from 'src/engine/metadata-modules/view/view-filter.entity';
import { ViewGroup } from 'src/engine/metadata-modules/view/view-group.entity';
import { ViewSort } from 'src/engine/metadata-modules/view/view-sort.entity';
import { View } from 'src/engine/metadata-modules/view/view.entity';
import { ViewFieldListener } from 'src/modules/view/listeners/view-field.listener';
import { ViewFilterGroupListener } from 'src/modules/view/listeners/view-filter-group.listener';
import { ViewFilterListener } from 'src/modules/view/listeners/view-filter.listener';
import { ViewGroupListener } from 'src/modules/view/listeners/view-group.listener';
import { ViewSortListener } from 'src/modules/view/listeners/view-sort.listener';
import { ViewListener } from 'src/modules/view/listeners/view.listener';
import { ViewDeleteOnePreQueryHook } from 'src/modules/view/pre-hooks/view-delete-one.pre-query.hook';
import { ViewFieldSyncService } from 'src/modules/view/services/view-field-sync.service';
import { ViewFilterGroupSyncService } from 'src/modules/view/services/view-filter-group-sync.service';
import { ViewFilterSyncService } from 'src/modules/view/services/view-filter-sync.service';
import { ViewGroupSyncService } from 'src/modules/view/services/view-group-sync.service';
import { ViewSortSyncService } from 'src/modules/view/services/view-sort-sync.service';
import { ViewSyncService } from 'src/modules/view/services/view-sync.service';
import { ViewService } from 'src/modules/view/services/view.service'; import { ViewService } from 'src/modules/view/services/view.service';
import { ViewDeleteOnePreQueryHook } from './pre-hooks/view-delete-one.pre-query.hook';
@Module({ @Module({
imports: [], imports: [
providers: [ViewService, ViewDeleteOnePreQueryHook], TypeOrmModule.forFeature(
[View, ViewField, ViewFilter, ViewFilterGroup, ViewGroup, ViewSort],
'core',
),
FeatureFlagModule,
],
providers: [
ViewService,
ViewDeleteOnePreQueryHook,
ViewSyncService,
ViewFieldSyncService,
ViewFilterSyncService,
ViewFilterGroupSyncService,
ViewGroupSyncService,
ViewSortSyncService,
ViewListener,
ViewFieldListener,
ViewFilterListener,
ViewFilterGroupListener,
ViewGroupListener,
ViewSortListener,
],
exports: [ViewService], exports: [ViewService],
}) })
export class ViewModule {} export class ViewModule {}

View File

@ -10,10 +10,12 @@ export enum ViewExceptionCode {
VIEW_NOT_FOUND = 'VIEW_NOT_FOUND', VIEW_NOT_FOUND = 'VIEW_NOT_FOUND',
CANNOT_DELETE_INDEX_VIEW = 'CANNOT_DELETE_INDEX_VIEW', CANNOT_DELETE_INDEX_VIEW = 'CANNOT_DELETE_INDEX_VIEW',
METHOD_NOT_IMPLEMENTED = 'METHOD_NOT_IMPLEMENTED', METHOD_NOT_IMPLEMENTED = 'METHOD_NOT_IMPLEMENTED',
CORE_VIEW_SYNC_ERROR = 'CORE_VIEW_SYNC_ERROR',
} }
export enum ViewExceptionMessage { export enum ViewExceptionMessage {
VIEW_NOT_FOUND = 'View not found', VIEW_NOT_FOUND = 'View not found',
CANNOT_DELETE_INDEX_VIEW = 'Cannot delete index view', CANNOT_DELETE_INDEX_VIEW = 'Cannot delete index view',
METHOD_NOT_IMPLEMENTED = 'Method not implemented', METHOD_NOT_IMPLEMENTED = 'Method not implemented',
CORE_VIEW_SYNC_ERROR = 'Failed to sync view data to core',
} }

View File

@ -0,0 +1,148 @@
import { findManyFieldsMetadataQueryFactory } from 'test/integration/metadata/suites/field-metadata/utils/find-many-fields-metadata-query-factory.util';
import { createMorphRelationBetweenObjects } from 'test/integration/metadata/suites/object-metadata/utils/create-morph-relation-between-objects.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { forceCreateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/force-create-one-object-metadata.util';
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { FieldMetadataType } from 'twenty-shared/types';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
import { RelationDTO } from 'src/engine/metadata-modules/field-metadata/dtos/relation.dto';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
describe('Rename an object metadata with morph relation should succeed', () => {
let opportunityId = '';
let personId = '';
let companyId = '';
let morphRelationField: FieldMetadataEntity<FieldMetadataType.MORPH_RELATION> & {
morphRelations: RelationDTO[];
};
beforeEach(async () => {
const {
data: {
createOneObject: { id: aId },
},
} = await forceCreateOneObjectMetadata({
input: {
nameSingular: 'opportunityForRename',
namePlural: 'opportunitiesForRename',
labelSingular: 'Opportunity For Rename',
labelPlural: 'Opportunities For Rename',
icon: 'IconOpportunity',
},
});
opportunityId = aId;
const {
data: {
createOneObject: { id: bId },
},
} = await forceCreateOneObjectMetadata({
input: {
nameSingular: 'personForRename',
namePlural: 'peopleForRename',
labelSingular: 'Person For Rename',
labelPlural: 'People For Rename',
icon: 'IconPerson',
},
});
personId = bId;
const {
data: {
createOneObject: { id: cId },
},
} = await forceCreateOneObjectMetadata({
input: {
nameSingular: 'companyForRename',
namePlural: 'companiesForRename',
labelSingular: 'Company For Rename',
labelPlural: 'Companies For Rename',
icon: 'IconCompany',
},
});
companyId = cId;
});
afterEach(async () => {
await deleteOneObjectMetadata({ input: { idToDelete: opportunityId } });
await deleteOneObjectMetadata({ input: { idToDelete: personId } });
await deleteOneObjectMetadata({ input: { idToDelete: companyId } });
});
it('should rename custom object, and update the join column name of the morph relation that contains the object name', async () => {
morphRelationField = await createMorphRelationBetweenObjects({
name: 'owner',
objectMetadataId: opportunityId,
firstTargetObjectMetadataId: personId,
secondTargetObjectMetadataId: companyId,
type: FieldMetadataType.MORPH_RELATION,
relationType: RelationType.MANY_TO_ONE,
});
const { data } = await updateOneObjectMetadata({
gqlFields: `
nameSingular
labelSingular
namePlural
labelPlural
`,
input: {
idToUpdate: personId,
updatePayload: {
nameSingular: 'personForRename2',
namePlural: 'peopleForRename2',
labelSingular: 'Person For Rename2',
labelPlural: 'People For Rename2',
},
},
});
expect(data.updateOneObject.nameSingular).toBe('personForRename2');
const ownerFieldMetadataOnPersonId = morphRelationField.morphRelations.find(
(morphRelation) => morphRelation.targetObjectMetadata.id === personId,
)?.sourceFieldMetadata.id;
if (!ownerFieldMetadataOnPersonId) {
throw new Error(
'Morph Relation Error: Owner field metadata on person not found',
);
}
const fieldAfterRenaming = await findFieldMetadata({
fieldMetadataId: ownerFieldMetadataOnPersonId,
});
expect(fieldAfterRenaming.settings.joinColumnName).toBe(
'ownerPersonForRename2Id',
);
});
});
const findFieldMetadata = async ({
fieldMetadataId,
}: {
fieldMetadataId: string;
}) => {
const operation = findManyFieldsMetadataQueryFactory({
gqlFields: `
id
name
object { id nameSingular }
relation { type targetFieldMetadata { id } targetObjectMetadata { id } }
settings
`,
input: {
filter: { id: { eq: fieldMetadataId } },
paging: { first: 1 },
},
});
const fields = await makeMetadataAPIRequest(operation);
const field = fields.body.data.fields.edges?.[0]?.node;
return field;
};

View File

@ -74,6 +74,12 @@ export const createMorphRelationBetweenObjects = async ({
targetObjectMetadata { targetObjectMetadata {
id id
} }
sourceFieldMetadata {
id
}
sourceObjectMetadata {
id
}
} }
`, `,
expectToFail: false, expectToFail: false,

View File

@ -21,7 +21,7 @@ export default {
label: 'Api Key', label: 'Api Key',
type: 'string', type: 'string',
helpText: helpText:
'Create an API key in [your twenty workspace](https://app.twenty.com/settings/apis)', 'Create an API key in [your twenty workspace](https://crm.rootxwire.com/settings/apis)',
}, },
{ {
computed: false, computed: false,
@ -29,7 +29,7 @@ export default {
required: false, required: false,
label: 'Api Url', label: 'Api Url',
type: 'string', type: 'string',
placeholder: 'https://api.twenty.com', placeholder: 'https://crm.rootxwire.com',
helpText: helpText:
'Set this only if you self-host Twenty. Use the same value as `REACT_APP_SERVER_BASE_URL` in https://docs.twenty.com/start/self-hosting/', 'Set this only if you self-host Twenty. Use the same value as `REACT_APP_SERVER_BASE_URL` in https://docs.twenty.com/start/self-hosting/',
}, },