Fix front data model edition + non nullable workspaceCustom application migration (#16016)

# Introduction
Two things:
- Enforcing non nullable workspace custom application Id for any
workspace
- Fixing front non editable data models following
https://github.com/twentyhq/twenty/pull/15911 that associate any custom
entities to an applicationId. The front was putting everything as
readonly when under an app ( we will have to handle the twenty standard
application in the future too )

## Fallback
### Migration
The non nullable migration will fail when released, that's why it's
being swallowed and re-run in an upgrade command post workspace custom
application creation for those that miss one. Allowing the migration to
pass in the end
The typeorm migration still need to exists for any new workspaces

### GetCurrentUser
In order to dynamically display isReadOnly in data model settings we're
fetching the workspaceCustomApplicationId through the `getCurrentUser`
If not fallback this endpoint would throw until we're handling existing
workspaces that do not have a custom workspace application
The fallback should be removed post release
This commit is contained in:
Paul Rastoin
2025-11-24 14:39:04 +01:00
committed by GitHub
parent 2b80d9e015
commit 8299488f21
33 changed files with 368 additions and 58 deletions
File diff suppressed because one or more lines are too long
@@ -4680,7 +4680,8 @@ export type Workspace = {
viewGroups?: Maybe<Array<CoreViewGroup>>;
viewSorts?: Maybe<Array<CoreViewSort>>;
views?: Maybe<Array<CoreView>>;
workspaceCustomApplicationId?: Maybe<Scalars['String']>;
workspaceCustomApplication?: Maybe<Application>;
workspaceCustomApplicationId: Scalars['String'];
workspaceMembersCount?: Maybe<Scalars['Float']>;
workspaceUrls: WorkspaceUrls;
};
@@ -1,6 +1,7 @@
import { ApolloError, gql, InMemoryCache } from '@apollo/client';
import fetchMock, { enableFetchMocks } from 'jest-fetch-mock';
import { CUSTOM_WORKSPACE_APPLICATION_MOCK } from '@/object-metadata/hooks/__tests__/constants/CustomWorkspaceApplicationMock.test.constant';
import { WorkspaceActivationStatus } from '~/generated/graphql';
import { ApolloFactory, type Options } from '../apollo.factory';
@@ -62,6 +63,8 @@ const mockWorkspace = {
isTwoFactorAuthenticationEnforced: false,
trashRetentionDays: 14,
routerModel: 'auto',
workspaceCustomApplication: CUSTOM_WORKSPACE_APPLICATION_MOCK,
workspaceCustomApplicationId: CUSTOM_WORKSPACE_APPLICATION_MOCK.id,
};
const createMockOptions = (): Options<any> => ({
@@ -1,5 +1,9 @@
import { createState } from 'twenty-ui/utilities';
import { type Role, type Workspace } from '~/generated/graphql';
import {
type Application,
type Role,
type Workspace,
} from '~/generated/graphql';
export type CurrentWorkspace = Pick<
Workspace,
@@ -32,6 +36,7 @@ export type CurrentWorkspace = Pick<
| 'editableProfileFields'
> & {
defaultRole?: Omit<Role, 'workspaceMembers' | 'agents' | 'apiKeys'> | null;
workspaceCustomApplication: Pick<Application, 'id'> | null;
};
export const currentWorkspaceState = createState<CurrentWorkspace | null>({
@@ -0,0 +1,14 @@
import { type Application } from '~/generated/graphql';
export const CUSTOM_WORKSPACE_APPLICATION_MOCK = {
id: 'dc75f982-35a2-4c1b-a63d-bd1131215377',
agents: [],
applicationVariables: [],
canBeUninstalled: false,
description: 'workpace custom application',
name: 'custom',
objects: [],
serverlessFunctions: [],
universalIdentifier: '66a698b6-f6c1-4d35-a6e7-20aeadc3cd95',
version: '1.0.0',
} as const satisfies Application;
@@ -1,6 +1,7 @@
import { renderHook } from '@testing-library/react';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { CUSTOM_WORKSPACE_APPLICATION_MOCK } from '@/object-metadata/hooks/__tests__/constants/CustomWorkspaceApplicationMock.test.constant';
import { useColumnDefinitionsFromObjectMetadata } from '@/object-metadata/hooks/useColumnDefinitionsFromObjectMetadata';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import {
@@ -17,6 +18,9 @@ const Wrapper = getJestMetadataAndApolloMocksAndActionMenuWrapper({
contextStoreCurrentObjectMetadataNameSingular: 'company',
onInitializeRecoilSnapshot: ({ set }) => {
set(currentWorkspaceState, {
workspaceCustomApplication: {
id: CUSTOM_WORKSPACE_APPLICATION_MOCK.id,
},
id: '1',
featureFlags: [],
allowImpersonation: false,
@@ -12,6 +12,7 @@ describe('isObjectMetadataSettingsReadOnly', () => {
isUIReadOnly: false,
isRemote: false,
},
workspaceCustomApplicationId: 'workspaceApplicationId',
});
expect(result).toBe(false);
@@ -29,8 +30,22 @@ describe('isObjectMetadataSettingsReadOnly', () => {
isUIReadOnly: false,
isRemote: false,
},
workspaceCustomApplicationId: null,
});
expect(result).toBe(true);
});
it('should return false if object is owned by workspace custom application', () => {
const result = isObjectMetadataSettingsReadOnly({
objectMetadataItem: {
isUIReadOnly: false,
isRemote: false,
applicationId: 'workspaceApplicationId',
},
workspaceCustomApplicationId: 'workspaceApplicationId',
});
expect(result).toBe(false);
});
});
@@ -1,7 +1,7 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { type ObjectPermission } from '~/generated/graphql';
import { isDefined } from 'twenty-shared/utils';
import { isObjectMetadataReadOnly } from '@/object-record/read-only/utils/isObjectMetadataReadOnly';
import { isDefined } from 'twenty-shared/utils';
import { type ObjectPermission } from '~/generated/graphql';
type IsObjectMetadataReadOnlyParams = {
objectPermissions?: ObjectPermission;
@@ -9,14 +9,20 @@ type IsObjectMetadataReadOnlyParams = {
ObjectMetadataItem,
'isUIReadOnly' | 'isRemote' | 'applicationId'
>;
workspaceCustomApplicationId: string | null;
};
export const isObjectMetadataSettingsReadOnly = ({
objectPermissions,
objectMetadataItem,
workspaceCustomApplicationId,
}: IsObjectMetadataReadOnlyParams) => {
return (
isObjectMetadataReadOnly({ objectPermissions, objectMetadataItem }) ||
isDefined(objectMetadataItem?.applicationId)
(isDefined(objectMetadataItem?.applicationId)
? isDefined(workspaceCustomApplicationId)
? objectMetadataItem.applicationId !== workspaceCustomApplicationId
: true
: false)
);
};
@@ -1,5 +1,7 @@
import { Tag } from 'twenty-ui/components';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { getItemTagInfo } from '@/settings/data-model/utils/getItemTagInfo';
import { useRecoilValue } from 'recoil';
import { Tag } from 'twenty-ui/components';
type SettingsItemTypeTagProps = {
item: {
@@ -14,7 +16,12 @@ export const SettingsItemTypeTag = ({
className,
item: { isCustom, isRemote, applicationId },
}: SettingsItemTypeTagProps) => {
const itemTagInfo = getItemTagInfo({ isCustom, isRemote, applicationId });
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const itemTagInfo = getItemTagInfo({
objectMetadataItem: { isCustom, isRemote, applicationId },
workspaceCustomApplicationId:
currentWorkspace?.workspaceCustomApplication?.id,
});
return (
<Tag
@@ -1,3 +1,4 @@
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useDeleteOneFieldMetadataItem } from '@/object-metadata/hooks/useDeleteOneFieldMetadataItem';
import { useFieldMetadataItem } from '@/object-metadata/hooks/useFieldMetadataItem';
import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata';
@@ -14,7 +15,7 @@ import { navigationMemorizedUrlState } from '@/ui/navigation/states/navigationMe
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useMemo } from 'react';
import { useRecoilState } from 'recoil';
import { useRecoilState, useRecoilValue } from 'recoil';
import { FieldMetadataType, SettingsPath } from 'twenty-shared/types';
import {
getSettingsPath,
@@ -66,8 +67,11 @@ export const SettingsObjectFieldItemTableRow = ({
const { fieldMetadataItem, identifierType, objectMetadataItem } =
settingsObjectDetailTableItem;
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const readonly = isObjectMetadataSettingsReadOnly({
objectMetadataItem,
workspaceCustomApplicationId:
currentWorkspace?.workspaceCustomApplication?.id,
});
const isRemoteObjectField = objectMetadataItem.isRemote;
@@ -1,3 +1,4 @@
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useUpdateOneObjectMetadataItem } from '@/object-metadata/hooks/useUpdateOneObjectMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { SettingsDataModelObjectAboutForm } from '@/settings/data-model/objects/forms/components/SettingsDataModelObjectAboutForm';
@@ -7,7 +8,7 @@ import {
} from '@/settings/data-model/validation-schemas/settingsDataModelObjectAboutFormSchema';
import { zodResolver } from '@hookform/resolvers/zod';
import { FormProvider, useForm } from 'react-hook-form';
import { useSetRecoilState } from 'recoil';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { SettingsPath } from 'twenty-shared/types';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { updatedObjectNamePluralState } from '~/pages/settings/data-model/states/updatedObjectNamePluralState';
@@ -20,7 +21,12 @@ type SettingsUpdateDataModelObjectAboutFormProps = {
export const SettingsUpdateDataModelObjectAboutForm = ({
objectMetadataItem,
}: SettingsUpdateDataModelObjectAboutFormProps) => {
const readonly = isObjectMetadataSettingsReadOnly({ objectMetadataItem });
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const readonly = isObjectMetadataSettingsReadOnly({
objectMetadataItem,
workspaceCustomApplicationId:
currentWorkspace?.workspaceCustomApplication?.id,
});
const navigate = useNavigateSettings();
const setUpdatedObjectNamePlural = useSetRecoilState(
updatedObjectNamePluralState,
@@ -1,3 +1,4 @@
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
@@ -10,6 +11,7 @@ import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { UndecoratedLink } from 'twenty-ui/navigation';
import { isObjectMetadataSettingsReadOnly } from '@/object-record/read-only/utils/isObjectMetadataSettingsReadOnly';
import { useRecoilValue } from 'recoil';
const StyledDiv = styled.div`
display: flex;
@@ -22,8 +24,11 @@ type ObjectFieldsProps = {
};
export const ObjectFields = ({ objectMetadataItem }: ObjectFieldsProps) => {
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const readonly = isObjectMetadataSettingsReadOnly({
objectMetadataItem,
workspaceCustomApplicationId:
currentWorkspace?.workspaceCustomApplication?.id,
});
const { t } = useLingui();
@@ -1,16 +1,18 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useUpdateOneObjectMetadataItem } from '@/object-metadata/hooks/useUpdateOneObjectMetadataItem';
import { isObjectMetadataSettingsReadOnly } from '@/object-record/read-only/utils/isObjectMetadataSettingsReadOnly';
import { SettingsUpdateDataModelObjectAboutForm } from '@/settings/data-model/object-details/components/SettingsUpdateDataModelObjectAboutForm';
import { SettingsDataModelObjectSettingsFormCard } from '@/settings/data-model/objects/forms/components/SettingsDataModelObjectSettingsFormCard';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { useRecoilValue } from 'recoil';
import { SettingsPath } from 'twenty-shared/types';
import { H2Title, IconArchive } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { isObjectMetadataSettingsReadOnly } from '@/object-record/read-only/utils/isObjectMetadataSettingsReadOnly';
type ObjectSettingsProps = {
objectMetadataItem: ObjectMetadataItem;
@@ -28,7 +30,12 @@ const StyledFormSection = styled(Section)`
export const ObjectSettings = ({ objectMetadataItem }: ObjectSettingsProps) => {
const { t } = useLingui();
const readonly = isObjectMetadataSettingsReadOnly({ objectMetadataItem });
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const readonly = isObjectMetadataSettingsReadOnly({
objectMetadataItem,
workspaceCustomApplicationId:
currentWorkspace?.workspaceCustomApplication?.id,
});
const navigate = useNavigateSettings();
const { updateOneObjectMetadataItem } = useUpdateOneObjectMetadataItem();
@@ -1,3 +1,4 @@
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useUpdateOneObjectMetadataItem } from '@/object-metadata/hooks/useUpdateOneObjectMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getActiveFieldMetadataItems } from '@/object-metadata/utils/getActiveFieldMetadataItems';
@@ -9,6 +10,7 @@ import { t } from '@lingui/core/macro';
import { useMemo } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import { useRecoilValue } from 'recoil';
import { isLabelIdentifierFieldMetadataTypes } from 'twenty-shared/utils';
import { IconCircleOff, IconPlus, useIcons } from 'twenty-ui/display';
import { type SelectOption } from 'twenty-ui/input';
@@ -42,7 +44,12 @@ const StyledContainer = styled.div`
export const SettingsDataModelObjectIdentifiersForm = ({
objectMetadataItem,
}: SettingsDataModelObjectIdentifiersFormProps) => {
const readonly = isObjectMetadataSettingsReadOnly({ objectMetadataItem });
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const readonly = isObjectMetadataSettingsReadOnly({
objectMetadataItem,
workspaceCustomApplicationId:
currentWorkspace?.workspaceCustomApplication?.id,
});
const formConfig = useForm<SettingsDataModelObjectIdentifiersFormValues>({
mode: 'onTouched',
resolver: zodResolver(settingsDataModelObjectIdentifiersFormSchema),
@@ -27,15 +27,20 @@ type ManagedItemTagInfo = {
};
export const getItemTagInfo = ({
isCustom,
isRemote,
applicationId,
objectMetadataItem: { isCustom, isRemote, applicationId },
workspaceCustomApplicationId,
}: {
isCustom?: boolean;
isRemote?: boolean;
applicationId?: string | null;
objectMetadataItem: {
isCustom?: boolean;
isRemote?: boolean;
applicationId?: string | null;
};
workspaceCustomApplicationId?: string;
}): ItemTagInfo => {
if (isDefined(applicationId)) {
if (
isDefined(applicationId) &&
applicationId !== workspaceCustomApplicationId
) {
return { labelText: 'Managed', labelColor: 'sky' };
}
@@ -118,6 +118,9 @@ export const UserAndViewsProviderEffect = () => {
...userQueryData.currentUser.currentWorkspace,
defaultRole:
userQueryData.currentUser.currentWorkspace.defaultRole ?? null,
workspaceCustomApplication:
userQueryData.currentUser.currentWorkspace
.workspaceCustomApplication ?? null,
});
}
@@ -60,7 +60,9 @@ export const USER_QUERY_FRAGMENT = gql`
isPasswordAuthBypassEnabled
subdomain
hasValidEnterpriseKey
customDomain
workspaceCustomApplication {
id
}
isCustomDomainEnabled
workspaceUrls {
...WorkspaceUrlsFragment
@@ -107,7 +107,13 @@ export const useLoadCurrentUser = () => {
);
}
const workspace = user.currentWorkspace ?? null;
const workspace = isDefined(user.currentWorkspace)
? {
...user.currentWorkspace,
workspaceCustomApplication:
user.currentWorkspace.workspaceCustomApplication ?? null,
}
: null;
setCurrentWorkspace(workspace);
@@ -14,6 +14,7 @@ import styled from '@emotion/styled';
import { useRecoilState, useRecoilValue } from 'recoil';
import { AppPath, SettingsPath } from 'twenty-shared/types';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { isObjectMetadataSettingsReadOnly } from '@/object-record/read-only/utils/isObjectMetadataSettingsReadOnly';
import { SettingsItemTypeTag } from '@/settings/components/SettingsItemTypeTag';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
@@ -67,7 +68,12 @@ export const SettingsObjectDetailPage = () => {
findObjectMetadataItemByNamePlural(objectNamePlural) ??
findObjectMetadataItemByNamePlural(updatedObjectNamePlural);
const readonly = isObjectMetadataSettingsReadOnly({ objectMetadataItem });
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const readonly = isObjectMetadataSettingsReadOnly({
objectMetadataItem,
workspaceCustomApplicationId:
currentWorkspace?.workspaceCustomApplication?.id,
});
const activeTabId = useRecoilComponentValue(
activeTabIdComponentState,
@@ -5,6 +5,7 @@ import { FormProvider, useForm } from 'react-hook-form';
import { useNavigate, useParams } from 'react-router-dom';
import { type z } from 'zod';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useFieldMetadataItem } from '@/object-metadata/hooks/useFieldMetadataItem';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata';
@@ -12,6 +13,7 @@ import { useUpdateOneFieldMetadataItem } from '@/object-metadata/hooks/useUpdate
import { CoreObjectNamePlural } from '@/object-metadata/types/CoreObjectNamePlural';
import { formatFieldMetadataItemInput } from '@/object-metadata/utils/formatFieldMetadataItemInput';
import { isLabelIdentifierField } from '@/object-metadata/utils/isLabelIdentifierField';
import { isObjectMetadataSettingsReadOnly } from '@/object-record/read-only/utils/isObjectMetadataSettingsReadOnly';
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { FIELD_NAME_MAXIMUM_LENGTH } from '@/settings/data-model/constants/FieldNameMaximumLength';
@@ -24,7 +26,7 @@ import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBa
import { navigationMemorizedUrlState } from '@/ui/navigation/states/navigationMemorizedUrlState';
import { shouldNavigateBackToMemorizedUrlOnSaveState } from '@/ui/navigation/states/shouldNavigateBackToMemorizedUrlOnSaveState';
import { useLingui } from '@lingui/react/macro';
import { useRecoilState } from 'recoil';
import { useRecoilState, useRecoilValue } from 'recoil';
import { AppPath, SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { H2Title, IconArchive, IconArchiveOff } from 'twenty-ui/display';
@@ -33,7 +35,6 @@ import { Section } from 'twenty-ui/layout';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { useNavigateApp } from '~/hooks/useNavigateApp';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { isObjectMetadataSettingsReadOnly } from '@/object-record/read-only/utils/isObjectMetadataSettingsReadOnly';
//TODO: fix this type
export type SettingsDataModelFieldEditFormValues = z.infer<
@@ -64,7 +65,12 @@ export const SettingsObjectFieldEdit = () => {
const objectMetadataItem =
findObjectMetadataItemByNamePlural(objectNamePlural);
const readonly = isObjectMetadataSettingsReadOnly({ objectMetadataItem });
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const readonly = isObjectMetadataSettingsReadOnly({
objectMetadataItem,
workspaceCustomApplicationId:
currentWorkspace?.workspaceCustomApplication?.id,
});
const { deactivateMetadataField, activateMetadataField } =
useFieldMetadataItem();
@@ -1,3 +1,4 @@
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useDeleteOneObjectMetadataItem } from '@/object-metadata/hooks/useDeleteOneObjectMetadataItem';
import { useUpdateOneObjectMetadataItem } from '@/object-metadata/hooks/useUpdateOneObjectMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
@@ -80,6 +81,8 @@ export const SettingsObjectTable = ({
},
);
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const allObjectSettingsArray = useMemo(
() =>
objectMetadataItems.map(
@@ -87,7 +90,11 @@ export const SettingsObjectTable = ({
({
objectMetadataItem,
labelPlural: objectMetadataItem.labelPlural,
objectTypeLabel: getItemTagInfo(objectMetadataItem).labelText,
objectTypeLabel: getItemTagInfo({
objectMetadataItem,
workspaceCustomApplicationId:
currentWorkspace?.workspaceCustomApplication?.id,
}).labelText,
fieldsCount: objectMetadataItem.fields.filter(
(field) => !field.isSystem,
).length,
@@ -97,7 +104,11 @@ export const SettingsObjectTable = ({
] ?? 0,
}) satisfies SettingsObjectTableItem,
),
[objectMetadataItems, totalCountByObjectMetadataItemNamePlural],
[
objectMetadataItems,
totalCountByObjectMetadataItemNamePlural,
currentWorkspace,
],
);
const sortedObjectSettingsItems = useSortedArray(
@@ -1,4 +1,5 @@
import { type CurrentUserWorkspace } from '@/auth/states/currentUserWorkspaceState';
import { CUSTOM_WORKSPACE_APPLICATION_MOCK } from '@/object-metadata/hooks/__tests__/constants/CustomWorkspaceApplicationMock.test.constant';
import { type WorkspaceMember } from '@/workspace-member/types/WorkspaceMember';
import {
FeatureFlagKey,
@@ -54,7 +55,9 @@ const PRO_METERED_MONTHLY_PRICE = PRO_METERED_PRODUCT?.prices?.find(
(pr) => pr.recurringInterval === 'Month',
)!;
export const mockCurrentWorkspace: Workspace = {
export const mockCurrentWorkspace = {
workspaceCustomApplication: CUSTOM_WORKSPACE_APPLICATION_MOCK,
workspaceCustomApplicationId: CUSTOM_WORKSPACE_APPLICATION_MOCK.id,
subdomain: 'acme.twenty.com',
id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6w',
displayName: 'Twenty',
@@ -158,7 +161,7 @@ export const mockCurrentWorkspace: Workspace = {
databaseUrl: '',
isTwoFactorAuthenticationEnforced: false,
__typename: 'Workspace',
};
} as const satisfies Workspace;
export const mockedWorkspaceMemberData: WorkspaceMember = {
__typename: 'WorkspaceMember',
@@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { CreateWorkspaceCustomApplicationCommand } from 'src/database/commands/upgrade-version-command/1-12/1-12-create-workspace-custom-application.command';
import { SetStandardApplicationNotUninstallableCommand } from 'src/database/commands/upgrade-version-command/1-12/1-12-set-standard-application-not-uninstallable.command';
import { WorkspaceCustomApplicationIdNonNullableCommand } from 'src/database/commands/upgrade-version-command/1-12/1-12-workspace-custom-application-id-non-nullable-migration.command';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceSchemaManagerModule } from 'src/engine/twenty-orm/workspace-schema-manager/workspace-schema-manager.module';
@@ -16,10 +17,12 @@ import { WorkspaceSchemaManagerModule } from 'src/engine/twenty-orm/workspace-sc
providers: [
CreateWorkspaceCustomApplicationCommand,
SetStandardApplicationNotUninstallableCommand,
WorkspaceCustomApplicationIdNonNullableCommand,
],
exports: [
CreateWorkspaceCustomApplicationCommand,
SetStandardApplicationNotUninstallableCommand,
WorkspaceCustomApplicationIdNonNullableCommand,
],
})
export class V1_12_UpgradeVersionCommandModule {}
@@ -0,0 +1,72 @@
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { DataSource, Repository } from 'typeorm';
import {
ActiveOrSuspendedWorkspacesMigrationCommandRunner,
type RunOnWorkspaceArgs,
} from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
@Command({
name: '1-12-workspace-custom-application-id-non-nullable-migration',
description: 'Create FK_3b1acb13a5dac9956d1a4b32755 foreign key',
})
export class WorkspaceCustomApplicationIdNonNullableCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
private hasRunOnce = false;
constructor(
@InjectRepository(WorkspaceEntity)
protected readonly workspaceRepository: Repository<WorkspaceEntity>,
protected readonly twentyORMGlobalManager: TwentyORMGlobalManager,
@InjectDataSource()
private readonly coreDataSource: DataSource,
) {
super(workspaceRepository, twentyORMGlobalManager);
}
override async runOnWorkspace({
options,
}: RunOnWorkspaceArgs): Promise<void> {
if (this.hasRunOnce) {
this.logger.log(
'Skipping has already been run once WorkspaceCustomApplicationIdNonNullableCommand',
);
return;
}
const queryRunner = this.coreDataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
if (!options.dryRun) {
try {
await queryRunner.query(
`ALTER TABLE "core"."workspace" DROP CONSTRAINT "FK_3b1acb13a5dac9956d1a4b32755"`,
);
await queryRunner.query(
`ALTER TABLE "core"."workspace" ALTER COLUMN "workspaceCustomApplicationId" SET NOT NULL`,
);
await queryRunner.query(
`ALTER TABLE "core"."workspace" ADD CONSTRAINT "FK_3b1acb13a5dac9956d1a4b32755" FOREIGN KEY ("workspaceCustomApplicationId") REFERENCES "core"."application"("id") ON DELETE RESTRICT ON UPDATE NO ACTION`,
);
await queryRunner.commitTransaction();
this.logger.log(
'Successfully run WorkspaceCustomApplicationIdNonNullableCommand',
);
this.hasRunOnce = true;
} catch (error) {
await queryRunner.rollbackTransaction();
this.logger.log(
`Rollbacking WorkspaceCustomApplicationIdNonNullableCommand: ${error.message}`,
);
} finally {
await queryRunner.release();
}
}
}
}
@@ -23,6 +23,7 @@ import { CleanOrphanedUserWorkspacesCommand } from 'src/database/commands/upgrad
import { CreateTwentyStandardApplicationCommand } from 'src/database/commands/upgrade-version-command/1-11/1-11-create-twenty-standard-application.command';
import { CreateWorkspaceCustomApplicationCommand } from 'src/database/commands/upgrade-version-command/1-12/1-12-create-workspace-custom-application.command';
import { SetStandardApplicationNotUninstallableCommand } from 'src/database/commands/upgrade-version-command/1-12/1-12-set-standard-application-not-uninstallable.command';
import { WorkspaceCustomApplicationIdNonNullableCommand } from 'src/database/commands/upgrade-version-command/1-12/1-12-workspace-custom-application-id-non-nullable-migration.command';
import { FixLabelIdentifierPositionAndVisibilityCommand } from 'src/database/commands/upgrade-version-command/1-6/1-6-fix-label-identifier-position-and-visibility.command';
import { BackfillWorkflowManualTriggerAvailabilityCommand } from 'src/database/commands/upgrade-version-command/1-7/1-7-backfill-workflow-manual-trigger-availability.command';
import { DeduplicateUniqueFieldsCommand } from 'src/database/commands/upgrade-version-command/1-8/1-8-deduplicate-unique-fields.command';
@@ -79,9 +80,10 @@ export class UpgradeCommand extends UpgradeCommandRunner {
protected readonly seedStandardApplicationsCommand: CreateTwentyStandardApplicationCommand,
// 1.12 Commands
protected readonlysetStandardApplicationNotUninstallableCommand: SetStandardApplicationNotUninstallableCommand,
protected readonly setStandardApplicationNotUninstallableCommand: SetStandardApplicationNotUninstallableCommand,
protected readonly createTwentyStandardApplicationCommand: CreateTwentyStandardApplicationCommand,
protected readonly createWorkspaceCustomApplicationCommand: CreateWorkspaceCustomApplicationCommand,
protected readonly workspaceCustomApplicationIdNonNullableCommand: WorkspaceCustomApplicationIdNonNullableCommand,
) {
super(
workspaceRepository,
@@ -138,10 +140,11 @@ export class UpgradeCommand extends UpgradeCommandRunner {
};
const commands_1120: VersionCommands = {
beforeSyncMetadata: [this.createWorkspaceCustomApplicationCommand],
afterSyncMetadata: [
this.readonlysetStandardApplicationNotUninstallableCommand,
beforeSyncMetadata: [
this.createWorkspaceCustomApplicationCommand,
this.workspaceCustomApplicationIdNonNullableCommand,
],
afterSyncMetadata: [this.setStandardApplicationNotUninstallableCommand],
};
this.allCommands = {
@@ -0,0 +1,39 @@
import { type MigrationInterface, type QueryRunner } from 'typeorm';
export class WorkspaceCustomApplicationIdNonNullable1763977334519
implements MigrationInterface
{
name = 'WorkspaceCustomApplicationIdNonNullable1763977334519';
public async up(queryRunner: QueryRunner): Promise<void> {
try {
await queryRunner.query(
`ALTER TABLE "core"."workspace" DROP CONSTRAINT "FK_3b1acb13a5dac9956d1a4b32755"`,
);
await queryRunner.query(
`ALTER TABLE "core"."workspace" ALTER COLUMN "workspaceCustomApplicationId" SET NOT NULL`,
);
await queryRunner.query(
`ALTER TABLE "core"."workspace" ADD CONSTRAINT "FK_3b1acb13a5dac9956d1a4b32755" FOREIGN KEY ("workspaceCustomApplicationId") REFERENCES "core"."application"("id") ON DELETE RESTRICT ON UPDATE NO ACTION`,
);
} catch (e) {
// eslint-disable-next-line no-console
console.error(
'Swallowing WorkspaceCustomApplicationIdNonNullable1763977334519 error',
e,
);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."workspace" DROP CONSTRAINT "FK_3b1acb13a5dac9956d1a4b32755"`,
);
await queryRunner.query(
`ALTER TABLE "core"."workspace" ALTER COLUMN "workspaceCustomApplicationId" DROP NOT NULL`,
);
await queryRunner.query(
`ALTER TABLE "core"."workspace" ADD CONSTRAINT "FK_3b1acb13a5dac9956d1a4b32755" FOREIGN KEY ("workspaceCustomApplicationId") REFERENCES "core"."application"("id") ON DELETE RESTRICT ON UPDATE NO ACTION`,
);
}
}
@@ -44,6 +44,7 @@ export class ApplicationEntity {
@Column({ nullable: true, type: 'text' })
description: string | null;
// TODO should not be nullable
@Column({ nullable: true, type: 'text' })
version: string | null;
@@ -27,15 +27,21 @@ export class ApplicationService {
) {}
async findWorkspaceTwentyStandardAndCustomApplicationOrThrow({
workspace: workspaceInput,
workspaceId,
}: {
workspaceId: string;
}) {
const workspace = await this.workspaceRepository.findOne({
where: {
id: workspaceId,
},
});
}:
| {
workspaceId: string;
workspace?: never;
}
| { workspace: WorkspaceEntity; workspaceId?: never }) {
const workspace = isDefined(workspaceInput)
? workspaceInput
: await this.workspaceRepository.findOne({
where: {
id: workspaceId,
},
});
if (!isDefined(workspace)) {
throw new ApplicationException(
@@ -47,7 +53,7 @@ export class ApplicationService {
const flatApplicationMaps =
await this.workspaceFlatApplicationMapCacheService.getExistingOrRecomputeFlatMaps(
{
workspaceId,
workspaceId: workspace.id,
},
);
const twentyStandardApplicationId =
@@ -1,6 +1,12 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { IsBoolean, IsNotEmpty, IsString, IsUUID } from 'class-validator';
import {
IsBoolean,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { ApplicationVariableEntityDTO } from 'src/engine/core-modules/applicationVariable/dtos/application-variable.dto';
@@ -19,13 +25,15 @@ export class ApplicationDTO {
@Field()
name: string;
@IsOptional()
@IsString()
@Field()
description: string;
description?: string;
@IsOptional()
@IsString()
@Field()
version: string;
version?: string;
@IsString()
@Field()
@@ -36,14 +44,14 @@ export class ApplicationDTO {
canBeUninstalled: boolean;
@Field(() => [AgentDTO])
agents: AgentDTO[];
agents?: AgentDTO[];
@Field(() => [ServerlessFunctionDTO])
serverlessFunctions: ServerlessFunctionDTO[];
serverlessFunctions?: ServerlessFunctionDTO[];
@Field(() => [ObjectMetadataDTO])
objects: ObjectMetadataDTO[];
objects?: ObjectMetadataDTO[];
@Field(() => [ApplicationVariableEntityDTO])
applicationVariables: ApplicationVariableEntityDTO[];
applicationVariables?: ApplicationVariableEntityDTO[];
}
@@ -0,0 +1,23 @@
import { type ApplicationDTO } from 'src/engine/core-modules/application/dtos/application.dto';
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
export const fromFlatApplicationToApplicationDto = ({
canBeUninstalled,
description,
id,
name,
universalIdentifier,
version,
}: FlatApplication): ApplicationDTO => {
return {
canBeUninstalled,
description: description ?? undefined,
id,
name,
objects: [],
universalIdentifier,
version: version ?? undefined,
};
};
@@ -23,6 +23,7 @@ import { ModelId } from 'src/engine/core-modules/ai/constants/ai-models.const';
import { ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
import { AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { ApplicationDTO } from 'src/engine/core-modules/application/dtos/application.dto';
import { ApprovedAccessDomainEntity } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.entity';
import { EmailingDomainEntity } from 'src/engine/core-modules/emailing-domain/emailing-domain.entity';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
@@ -286,12 +287,10 @@ export class WorkspaceEntity {
@Column({ type: 'varchar', nullable: false, default: 'auto' })
routerModel: ModelId;
// TODO prastoin
// Temporarily setting as nullable for retro compatibility, not udpating TypeScript types
@Field(() => String, { nullable: true })
@Column({ nullable: true, type: 'uuid' })
@Column({ nullable: false, type: 'uuid' })
workspaceCustomApplicationId: string;
@Field(() => ApplicationDTO, { nullable: true })
@ManyToOne(() => ApplicationEntity, {
onDelete: 'RESTRICT',
nullable: false,
@@ -28,6 +28,7 @@ import { WorkspaceWorkspaceMemberListener } from 'src/engine/core-modules/worksp
import { workspaceAutoResolverOpts } from 'src/engine/core-modules/workspace/workspace.auto-resolver-opts';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceResolver } from 'src/engine/core-modules/workspace/workspace.resolver';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { AgentModule } from 'src/engine/metadata-modules/agent/agent.module';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
@@ -72,6 +73,7 @@ import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-m
CustomDomainManagerModule,
ViewModule,
WorkspaceManyOrAllFlatEntityMapsCacheModule,
ApplicationModule,
],
services: [WorkspaceService],
resolvers: workspaceAutoResolverOpts,
@@ -23,6 +23,9 @@ import { FileFolder } from 'src/engine/core-modules/file/interfaces/file-folder.
import type { FileUpload } from 'graphql-upload/processRequest.mjs';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationDTO } from 'src/engine/core-modules/application/dtos/application.dto';
import { fromFlatApplicationToApplicationDto } from 'src/engine/core-modules/application/utils/from-flat-application-to-application-dto.util';
import { BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
import { DomainValidRecords } from 'src/engine/core-modules/dns-manager/dtos/domain-valid-records';
@@ -103,6 +106,7 @@ export class WorkspaceResolver {
private readonly viewService: ViewService,
private readonly dnsManagerService: DnsManagerService,
private readonly customDomainManagerService: CustomDomainManagerService,
private readonly applicationService: ApplicationService,
) {}
@Query(() => WorkspaceEntity)
@@ -240,6 +244,27 @@ export class WorkspaceResolver {
return workspace.routerModel;
}
@ResolveField(() => ApplicationDTO, { nullable: true })
async workspaceCustomApplication(
@Parent() workspace: WorkspaceEntity,
): Promise<ApplicationDTO | null> {
try {
const { workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{
workspace,
},
);
return fromFlatApplicationToApplicationDto(
workspaceCustomFlatApplication,
);
} catch {
// Temporary should be removed after CreateWorkspaceCustomApplicationCommand is run
return null;
}
}
@ResolveField(() => BillingSubscriptionEntity, { nullable: true })
async currentBillingSubscription(
@Parent() workspace: WorkspaceEntity,