From 7a1e699fc8b2e05efafc7b3ea1fd657562b0535e Mon Sep 17 00:00:00 2001 From: Paul Rastoin <45004772+prastoin@users.noreply.github.com> Date: Mon, 10 Nov 2025 16:13:12 +0100 Subject: [PATCH] Twenty standard and workspace custom applications 1/3 (#15625) # Introduction related to https://github.com/twentyhq/core-team-issues/issues/1833 In this PR we're starting the sync-metadata and standardIds deprecation by introducing `twenty-standard` application that will regroup every standard object such as company and opportunities. But also the `custom-workspace-application` which is an app created at the same time as a workspace and that will regroup everything configure within the workspace ( custom objects fields etc ) ## What's done On both new workspace and seeded workspace creation: - Creating a custom workspace app - Creating a twenty standard app - Refactored the seed core schema and workspace creation to be run within a transaction in order to handle circular dependency foreignkey requirements ( which is deferred for app toward workspace ) - Updated workspace entity to have a custom workspace relation ( nullable for the moment until we implem an upgrade command to handle retro comp ) - Integration testing on user, workspace creation deletion and expected default apps creation - ~~Soft deleted user on `deleteUser`~~ Done by marie and rebased on it ## What's next - Update seeder to propagate the `twenty-standard` workspace `applicationId` to every standard synchronized entities ( cheap and fast iteration through the about to be deprecated sync-metadata as an easy way to synchronize standards metadata entities ). - Update seeder to propagate the `custom-workspace-application` workspace `applicationId` to anything custom ( `pets` and `rockets` ) - Prepend `custom-workspace-application` `applicationId` to every metadata API operations ( create a specific cache etc ) - Upgrade command on all existing workspace to create a custom app and associate its applicationId to any existing custom entities - Make `universalIdentifier` and `applicationId` required for any syncable entity --- .../src/generated-metadata/graphql.ts | 2 + .../twenty-front/src/generated/graphql.ts | 2 + .../SettingsApplicationVersionContainer.tsx | 6 +- .../SettingsApplicationDetailAboutTab.tsx | 10 +- .../SettingsApplicationDetailContentTab.tsx | 4 +- .../SettingsApplicationDetailSettingsTab.tsx | 8 +- packages/twenty-server/.env.test | 2 +- .../data-seed-dev-workspace.command.ts | 7 +- ...ate-twenty-standard-application.command.ts | 75 ++++ .../1-11-upgrade-version-command.module.ts | 7 + .../upgrade.command.ts | 4 +- ...e-application-serverless-function-layer.ts | 19 + ...45-makeApplicationWorkspaceFkDeferrable.ts | 29 ++ ...-workspace-custom-application-id-column.ts | 37 ++ ...space-custom-application-id-foreign-key.ts | 19 + .../application/application-sync.service.ts | 108 ++--- .../application/application.entity.ts | 8 +- .../application/application.module.ts | 1 + .../application/application.service.ts | 74 ++-- .../application/dtos/application.dto.ts | 8 +- .../engine/core-modules/auth/auth.module.ts | 2 + .../auth/services/sign-in-up.service.spec.ts | 409 ------------------ .../auth/services/sign-in-up.service.ts | 160 +++++-- .../key-value-pair/key-value-pair.service.ts | 85 ++-- .../onboarding/onboarding.service.ts | 144 +++--- .../user-workspace.service.spec.ts | 29 +- .../user-workspace/user-workspace.service.ts | 59 ++- .../user/services/user.service.spec.ts | 6 + .../user/services/user.service.ts | 8 +- .../user-vars/services/user-vars.service.ts | 78 ++-- .../services/workspace-invitation.service.ts | 20 +- .../workspace/services/workspace.service.ts | 2 +- .../workspace/workspace.entity.ts | 22 + .../workspace/workspace.resolver.ts | 5 + .../route-trigger/route-trigger.module.ts | 4 +- .../user-role/user-role.service.ts | 31 +- .../workspace-manager.service.spec.ts | 5 + .../utils/seed-billing-customers.util.ts | 20 +- .../utils/seed-billing-subscriptions.util.ts | 20 +- .../constants/seeder-workspaces.constant.ts | 52 +++ .../dev-seeder-permissions.service.ts | 8 +- .../dev-seeder/core/utils/seed-agents.util.ts | 76 +++- .../core/utils/seed-api-keys.util.ts | 20 +- .../core/utils/seed-core-schema.util.ts | 89 +++- .../core/utils/seed-feature-flags.util.ts | 40 +- .../core/utils/seed-user-workspaces.util.ts | 44 +- .../dev-seeder/core/utils/seed-users.util.ts | 11 +- .../core/utils/seed-workspace.util.ts | 47 ++ .../core/utils/seed-workspaces.util.ts | 87 ---- .../workspace-member-data-seeds.constant.ts | 6 +- .../dev-seeder/dev-seeder.module.ts | 2 + .../services/dev-seeder-metadata.service.ts | 2 +- .../dev-seeder/services/dev-seeder.service.ts | 20 +- .../workspace-manager.module.ts | 2 + .../workspace-manager.service.ts | 14 +- ...workspace-sync-metadata-commands.module.ts | 2 + .../constants/twenty-standard-applications.ts | 28 ++ ...rkspace-custom-create-application-input.ts | 26 ++ .../workspace-sync-metadata.module.ts | 2 + .../attachment.workspace-entity.ts | 1 + .../blocklist.workspace-entity.ts | 1 + ...nnel-event-association.workspace-entity.ts | 1 + .../calendar-channel.workspace-entity.ts | 1 + ...ndar-event-participant.workspace-entity.ts | 1 + .../calendar-event.workspace-entity.ts | 1 + .../company.workspace-entity.ts | 1 + .../connected-account.workspace-entity.ts | 1 + .../dashboard.workspace-entity.ts | 1 + .../favorite-folder.workspace-entity.ts | 1 + .../favorite.workspace-entity.ts | 1 + ...el-message-association.workspace-entity.ts | 1 + .../message-channel.workspace-entity.ts | 1 + .../message-folder.workspace-entity.ts | 1 + .../message-participant.workspace-entity.ts | 1 + .../message-thread.workspace-entity.ts | 1 + .../message.workspace-entity.ts | 1 + .../note-target.workspace-entity.ts | 1 + .../standard-objects/note.workspace-entity.ts | 1 + .../opportunity.workspace-entity.ts | 1 + .../person.workspace-entity.ts | 1 + .../task-target.workspace-entity.ts | 1 + .../standard-objects/task.workspace-entity.ts | 1 + .../timeline-activity.workspace-entity.ts | 1 + ...flow-automated-trigger.workspace-entity.ts | 1 + .../workflow-run.workspace-entity.ts | 1 + .../workflow-version.workspace-entity.ts | 1 + .../workflow.workspace-entity.ts | 1 + .../workspace-member.workspace-entity.ts | 1 + .../failing-sign-up.integration-spec.ts.snap | 36 ++ ...uccessful-sign-up.integration-spec.ts.snap | 51 +++ .../failing-sign-up.integration-spec.ts | 60 +++ .../successful-sign-up.integration-spec.ts | 95 ++++ .../search-resolver.integration-spec.ts | 91 ++-- ...and-workspace-creation.integration-spec.ts | 121 ++++++ .../graphql/utils/activate-workspace.util.ts | 62 +++ .../graphql/utils/delete-user.util.ts | 55 +++ .../utils/find-many-applications.util.ts | 57 +++ .../get-auth-tokens-from-login-token.util.ts | 70 +++ .../graphql/utils/get-current-user.util.ts | 76 ++++ .../utils/make-graphql-api-request.util.ts | 23 +- .../integration/graphql/utils/search.util.ts | 53 +++ .../utils/sign-up-in-new-workspace.util.ts | 61 +++ .../utils/sign-up-operation-factory.util.ts | 2 +- .../integration/graphql/utils/sign-up.util.ts | 87 ++++ ...te-one-object-metadata.integration-spec.ts | 2 +- .../suites/utils/update-feature-flag.util.ts | 2 +- 106 files changed, 2166 insertions(+), 984 deletions(-) create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/1-11/1-11-create-twenty-standard-application.command.ts create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/common/1762333916255-nullable-application-serverless-function-layer.ts create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/common/1762339932345-makeApplicationWorkspaceFkDeferrable.ts create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/common/1762343994716-add-workspace-custom-application-id-column.ts create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/common/1762437814771-workspace-custom-application-id-foreign-key.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.spec.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-workspace.util.ts delete mode 100644 packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-workspaces.util.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/twenty-standard-applications.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/utils/compute-workspace-custom-create-application-input.ts create mode 100644 packages/twenty-server/test/integration/graphql/suites/auth/sign-up/__snapshots__/failing-sign-up.integration-spec.ts.snap create mode 100644 packages/twenty-server/test/integration/graphql/suites/auth/sign-up/__snapshots__/successful-sign-up.integration-spec.ts.snap create mode 100644 packages/twenty-server/test/integration/graphql/suites/auth/sign-up/failing-sign-up.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/graphql/suites/auth/sign-up/successful-sign-up.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/graphql/suites/workspace/successful-user-and-workspace-creation.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/graphql/utils/activate-workspace.util.ts create mode 100644 packages/twenty-server/test/integration/graphql/utils/delete-user.util.ts create mode 100644 packages/twenty-server/test/integration/graphql/utils/find-many-applications.util.ts create mode 100644 packages/twenty-server/test/integration/graphql/utils/get-auth-tokens-from-login-token.util.ts create mode 100644 packages/twenty-server/test/integration/graphql/utils/get-current-user.util.ts create mode 100644 packages/twenty-server/test/integration/graphql/utils/search.util.ts create mode 100644 packages/twenty-server/test/integration/graphql/utils/sign-up-in-new-workspace.util.ts create mode 100644 packages/twenty-server/test/integration/graphql/utils/sign-up.util.ts diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 9b46ecf1364..6582e47c30c 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -222,6 +222,7 @@ export type Application = { name: Scalars['String']; objects: Array; serverlessFunctions: Array; + universalIdentifier: Scalars['String']; version: Scalars['String']; }; @@ -4802,6 +4803,7 @@ export type Workspace = { viewGroups?: Maybe>; viewSorts?: Maybe>; views?: Maybe>; + workspaceCustomApplicationId?: Maybe; workspaceMembersCount?: Maybe; workspaceUrls: WorkspaceUrls; }; diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index e6540c3b3ab..b4128843206 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -222,6 +222,7 @@ export type Application = { name: Scalars['String']; objects: Array; serverlessFunctions: Array; + universalIdentifier: Scalars['String']; version: Scalars['String']; }; @@ -4630,6 +4631,7 @@ export type Workspace = { viewGroups?: Maybe>; viewSorts?: Maybe>; views?: Maybe>; + workspaceCustomApplicationId?: Maybe; workspaceMembersCount?: Maybe; workspaceUrls: WorkspaceUrls; }; diff --git a/packages/twenty-front/src/pages/settings/applications/components/SettingsApplicationVersionContainer.tsx b/packages/twenty-front/src/pages/settings/applications/components/SettingsApplicationVersionContainer.tsx index f1bfb22740f..ae75ca53944 100644 --- a/packages/twenty-front/src/pages/settings/applications/components/SettingsApplicationVersionContainer.tsx +++ b/packages/twenty-front/src/pages/settings/applications/components/SettingsApplicationVersionContainer.tsx @@ -1,14 +1,16 @@ import { SettingsAdminTableCard } from '@/settings/admin-panel/components/SettingsAdminTableCard'; import { SettingsAdminVersionDisplay } from '@/settings/admin-panel/components/SettingsAdminVersionDisplay'; import { t } from '@lingui/core/macro'; +import { isDefined } from 'twenty-shared/utils'; import { IconCircleDot, IconStatusChange } from 'twenty-ui/display'; import type { Application } from '~/generated/graphql'; -import { isDefined } from 'twenty-shared/utils'; export const SettingsApplicationVersionContainer = ({ application, }: { - application?: Omit & { objects: { id: string }[] }; + application?: Omit & { + objects: { id: string }[]; + }; }) => { const loading = !isDefined(application); diff --git a/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationDetailAboutTab.tsx b/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationDetailAboutTab.tsx index 0bbb84d7bc3..a3bed266998 100644 --- a/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationDetailAboutTab.tsx +++ b/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationDetailAboutTab.tsx @@ -1,15 +1,17 @@ -import type { Application } from '~/generated/graphql'; +import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput'; +import { t } from '@lingui/core/macro'; import { isDefined } from 'twenty-shared/utils'; import { H2Title } from 'twenty-ui/display'; -import { t } from '@lingui/core/macro'; import { Section } from 'twenty-ui/layout'; -import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput'; +import type { Application } from '~/generated/graphql'; import { SettingsApplicationVersionContainer } from '~/pages/settings/applications/components/SettingsApplicationVersionContainer'; export const SettingsApplicationDetailAboutTab = ({ application, }: { - application?: Omit & { objects: { id: string }[] }; + application?: Omit & { + objects: { id: string }[]; + }; }) => { if (!isDefined(application)) { return null; diff --git a/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationDetailContentTab.tsx b/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationDetailContentTab.tsx index b72b93c77b5..0b581cd5d0e 100644 --- a/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationDetailContentTab.tsx +++ b/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationDetailContentTab.tsx @@ -12,7 +12,9 @@ import { SettingsObjectTable } from '~/pages/settings/data-model/SettingsObjectT export const SettingsApplicationDetailContentTab = ({ application, }: { - application?: Omit & { objects: { id: string }[] }; + application?: Omit & { + objects: { id: string }[]; + }; }) => { const objectMetadataItems = useRecoilValue(objectMetadataItemsState); diff --git a/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationDetailSettingsTab.tsx b/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationDetailSettingsTab.tsx index d531c18145d..f7753238316 100644 --- a/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationDetailSettingsTab.tsx +++ b/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationDetailSettingsTab.tsx @@ -1,12 +1,14 @@ -import type { Application } from '~/generated/graphql'; import { isDefined } from 'twenty-shared/utils'; -import { SettingsApplicationDetailEnvironmentVariablesTable } from '~/pages/settings/applications/tabs/SettingsApplicationDetailEnvironmentVariablesTable'; +import type { Application } from '~/generated/graphql'; import { useUpdateOneApplicationVariable } from '~/pages/settings/applications/hooks/useUpdateOneApplicationVariable'; +import { SettingsApplicationDetailEnvironmentVariablesTable } from '~/pages/settings/applications/tabs/SettingsApplicationDetailEnvironmentVariablesTable'; export const SettingsApplicationDetailSettingsTab = ({ application, }: { - application?: Omit & { objects: { id: string }[] }; + application?: Omit & { + objects: { id: string }[]; + }; }) => { const { updateOneApplicationVariable } = useUpdateOneApplicationVariable(); diff --git a/packages/twenty-server/.env.test b/packages/twenty-server/.env.test index 00e72259f0a..bd915e82565 100644 --- a/packages/twenty-server/.env.test +++ b/packages/twenty-server/.env.test @@ -6,7 +6,7 @@ SIGN_IN_PREFILLED=true EXCEPTION_HANDLER_DRIVER=CONSOLE SENTRY_DSN=https://ba869cb8fd72d5faeb6643560939cee0@o4505516959793152.ingest.sentry.io/4506660900306944 MUTATION_MAXIMUM_RECORD_AFFECTED=100 - +IS_MULTIWORKSPACE_ENABLED=true FRONTEND_URL=http://localhost:3001 AUTH_GOOGLE_ENABLED=false diff --git a/packages/twenty-server/src/database/commands/data-seed-dev-workspace.command.ts b/packages/twenty-server/src/database/commands/data-seed-dev-workspace.command.ts index 21ee26cc7a8..fdc8f22c952 100644 --- a/packages/twenty-server/src/database/commands/data-seed-dev-workspace.command.ts +++ b/packages/twenty-server/src/database/commands/data-seed-dev-workspace.command.ts @@ -5,7 +5,7 @@ import { Command, CommandRunner } from 'nest-commander'; import { SEED_APPLE_WORKSPACE_ID, SEED_YCOMBINATOR_WORKSPACE_ID, -} from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-workspaces.util'; +} from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant'; import { DevSeederService } from 'src/engine/workspace-manager/dev-seeder/services/dev-seeder.service'; @Command({ name: 'workspace:seed:dev', @@ -13,7 +13,10 @@ import { DevSeederService } from 'src/engine/workspace-manager/dev-seeder/servic 'Seed workspace with initial data. This command is intended for development only.', }) export class DataSeedWorkspaceCommand extends CommandRunner { - workspaceIds = [SEED_APPLE_WORKSPACE_ID, SEED_YCOMBINATOR_WORKSPACE_ID]; + workspaceIds = [ + SEED_APPLE_WORKSPACE_ID, + SEED_YCOMBINATOR_WORKSPACE_ID, + ] as const; private readonly logger = new Logger(DataSeedWorkspaceCommand.name); constructor(private readonly devSeederService: DevSeederService) { diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-11/1-11-create-twenty-standard-application.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-11/1-11-create-twenty-standard-application.command.ts new file mode 100644 index 00000000000..f77335ec930 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/1-11/1-11-create-twenty-standard-application.command.ts @@ -0,0 +1,75 @@ +import { InjectRepository } from '@nestjs/typeorm'; + +import { Command } from 'nest-commander'; +import { In, Repository } from 'typeorm'; + +import { + ActiveOrSuspendedWorkspacesMigrationCommandRunner, + type RunOnWorkspaceArgs, +} from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner'; +import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; +import { ApplicationService } from 'src/engine/core-modules/application/application.service'; +import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; +import { TWENTY_STANDARD_APPLICATION } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/twenty-standard-applications'; + +@Command({ + name: 'upgrade:1-11:create-twenty-standard-application', + description: + 'Create twenty-standard application for workspaces that do not have them', +}) +export class CreateTwentyStandardApplicationCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner { + constructor( + @InjectRepository(WorkspaceEntity) + protected readonly workspaceRepository: Repository, + protected readonly twentyORMGlobalManager: TwentyORMGlobalManager, + @InjectRepository(ApplicationEntity) + private readonly applicationRepository: Repository, + private readonly applicationService: ApplicationService, + ) { + super(workspaceRepository, twentyORMGlobalManager); + } + + override async runOnWorkspace({ + workspaceId, + options, + }: RunOnWorkspaceArgs): Promise { + this.logger.log( + `Checking standard applications for workspace ${workspaceId}`, + ); + + const existingApplications = await this.applicationRepository.find({ + where: { + workspaceId, + universalIdentifier: In([TWENTY_STANDARD_APPLICATION]), + }, + }); + + const existingIds = new Set( + existingApplications.map((app) => app.universalIdentifier), + ); + + if (existingIds.has(TWENTY_STANDARD_APPLICATION.universalIdentifier)) { + this.logger.log( + `Skipping twenty standard application as it already exists`, + ); + + return; + } + + this.logger.log(`About to seed twenty standard application`); + if (options.dryRun) { + return; + } + + try { + await this.applicationService.createTwentyStandardApplication({ + workspaceId, + }); + + this.logger.log(`Successfully seeded twenty standard`); + } catch (e) { + this.logger.error(`Failed to seed twenty standard`, e); + } + } +} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-11/1-11-upgrade-version-command.module.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-11/1-11-upgrade-version-command.module.ts index ec4d1921d74..b9d11f10492 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/1-11/1-11-upgrade-version-command.module.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/1-11/1-11-upgrade-version-command.module.ts @@ -3,6 +3,9 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { CleanOrphanedRoleTargetsCommand } from 'src/database/commands/upgrade-version-command/1-11/1-11-clean-orphaned-role-targets.command'; import { CleanOrphanedUserWorkspacesCommand } from 'src/database/commands/upgrade-version-command/1-11/1-11-clean-orphaned-user-workspaces.command'; +import { CreateTwentyStandardApplicationCommand } from 'src/database/commands/upgrade-version-command/1-11/1-11-create-twenty-standard-application.command'; +import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; +import { ApplicationModule } from 'src/engine/core-modules/application/application.module'; import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; @@ -22,16 +25,20 @@ import { WorkspaceSchemaManagerModule } from 'src/engine/twenty-orm/workspace-sc ViewEntity, UserWorkspaceEntity, RoleTargetsEntity, + ApplicationEntity, ]), WorkspaceSchemaManagerModule, + ApplicationModule, ], providers: [ CleanOrphanedUserWorkspacesCommand, CleanOrphanedRoleTargetsCommand, + CreateTwentyStandardApplicationCommand, ], exports: [ CleanOrphanedUserWorkspacesCommand, CleanOrphanedRoleTargetsCommand, + CreateTwentyStandardApplicationCommand, ], }) export class V1_11_UpgradeVersionCommandModule {} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts index cc7ade3c96e..562ac94735c 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts @@ -21,6 +21,7 @@ import { RegenerateSearchVectorsCommand } from 'src/database/commands/upgrade-ve import { SeedDashboardViewCommand } from 'src/database/commands/upgrade-version-command/1-10/1-10-seed-dashboard-view.command'; import { CleanOrphanedRoleTargetsCommand } from 'src/database/commands/upgrade-version-command/1-11/1-11-clean-orphaned-role-targets.command'; import { CleanOrphanedUserWorkspacesCommand } from 'src/database/commands/upgrade-version-command/1-11/1-11-clean-orphaned-user-workspaces.command'; +import { CreateTwentyStandardApplicationCommand } from 'src/database/commands/upgrade-version-command/1-11/1-11-create-twenty-standard-application.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'; @@ -75,6 +76,7 @@ export class UpgradeCommand extends UpgradeCommandRunner { // 1.11 Commands protected readonly cleanOrphanedUserWorkspacesCommand: CleanOrphanedUserWorkspacesCommand, protected readonly cleanOrphanedRoleTargetsCommand: CleanOrphanedRoleTargetsCommand, + protected readonly seedStandardApplicationsCommand: CreateTwentyStandardApplicationCommand, ) { super( workspaceRepository, @@ -124,7 +126,7 @@ export class UpgradeCommand extends UpgradeCommandRunner { }; const commands_1110: VersionCommands = { - beforeSyncMetadata: [], + beforeSyncMetadata: [this.seedStandardApplicationsCommand], afterSyncMetadata: [ this.cleanOrphanedUserWorkspacesCommand, this.cleanOrphanedRoleTargetsCommand, diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1762333916255-nullable-application-serverless-function-layer.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1762333916255-nullable-application-serverless-function-layer.ts new file mode 100644 index 00000000000..9629cd677eb --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1762333916255-nullable-application-serverless-function-layer.ts @@ -0,0 +1,19 @@ +import { type MigrationInterface, type QueryRunner } from 'typeorm'; + +export class NullableApplicationServerlessFunctionLayer1762333916255 + implements MigrationInterface +{ + name = 'NullableApplicationServerlessFunctionLayer1762333916255'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."application" ALTER COLUMN "serverlessFunctionLayerId" DROP NOT NULL`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."application" ALTER COLUMN "serverlessFunctionLayerId" SET NOT NULL`, + ); + } +} diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1762339932345-makeApplicationWorkspaceFkDeferrable.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1762339932345-makeApplicationWorkspaceFkDeferrable.ts new file mode 100644 index 00000000000..2ef2e834dce --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1762339932345-makeApplicationWorkspaceFkDeferrable.ts @@ -0,0 +1,29 @@ +import { type MigrationInterface, type QueryRunner } from 'typeorm'; + +export class MakeApplicationWorkspaceFkDeferrable1762339932345 + implements MigrationInterface +{ + name = 'MakeApplicationWorkspaceFkDeferrable1762339932345'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."application" DROP CONSTRAINT IF EXISTS "FK_08d1d5e33c2a3ce7c140e9b335b"`, + ); + + // Recreate application.workspaceId FK as DEFERRABLE INITIALLY DEFERRED + // This is the ONLY deferrable constraint - needed because we create application before workspace + await queryRunner.query( + `ALTER TABLE "core"."application" ADD CONSTRAINT "FK_08d1d5e33c2a3ce7c140e9b335b" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION DEFERRABLE INITIALLY DEFERRED`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."application" DROP CONSTRAINT IF EXISTS "FK_08d1d5e33c2a3ce7c140e9b335b"`, + ); + + await queryRunner.query( + `ALTER TABLE "core"."application" ADD CONSTRAINT "FK_08d1d5e33c2a3ce7c140e9b335b" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } +} diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1762343994716-add-workspace-custom-application-id-column.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1762343994716-add-workspace-custom-application-id-column.ts new file mode 100644 index 00000000000..9ab3005afd4 --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1762343994716-add-workspace-custom-application-id-column.ts @@ -0,0 +1,37 @@ +import { type MigrationInterface, type QueryRunner } from 'typeorm'; + +export class AddWorkspaceCustomApplicationIdColumn1762343994716 + implements MigrationInterface +{ + name = 'AddWorkspaceCustomApplicationIdColumn1762343994716'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."workspace" ADD "workspaceCustomApplicationId" uuid`, + ); + await queryRunner.query( + `DROP INDEX "core"."IDX_APPLICATION_UNIVERSAL_IDENTIFIER_WORKSPACE_ID_UNIQUE"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."application" ALTER COLUMN "universalIdentifier" SET NOT NULL`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_APPLICATION_UNIVERSAL_IDENTIFIER_WORKSPACE_ID_UNIQUE" ON "core"."application" ("universalIdentifier", "workspaceId") WHERE "deletedAt" IS NULL AND "universalIdentifier" IS NOT NULL`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX "core"."IDX_APPLICATION_UNIVERSAL_IDENTIFIER_WORKSPACE_ID_UNIQUE"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."application" ALTER COLUMN "universalIdentifier" DROP NOT NULL`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_APPLICATION_UNIVERSAL_IDENTIFIER_WORKSPACE_ID_UNIQUE" ON "core"."application" ("universalIdentifier", "workspaceId") WHERE (("deletedAt" IS NULL) AND ("universalIdentifier" IS NOT NULL"))`, + ); + await queryRunner.query( + `ALTER TABLE "core"."workspace" DROP COLUMN "workspaceCustomApplicationId"`, + ); + } +} diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1762437814771-workspace-custom-application-id-foreign-key.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1762437814771-workspace-custom-application-id-foreign-key.ts new file mode 100644 index 00000000000..2cfdc43e825 --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1762437814771-workspace-custom-application-id-foreign-key.ts @@ -0,0 +1,19 @@ +import { type MigrationInterface, type QueryRunner } from 'typeorm'; + +export class WorkspaceCustomApplicationIdForeignKey1762437814771 + implements MigrationInterface +{ + name = 'WorkspaceCustomApplicationIdForeignKey1762437814771'; + + public async up(queryRunner: QueryRunner): Promise { + 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`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."workspace" DROP CONSTRAINT "FK_3b1acb13a5dac9956d1a4b32755"`, + ); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/application/application-sync.service.ts b/packages/twenty-server/src/engine/core-modules/application/application-sync.service.ts index 0a8923c16ab..53336667c05 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-sync.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-sync.service.ts @@ -19,27 +19,27 @@ import { ServerlessFunctionTriggerManifest, } from 'src/engine/core-modules/application/types/application.types'; import { ApplicationVariableEntityService } from 'src/engine/core-modules/applicationVariable/application-variable.service'; +import { Sources } from 'src/engine/core-modules/file-storage/types/source.type'; import { CronTriggerV2Service } from 'src/engine/metadata-modules/cron-trigger/services/cron-trigger-v2.service'; import { FlatCronTrigger } from 'src/engine/metadata-modules/cron-trigger/types/flat-cron-trigger.type'; import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service'; import { DatabaseEventTriggerV2Service } from 'src/engine/metadata-modules/database-event-trigger/services/database-event-trigger-v2.service'; import { FlatDatabaseEventTrigger } from 'src/engine/metadata-modules/database-event-trigger/types/flat-database-event-trigger.type'; +import { CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input'; +import { FieldMetadataServiceV2 } from 'src/engine/metadata-modules/field-metadata/services/field-metadata.service-v2'; import { createEmptyFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-flat-entity-maps.constant'; import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; import { getFlatEntitiesByApplicationId } from 'src/engine/metadata-modules/flat-entity/utils/get-flat-entities-by-application-id.util'; import { getSubFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/get-sub-flat-entity-maps-or-throw.util'; +import { FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; import { ObjectMetadataServiceV2 } from 'src/engine/metadata-modules/object-metadata/object-metadata-v2.service'; import { RouteTriggerV2Service } from 'src/engine/metadata-modules/route-trigger/services/route-trigger-v2.service'; import { FlatRouteTrigger } from 'src/engine/metadata-modules/route-trigger/types/flat-route-trigger.type'; import { ServerlessFunctionLayerService } from 'src/engine/metadata-modules/serverless-function-layer/serverless-function-layer.service'; import { ServerlessFunctionV2Service } from 'src/engine/metadata-modules/serverless-function/services/serverless-function-v2.service'; import { FlatServerlessFunction } from 'src/engine/metadata-modules/serverless-function/types/flat-serverless-function.type'; -import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration-v2/services/workspace-migration-validate-build-and-run-service'; -import { Sources } from 'src/engine/core-modules/file-storage/types/source.type'; -import { FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; -import { FieldMetadataServiceV2 } from 'src/engine/metadata-modules/field-metadata/services/field-metadata.service-v2'; -import { CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input'; import { computeMetadataNameFromLabel } from 'src/engine/metadata-modules/utils/validate-name-and-label-are-sync-or-throw.util'; +import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration-v2/services/workspace-migration-validate-build-and-run-service'; @Injectable() export class ApplicationSyncService { @@ -81,13 +81,22 @@ export class ApplicationSyncService { applicationId: application.id, }); - await this.syncServerlessFunctions({ - serverlessFunctionsToSync: manifest.serverlessFunctions, - code: manifest.sources, - workspaceId, - applicationId: application.id, - serverlessFunctionLayerId: application.serverlessFunctionLayerId, - }); + if (manifest.serverlessFunctions.length > 0) { + if (!isDefined(application.serverlessFunctionLayerId)) { + throw new ApplicationException( + `Failed to sync serverless function, could not find a serverless function layer.`, + ApplicationExceptionCode.FIELD_NOT_FOUND, + ); + } + + await this.syncServerlessFunctions({ + serverlessFunctionsToSync: manifest.serverlessFunctions, + code: manifest.sources, + workspaceId, + applicationId: application.id, + serverlessFunctionLayerId: application.serverlessFunctionLayerId, + }); + } this.logger.log('✅ Application sync from manifest completed'); } @@ -100,57 +109,46 @@ export class ApplicationSyncService { }: ApplicationInput & { workspaceId: string; }): Promise { - const application = await this.applicationService.findByUniversalIdentifier( - manifest.application.universalIdentifier, - workspaceId, - ); - const name = manifest.application.displayName ?? packageJson.name; - - if (!isDefined(application)) { - const serverlessFunctionLayer = - await this.serverlessFunctionLayerService.create( - { - packageJson, - yarnLock, - }, - workspaceId, - ); - - const application = await this.applicationService.create({ + const application = + (await this.applicationService.findByUniversalIdentifier({ + universalIdentifier: manifest.application.universalIdentifier, + workspaceId, + })) ?? + (await this.applicationService.create({ universalIdentifier: manifest.application.universalIdentifier, name, description: manifest.application.description, version: packageJson.version, sourcePath: 'cli-sync', // Placeholder for CLI-synced apps - serverlessFunctionLayerId: serverlessFunctionLayer.id, + serverlessFunctionLayerId: null, workspaceId, - }); + })); - await this.applicationVariableService.upsertManyApplicationVariableEntities( + let serverlessFunctionLayerId = application.serverlessFunctionLayerId; + + if (manifest.serverlessFunctions.length > 0) { + if (!isDefined(serverlessFunctionLayerId)) { + serverlessFunctionLayerId = ( + await this.serverlessFunctionLayerService.create( + { + packageJson, + yarnLock, + }, + workspaceId, + ) + ).id; + } + + await this.serverlessFunctionLayerService.update( + serverlessFunctionLayerId, { - applicationVariables: manifest.application.applicationVariables, - applicationId: application.id, + packageJson, + yarnLock, }, ); - - return application; } - await this.serverlessFunctionLayerService.update( - application.serverlessFunctionLayerId, - { - packageJson, - yarnLock, - }, - ); - - await this.applicationService.update(application.id, { - name, - description: manifest.application.description, - version: packageJson.version, - }); - await this.applicationVariableService.upsertManyApplicationVariableEntities( { applicationVariables: manifest.application.applicationVariables, @@ -158,7 +156,12 @@ export class ApplicationSyncService { }, ); - return application; + return await this.applicationService.update(application.id, { + name, + description: manifest.application.description, + version: packageJson.version, + serverlessFunctionLayerId, + }); } private async syncFields({ @@ -947,8 +950,7 @@ export class ApplicationSyncService { ); const application = await this.applicationService.findByUniversalIdentifier( - applicationUniversalIdentifier, - workspaceId, + { universalIdentifier: applicationUniversalIdentifier, workspaceId }, ); if (!isDefined(application)) { diff --git a/packages/twenty-server/src/engine/core-modules/application/application.entity.ts b/packages/twenty-server/src/engine/core-modules/application/application.entity.ts index 15eb6341e00..21de01cad32 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application.entity.ts @@ -35,8 +35,8 @@ export class ApplicationEntity { @PrimaryGeneratedColumn('uuid') id: string; - @Column({ nullable: true, type: 'uuid' }) - universalIdentifier?: string; + @Column({ nullable: false, type: 'uuid' }) + universalIdentifier: string; @Column({ nullable: false, type: 'text' }) name: string; @@ -56,8 +56,8 @@ export class ApplicationEntity { @Column({ nullable: false, type: 'uuid' }) workspaceId: string; - @Column({ nullable: false, type: 'uuid' }) - serverlessFunctionLayerId: string; + @Column({ nullable: true, type: 'uuid' }) + serverlessFunctionLayerId: string | null; @ManyToOne(() => WorkspaceEntity, { onDelete: 'CASCADE', diff --git a/packages/twenty-server/src/engine/core-modules/application/application.module.ts b/packages/twenty-server/src/engine/core-modules/application/application.module.ts index e4dc11ce863..dfb8f7290e7 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application.module.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application.module.ts @@ -36,6 +36,7 @@ import { FieldMetadataModule } from 'src/engine/metadata-modules/field-metadata/ WorkspaceMigrationV2Module, PermissionsModule, ], + exports: [ApplicationService], providers: [ApplicationResolver, ApplicationService, ApplicationSyncService], }) export class ApplicationModule {} diff --git a/packages/twenty-server/src/engine/core-modules/application/application.service.ts b/packages/twenty-server/src/engine/core-modules/application/application.service.ts index 28316d6a1b7..3311019bcbe 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application.service.ts @@ -2,15 +2,15 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { isDefined } from 'twenty-shared/utils'; -import { Repository } from 'typeorm'; +import { type QueryRunner, Repository } from 'typeorm'; import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; -import { PackageJson } from 'src/engine/core-modules/application/types/application.types'; -import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; import { ApplicationException, ApplicationExceptionCode, } from 'src/engine/core-modules/application/application.exception'; +import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; +import { TWENTY_STANDARD_APPLICATION } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/twenty-standard-applications'; @Injectable() export class ApplicationService { @@ -64,10 +64,13 @@ export class ApplicationService { }); } - async findByUniversalIdentifier( - universalIdentifier: string, - workspaceId: string, - ) { + async findByUniversalIdentifier({ + universalIdentifier, + workspaceId, + }: { + universalIdentifier: string; + workspaceId: string; + }) { return this.applicationRepository.findOne({ where: { universalIdentifier, @@ -76,34 +79,51 @@ export class ApplicationService { }); } - async create(data: { - universalIdentifier?: string; - name: string; - description?: string; - version?: string; - serverlessFunctionLayerId: string; - sourcePath: string; - workspaceId: string; - }): Promise { + async createTwentyStandardApplication( + { + workspaceId, + }: { + workspaceId: string; + }, + queryRunner?: QueryRunner, + ) { + return await this.create( + { + ...TWENTY_STANDARD_APPLICATION, + serverlessFunctionLayerId: null, + workspaceId, + }, + queryRunner, + ); + } + + async create( + data: { + universalIdentifier?: string; + name: string; + description?: string; + version?: string; + serverlessFunctionLayerId: string | null; + sourcePath: string; + workspaceId: string; + }, + queryRunner?: QueryRunner, + ): Promise { const application = this.applicationRepository.create({ ...data, sourceType: 'local', }); + if (queryRunner) { + return queryRunner.manager.save(ApplicationEntity, application); + } + return this.applicationRepository.save(application); } async update( id: string, - data: { - name?: string; - description?: string; - version?: string; - sourcePath?: string; - packageJson?: PackageJson; - yarnLock?: string; - packageChecksum?: string; - }, + data: Parameters[1], ): Promise { await this.applicationRepository.update({ id }, data); @@ -117,10 +137,10 @@ export class ApplicationService { } async delete(universalIdentifier: string, workspaceId: string) { - const application = await this.findByUniversalIdentifier( + const application = await this.findByUniversalIdentifier({ universalIdentifier, workspaceId, - ); + }); if (!isDefined(application)) { throw new Error(`Application does not exist`); diff --git a/packages/twenty-server/src/engine/core-modules/application/dtos/application.dto.ts b/packages/twenty-server/src/engine/core-modules/application/dtos/application.dto.ts index 72c2add1e57..dec35dd123a 100644 --- a/packages/twenty-server/src/engine/core-modules/application/dtos/application.dto.ts +++ b/packages/twenty-server/src/engine/core-modules/application/dtos/application.dto.ts @@ -3,10 +3,10 @@ import { Field, ObjectType } from '@nestjs/graphql'; import { IsNotEmpty, IsString, IsUUID } from 'class-validator'; import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; -import { ServerlessFunctionDTO } from 'src/engine/metadata-modules/serverless-function/dtos/serverless-function.dto'; +import { ApplicationVariableEntityDTO } from 'src/engine/core-modules/applicationVariable/dtos/application-variable.dto'; import { AgentDTO } from 'src/engine/metadata-modules/agent/dtos/agent.dto'; import { ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto'; -import { ApplicationVariableEntityDTO } from 'src/engine/core-modules/applicationVariable/dtos/application-variable.dto'; +import { ServerlessFunctionDTO } from 'src/engine/metadata-modules/serverless-function/dtos/serverless-function.dto'; @ObjectType('Application') export class ApplicationDTO { @@ -38,4 +38,8 @@ export class ApplicationDTO { @Field(() => [ApplicationVariableEntityDTO]) applicationVariables: ApplicationVariableEntityDTO[]; + + @IsString() + @Field() + universalIdentifier: string; } diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts index 7517c4935ff..b319a279342 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts @@ -62,6 +62,7 @@ import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/works import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-manager.module'; import { ConnectedAccountModule } from 'src/modules/connected-account/connected-account.module'; import { MessagingFolderSyncManagerModule } from 'src/modules/messaging/message-folder-manager/messaging-folder-sync-manager.module'; +import { ApplicationModule } from 'src/engine/core-modules/application/application.module'; import { TwoFactorAuthenticationMethodEntity } from '../two-factor-authentication/entities/two-factor-authentication-method.entity'; import { TwoFactorAuthenticationModule } from '../two-factor-authentication/two-factor-authentication.module'; @@ -113,6 +114,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy'; AuditModule, SubdomainManagerModule, DomainServerConfigModule, + ApplicationModule, ], controllers: [ GoogleAuthController, diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.spec.ts deleted file mode 100644 index 4a3d045f4e9..00000000000 --- a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.spec.ts +++ /dev/null @@ -1,409 +0,0 @@ -import { HttpService } from '@nestjs/axios'; -import { Test, type TestingModule } from '@nestjs/testing'; -import { getRepositoryToken } from '@nestjs/typeorm'; - -import { WorkspaceActivationStatus } from 'twenty-shared/workspace'; -import { type Repository } from 'typeorm'; - -import { type AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity'; -import { - AuthException, - AuthExceptionCode, -} from 'src/engine/core-modules/auth/auth.exception'; -import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service'; -import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; -import { - type AuthProviderWithPasswordType, - type ExistingUserOrPartialUserWithPicture, - type SignInUpBaseParams, -} from 'src/engine/core-modules/auth/types/signInUp.type'; -import { SubdomainManagerService } from 'src/engine/core-modules/domain/subdomain-manager/services/subdomain-manager.service'; -import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; -import { FileUploadService } from 'src/engine/core-modules/file/file-upload/services/file-upload.service'; -import { MetricsService } from 'src/engine/core-modules/metrics/metrics.service'; -import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service'; -import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; -import { type UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; -import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service'; -import { UserService } from 'src/engine/core-modules/user/services/user.service'; -import { UserEntity } from 'src/engine/core-modules/user/user.entity'; -import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service'; -import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type'; -import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; -import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service'; -import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter'; - -jest.mock('src/utils/image', () => { - return { - getImageBufferFromUrl: () => Promise.resolve(Buffer.from('')), - }; -}); - -describe('SignInUpService', () => { - let service: SignInUpService; - let UserRepository: Repository; - let WorkspaceRepository: Repository; - let workspaceInvitationService: WorkspaceInvitationService; - let userWorkspaceService: UserWorkspaceService; - let twentyConfigService: TwentyConfigService; - let subdomainManagerService: SubdomainManagerService; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - SignInUpService, - { - provide: getRepositoryToken(UserEntity), - useValue: { - create: jest.fn(), - save: jest.fn(), - }, - }, - { - provide: getRepositoryToken(WorkspaceEntity), - useValue: { - save: jest.fn(), - create: jest.fn(), - get: jest.fn(), - count: jest.fn(), - }, - }, - { - provide: FileUploadService, - useValue: { - uploadImage: jest.fn(), - }, - }, - { - provide: WorkspaceInvitationService, - useValue: { - validatePersonalInvitation: jest.fn(), - invalidateWorkspaceInvitation: jest.fn(), - }, - }, - { - provide: UserWorkspaceService, - useValue: { - addUserToWorkspaceIfUserNotInWorkspace: jest.fn(), - checkUserWorkspaceExists: jest.fn(), - create: jest.fn(), - }, - }, - { - provide: OnboardingService, - useValue: { - setOnboardingConnectAccountPending: jest.fn(), - setOnboardingInviteTeamPending: jest.fn(), - }, - }, - { - provide: HttpService, - useValue: {}, - }, - { - provide: LoginTokenService, - useValue: {}, - }, - { - provide: TwentyConfigService, - useValue: { - get: jest.fn(), - }, - }, - { - provide: UserService, - useValue: { - markEmailAsVerified: jest.fn().mockReturnValue({ - id: 'test-user-id', - email: 'test@test.com', - isEmailVerified: true, - } as UserEntity), - }, - }, - { - provide: SubdomainManagerService, - useValue: { - generateSubdomain: jest.fn(), - }, - }, - { - provide: UserRoleService, - useValue: { - assignRoleToUserWorkspace: jest.fn(), - }, - }, - { - provide: FeatureFlagService, - useValue: { - isFeatureEnabled: jest.fn(), - }, - }, - { - provide: WorkspaceEventEmitter, - useValue: { - emitCustomBatchEvent: jest.fn(), - }, - }, - { - provide: MetricsService, - useValue: { - incrementCounter: jest.fn(), - }, - }, - ], - }).compile(); - - service = module.get(SignInUpService); - UserRepository = module.get(getRepositoryToken(UserEntity)); - WorkspaceRepository = module.get(getRepositoryToken(WorkspaceEntity)); - workspaceInvitationService = module.get( - WorkspaceInvitationService, - ); - userWorkspaceService = - module.get(UserWorkspaceService); - twentyConfigService = module.get(TwentyConfigService); - subdomainManagerService = module.get( - SubdomainManagerService, - ); - }); - - it('should handle signInUp with valid personal invitation', async () => { - const params: SignInUpBaseParams & - ExistingUserOrPartialUserWithPicture & - AuthProviderWithPasswordType = { - invitation: { value: 'invitationToken' } as AppTokenEntity, - workspace: { - id: 'workspaceId', - activationStatus: WorkspaceActivationStatus.ACTIVE, - } as WorkspaceEntity, - authParams: { - provider: AuthProviderEnum.Password, - password: 'validPassword', - }, - userData: { - type: 'existingUser', - existingUser: { email: 'test@example.com' } as UserEntity, - }, - }; - - jest - .spyOn(workspaceInvitationService, 'validatePersonalInvitation') - .mockResolvedValue({ - isValid: true, - workspace: params.workspace as WorkspaceEntity, - }); - - jest - .spyOn(workspaceInvitationService, 'invalidateWorkspaceInvitation') - .mockResolvedValue(undefined); - - jest - .spyOn(userWorkspaceService, 'addUserToWorkspaceIfUserNotInWorkspace') - .mockResolvedValue(undefined); - - const result = await service.signInUp(params); - - expect(result.workspace).toEqual(params.workspace); - expect(result.user).toBeDefined(); - expect( - workspaceInvitationService.validatePersonalInvitation, - ).toHaveBeenCalledWith({ - workspacePersonalInviteToken: 'invitationToken', - email: 'test@example.com', - }); - expect( - workspaceInvitationService.invalidateWorkspaceInvitation, - ).toHaveBeenCalledWith( - (params.workspace as WorkspaceEntity).id, - 'test@example.com', - ); - expect( - userWorkspaceService.addUserToWorkspaceIfUserNotInWorkspace, - ).toHaveBeenCalled(); - }); - - it('should handle signInUp on existing workspace without invitation', async () => { - const params: SignInUpBaseParams & - ExistingUserOrPartialUserWithPicture & - AuthProviderWithPasswordType = { - workspace: { - id: 'workspaceId', - activationStatus: WorkspaceActivationStatus.ACTIVE, - } as WorkspaceEntity, - authParams: { - provider: AuthProviderEnum.Password, - password: 'validPassword', - }, - userData: { - type: 'existingUser', - existingUser: { email: 'test@example.com' } as UserEntity, - }, - }; - - jest - .spyOn(userWorkspaceService, 'addUserToWorkspaceIfUserNotInWorkspace') - .mockResolvedValue(undefined); - - const result = await service.signInUp(params); - - expect(result.workspace).toEqual(params.workspace); - expect(result.user).toBeDefined(); - expect( - userWorkspaceService.addUserToWorkspaceIfUserNotInWorkspace, - ).toHaveBeenCalled(); - }); - - it('should handle signUp on new workspace for a new user', async () => { - const params: SignInUpBaseParams & - ExistingUserOrPartialUserWithPicture & - AuthProviderWithPasswordType = { - authParams: { - provider: AuthProviderEnum.Password, - password: 'validPassword', - }, - userData: { - type: 'newUserWithPicture', - newUserWithPicture: { - email: 'newuser@example.com', - picture: 'pictureUrl', - }, - }, - }; - - jest.spyOn(twentyConfigService, 'get').mockReturnValue(false); - jest.spyOn(WorkspaceRepository, 'count').mockResolvedValue(0); - jest - .spyOn(WorkspaceRepository, 'create') - .mockReturnValue({} as WorkspaceEntity); - jest.spyOn(WorkspaceRepository, 'save').mockResolvedValue({ - id: 'newWorkspaceId', - activationStatus: WorkspaceActivationStatus.ACTIVE, - } as WorkspaceEntity); - jest.spyOn(UserRepository, 'create').mockReturnValue({} as UserEntity); - jest - .spyOn(subdomainManagerService, 'generateSubdomain') - .mockResolvedValue('a-subdomain'); - jest - .spyOn(UserRepository, 'save') - - .mockResolvedValue({ id: 'newUserId' } as UserEntity); - jest - .spyOn(userWorkspaceService, 'create') - .mockResolvedValue({} as UserWorkspaceEntity); - - const result = await service.signInUp(params); - - expect(result.workspace).toBeDefined(); - expect(result.user).toBeDefined(); - expect(WorkspaceRepository.create).toHaveBeenCalled(); - expect(WorkspaceRepository.save).toHaveBeenCalled(); - expect(UserRepository.create).toHaveBeenCalled(); - expect(UserRepository.save).toHaveBeenCalled(); - expect(userWorkspaceService.create).toHaveBeenCalledWith({ - workspaceId: 'newWorkspaceId', - userId: 'newUserId', - isExistingUser: false, - pictureUrl: 'pictureUrl', - }); - }); - - it('should handle signIn on workspace in pending state', async () => { - const params: SignInUpBaseParams & - ExistingUserOrPartialUserWithPicture & - AuthProviderWithPasswordType = { - workspace: { - id: 'workspaceId', - activationStatus: WorkspaceActivationStatus.PENDING_CREATION, - } as WorkspaceEntity, - authParams: { - provider: AuthProviderEnum.Password, - password: 'validPassword', - }, - userData: { - type: 'existingUser', - existingUser: { email: 'test@example.com' } as UserEntity, - }, - }; - - jest.spyOn(twentyConfigService, 'get').mockReturnValue(false); - jest - .spyOn(userWorkspaceService, 'addUserToWorkspaceIfUserNotInWorkspace') - .mockResolvedValue(undefined); - jest - .spyOn(userWorkspaceService, 'checkUserWorkspaceExists') - .mockResolvedValue({} as UserWorkspaceEntity); - - const result = await service.signInUp(params); - - expect(result.workspace).toEqual(params.workspace); - expect(result.user).toBeDefined(); - expect( - userWorkspaceService.addUserToWorkspaceIfUserNotInWorkspace, - ).toHaveBeenCalled(); - }); - - it('should throw - handle signUp on workspace in pending state', async () => { - const params: SignInUpBaseParams & - ExistingUserOrPartialUserWithPicture & - AuthProviderWithPasswordType = { - workspace: { - id: 'workspaceId', - activationStatus: WorkspaceActivationStatus.PENDING_CREATION, - } as WorkspaceEntity, - authParams: { - provider: AuthProviderEnum.Password, - password: 'validPassword', - }, - userData: { - type: 'existingUser', - existingUser: { email: 'test@example.com' } as UserEntity, - }, - }; - - jest.spyOn(twentyConfigService, 'get').mockReturnValue(false); - jest - .spyOn(userWorkspaceService, 'checkUserWorkspaceExists') - .mockResolvedValue(null); - - await expect(() => service.signInUp(params)).rejects.toThrow( - new AuthException( - 'User is not part of the workspace', - AuthExceptionCode.FORBIDDEN_EXCEPTION, - ), - ); - }); - - it('should handle signup for existing user on new workspace', async () => { - const params: SignInUpBaseParams & - ExistingUserOrPartialUserWithPicture & - AuthProviderWithPasswordType = { - workspace: null, - authParams: { - provider: AuthProviderEnum.Password, - password: 'validPassword', - }, - userData: { - type: 'existingUser', - existingUser: { email: 'existinguser@example.com' } as UserEntity, - }, - }; - - jest.spyOn(twentyConfigService, 'get').mockReturnValue(false); - jest.spyOn(WorkspaceRepository, 'count').mockResolvedValue(0); - jest - .spyOn(WorkspaceRepository, 'create') - .mockReturnValue({} as WorkspaceEntity); - jest.spyOn(WorkspaceRepository, 'save').mockResolvedValue({ - id: 'newWorkspaceId', - activationStatus: WorkspaceActivationStatus.PENDING_CREATION, - } as WorkspaceEntity); - jest.spyOn(userWorkspaceService, 'create').mockResolvedValue({} as any); - - const result = await service.signInUp(params); - - expect(result.workspace).toBeDefined(); - expect(result.user).toBeDefined(); - expect(WorkspaceRepository.create).toHaveBeenCalled(); - expect(WorkspaceRepository.save).toHaveBeenCalled(); - }); -}); diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts index 6d85288a3ff..4898e6b6188 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts @@ -1,15 +1,16 @@ import { HttpService } from '@nestjs/axios'; import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; +import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; import { msg } from '@lingui/core/macro'; import { TWENTY_ICONS_BASE_URL } from 'twenty-shared/constants'; import { WorkspaceActivationStatus } from 'twenty-shared/workspace'; -import { Repository } from 'typeorm'; +import { type DataSource, type QueryRunner, Repository } from 'typeorm'; import { v4 } from 'uuid'; import { USER_SIGNUP_EVENT_NAME } from 'src/engine/api/graphql/workspace-query-runner/constants/user-signup-event-name.constants'; import { type AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity'; +import { ApplicationService } from 'src/engine/core-modules/application/application.service'; import { AuthException, AuthExceptionCode, @@ -38,6 +39,7 @@ import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-in import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter'; +import { computeWorkspaceCustomCreateApplicationInput } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/compute-workspace-custom-create-application-input'; import { getDomainNameByEmail } from 'src/utils/get-domain-name-by-email'; import { isWorkEmail } from 'src/utils/is-work-email'; @@ -58,6 +60,9 @@ export class SignInUpService { private readonly subdomainManagerService: SubdomainManagerService, private readonly userService: UserService, private readonly metricsService: MetricsService, + private readonly applicationService: ApplicationService, + @InjectDataSource() + private readonly dataSource: DataSource, ) {} async computePartialUserFromUserPayload( @@ -263,7 +268,10 @@ export class SignInUpService { canImpersonate: false, }); - await this.activateOnboardingForUser(user, params.workspace); + await this.activateOnboardingForUser({ + user, + workspace: params.workspace, + }); await this.userWorkspaceService.addUserToWorkspaceIfUserNotInWorkspace( user, @@ -289,21 +297,33 @@ export class SignInUpService { } private async activateOnboardingForUser( - user: UserEntity, - workspace: WorkspaceEntity, + { + user, + workspace, + }: { + user: UserEntity; + workspace: WorkspaceEntity; + }, + queryRunner?: QueryRunner, ) { - await this.onboardingService.setOnboardingConnectAccountPending({ - userId: user.id, - workspaceId: workspace.id, - value: true, - }); - - if (user.firstName === '' && user.lastName === '') { - await this.onboardingService.setOnboardingCreateProfilePending({ + await this.onboardingService.setOnboardingConnectAccountPending( + { userId: user.id, workspaceId: workspace.id, value: true, - }); + }, + queryRunner, + ); + + if (user.firstName === '' && user.lastName === '') { + await this.onboardingService.setOnboardingCreateProfilePending( + { + userId: user.id, + workspaceId: workspace.id, + value: true, + }, + queryRunner, + ); } } @@ -316,6 +336,7 @@ export class SignInUpService { canImpersonate: boolean; canAccessFullAdminPanel: boolean; }, + queryRunner?: QueryRunner, ) { const userCreated = this.userRepository.create({ ...newUserWithPicture, @@ -323,7 +344,9 @@ export class SignInUpService { canAccessFullAdminPanel, }); - const savedUser = await this.userRepository.save(userCreated); + const savedUser = queryRunner + ? await queryRunner.manager.save(UserEntity, userCreated) + : await this.userRepository.save(userCreated); const serverUrl = this.twentyConfigService.get('SERVER_URL'); @@ -435,43 +458,88 @@ export class SignInUpService { const logo = isWorkEmailFound && (await isLogoUrlValid()) ? logoUrl : undefined; - const workspaceToCreate = this.workspaceRepository.create({ - subdomain: await this.subdomainManagerService.generateSubdomain( - isWorkEmailFound ? { userEmail: email } : {}, - ), - displayName: '', - inviteHash: v4(), - activationStatus: WorkspaceActivationStatus.PENDING_CREATION, - logo, - }); + const workspaceId = v4(); + const workspaceCustomApplicationCreateInput = + computeWorkspaceCustomCreateApplicationInput({ + workspace: { + id: workspaceId, + }, + }); - const workspace = await this.workspaceRepository.save(workspaceToCreate); + const queryRunner = this.dataSource.createQueryRunner(); - const isExistingUser = userData.type === 'existingUser'; - const user = isExistingUser - ? userData.existingUser - : await this.saveNewUser(userData.newUserWithPicture, { - canImpersonate, - canAccessFullAdminPanel, - }); + await queryRunner.connect(); + await queryRunner.startTransaction(); - await this.userWorkspaceService.create({ - userId: user.id, - workspaceId: workspace.id, - isExistingUser, - pictureUrl: isExistingUser - ? undefined - : userData.newUserWithPicture.picture, - }); + try { + const workspaceCustomApplication = await this.applicationService.create( + { + ...workspaceCustomApplicationCreateInput, + serverlessFunctionLayerId: null, + }, + queryRunner, + ); - await this.activateOnboardingForUser(user, workspace); + const workspaceToCreate = this.workspaceRepository.create({ + id: workspaceId, + subdomain: await this.subdomainManagerService.generateSubdomain( + isWorkEmailFound ? { userEmail: email } : {}, + ), + workspaceCustomApplicationId: workspaceCustomApplication.id, + displayName: '', + inviteHash: v4(), + activationStatus: WorkspaceActivationStatus.PENDING_CREATION, + logo, + }); - await this.onboardingService.setOnboardingInviteTeamPending({ - workspaceId: workspace.id, - value: true, - }); + const workspace = await queryRunner.manager.save( + WorkspaceEntity, + workspaceToCreate, + ); - return { user, workspace }; + const isExistingUser = userData.type === 'existingUser'; + const user = isExistingUser + ? userData.existingUser + : await this.saveNewUser( + userData.newUserWithPicture, + { + canImpersonate, + canAccessFullAdminPanel, + }, + queryRunner, + ); + + await this.userWorkspaceService.create( + { + userId: user.id, + workspaceId: workspace.id, + isExistingUser, + pictureUrl: isExistingUser + ? undefined + : userData.newUserWithPicture.picture, + }, + queryRunner, + ); + + await this.activateOnboardingForUser({ user, workspace }, queryRunner); + + await this.onboardingService.setOnboardingInviteTeamPending( + { + workspaceId: workspace.id, + value: true, + }, + queryRunner, + ); + + await queryRunner.commitTransaction(); + + return { user, workspace }; + } catch (error) { + await queryRunner.rollbackTransaction(); + throw error; + } finally { + await queryRunner.release(); + } } async signUpWithoutWorkspace( diff --git a/packages/twenty-server/src/engine/core-modules/key-value-pair/key-value-pair.service.ts b/packages/twenty-server/src/engine/core-modules/key-value-pair/key-value-pair.service.ts index 5eecbc92b41..24da334c1b4 100644 --- a/packages/twenty-server/src/engine/core-modules/key-value-pair/key-value-pair.service.ts +++ b/packages/twenty-server/src/engine/core-modules/key-value-pair/key-value-pair.service.ts @@ -1,6 +1,6 @@ import { InjectRepository } from '@nestjs/typeorm'; -import { IsNull, Repository } from 'typeorm'; +import { type QueryRunner, IsNull, Repository } from 'typeorm'; import { KeyValuePairEntity, @@ -50,19 +50,22 @@ export class KeyValuePairService< })); } - async set({ - userId, - workspaceId, - key, - value, - type, - }: { - userId?: string | null; - workspaceId?: string | null; - key: Extract; - value: KeyValueTypesMap[K]; - type: KeyValuePairType; - }) { + async set( + { + userId, + workspaceId, + key, + value, + type, + }: { + userId?: string | null; + workspaceId?: string | null; + key: Extract; + value: KeyValueTypesMap[K]; + type: KeyValuePairType; + }, + queryRunner?: QueryRunner, + ) { const upsertData = { userId, workspaceId, @@ -84,24 +87,36 @@ export class KeyValuePairService< ? '"workspaceId" is NULL' : undefined; - await this.keyValuePairRepository.upsert(upsertData, { - conflictPaths, - indexPredicate, - }); + if (queryRunner) { + await queryRunner.manager + .getRepository(KeyValuePairEntity) + .upsert(upsertData, { + conflictPaths, + indexPredicate, + }); + } else { + await this.keyValuePairRepository.upsert(upsertData, { + conflictPaths, + indexPredicate, + }); + } } - async delete({ - userId, - workspaceId, - type, - key, - }: { - userId?: string | null; - workspaceId?: string | null; - type: KeyValuePairType; - key: Extract; - }) { - await this.keyValuePairRepository.delete({ + async delete( + { + userId, + workspaceId, + type, + key, + }: { + userId?: string | null; + workspaceId?: string | null; + type: KeyValuePairType; + key: Extract; + }, + queryRunner?: QueryRunner, + ) { + const deleteConditions = { ...(userId === undefined ? {} : userId === null @@ -114,6 +129,14 @@ export class KeyValuePairService< : { workspaceId }), type, key, - }); + }; + + if (queryRunner) { + await queryRunner.manager + .getRepository(KeyValuePairEntity) + .delete(deleteConditions); + } else { + await this.keyValuePairRepository.delete(deleteConditions); + } } } diff --git a/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.service.ts b/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.service.ts index 4518f78667f..42f28f1953f 100644 --- a/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.service.ts +++ b/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.service.ts @@ -3,6 +3,7 @@ import { Injectable } from '@nestjs/common'; import { isNonEmptyString } from '@sniptt/guards'; import { isDefined } from 'twenty-shared/utils'; import { WorkspaceActivationStatus } from 'twenty-shared/workspace'; +import { type QueryRunner } from 'typeorm'; import { BillingService } from 'src/engine/core-modules/billing/services/billing.service'; import { OnboardingStatus } from 'src/engine/core-modules/onboarding/enums/onboarding-status.enum'; @@ -107,81 +108,108 @@ export class OnboardingService { return OnboardingStatus.COMPLETED; } - async setOnboardingConnectAccountPending({ - userId, - workspaceId, - value, - }: { - userId: string; - workspaceId: string; - value: boolean; - }) { + async setOnboardingConnectAccountPending( + { + userId, + workspaceId, + value, + }: { + userId: string; + workspaceId: string; + value: boolean; + }, + queryRunner?: QueryRunner, + ) { if (!value) { - await this.userVarsService.delete({ - userId, - workspaceId, - key: OnboardingStepKeys.ONBOARDING_CONNECT_ACCOUNT_PENDING, - }); + await this.userVarsService.delete( + { + userId, + workspaceId, + key: OnboardingStepKeys.ONBOARDING_CONNECT_ACCOUNT_PENDING, + }, + queryRunner, + ); return; } - await this.userVarsService.set({ - userId, - workspaceId: workspaceId, - key: OnboardingStepKeys.ONBOARDING_CONNECT_ACCOUNT_PENDING, - value: true, - }); + await this.userVarsService.set( + { + userId, + workspaceId: workspaceId, + key: OnboardingStepKeys.ONBOARDING_CONNECT_ACCOUNT_PENDING, + value: true, + }, + queryRunner, + ); } - async setOnboardingInviteTeamPending({ - workspaceId, - value, - }: { - workspaceId: string; - value: boolean; - }) { + async setOnboardingInviteTeamPending( + { + workspaceId, + value, + }: { + workspaceId: string; + value: boolean; + }, + queryRunner?: QueryRunner, + ) { if (!value) { - await this.userVarsService.delete({ + await this.userVarsService.delete( + { + workspaceId, + key: OnboardingStepKeys.ONBOARDING_INVITE_TEAM_PENDING, + }, + queryRunner, + ); + + return; + } + + await this.userVarsService.set( + { workspaceId, key: OnboardingStepKeys.ONBOARDING_INVITE_TEAM_PENDING, - }); + value: true, + }, + queryRunner, + ); + } + + async setOnboardingCreateProfilePending( + { + userId, + workspaceId, + value, + }: { + userId: string; + workspaceId: string; + value: boolean; + }, + queryRunner?: QueryRunner, + ) { + if (!value) { + await this.userVarsService.delete( + { + userId, + workspaceId, + key: OnboardingStepKeys.ONBOARDING_CREATE_PROFILE_PENDING, + }, + queryRunner, + ); return; } - await this.userVarsService.set({ - workspaceId, - key: OnboardingStepKeys.ONBOARDING_INVITE_TEAM_PENDING, - value: true, - }); - } - - async setOnboardingCreateProfilePending({ - userId, - workspaceId, - value, - }: { - userId: string; - workspaceId: string; - value: boolean; - }) { - if (!value) { - await this.userVarsService.delete({ + await this.userVarsService.set( + { userId, workspaceId, key: OnboardingStepKeys.ONBOARDING_CREATE_PROFILE_PENDING, - }); - - return; - } - - await this.userVarsService.set({ - userId, - workspaceId, - key: OnboardingStepKeys.ONBOARDING_CREATE_PROFILE_PENDING, - value: true, - }); + value: true, + }, + queryRunner, + ); } async setOnboardingBookOnboardingPending({ diff --git a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.spec.ts b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.spec.ts index 1dafefab7bb..8f58d09b8a5 100644 --- a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.spec.ts @@ -420,23 +420,30 @@ describe('UserWorkspaceService', () => { user.id, workspace.id, ); - expect(service.create).toHaveBeenCalledWith({ - workspaceId: workspace.id, - userId: user.id, - isExistingUser: true, - }); + expect(service.create).toHaveBeenCalled(); + expect(service.create).toHaveBeenCalledWith( + { + workspaceId: workspace.id, + userId: user.id, + isExistingUser: true, + }, + undefined, + ); expect(service.createWorkspaceMember).toHaveBeenCalledWith( workspace.id, user, ); - expect(userRoleService.assignRoleToUserWorkspace).toHaveBeenCalledWith({ - workspaceId: workspace.id, - userWorkspaceId: userWorkspace.id, - roleId: workspace.defaultRoleId, - }); + expect(userRoleService.assignRoleToUserWorkspace).toHaveBeenCalledWith( + { + workspaceId: workspace.id, + userWorkspaceId: userWorkspace.id, + roleId: workspace.defaultRoleId, + }, + undefined, + ); expect( workspaceInvitationService.invalidateWorkspaceInvitation, - ).toHaveBeenCalledWith(workspace.id, user.email); + ).toHaveBeenCalledWith(workspace.id, user.email, undefined); }); it('should not add user to workspace if already in workspace', async () => { diff --git a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts index 477defac2b5..6e1dabc845e 100644 --- a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts +++ b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts @@ -3,7 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm'; import { type APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations'; import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils'; -import { IsNull, Not, Repository } from 'typeorm'; +import { type QueryRunner, IsNull, Not, Repository } from 'typeorm'; import { FileStorageExceptionCode } from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception'; import { FileFolder } from 'src/engine/core-modules/file/interfaces/file-folder.interface'; @@ -57,17 +57,20 @@ export class UserWorkspaceService extends TypeOrmQueryService { + async create( + { + userId, + workspaceId, + isExistingUser, + pictureUrl, + }: { + userId: string; + workspaceId: string; + isExistingUser: boolean; + pictureUrl?: string; + }, + queryRunner?: QueryRunner, + ): Promise { const defaultAvatarUrl = await this.computeDefaultAvatarUrl( userId, workspaceId, @@ -81,7 +84,9 @@ export class UserWorkspaceService extends TypeOrmQueryService { findOne: jest.fn(), save: jest.fn(), softDelete: jest.fn(), + update: jest.fn(), }, }, { @@ -67,6 +69,10 @@ describe('UserService', () => { deleteUserWorkspace: jest.fn(), }, }, + { + provide: ApplicationService, + useValue: {}, + }, ], }).compile(); diff --git a/packages/twenty-server/src/engine/core-modules/user/services/user.service.ts b/packages/twenty-server/src/engine/core-modules/user/services/user.service.ts index 1a9a7bcfdb8..3937c53b55b 100644 --- a/packages/twenty-server/src/engine/core-modules/user/services/user.service.ts +++ b/packages/twenty-server/src/engine/core-modules/user/services/user.service.ts @@ -6,7 +6,7 @@ import { msg } from '@lingui/core/macro'; import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm'; import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils'; import { isWorkspaceActiveOrSuspended } from 'twenty-shared/workspace'; -import { IsNull, Not, Repository } from 'typeorm'; +import { type QueryRunner, IsNull, Not, Repository } from 'typeorm'; import { AuthException, @@ -275,11 +275,13 @@ export class UserService extends TypeOrmQueryService { return user; } - async markEmailAsVerified(userId: string) { + async markEmailAsVerified(userId: string, queryRunner?: QueryRunner) { const user = await this.findUserByIdOrThrow(userId); user.isEmailVerified = true; - return await this.userRepository.save(user); + return queryRunner + ? await queryRunner.manager.save(UserEntity, user) + : await this.userRepository.save(user); } } diff --git a/packages/twenty-server/src/engine/core-modules/user/user-vars/services/user-vars.service.ts b/packages/twenty-server/src/engine/core-modules/user/user-vars/services/user-vars.service.ts index 0333e7ec3c3..db5ca55cb0a 100644 --- a/packages/twenty-server/src/engine/core-modules/user/user-vars/services/user-vars.service.ts +++ b/packages/twenty-server/src/engine/core-modules/user/user-vars/services/user-vars.service.ts @@ -1,5 +1,7 @@ import { Injectable } from '@nestjs/common'; +import { type QueryRunner } from 'typeorm'; + import { KeyValuePairType } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity'; import { KeyValuePairService } from 'src/engine/core-modules/key-value-pair/key-value-pair.service'; import { mergeUserVars } from 'src/engine/core-modules/user/user-vars/utils/merge-user-vars.util'; @@ -126,40 +128,52 @@ export class UserVarsService< return mergeUserVars>(result); } - set({ - userId, - workspaceId, - key, - value, - }: { - userId?: string; - workspaceId?: string; - key: Extract; - value: KeyValueTypesMap[K]; - }) { - return this.keyValuePairService.set({ - userId, - workspaceId, - key: key, - value, - type: KeyValuePairType.USER_VARIABLE, - }); - } - - async delete({ - userId, - workspaceId, - key, - }: { - userId?: string; - workspaceId?: string; - key: Extract; - }) { - return this.keyValuePairService.delete({ + set( + { userId, workspaceId, key, - type: KeyValuePairType.USER_VARIABLE, - }); + value, + }: { + userId?: string; + workspaceId?: string; + key: Extract; + value: KeyValueTypesMap[K]; + }, + queryRunner?: QueryRunner, + ) { + return this.keyValuePairService.set( + { + userId, + workspaceId, + key: key, + value, + type: KeyValuePairType.USER_VARIABLE, + }, + queryRunner, + ); + } + + async delete( + { + userId, + workspaceId, + key, + }: { + userId?: string; + workspaceId?: string; + key: Extract; + }, + queryRunner?: QueryRunner, + ) { + return this.keyValuePairService.delete( + { + userId, + workspaceId, + key, + type: KeyValuePairType.USER_VARIABLE, + }, + queryRunner, + ); } } diff --git a/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.ts b/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.ts index ca94f218d84..3ea59910bfb 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.ts @@ -9,8 +9,8 @@ import { addMilliseconds } from 'date-fns'; import ms from 'ms'; import { SendInviteLinkEmail } from 'twenty-emails'; import { AppPath } from 'twenty-shared/types'; -import { getAppPath } from 'twenty-shared/utils'; -import { IsNull, Repository } from 'typeorm'; +import { getAppPath, isDefined } from 'twenty-shared/utils'; +import { type QueryRunner, IsNull, Repository } from 'typeorm'; import { AppTokenEntity, @@ -203,10 +203,22 @@ export class WorkspaceInvitationService { return 'success'; } - async invalidateWorkspaceInvitation(workspaceId: string, email: string) { + async invalidateWorkspaceInvitation( + workspaceId: string, + email: string, + queryRunner?: QueryRunner, + ) { const appToken = await this.getOneWorkspaceInvitation(workspaceId, email); - if (appToken) { + if (!isDefined(appToken)) { + return; + } + + if (queryRunner) { + await queryRunner.manager + .getRepository(AppTokenEntity) + .delete(appToken.id); + } else { await this.appTokenRepository.delete(appToken.id); } } diff --git a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts index 24676816504..ac5f3076bbc 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts @@ -247,7 +247,7 @@ export class WorkspaceService extends TypeOrmQueryService { ); await this.workspaceManagerService.init({ - workspaceId: workspace.id, + workspace, userId: user.id, }); await this.userWorkspaceService.createWorkspaceMember(workspace.id, user); diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts index 3e2632ed3a3..d9edd1c7055 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts @@ -1,6 +1,7 @@ import { Field, ObjectType, registerEnumType } from '@nestjs/graphql'; import { IDField } from '@ptc-org/nestjs-query-graphql'; +import { Application } from 'cloudflare/resources/zero-trust/access/applications/applications'; import { WorkspaceActivationStatus } from 'twenty-shared/workspace'; import { Check, @@ -9,6 +10,8 @@ import { DeleteDateColumn, Entity, Index, + JoinColumn, + ManyToOne, OneToMany, PrimaryGeneratedColumn, Relation, @@ -19,6 +22,7 @@ import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/ 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 { 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'; @@ -272,4 +276,22 @@ export class WorkspaceEntity { @Field(() => String, { nullable: false }) @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' }) + workspaceCustomApplicationId: string; + + @ManyToOne(() => ApplicationEntity, { + onDelete: 'RESTRICT', + nullable: false, + }) + @JoinColumn({ name: 'workspaceCustomApplicationId' }) + workspaceCustomApplication: Relation; + + @OneToMany(() => ApplicationEntity, (application) => application.workspace, { + onDelete: 'CASCADE', + }) + applications: Relation; } diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts index 7e8a0dcf264..cfcf76e4920 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts @@ -292,6 +292,11 @@ export class WorkspaceResolver { ); } + @ResolveField(() => String) + workspaceCustomApplicationId(@Parent() workspace: WorkspaceEntity) { + return workspace.workspaceCustomApplicationId; + } + @ResolveField(() => Boolean) isMicrosoftAuthEnabled(@Parent() workspace: WorkspaceEntity) { return ( diff --git a/packages/twenty-server/src/engine/metadata-modules/route-trigger/route-trigger.module.ts b/packages/twenty-server/src/engine/metadata-modules/route-trigger/route-trigger.module.ts index 0a0e8c8c0d3..9209ee210ad 100644 --- a/packages/twenty-server/src/engine/metadata-modules/route-trigger/route-trigger.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/route-trigger/route-trigger.module.ts @@ -1,7 +1,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { AuthModule } from 'src/engine/core-modules/auth/auth.module'; +import { TokenModule } from 'src/engine/core-modules/auth/token/token.module'; import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module'; import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; @@ -17,7 +17,7 @@ import { WorkspaceMigrationV2Module } from 'src/engine/workspace-manager/workspa @Module({ imports: [ TypeOrmModule.forFeature([RouteTriggerEntity]), - AuthModule, + TokenModule, WorkspaceDomainsModule, ServerlessFunctionModule, WorkspaceManyOrAllFlatEntityMapsCacheModule, diff --git a/packages/twenty-server/src/engine/metadata-modules/user-role/user-role.service.ts b/packages/twenty-server/src/engine/metadata-modules/user-role/user-role.service.ts index 5a8db9022a3..4c89303d890 100644 --- a/packages/twenty-server/src/engine/metadata-modules/user-role/user-role.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/user-role/user-role.service.ts @@ -2,7 +2,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { msg } from '@lingui/core/macro'; import { isDefined } from 'twenty-shared/utils'; -import { In, Not, Repository } from 'typeorm'; +import { type QueryRunner, In, Not, Repository } from 'typeorm'; import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { @@ -29,15 +29,18 @@ export class UserRoleService { private readonly workspacePermissionsCacheService: WorkspacePermissionsCacheService, ) {} - public async assignRoleToUserWorkspace({ - workspaceId, - userWorkspaceId, - roleId, - }: { - workspaceId: string; - userWorkspaceId: string; - roleId: string; - }): Promise { + public async assignRoleToUserWorkspace( + { + workspaceId, + userWorkspaceId, + roleId, + }: { + workspaceId: string; + userWorkspaceId: string; + roleId: string; + }, + queryRunner?: QueryRunner, + ): Promise { const validationResult = await this.validateAssignRoleInput({ userWorkspaceId, workspaceId, @@ -48,13 +51,17 @@ export class UserRoleService { return; } - const newRoleTarget = await this.roleTargetsRepository.save({ + const roleTargetsRepo = queryRunner + ? queryRunner.manager.getRepository(RoleTargetsEntity) + : this.roleTargetsRepository; + + const newRoleTarget = await roleTargetsRepo.save({ roleId, userWorkspaceId, workspaceId, }); - await this.roleTargetsRepository.delete({ + await roleTargetsRepo.delete({ userWorkspaceId, workspaceId, id: Not(newRoleTarget.id), diff --git a/packages/twenty-server/src/engine/workspace-manager/__tests__/workspace-manager.service.spec.ts b/packages/twenty-server/src/engine/workspace-manager/__tests__/workspace-manager.service.spec.ts index 9981c4cd835..7e2b7644f9e 100644 --- a/packages/twenty-server/src/engine/workspace-manager/__tests__/workspace-manager.service.spec.ts +++ b/packages/twenty-server/src/engine/workspace-manager/__tests__/workspace-manager.service.spec.ts @@ -3,6 +3,7 @@ import { getDataSourceToken, getRepositoryToken } from '@nestjs/typeorm'; import { type DataSource, type Repository } from 'typeorm'; +import { ApplicationService } from 'src/engine/core-modules/application/application.service'; import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; @@ -104,6 +105,10 @@ describe('WorkspaceManagerService', () => { provide: RoleService, useValue: {}, }, + { + provide: ApplicationService, + useValue: {}, + }, { provide: UserRoleService, useValue: {}, diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/billing/utils/seed-billing-customers.util.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/billing/utils/seed-billing-customers.util.ts index 426fe8017c4..3465dc22ff6 100644 --- a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/billing/utils/seed-billing-customers.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/billing/utils/seed-billing-customers.util.ts @@ -1,13 +1,19 @@ -import { type DataSource } from 'typeorm'; +import { type QueryRunner } from 'typeorm'; const tableName = 'billingCustomer'; -export const seedBillingCustomers = async ( - dataSource: DataSource, - schemaName: string, - workspaceId: string, -) => { - await dataSource +type SeedBillingCustomersArgs = { + queryRunner: QueryRunner; + schemaName: string; + workspaceId: string; +}; + +export const seedBillingCustomers = async ({ + queryRunner, + schemaName, + workspaceId, +}: SeedBillingCustomersArgs) => { + await queryRunner.manager .createQueryBuilder() .insert() .into(`${schemaName}.${tableName}`, ['workspaceId', 'stripeCustomerId']) diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/billing/utils/seed-billing-subscriptions.util.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/billing/utils/seed-billing-subscriptions.util.ts index b1c7b19f431..b214afef738 100644 --- a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/billing/utils/seed-billing-subscriptions.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/billing/utils/seed-billing-subscriptions.util.ts @@ -1,13 +1,19 @@ -import { type DataSource } from 'typeorm'; +import { type QueryRunner } from 'typeorm'; const tableName = 'billingSubscription'; -export const seedBillingSubscriptions = async ( - dataSource: DataSource, - schemaName: string, - workspaceId: string, -) => { - await dataSource +type SeedBillingSubscriptionsArgs = { + queryRunner: QueryRunner; + schemaName: string; + workspaceId: string; +}; + +export const seedBillingSubscriptions = async ({ + queryRunner, + schemaName, + workspaceId, +}: SeedBillingSubscriptionsArgs) => { + await queryRunner.manager .createQueryBuilder() .insert() .into(`${schemaName}.${tableName}`, [ diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant.ts new file mode 100644 index 00000000000..cdcc967ba8c --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant.ts @@ -0,0 +1,52 @@ +import { WorkspaceActivationStatus } from 'twenty-shared/workspace'; + +import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; + +export const WORKSPACE_FIELDS_TO_SEED = [ + 'id', + 'displayName', + 'subdomain', + 'inviteHash', + 'logo', + 'activationStatus', + 'isTwoFactorAuthenticationEnforced', + 'version', + 'workspaceCustomApplicationId', +] as const satisfies (keyof WorkspaceEntity)[]; + +export type CreateWorkspaceInput = Pick< + WorkspaceEntity, + (typeof WORKSPACE_FIELDS_TO_SEED)[number] +>; + +export const SEED_APPLE_WORKSPACE_ID = '20202020-1c25-4d02-bf25-6aeccf7ea419'; +export const SEED_YCOMBINATOR_WORKSPACE_ID = + '3b8e6458-5fc1-4e63-8563-008ccddaa6db'; + +export type SeededWorkspacesIds = + | typeof SEED_APPLE_WORKSPACE_ID + | typeof SEED_YCOMBINATOR_WORKSPACE_ID; + +export const SEEDER_CREATE_WORKSPACE_INPUT = { + [SEED_APPLE_WORKSPACE_ID]: { + id: SEED_APPLE_WORKSPACE_ID, + displayName: 'Apple', + subdomain: 'apple', + inviteHash: 'apple.dev-invite-hash', + logo: 'https://twentyhq.github.io/placeholder-images/workspaces/apple-logo.png', + activationStatus: WorkspaceActivationStatus.PENDING_CREATION, // will be set to active after default role creation + isTwoFactorAuthenticationEnforced: false, + }, + [SEED_YCOMBINATOR_WORKSPACE_ID]: { + id: SEED_YCOMBINATOR_WORKSPACE_ID, + displayName: 'YCombinator', + subdomain: 'yc', + inviteHash: 'yc.dev-invite-hash', + logo: 'https://twentyhq.github.io/placeholder-images/workspaces/ycombinator-logo.png', + activationStatus: WorkspaceActivationStatus.PENDING_CREATION, // will be set to active after default role creation + isTwoFactorAuthenticationEnforced: false, + }, +} as const satisfies Record< + SeededWorkspacesIds, + Omit +>; diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/services/dev-seeder-permissions.service.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/services/dev-seeder-permissions.service.ts index 40bca68d88d..68d03fc34ae 100644 --- a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/services/dev-seeder-permissions.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/services/dev-seeder-permissions.service.ts @@ -12,14 +12,14 @@ import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; import { RoleService } from 'src/engine/metadata-modules/role/role.service'; import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service'; import { WorkspacePermissionsCacheService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service'; +import { + SEED_APPLE_WORKSPACE_ID, + SEED_YCOMBINATOR_WORKSPACE_ID, +} from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant'; import { RANDOM_USER_WORKSPACE_IDS, USER_WORKSPACE_DATA_SEED_IDS, } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-user-workspaces.util'; -import { - SEED_APPLE_WORKSPACE_ID, - SEED_YCOMBINATOR_WORKSPACE_ID, -} from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-workspaces.util'; import { API_KEY_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/api-key-data-seeds.constant'; import { ADMIN_ROLE } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-roles/roles/admin-role'; diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-agents.util.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-agents.util.ts index 4e7fc3b3a0f..d835be4f62d 100644 --- a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-agents.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-agents.util.ts @@ -1,11 +1,11 @@ -import { type DataSource } from 'typeorm'; +import { type QueryRunner } from 'typeorm'; import { AgentChatMessageRole } from 'src/engine/metadata-modules/agent/agent-chat-message.entity'; -import { USER_WORKSPACE_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-user-workspaces.util'; import { SEED_APPLE_WORKSPACE_ID, SEED_YCOMBINATOR_WORKSPACE_ID, -} from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-workspaces.util'; +} from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant'; +import { USER_WORKSPACE_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-user-workspaces.util'; const agentChatThreadTableName = 'agentChatThread'; const agentChatMessageTableName = 'agentChatMessage'; @@ -43,11 +43,17 @@ export const AGENT_CHAT_MESSAGE_PART_DATA_SEED_IDS = { YCOMBINATOR_MESSAGE_4_PART_1: '20202020-0000-4000-8000-000000000054', }; -const seedChatThreads = async ( - dataSource: DataSource, - schemaName: string, - workspaceId: string, -) => { +type SeedChatThreadsArgs = { + queryRunner: QueryRunner; + schemaName: string; + workspaceId: string; +}; + +const seedChatThreads = async ({ + queryRunner, + schemaName, + workspaceId, +}: SeedChatThreadsArgs) => { let threadId: string; let userWorkspaceId: string; @@ -65,7 +71,7 @@ const seedChatThreads = async ( const now = new Date(); - await dataSource + await queryRunner.manager .createQueryBuilder() .insert() .into(`${schemaName}.${agentChatThreadTableName}`, [ @@ -88,12 +94,19 @@ const seedChatThreads = async ( return threadId; }; -const seedChatMessages = async ( - dataSource: DataSource, - schemaName: string, - workspaceId: string, - threadId: string, -) => { +type SeedChatMessagesArgs = { + queryRunner: QueryRunner; + schemaName: string; + workspaceId: string; + threadId: string; +}; + +const seedChatMessages = async ({ + queryRunner, + schemaName, + workspaceId, + threadId, +}: SeedChatMessagesArgs) => { let messageIds: string[]; let partIds: string[]; let messages: Array<{ @@ -274,7 +287,7 @@ const seedChatMessages = async ( ); } - await dataSource + await queryRunner.manager .createQueryBuilder() .insert() .into(`${schemaName}.${agentChatMessageTableName}`, [ @@ -287,7 +300,7 @@ const seedChatMessages = async ( .values(messages) .execute(); - await dataSource + await queryRunner.manager .createQueryBuilder() .insert() .into(`${schemaName}.${agentChatMessagePartTableName}`, [ @@ -303,12 +316,27 @@ const seedChatMessages = async ( .execute(); }; -export const seedAgents = async ( - dataSource: DataSource, - schemaName: string, - workspaceId: string, -) => { - const threadId = await seedChatThreads(dataSource, schemaName, workspaceId); +type SeedAgentsArgs = { + queryRunner: QueryRunner; + schemaName: string; + workspaceId: string; +}; - await seedChatMessages(dataSource, schemaName, workspaceId, threadId); +export const seedAgents = async ({ + queryRunner, + schemaName, + workspaceId, +}: SeedAgentsArgs) => { + const threadId = await seedChatThreads({ + queryRunner, + schemaName, + workspaceId, + }); + + await seedChatMessages({ + queryRunner, + schemaName, + workspaceId, + threadId, + }); }; diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-api-keys.util.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-api-keys.util.ts index 215ee412659..bdd3680b6d1 100644 --- a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-api-keys.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-api-keys.util.ts @@ -1,15 +1,21 @@ -import { type DataSource } from 'typeorm'; +import { type QueryRunner } from 'typeorm'; import { API_KEY_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/api-key-data-seeds.constant'; const tableName = 'apiKey'; -export const seedApiKeys = async ( - dataSource: DataSource, - schemaName: string, - workspaceId: string, -) => { - await dataSource +type SeedApiKeysArgs = { + queryRunner: QueryRunner; + schemaName: string; + workspaceId: string; +}; + +export const seedApiKeys = async ({ + queryRunner, + schemaName, + workspaceId, +}: SeedApiKeysArgs) => { + await queryRunner.manager .createQueryBuilder() .insert() .into(`${schemaName}.${tableName}`, [ diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-core-schema.util.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-core-schema.util.ts index 2fac8d7d170..248e6f4ea85 100644 --- a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-core-schema.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-core-schema.util.ts @@ -1,18 +1,26 @@ import { type DataSource } from 'typeorm'; +import { type ApplicationService } from 'src/engine/core-modules/application/application.service'; import { seedBillingCustomers } from 'src/engine/workspace-manager/dev-seeder/core/billing/utils/seed-billing-customers.util'; import { seedBillingSubscriptions } from 'src/engine/workspace-manager/dev-seeder/core/billing/utils/seed-billing-subscriptions.util'; +import { + type SeededWorkspacesIds, + SEEDER_CREATE_WORKSPACE_INPUT, +} from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant'; import { seedAgents } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-agents.util'; import { seedApiKeys } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-api-keys.util'; import { seedFeatureFlags } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util'; import { seedUserWorkspaces } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-user-workspaces.util'; import { seedUsers } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-users.util'; -import { seedWorkspaces } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-workspaces.util'; +import { createWorkspace } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-workspace.util'; +import { computeWorkspaceCustomCreateApplicationInput } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/compute-workspace-custom-create-application-input'; +import { extractVersionMajorMinorPatch } from 'src/utils/version/extract-version-major-minor-patch'; type SeedCoreSchemaArgs = { dataSource: DataSource; - workspaceId: string; + workspaceId: SeededWorkspacesIds; appVersion: string | undefined; + applicationService: ApplicationService; seedBilling?: boolean; seedFeatureFlags?: boolean; }; @@ -21,30 +29,75 @@ export const seedCoreSchema = async ({ appVersion, dataSource, workspaceId, + applicationService, seedBilling = true, seedFeatureFlags: shouldSeedFeatureFlags = true, }: SeedCoreSchemaArgs) => { const schemaName = 'core'; - await seedWorkspaces({ - dataSource, - schemaName, - workspaceId, - appVersion, - }); - await seedUsers(dataSource, schemaName); - await seedUserWorkspaces(dataSource, schemaName, workspaceId); + const createWorkspaceStaticInput = SEEDER_CREATE_WORKSPACE_INPUT[workspaceId]; + const workspaceCustomApplicationCreateInput = + computeWorkspaceCustomCreateApplicationInput({ + workspace: { + id: workspaceId, + displayName: createWorkspaceStaticInput.displayName, + }, + }); - await seedAgents(dataSource, schemaName, workspaceId); + const version = extractVersionMajorMinorPatch(appVersion); + const queryRunner = dataSource.createQueryRunner(); - await seedApiKeys(dataSource, schemaName, workspaceId); + await queryRunner.connect(); + await queryRunner.startTransaction(); - if (shouldSeedFeatureFlags) { - await seedFeatureFlags(dataSource, schemaName, workspaceId); - } + try { + const customWorkspaceApplication = await applicationService.create( + { + ...workspaceCustomApplicationCreateInput, + serverlessFunctionLayerId: null, + }, + queryRunner, + ); - if (seedBilling) { - await seedBillingCustomers(dataSource, schemaName, workspaceId); - await seedBillingSubscriptions(dataSource, schemaName, workspaceId); + await createWorkspace({ + queryRunner, + schemaName, + createWorkspaceInput: { + ...createWorkspaceStaticInput, + version, + workspaceCustomApplicationId: customWorkspaceApplication.id, + }, + }); + + await seedUsers({ queryRunner, schemaName }); + + await seedUserWorkspaces({ queryRunner, schemaName, workspaceId }); + + await applicationService.createTwentyStandardApplication( + { + workspaceId, + }, + queryRunner, + ); + + await seedAgents({ queryRunner, schemaName, workspaceId }); + + await seedApiKeys({ queryRunner, schemaName, workspaceId }); + + if (shouldSeedFeatureFlags) { + await seedFeatureFlags({ queryRunner, schemaName, workspaceId }); + } + + if (seedBilling) { + await seedBillingCustomers({ queryRunner, schemaName, workspaceId }); + await seedBillingSubscriptions({ queryRunner, schemaName, workspaceId }); + } + + await queryRunner.commitTransaction(); + } catch (error) { + await queryRunner.rollbackTransaction(); + throw error; + } finally { + await queryRunner.release(); } }; diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts index a2302e7262b..feb100a2f32 100644 --- a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts @@ -1,16 +1,22 @@ -import { type DataSource } from 'typeorm'; +import { type QueryRunner } from 'typeorm'; import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; -import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-workspaces.util'; +import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant'; const tableName = 'featureFlag'; -export const seedFeatureFlags = async ( - dataSource: DataSource, - schemaName: string, - workspaceId: string, -) => { - await dataSource +type SeedFeatureFlagsArgs = { + queryRunner: QueryRunner; + schemaName: string; + workspaceId: string; +}; + +export const seedFeatureFlags = async ({ + queryRunner, + schemaName, + workspaceId, +}: SeedFeatureFlagsArgs) => { + await queryRunner.manager .createQueryBuilder() .insert() .into(`${schemaName}.${tableName}`, ['key', 'workspaceId', 'value']) @@ -90,12 +96,18 @@ export const seedFeatureFlags = async ( .execute(); }; -export const deleteFeatureFlags = async ( - dataSource: DataSource, - schemaName: string, - workspaceId: string, -) => { - await dataSource +type DeleteFeatureFlagsArgs = { + queryRunner: QueryRunner; + schemaName: string; + workspaceId: string; +}; + +export const deleteFeatureFlags = async ({ + queryRunner, + schemaName, + workspaceId, +}: DeleteFeatureFlagsArgs) => { + await queryRunner.manager .createQueryBuilder() .delete() .from(`${schemaName}.${tableName}`) diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-user-workspaces.util.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-user-workspaces.util.ts index 1732ab26d9d..c786a390e91 100644 --- a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-user-workspaces.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-user-workspaces.util.ts @@ -1,12 +1,12 @@ -import { type DataSource } from 'typeorm'; +import { type QueryRunner } from 'typeorm'; import { type UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; -import { generateRandomUsers } from 'src/engine/workspace-manager/dev-seeder/core/utils/generate-random-users.util'; -import { USER_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-users.util'; import { SEED_APPLE_WORKSPACE_ID, SEED_YCOMBINATOR_WORKSPACE_ID, -} from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-workspaces.util'; +} from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant'; +import { generateRandomUsers } from 'src/engine/workspace-manager/dev-seeder/core/utils/generate-random-users.util'; +import { USER_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-users.util'; const tableName = 'userWorkspace'; @@ -28,11 +28,17 @@ const { export const RANDOM_USER_WORKSPACE_IDS = randomUserWorkspaceIds; -export const seedUserWorkspaces = async ( - dataSource: DataSource, - schemaName: string, - workspaceId: string, -) => { +type SeedUserWorkspacesArgs = { + queryRunner: QueryRunner; + schemaName: string; + workspaceId: string; +}; + +export const seedUserWorkspaces = async ({ + queryRunner, + schemaName, + workspaceId, +}: SeedUserWorkspacesArgs) => { let userWorkspaces: Pick< UserWorkspaceEntity, 'id' | 'userId' | 'workspaceId' @@ -89,7 +95,7 @@ export const seedUserWorkspaces = async ( }, ]; } - await dataSource + await queryRunner.manager .createQueryBuilder() .insert() .into(`${schemaName}.${tableName}`, ['id', 'userId', 'workspaceId']) @@ -98,12 +104,18 @@ export const seedUserWorkspaces = async ( .execute(); }; -export const deleteUserWorkspaces = async ( - dataSource: DataSource, - schemaName: string, - workspaceId: string, -) => { - await dataSource +type DeleteUserWorkspacesArgs = { + queryRunner: QueryRunner; + schemaName: string; + workspaceId: string; +}; + +export const deleteUserWorkspaces = async ({ + queryRunner, + schemaName, + workspaceId, +}: DeleteUserWorkspacesArgs) => { + await queryRunner.manager .createQueryBuilder() .delete() .from(`${schemaName}.${tableName}`) diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-users.util.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-users.util.ts index 36f33d61989..cc902b234db 100644 --- a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-users.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-users.util.ts @@ -1,4 +1,4 @@ -import { type DataSource } from 'typeorm'; +import { type QueryRunner } from 'typeorm'; import { generateRandomUsers } from './generate-random-users.util'; @@ -15,7 +15,12 @@ const { users: randomUsers, userIds: randomUserIds } = generateRandomUsers(); export const RANDOM_USER_IDS = randomUserIds; -export const seedUsers = async (dataSource: DataSource, schemaName: string) => { +type SeedUsersArgs = { + queryRunner: QueryRunner; + schemaName: string; +}; + +export const seedUsers = async ({ queryRunner, schemaName }: SeedUsersArgs) => { const originalUsers = [ { id: USER_DATA_SEED_IDS.TIM, @@ -65,7 +70,7 @@ export const seedUsers = async (dataSource: DataSource, schemaName: string) => { const allUsers = [...originalUsers, ...randomUsers]; - await dataSource + await queryRunner.manager .createQueryBuilder() .insert() .into(`${schemaName}.${tableName}`, [ diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-workspace.util.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-workspace.util.ts new file mode 100644 index 00000000000..9bda3db2398 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-workspace.util.ts @@ -0,0 +1,47 @@ +import { type QueryRunner } from 'typeorm'; + +import { + type CreateWorkspaceInput, + WORKSPACE_FIELDS_TO_SEED, +} from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant'; + +const tableName = 'workspace'; + +export type SeedWorkspaceArgs = { + queryRunner: QueryRunner; + schemaName: string; + createWorkspaceInput: CreateWorkspaceInput; +}; + +export const createWorkspace = async ({ + schemaName, + queryRunner, + createWorkspaceInput, +}: SeedWorkspaceArgs) => { + await queryRunner.manager + .createQueryBuilder() + .insert() + .into(`${schemaName}.${tableName}`, WORKSPACE_FIELDS_TO_SEED) + .orIgnore() + .values(createWorkspaceInput) + .execute(); +}; + +type DeleteWorkspacesArgs = { + queryRunner: QueryRunner; + schemaName: string; + workspaceId: string; +}; + +export const deleteWorkspaces = async ({ + queryRunner, + schemaName, + workspaceId, +}: DeleteWorkspacesArgs) => { + await queryRunner.manager + .createQueryBuilder() + .delete() + .from(`${schemaName}.${tableName}`) + .where(`${tableName}."id" = :id`, { id: workspaceId }) + .execute(); +}; diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-workspaces.util.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-workspaces.util.ts deleted file mode 100644 index e65ec99b381..00000000000 --- a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-workspaces.util.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { WorkspaceActivationStatus } from 'twenty-shared/workspace'; -import { type DataSource } from 'typeorm'; - -import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; -import { extractVersionMajorMinorPatch } from 'src/utils/version/extract-version-major-minor-patch'; - -const tableName = 'workspace'; - -export const SEED_APPLE_WORKSPACE_ID = '20202020-1c25-4d02-bf25-6aeccf7ea419'; -export const SEED_YCOMBINATOR_WORKSPACE_ID = - '3b8e6458-5fc1-4e63-8563-008ccddaa6db'; - -export type SeedWorkspaceArgs = { - dataSource: DataSource; - schemaName: string; - workspaceId: string; - appVersion: string | undefined; -}; - -const workspaceSeederFields = [ - 'id', - 'displayName', - 'subdomain', - 'inviteHash', - 'logo', - 'activationStatus', - 'version', - 'isTwoFactorAuthenticationEnforced', -] as const satisfies (keyof WorkspaceEntity)[]; - -type WorkspaceSeederFields = Pick< - WorkspaceEntity, - (typeof workspaceSeederFields)[number] ->; - -export const seedWorkspaces = async ({ - schemaName, - dataSource, - workspaceId, - appVersion, -}: SeedWorkspaceArgs) => { - const version = extractVersionMajorMinorPatch(appVersion); - - const workspaces: Record = { - [SEED_APPLE_WORKSPACE_ID]: { - id: SEED_APPLE_WORKSPACE_ID, - displayName: 'Apple', - subdomain: 'apple', - inviteHash: 'apple.dev-invite-hash', - logo: 'https://twentyhq.github.io/placeholder-images/workspaces/apple-logo.png', - activationStatus: WorkspaceActivationStatus.PENDING_CREATION, // will be set to active after default role creation - version: version, - isTwoFactorAuthenticationEnforced: false, - }, - [SEED_YCOMBINATOR_WORKSPACE_ID]: { - id: SEED_YCOMBINATOR_WORKSPACE_ID, - displayName: 'YCombinator', - subdomain: 'yc', - inviteHash: 'yc.dev-invite-hash', - logo: 'https://twentyhq.github.io/placeholder-images/workspaces/ycombinator-logo.png', - activationStatus: WorkspaceActivationStatus.PENDING_CREATION, // will be set to active after default role creation - version: version, - isTwoFactorAuthenticationEnforced: false, - }, - }; - - await dataSource - .createQueryBuilder() - .insert() - .into(`${schemaName}.${tableName}`, workspaceSeederFields) - .orIgnore() - .values(workspaces[workspaceId]) - .execute(); -}; - -export const deleteWorkspaces = async ( - dataSource: DataSource, - schemaName: string, - workspaceId: string, -) => { - await dataSource - .createQueryBuilder() - .delete() - .from(`${schemaName}.${tableName}`) - .where(`${tableName}."id" = :id`, { id: workspaceId }) - .execute(); -}; diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/data/constants/workspace-member-data-seeds.constant.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/data/constants/workspace-member-data-seeds.constant.ts index 9bf9c47a11c..8c56651c4b9 100644 --- a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/data/constants/workspace-member-data-seeds.constant.ts +++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/data/constants/workspace-member-data-seeds.constant.ts @@ -1,9 +1,9 @@ -import { generateRandomUsers } from 'src/engine/workspace-manager/dev-seeder/core/utils/generate-random-users.util'; -import { USER_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-users.util'; import { SEED_APPLE_WORKSPACE_ID, SEED_YCOMBINATOR_WORKSPACE_ID, -} from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-workspaces.util'; +} from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant'; +import { generateRandomUsers } from 'src/engine/workspace-manager/dev-seeder/core/utils/generate-random-users.util'; +import { USER_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-users.util'; type WorkspaceMemberDataSeed = { id: string; diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/dev-seeder.module.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/dev-seeder.module.ts index d59e0175a63..f8ae737cb02 100644 --- a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/dev-seeder.module.ts +++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/dev-seeder.module.ts @@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeORMModule } from 'src/database/typeorm/typeorm.module'; import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module'; +import { ApplicationModule } from 'src/engine/core-modules/application/application.module'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; @@ -36,6 +37,7 @@ import { WorkspaceSyncMetadataModule } from 'src/engine/workspace-manager/worksp RoleModule, UserRoleModule, ApiKeyModule, + ApplicationModule, FeatureFlagModule, FileStorageModule, WorkspaceSyncMetadataModule, diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/metadata/services/dev-seeder-metadata.service.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/metadata/services/dev-seeder-metadata.service.ts index da08dadfdce..2f5b64fd76b 100644 --- a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/metadata/services/dev-seeder-metadata.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/metadata/services/dev-seeder-metadata.service.ts @@ -14,7 +14,7 @@ import { WorkspaceMetadataCacheService } from 'src/engine/metadata-modules/works import { SEED_APPLE_WORKSPACE_ID, SEED_YCOMBINATOR_WORKSPACE_ID, -} from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-workspaces.util'; +} from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant'; import { COMPANY_CUSTOM_FIELD_SEEDS } from 'src/engine/workspace-manager/dev-seeder/metadata/custom-fields/constants/company-custom-field-seeds.constant'; import { PERSON_CUSTOM_FIELD_SEEDS } from 'src/engine/workspace-manager/dev-seeder/metadata/custom-fields/constants/person-custom-field-seeds.constant'; import { PET_CUSTOM_FIELD_SEEDS } from 'src/engine/workspace-manager/dev-seeder/metadata/custom-fields/constants/pet-custom-field-seeds.constant'; diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/services/dev-seeder.service.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/services/dev-seeder.service.ts index 88eeff81954..25c17dde7b3 100644 --- a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/services/dev-seeder.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/services/dev-seeder.service.ts @@ -1,8 +1,10 @@ import { Injectable } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; +import { isDefined } from 'twenty-shared/utils'; import { DataSource } from 'typeorm'; +import { ApplicationService } from 'src/engine/core-modules/application/application.service'; import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; @@ -11,6 +13,7 @@ import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadat import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service'; import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service'; +import { SeededWorkspacesIds } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant'; import { DevSeederPermissionsService } from 'src/engine/workspace-manager/dev-seeder/core/services/dev-seeder-permissions.service'; import { seedCoreSchema } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-core-schema.util'; import { seedPageLayoutTabs } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-page-layout-tabs.util'; @@ -18,6 +21,7 @@ import { seedPageLayoutWidgets } from 'src/engine/workspace-manager/dev-seeder/c import { seedPageLayouts } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-page-layouts.util'; import { DevSeederDataService } from 'src/engine/workspace-manager/dev-seeder/data/services/dev-seeder-data.service'; import { DevSeederMetadataService } from 'src/engine/workspace-manager/dev-seeder/metadata/services/dev-seeder-metadata.service'; +import { TWENTY_STANDARD_APPLICATION } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/twenty-standard-applications'; import { WorkspaceSyncMetadataService } from 'src/engine/workspace-manager/workspace-sync-metadata/workspace-sync-metadata.service'; @Injectable() @@ -33,17 +37,19 @@ export class DevSeederService { private readonly devSeederPermissionsService: DevSeederPermissionsService, private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService, private readonly devSeederDataService: DevSeederDataService, + private readonly applicationService: ApplicationService, @InjectDataSource() private readonly coreDataSource: DataSource, ) {} - public async seedDev(workspaceId: string): Promise { + public async seedDev(workspaceId: SeededWorkspacesIds): Promise { const isBillingEnabled = this.twentyConfigService.get('IS_BILLING_ENABLED'); const appVersion = this.twentyConfigService.get('APP_VERSION'); await seedCoreSchema({ dataSource: this.coreDataSource, workspaceId, + applicationService: this.applicationService, seedBilling: isBillingEnabled, appVersion, }); @@ -62,6 +68,18 @@ export class DevSeederService { const featureFlags = await this.featureFlagService.getWorkspaceFeatureFlagsMap(workspaceId); + const twentyStandardApplication = + await this.applicationService.findByUniversalIdentifier({ + workspaceId, + universalIdentifier: TWENTY_STANDARD_APPLICATION.universalIdentifier, + }); + + if (!isDefined(twentyStandardApplication)) { + throw new Error( + 'Seeder failed to find twenty standard application, should never occur', + ); + } + await this.workspaceSyncMetadataService.synchronize({ workspaceId: workspaceId, dataSourceId: dataSourceMetadata.id, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-manager.module.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-manager.module.ts index 138961058ba..545c79c29d2 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-manager.module.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-manager.module.ts @@ -19,6 +19,7 @@ import { DevSeederModule } from 'src/engine/workspace-manager/dev-seeder/dev-see import { WorkspaceHealthModule } from 'src/engine/workspace-manager/workspace-health/workspace-health.module'; import { WorkspaceMigrationV2Module } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-v2.module'; import { WorkspaceSyncMetadataModule } from 'src/engine/workspace-manager/workspace-sync-metadata/workspace-sync-metadata.module'; +import { ApplicationModule } from 'src/engine/core-modules/application/application.module'; import { WorkspaceManagerService } from './workspace-manager.service'; @@ -38,6 +39,7 @@ import { WorkspaceManagerService } from './workspace-manager.service'; TypeOrmModule.forFeature([UserWorkspaceEntity, WorkspaceEntity]), RoleModule, UserRoleModule, + ApplicationModule, TypeOrmModule.forFeature([ FieldMetadataEntity, RoleTargetsEntity, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-manager.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-manager.service.ts index 7282f18fd6e..69660fbe01a 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-manager.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-manager.service.ts @@ -3,10 +3,10 @@ import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; import { DataSource, Repository } from 'typeorm'; +import { ApplicationService } from 'src/engine/core-modules/application/application.service'; import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; -import { AgentService } from 'src/engine/metadata-modules/agent/agent.service'; import { type DataSourceEntity } from 'src/engine/metadata-modules/data-source/data-source.entity'; import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service'; import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service'; @@ -45,17 +45,18 @@ export class WorkspaceManagerService { private readonly roleRepository: Repository, @InjectRepository(RoleTargetsEntity) private readonly roleTargetsRepository: Repository, - private readonly agentService: AgentService, protected readonly twentyORMGlobalManager: TwentyORMGlobalManager, + private readonly applicationService: ApplicationService, ) {} public async init({ - workspaceId, + workspace, userId, }: { - workspaceId: string; + workspace: WorkspaceEntity; userId: string; }): Promise { + const workspaceId = workspace.id; const schemaCreationStart = performance.now(); const schemaName = await this.workspaceDataSourceService.createWorkspaceDBSchema( @@ -78,6 +79,11 @@ export class WorkspaceManagerService { const featureFlags = await this.featureFlagService.getWorkspaceFeatureFlagsMap(workspaceId); + await this.applicationService.createTwentyStandardApplication({ + workspaceId, + }); + + // TODO later replace by twenty-standard installation aka workspaceMigration run await this.workspaceSyncMetadataService.synchronize({ workspaceId, dataSourceId: dataSourceMetadata.id, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/commands/workspace-sync-metadata-commands.module.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/commands/workspace-sync-metadata-commands.module.ts index 52b1267b10e..f6efdfd4acc 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/commands/workspace-sync-metadata-commands.module.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/commands/workspace-sync-metadata-commands.module.ts @@ -9,6 +9,7 @@ import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/works import { WorkspaceHealthModule } from 'src/engine/workspace-manager/workspace-health/workspace-health.module'; import { SyncWorkspaceLoggerModule } from 'src/engine/workspace-manager/workspace-sync-metadata/commands/services/sync-workspace-logger.module'; import { WorkspaceSyncMetadataModule } from 'src/engine/workspace-manager/workspace-sync-metadata/workspace-sync-metadata.module'; +import { ApplicationModule } from 'src/engine/core-modules/application/application.module'; import { SyncWorkspaceMetadataCommand } from './sync-workspace-metadata.command'; @@ -22,6 +23,7 @@ import { SyncWorkspaceMetadataCommand } from './sync-workspace-metadata.command' FeatureFlagModule, TypeOrmModule.forFeature([WorkspaceEntity]), SyncWorkspaceLoggerModule, + ApplicationModule, ], providers: [SyncWorkspaceMetadataCommand], exports: [SyncWorkspaceMetadataCommand], diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/twenty-standard-applications.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/twenty-standard-applications.ts new file mode 100644 index 00000000000..d88237cb0ae --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/twenty-standard-applications.ts @@ -0,0 +1,28 @@ +import { type ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; + +export const TWENTY_STANDARD_APPLICATION = { + universalIdentifier: '20202020-64aa-4b6f-b003-9c74b97cee20', + name: 'Twenty Standard', + description: + 'Twenty is an open-source CRM that allows you to manage your sales and customer relationships', + version: '1.0.0', + sourcePath: 'cli-sync', + sourceType: 'local', +} as const satisfies CreateApplicationInput; + +export type CreateApplicationInput = Omit< + ApplicationEntity, + | 'workspaceId' + | 'serverlessFunctionLayerId' + | 'id' + | 'createdAt' + | 'updatedAt' + | 'deletedAt' + | 'workspace' + | 'agents' + | 'applicationVariables' + | 'objects' + | 'serverlessFunctions' +>; +export type TwentyStandardApplicationUniversalIdentifiers = + (typeof TWENTY_STANDARD_APPLICATION)['universalIdentifier']; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/utils/compute-workspace-custom-create-application-input.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/utils/compute-workspace-custom-create-application-input.ts new file mode 100644 index 00000000000..8bb10dc30e9 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/utils/compute-workspace-custom-create-application-input.ts @@ -0,0 +1,26 @@ +import { isDefined } from 'twenty-shared/utils'; +import { v4 } from 'uuid'; + +import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { type CreateApplicationInput } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/twenty-standard-applications'; + +export const computeWorkspaceCustomCreateApplicationInput = ({ + workspace, + applicationId = v4(), +}: { + applicationId?: string; + workspace: Pick; +}) => + ({ + description: 'Workspace custom application', + name: `${isDefined(workspace.displayName) ? `${workspace.displayName}'s ` : ''}custom application`, + sourcePath: 'workspace-custom', + sourceType: 'local', + version: '1.0.0', + universalIdentifier: applicationId, + workspaceId: workspace.id, + id: applicationId, + }) as const satisfies CreateApplicationInput & { + workspaceId: string; + id: string; + }; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/workspace-sync-metadata.module.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/workspace-sync-metadata.module.ts index ab52b63e1b8..3abf329cfa3 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/workspace-sync-metadata.module.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/workspace-sync-metadata.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { ApplicationModule } from 'src/engine/core-modules/application/application.module'; import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; @@ -29,6 +30,7 @@ import { WorkspaceSyncMetadataService } from 'src/engine/workspace-manager/works @Module({ imports: [ + ApplicationModule, FeatureFlagModule, WorkspaceMigrationBuilderModule, WorkspaceMigrationRunnerModule, diff --git a/packages/twenty-server/src/modules/attachment/standard-objects/attachment.workspace-entity.ts b/packages/twenty-server/src/modules/attachment/standard-objects/attachment.workspace-entity.ts index a81c0e41a67..cdb505037d5 100644 --- a/packages/twenty-server/src/modules/attachment/standard-objects/attachment.workspace-entity.ts +++ b/packages/twenty-server/src/modules/attachment/standard-objects/attachment.workspace-entity.ts @@ -29,6 +29,7 @@ import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/sta @WorkspaceEntity({ standardId: STANDARD_OBJECT_IDS.attachment, + namePlural: 'attachments', labelSingular: msg`Attachment`, labelPlural: msg`Attachments`, diff --git a/packages/twenty-server/src/modules/blocklist/standard-objects/blocklist.workspace-entity.ts b/packages/twenty-server/src/modules/blocklist/standard-objects/blocklist.workspace-entity.ts index f704a4221f0..bb7baa01b45 100644 --- a/packages/twenty-server/src/modules/blocklist/standard-objects/blocklist.workspace-entity.ts +++ b/packages/twenty-server/src/modules/blocklist/standard-objects/blocklist.workspace-entity.ts @@ -17,6 +17,7 @@ import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/sta @WorkspaceEntity({ standardId: STANDARD_OBJECT_IDS.blocklist, + namePlural: 'blocklists', labelSingular: msg`Blocklist`, labelPlural: msg`Blocklists`, diff --git a/packages/twenty-server/src/modules/calendar/common/standard-objects/calendar-channel-event-association.workspace-entity.ts b/packages/twenty-server/src/modules/calendar/common/standard-objects/calendar-channel-event-association.workspace-entity.ts index 995962004c3..43aca2ec45d 100644 --- a/packages/twenty-server/src/modules/calendar/common/standard-objects/calendar-channel-event-association.workspace-entity.ts +++ b/packages/twenty-server/src/modules/calendar/common/standard-objects/calendar-channel-event-association.workspace-entity.ts @@ -19,6 +19,7 @@ import { CalendarEventWorkspaceEntity } from 'src/modules/calendar/common/standa @WorkspaceEntity({ standardId: STANDARD_OBJECT_IDS.calendarChannelEventAssociation, + namePlural: 'calendarChannelEventAssociations', labelSingular: msg`Calendar Channel Event Association`, labelPlural: msg`Calendar Channel Event Associations`, diff --git a/packages/twenty-server/src/modules/calendar/common/standard-objects/calendar-channel.workspace-entity.ts b/packages/twenty-server/src/modules/calendar/common/standard-objects/calendar-channel.workspace-entity.ts index b6dd8bbcc55..13f0943e416 100644 --- a/packages/twenty-server/src/modules/calendar/common/standard-objects/calendar-channel.workspace-entity.ts +++ b/packages/twenty-server/src/modules/calendar/common/standard-objects/calendar-channel.workspace-entity.ts @@ -69,6 +69,7 @@ registerEnumType(CalendarChannelContactAutoCreationPolicy, { @WorkspaceEntity({ standardId: STANDARD_OBJECT_IDS.calendarChannel, + namePlural: 'calendarChannels', labelSingular: msg`Calendar Channel`, labelPlural: msg`Calendar Channels`, diff --git a/packages/twenty-server/src/modules/calendar/common/standard-objects/calendar-event-participant.workspace-entity.ts b/packages/twenty-server/src/modules/calendar/common/standard-objects/calendar-event-participant.workspace-entity.ts index c53080e40c8..25803c3307b 100644 --- a/packages/twenty-server/src/modules/calendar/common/standard-objects/calendar-event-participant.workspace-entity.ts +++ b/packages/twenty-server/src/modules/calendar/common/standard-objects/calendar-event-participant.workspace-entity.ts @@ -28,6 +28,7 @@ export enum CalendarEventParticipantResponseStatus { @WorkspaceEntity({ standardId: STANDARD_OBJECT_IDS.calendarEventParticipant, + namePlural: 'calendarEventParticipants', labelSingular: msg`Calendar event participant`, labelPlural: msg`Calendar event participants`, diff --git a/packages/twenty-server/src/modules/calendar/common/standard-objects/calendar-event.workspace-entity.ts b/packages/twenty-server/src/modules/calendar/common/standard-objects/calendar-event.workspace-entity.ts index 0de3d8ceb68..f4fcbf40975 100644 --- a/packages/twenty-server/src/modules/calendar/common/standard-objects/calendar-event.workspace-entity.ts +++ b/packages/twenty-server/src/modules/calendar/common/standard-objects/calendar-event.workspace-entity.ts @@ -21,6 +21,7 @@ import { CalendarEventParticipantWorkspaceEntity } from 'src/modules/calendar/co @WorkspaceEntity({ standardId: STANDARD_OBJECT_IDS.calendarEvent, + namePlural: 'calendarEvents', labelSingular: msg`Calendar event`, labelPlural: msg`Calendar events`, diff --git a/packages/twenty-server/src/modules/company/standard-objects/company.workspace-entity.ts b/packages/twenty-server/src/modules/company/standard-objects/company.workspace-entity.ts index 5daa03ee06d..d06d59d1082 100644 --- a/packages/twenty-server/src/modules/company/standard-objects/company.workspace-entity.ts +++ b/packages/twenty-server/src/modules/company/standard-objects/company.workspace-entity.ts @@ -49,6 +49,7 @@ export const SEARCH_FIELDS_FOR_COMPANY: FieldTypeAndNameMetadata[] = [ @WorkspaceEntity({ standardId: STANDARD_OBJECT_IDS.company, + namePlural: 'companies', labelSingular: msg`Company`, labelPlural: msg`Companies`, diff --git a/packages/twenty-server/src/modules/connected-account/standard-objects/connected-account.workspace-entity.ts b/packages/twenty-server/src/modules/connected-account/standard-objects/connected-account.workspace-entity.ts index 6f04dbb7fe6..02dbc360e7e 100644 --- a/packages/twenty-server/src/modules/connected-account/standard-objects/connected-account.workspace-entity.ts +++ b/packages/twenty-server/src/modules/connected-account/standard-objects/connected-account.workspace-entity.ts @@ -25,6 +25,7 @@ import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/sta @WorkspaceEntity({ standardId: STANDARD_OBJECT_IDS.connectedAccount, + namePlural: 'connectedAccounts', labelSingular: msg`Connected Account`, labelPlural: msg`Connected Accounts`, diff --git a/packages/twenty-server/src/modules/dashboard/standard-objects/dashboard.workspace-entity.ts b/packages/twenty-server/src/modules/dashboard/standard-objects/dashboard.workspace-entity.ts index 9526a23bb58..506ed2ee616 100644 --- a/packages/twenty-server/src/modules/dashboard/standard-objects/dashboard.workspace-entity.ts +++ b/packages/twenty-server/src/modules/dashboard/standard-objects/dashboard.workspace-entity.ts @@ -35,6 +35,7 @@ export const SEARCH_FIELDS_FOR_DASHBOARD: FieldTypeAndNameMetadata[] = [ @WorkspaceEntity({ standardId: STANDARD_OBJECT_IDS.dashboard, + namePlural: 'dashboards', labelSingular: msg`Dashboard`, labelPlural: msg`Dashboards`, diff --git a/packages/twenty-server/src/modules/favorite-folder/standard-objects/favorite-folder.workspace-entity.ts b/packages/twenty-server/src/modules/favorite-folder/standard-objects/favorite-folder.workspace-entity.ts index 85ee25a6ec4..3c67a54c86a 100644 --- a/packages/twenty-server/src/modules/favorite-folder/standard-objects/favorite-folder.workspace-entity.ts +++ b/packages/twenty-server/src/modules/favorite-folder/standard-objects/favorite-folder.workspace-entity.ts @@ -15,6 +15,7 @@ import { FavoriteWorkspaceEntity } from 'src/modules/favorite/standard-objects/f @WorkspaceEntity({ standardId: STANDARD_OBJECT_IDS.favoriteFolder, + namePlural: 'favoriteFolders', labelSingular: msg`Favorite Folder`, labelPlural: msg`Favorite Folders`, diff --git a/packages/twenty-server/src/modules/favorite/standard-objects/favorite.workspace-entity.ts b/packages/twenty-server/src/modules/favorite/standard-objects/favorite.workspace-entity.ts index a4451c6a843..d9665d35375 100644 --- a/packages/twenty-server/src/modules/favorite/standard-objects/favorite.workspace-entity.ts +++ b/packages/twenty-server/src/modules/favorite/standard-objects/favorite.workspace-entity.ts @@ -30,6 +30,7 @@ import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/sta @WorkspaceEntity({ standardId: STANDARD_OBJECT_IDS.favorite, + namePlural: 'favorites', labelSingular: msg`Favorite`, labelPlural: msg`Favorites`, diff --git a/packages/twenty-server/src/modules/messaging/common/standard-objects/message-channel-message-association.workspace-entity.ts b/packages/twenty-server/src/modules/messaging/common/standard-objects/message-channel-message-association.workspace-entity.ts index cca49a04033..5e0afc9c9bc 100644 --- a/packages/twenty-server/src/modules/messaging/common/standard-objects/message-channel-message-association.workspace-entity.ts +++ b/packages/twenty-server/src/modules/messaging/common/standard-objects/message-channel-message-association.workspace-entity.ts @@ -25,6 +25,7 @@ import { MessageWorkspaceEntity } from 'src/modules/messaging/common/standard-ob @WorkspaceEntity({ standardId: STANDARD_OBJECT_IDS.messageChannelMessageAssociation, + namePlural: 'messageChannelMessageAssociations', labelSingular: msg`Message Channel Message Association`, labelPlural: msg`Message Channel Message Associations`, diff --git a/packages/twenty-server/src/modules/messaging/common/standard-objects/message-channel.workspace-entity.ts b/packages/twenty-server/src/modules/messaging/common/standard-objects/message-channel.workspace-entity.ts index 5d3fac13706..f9dfe2ee55a 100644 --- a/packages/twenty-server/src/modules/messaging/common/standard-objects/message-channel.workspace-entity.ts +++ b/packages/twenty-server/src/modules/messaging/common/standard-objects/message-channel.workspace-entity.ts @@ -98,6 +98,7 @@ registerEnumType(MessageChannelPendingGroupEmailsAction, { @WorkspaceEntity({ standardId: STANDARD_OBJECT_IDS.messageChannel, + namePlural: 'messageChannels', labelSingular: msg`Message Channel`, labelPlural: msg`Message Channels`, diff --git a/packages/twenty-server/src/modules/messaging/common/standard-objects/message-folder.workspace-entity.ts b/packages/twenty-server/src/modules/messaging/common/standard-objects/message-folder.workspace-entity.ts index a25ec5a5368..3459cf3813c 100644 --- a/packages/twenty-server/src/modules/messaging/common/standard-objects/message-folder.workspace-entity.ts +++ b/packages/twenty-server/src/modules/messaging/common/standard-objects/message-folder.workspace-entity.ts @@ -31,6 +31,7 @@ registerEnumType(MessageFolderPendingSyncAction, { @WorkspaceEntity({ standardId: STANDARD_OBJECT_IDS.messageFolder, + namePlural: 'messageFolders', labelSingular: msg`Message Folder`, labelPlural: msg`Message Folders`, diff --git a/packages/twenty-server/src/modules/messaging/common/standard-objects/message-participant.workspace-entity.ts b/packages/twenty-server/src/modules/messaging/common/standard-objects/message-participant.workspace-entity.ts index 21a6ad074a2..1934695d86f 100644 --- a/packages/twenty-server/src/modules/messaging/common/standard-objects/message-participant.workspace-entity.ts +++ b/packages/twenty-server/src/modules/messaging/common/standard-objects/message-participant.workspace-entity.ts @@ -21,6 +21,7 @@ import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/sta @WorkspaceEntity({ standardId: STANDARD_OBJECT_IDS.messageParticipant, + namePlural: 'messageParticipants', labelSingular: msg`Message Participant`, labelPlural: msg`Message Participants`, diff --git a/packages/twenty-server/src/modules/messaging/common/standard-objects/message-thread.workspace-entity.ts b/packages/twenty-server/src/modules/messaging/common/standard-objects/message-thread.workspace-entity.ts index 798a9d53cdf..98795eab183 100644 --- a/packages/twenty-server/src/modules/messaging/common/standard-objects/message-thread.workspace-entity.ts +++ b/packages/twenty-server/src/modules/messaging/common/standard-objects/message-thread.workspace-entity.ts @@ -17,6 +17,7 @@ import { MessageWorkspaceEntity } from 'src/modules/messaging/common/standard-ob @WorkspaceEntity({ standardId: STANDARD_OBJECT_IDS.messageThread, + namePlural: 'messageThreads', labelSingular: msg`Message Thread`, labelPlural: msg`Message Threads`, diff --git a/packages/twenty-server/src/modules/messaging/common/standard-objects/message.workspace-entity.ts b/packages/twenty-server/src/modules/messaging/common/standard-objects/message.workspace-entity.ts index e7bf2dc0df0..23e1b3235a8 100644 --- a/packages/twenty-server/src/modules/messaging/common/standard-objects/message.workspace-entity.ts +++ b/packages/twenty-server/src/modules/messaging/common/standard-objects/message.workspace-entity.ts @@ -21,6 +21,7 @@ import { MessageThreadWorkspaceEntity } from 'src/modules/messaging/common/stand @WorkspaceEntity({ standardId: STANDARD_OBJECT_IDS.message, + namePlural: 'messages', labelSingular: msg`Message`, labelPlural: msg`Messages`, diff --git a/packages/twenty-server/src/modules/note/standard-objects/note-target.workspace-entity.ts b/packages/twenty-server/src/modules/note/standard-objects/note-target.workspace-entity.ts index 94aecf8244f..c59f324028b 100644 --- a/packages/twenty-server/src/modules/note/standard-objects/note-target.workspace-entity.ts +++ b/packages/twenty-server/src/modules/note/standard-objects/note-target.workspace-entity.ts @@ -22,6 +22,7 @@ import { PersonWorkspaceEntity } from 'src/modules/person/standard-objects/perso @WorkspaceEntity({ standardId: STANDARD_OBJECT_IDS.noteTarget, + namePlural: 'noteTargets', labelSingular: msg`Note Target`, labelPlural: msg`Note Targets`, diff --git a/packages/twenty-server/src/modules/note/standard-objects/note.workspace-entity.ts b/packages/twenty-server/src/modules/note/standard-objects/note.workspace-entity.ts index fde330ae1bf..6c26ee02975 100644 --- a/packages/twenty-server/src/modules/note/standard-objects/note.workspace-entity.ts +++ b/packages/twenty-server/src/modules/note/standard-objects/note.workspace-entity.ts @@ -39,6 +39,7 @@ export const SEARCH_FIELDS_FOR_NOTES: FieldTypeAndNameMetadata[] = [ @WorkspaceEntity({ standardId: STANDARD_OBJECT_IDS.note, + namePlural: 'notes', labelSingular: msg`Note`, labelPlural: msg`Notes`, diff --git a/packages/twenty-server/src/modules/opportunity/standard-objects/opportunity.workspace-entity.ts b/packages/twenty-server/src/modules/opportunity/standard-objects/opportunity.workspace-entity.ts index e938b500519..f19a5190802 100644 --- a/packages/twenty-server/src/modules/opportunity/standard-objects/opportunity.workspace-entity.ts +++ b/packages/twenty-server/src/modules/opportunity/standard-objects/opportunity.workspace-entity.ts @@ -42,6 +42,7 @@ export const SEARCH_FIELDS_FOR_OPPORTUNITY: FieldTypeAndNameMetadata[] = [ @WorkspaceEntity({ standardId: STANDARD_OBJECT_IDS.opportunity, + namePlural: 'opportunities', labelSingular: msg`Opportunity`, labelPlural: msg`Opportunities`, diff --git a/packages/twenty-server/src/modules/person/standard-objects/person.workspace-entity.ts b/packages/twenty-server/src/modules/person/standard-objects/person.workspace-entity.ts index d9cdbb58f0b..3a33e0dfac2 100644 --- a/packages/twenty-server/src/modules/person/standard-objects/person.workspace-entity.ts +++ b/packages/twenty-server/src/modules/person/standard-objects/person.workspace-entity.ts @@ -55,6 +55,7 @@ export const SEARCH_FIELDS_FOR_PERSON: FieldTypeAndNameMetadata[] = [ @WorkspaceEntity({ standardId: STANDARD_OBJECT_IDS.person, + namePlural: 'people', labelSingular: msg`Person`, labelPlural: msg`People`, diff --git a/packages/twenty-server/src/modules/task/standard-objects/task-target.workspace-entity.ts b/packages/twenty-server/src/modules/task/standard-objects/task-target.workspace-entity.ts index 2d5e24e30f7..72113ffe6eb 100644 --- a/packages/twenty-server/src/modules/task/standard-objects/task-target.workspace-entity.ts +++ b/packages/twenty-server/src/modules/task/standard-objects/task-target.workspace-entity.ts @@ -22,6 +22,7 @@ import { TaskWorkspaceEntity } from 'src/modules/task/standard-objects/task.work @WorkspaceEntity({ standardId: STANDARD_OBJECT_IDS.taskTarget, + namePlural: 'taskTargets', labelSingular: msg`Task Target`, labelPlural: msg`Task Targets`, diff --git a/packages/twenty-server/src/modules/task/standard-objects/task.workspace-entity.ts b/packages/twenty-server/src/modules/task/standard-objects/task.workspace-entity.ts index 5bdd8c78ea9..ee272aa1ac4 100644 --- a/packages/twenty-server/src/modules/task/standard-objects/task.workspace-entity.ts +++ b/packages/twenty-server/src/modules/task/standard-objects/task.workspace-entity.ts @@ -42,6 +42,7 @@ export const SEARCH_FIELDS_FOR_TASKS: FieldTypeAndNameMetadata[] = [ @WorkspaceEntity({ standardId: STANDARD_OBJECT_IDS.task, + namePlural: 'tasks', labelSingular: msg`Task`, labelPlural: msg`Tasks`, diff --git a/packages/twenty-server/src/modules/timeline/standard-objects/timeline-activity.workspace-entity.ts b/packages/twenty-server/src/modules/timeline/standard-objects/timeline-activity.workspace-entity.ts index 4aa22ee9dda..35a2f2848fe 100644 --- a/packages/twenty-server/src/modules/timeline/standard-objects/timeline-activity.workspace-entity.ts +++ b/packages/twenty-server/src/modules/timeline/standard-objects/timeline-activity.workspace-entity.ts @@ -30,6 +30,7 @@ import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/sta @WorkspaceEntity({ standardId: STANDARD_OBJECT_IDS.timelineActivity, + namePlural: 'timelineActivities', labelSingular: msg`Timeline Activity`, labelPlural: msg`Timeline Activities`, diff --git a/packages/twenty-server/src/modules/workflow/common/standard-objects/workflow-automated-trigger.workspace-entity.ts b/packages/twenty-server/src/modules/workflow/common/standard-objects/workflow-automated-trigger.workspace-entity.ts index bd1aed98dea..7d89ca54d93 100644 --- a/packages/twenty-server/src/modules/workflow/common/standard-objects/workflow-automated-trigger.workspace-entity.ts +++ b/packages/twenty-server/src/modules/workflow/common/standard-objects/workflow-automated-trigger.workspace-entity.ts @@ -24,6 +24,7 @@ export enum AutomatedTriggerType { @WorkspaceEntity({ standardId: STANDARD_OBJECT_IDS.workflowAutomatedTrigger, + namePlural: 'workflowAutomatedTriggers', labelSingular: msg`WorkflowAutomatedTrigger`, labelPlural: msg`WorkflowAutomatedTriggers`, diff --git a/packages/twenty-server/src/modules/workflow/common/standard-objects/workflow-run.workspace-entity.ts b/packages/twenty-server/src/modules/workflow/common/standard-objects/workflow-run.workspace-entity.ts index e0e9e814796..5cb4f61b630 100644 --- a/packages/twenty-server/src/modules/workflow/common/standard-objects/workflow-run.workspace-entity.ts +++ b/packages/twenty-server/src/modules/workflow/common/standard-objects/workflow-run.workspace-entity.ts @@ -81,6 +81,7 @@ export const SEARCH_FIELDS_FOR_WORKFLOW_RUNS: FieldTypeAndNameMetadata[] = [ @WorkspaceEntity({ standardId: STANDARD_OBJECT_IDS.workflowRun, + namePlural: 'workflowRuns', labelSingular: msg`Workflow Run`, labelPlural: msg`Workflow Runs`, diff --git a/packages/twenty-server/src/modules/workflow/common/standard-objects/workflow-version.workspace-entity.ts b/packages/twenty-server/src/modules/workflow/common/standard-objects/workflow-version.workspace-entity.ts index bf1436c8bdd..ccf782b3ec8 100644 --- a/packages/twenty-server/src/modules/workflow/common/standard-objects/workflow-version.workspace-entity.ts +++ b/packages/twenty-server/src/modules/workflow/common/standard-objects/workflow-version.workspace-entity.ts @@ -72,6 +72,7 @@ export const SEARCH_FIELDS_FOR_WORKFLOW_VERSIONS: FieldTypeAndNameMetadata[] = [ @WorkspaceEntity({ standardId: STANDARD_OBJECT_IDS.workflowVersion, + namePlural: 'workflowVersions', labelSingular: msg`Workflow Version`, labelPlural: msg`Workflow Versions`, diff --git a/packages/twenty-server/src/modules/workflow/common/standard-objects/workflow.workspace-entity.ts b/packages/twenty-server/src/modules/workflow/common/standard-objects/workflow.workspace-entity.ts index 7aa5d912dd6..12320823fc7 100644 --- a/packages/twenty-server/src/modules/workflow/common/standard-objects/workflow.workspace-entity.ts +++ b/packages/twenty-server/src/modules/workflow/common/standard-objects/workflow.workspace-entity.ts @@ -65,6 +65,7 @@ export const SEARCH_FIELDS_FOR_WORKFLOWS: FieldTypeAndNameMetadata[] = [ @WorkspaceEntity({ standardId: STANDARD_OBJECT_IDS.workflow, + namePlural: 'workflows', labelSingular: msg`Workflow`, labelPlural: msg`Workflows`, diff --git a/packages/twenty-server/src/modules/workspace-member/standard-objects/workspace-member.workspace-entity.ts b/packages/twenty-server/src/modules/workspace-member/standard-objects/workspace-member.workspace-entity.ts index cbe62e7119f..fcbb6c79759 100644 --- a/packages/twenty-server/src/modules/workspace-member/standard-objects/workspace-member.workspace-entity.ts +++ b/packages/twenty-server/src/modules/workspace-member/standard-objects/workspace-member.workspace-entity.ts @@ -87,6 +87,7 @@ export const SEARCH_FIELDS_FOR_WORKSPACE_MEMBER: FieldTypeAndNameMetadata[] = [ @WorkspaceEntity({ standardId: STANDARD_OBJECT_IDS.workspaceMember, + namePlural: 'workspaceMembers', labelSingular: msg`Workspace Member`, labelPlural: msg`Workspace Members`, diff --git a/packages/twenty-server/test/integration/graphql/suites/auth/sign-up/__snapshots__/failing-sign-up.integration-spec.ts.snap b/packages/twenty-server/test/integration/graphql/suites/auth/sign-up/__snapshots__/failing-sign-up.integration-spec.ts.snap new file mode 100644 index 00000000000..c63bb04de6d --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/auth/sign-up/__snapshots__/failing-sign-up.integration-spec.ts.snap @@ -0,0 +1,36 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Successful User Sign Up (integration) should fail to sign up with duplicate email 1`] = ` +{ + "extensions": { + "code": "FORBIDDEN", + "subCode": "USER_ALREADY_EXISTS", + "userFriendlyMessage": "User already exists", + }, + "message": "User already exists", + "name": "ForbiddenError", +} +`; + +exports[`Successful User Sign Up (integration) should fail to sign up with invalid email 1`] = ` +{ + "extensions": { + "code": "BAD_USER_INPUT", + "userFriendlyMessage": "An error occurred.", + }, + "message": "email must be an email", + "name": "UserInputError", +} +`; + +exports[`Successful User Sign Up (integration) should fail to sign up with weak password 1`] = ` +{ + "extensions": { + "code": "BAD_USER_INPUT", + "subCode": "INVALID_INPUT", + "userFriendlyMessage": "Password too weak", + }, + "message": "Password too weak", + "name": "UserInputError", +} +`; diff --git a/packages/twenty-server/test/integration/graphql/suites/auth/sign-up/__snapshots__/successful-sign-up.integration-spec.ts.snap b/packages/twenty-server/test/integration/graphql/suites/auth/sign-up/__snapshots__/successful-sign-up.integration-spec.ts.snap new file mode 100644 index 00000000000..b2146334247 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/auth/sign-up/__snapshots__/successful-sign-up.integration-spec.ts.snap @@ -0,0 +1,51 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Successful User Sign Up (integration) should sign up, delete and signup same new user successfully 1`] = ` +{ + "canAccessFullAdminPanel": false, + "canImpersonate": false, + "createdAt": Any, + "currentUserWorkspace": null, + "currentWorkspace": null, + "defaultAvatarUrl": null, + "deletedAt": null, + "disabled": false, + "email": "test-123@example.com", + "firstName": "", + "id": Any, + "isEmailVerified": false, + "lastName": "", + "locale": "en", + "updatedAt": Any, +} +`; + +exports[`Successful User Sign Up (integration) should sign up, delete and signup same new user successfully 2`] = ` +{ + "extensions": { + "code": "UNAUTHENTICATED", + "subCode": "USER_NOT_FOUND", + }, + "message": "User not found", +} +`; + +exports[`Successful User Sign Up (integration) should sign up, delete and signup same new user successfully 3`] = ` +{ + "canAccessFullAdminPanel": false, + "canImpersonate": false, + "createdAt": Any, + "currentUserWorkspace": null, + "currentWorkspace": null, + "defaultAvatarUrl": null, + "deletedAt": null, + "disabled": false, + "email": "test-123@example.com", + "firstName": "", + "id": Any, + "isEmailVerified": false, + "lastName": "", + "locale": "en", + "updatedAt": Any, +} +`; diff --git a/packages/twenty-server/test/integration/graphql/suites/auth/sign-up/failing-sign-up.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/auth/sign-up/failing-sign-up.integration-spec.ts new file mode 100644 index 00000000000..dc85be914c7 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/auth/sign-up/failing-sign-up.integration-spec.ts @@ -0,0 +1,60 @@ +import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util'; +import { signUp } from 'test/integration/graphql/utils/sign-up.util'; + +describe('Successful User Sign Up (integration)', () => { + it('should fail to sign up with invalid email', async () => { + const { errors } = await signUp({ + input: { + email: 'invalid-email', + password: 'Test123!@#', + }, + expectToFail: true, + }); + + expectOneNotInternalServerErrorSnapshot({ + errors, + }); + }); + + it('should fail to sign up with duplicate email', async () => { + const testEmail = `test-duplicate@example.com`; + + const { data: firstSignUp } = await signUp({ + input: { + email: testEmail, + password: 'Test123!@#', + }, + expectToFail: false, + }); + + expect( + firstSignUp.signUp.tokens.accessOrWorkspaceAgnosticToken.token, + ).toBeDefined(); + + const { errors } = await signUp({ + input: { + email: testEmail, + password: 'AnotherPassword123!', + }, + expectToFail: true, + }); + + expectOneNotInternalServerErrorSnapshot({ + errors, + }); + }); + + it('should fail to sign up with weak password', async () => { + const { errors } = await signUp({ + input: { + email: `test-123@example.com`, + password: '123', + }, + expectToFail: true, + }); + + expectOneNotInternalServerErrorSnapshot({ + errors, + }); + }); +}); diff --git a/packages/twenty-server/test/integration/graphql/suites/auth/sign-up/successful-sign-up.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/auth/sign-up/successful-sign-up.integration-spec.ts new file mode 100644 index 00000000000..99525c8f1a8 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/auth/sign-up/successful-sign-up.integration-spec.ts @@ -0,0 +1,95 @@ +import { deleteUser } from 'test/integration/graphql/utils/delete-user.util'; +import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util'; +import { getCurrentUser } from 'test/integration/graphql/utils/get-current-user.util'; +import { signUp } from 'test/integration/graphql/utils/sign-up.util'; +import { extractRecordIdsAndDatesAsExpectAny } from 'test/utils/extract-record-ids-and-dates-as-expect-any'; +import { isDefined } from 'twenty-shared/utils'; + +import { type SignUpInput } from 'src/engine/core-modules/auth/dto/sign-up.input'; + +describe('Successful User Sign Up (integration)', () => { + let createdUserAccessToken: string | undefined; + + afterEach(async () => { + if (isDefined(createdUserAccessToken)) { + await deleteUser({ + accessToken: createdUserAccessToken, + expectToFail: false, + }); + + createdUserAccessToken = undefined; + } + }); + + it('should sign up, delete and signup same new user successfully', async () => { + const input: SignUpInput = { + email: `test-123@example.com`, + password: 'Test123!@#', + }; + + const { data: firstSignUpData } = await signUp({ + input, + expectToFail: false, + }); + + createdUserAccessToken = + firstSignUpData.signUp.tokens.accessOrWorkspaceAgnosticToken.token; + + expect( + firstSignUpData.signUp.tokens.accessOrWorkspaceAgnosticToken.token, + ).toBeDefined(); + expect(firstSignUpData.signUp.tokens.refreshToken.token).toBeDefined(); + expect( + firstSignUpData.signUp.availableWorkspaces.availableWorkspacesForSignIn, + ).toEqual([]); + expect( + firstSignUpData.signUp.availableWorkspaces.availableWorkspacesForSignUp, + ).toEqual([]); + + const { + data: { currentUser: currentUserAfterSignUp }, + } = await getCurrentUser({ + accessToken: createdUserAccessToken, + expectToFail: false, + }); + + expect(currentUserAfterSignUp.deletedAt).toBeNull(); + expect(currentUserAfterSignUp).toMatchSnapshot( + extractRecordIdsAndDatesAsExpectAny({ ...currentUserAfterSignUp }), + ); + + await deleteUser({ + accessToken: createdUserAccessToken, + expectToFail: false, + }); + + const { errors: getCurrentUserAfterDeleteErrors } = await getCurrentUser({ + accessToken: createdUserAccessToken, + expectToFail: true, + }); + + expectOneNotInternalServerErrorSnapshot({ + errors: getCurrentUserAfterDeleteErrors, + }); + + const { data: secondSignUpData } = await signUp({ + input, + expectToFail: false, + }); + + createdUserAccessToken = + secondSignUpData.signUp.tokens.accessOrWorkspaceAgnosticToken.token; + + const { + data: { currentUser: currentUserAfterSecondSignUp }, + } = await getCurrentUser({ + accessToken: createdUserAccessToken, + expectToFail: false, + }); + + expect(currentUserAfterSecondSignUp.deletedAt).toBeNull(); + expect(currentUserAfterSecondSignUp).toMatchSnapshot( + extractRecordIdsAndDatesAsExpectAny({ ...currentUserAfterSecondSignUp }), + ); + }); +}); diff --git a/packages/twenty-server/test/integration/graphql/suites/search/search-resolver.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/search/search-resolver.integration-spec.ts index 793c972460e..5e8bff8a7bb 100644 --- a/packages/twenty-server/test/integration/graphql/suites/search/search-resolver.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/search/search-resolver.integration-spec.ts @@ -20,11 +20,13 @@ import { TEST_PET_ID_3, TEST_PET_ID_4, } from 'test/integration/constants/test-pet-ids.constants'; -import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util'; import { performCreateManyOperation } from 'test/integration/graphql/utils/perform-create-many-operation.utils'; -import { searchFactory } from 'test/integration/graphql/utils/search-factory.util'; +import { search } from 'test/integration/graphql/utils/search.util'; import { deleteAllRecords } from 'test/integration/utils/delete-all-records'; -import { type EachTestingContext } from 'twenty-shared/testing'; +import { + eachTestingContextFilter, + type EachTestingContext, +} from 'twenty-shared/testing'; import { decodeCursor, @@ -909,45 +911,49 @@ describe('SearchResolver', () => { }, ]; - it.each(testsUseCases)('$title', async ({ context }) => { - const graphqlOperation = searchFactory(context.input); - const response = await makeGraphqlAPIRequest(graphqlOperation); + it.each(eachTestingContextFilter(testsUseCases))( + '$title', + async ({ context }) => { + const response = await search({ + ...context.input, + expectToFail: false, + }); - expect(response.body.data).toBeDefined(); - expect(response.body.data.search).toBeDefined(); + expect(response.data).toBeDefined(); + expect(response.data.search).toBeDefined(); - const search = response.body.data.search; - const edges = search.edges; - const pageInfo = search.pageInfo; + const searchResult = response.data.search; + const edges = searchResult.edges; + const pageInfo = searchResult.pageInfo; - if (context.eval.orderedRecordIds.length > 0) { - expect(edges).not.toHaveLength(0); - } else { - expect(edges).toHaveLength(0); - } + if (context.eval.orderedRecordIds.length > 0) { + expect(edges).not.toHaveLength(0); + } else { + expect(edges).toHaveLength(0); + } - expect( - edges.map((edge: SearchResultEdgeDTO) => edge.node.recordId), - ).toEqual(context.eval.orderedRecordIds); + expect( + edges.map((edge: SearchResultEdgeDTO) => edge.node.recordId), + ).toEqual(context.eval.orderedRecordIds); - expect(pageInfo).toBeDefined(); - expect(context.eval.pageInfo.hasNextPage).toEqual(pageInfo.hasNextPage); - expect(context.eval.pageInfo.decodedEndCursor).toEqual( - pageInfo.endCursor - ? decodeCursor(pageInfo.endCursor) - : pageInfo.endCursor, - ); - }); + expect(pageInfo).toBeDefined(); + expect(context.eval.pageInfo.hasNextPage).toEqual(pageInfo.hasNextPage); + expect(context.eval.pageInfo.decodedEndCursor).toEqual( + pageInfo.endCursor + ? decodeCursor(pageInfo.endCursor) + : pageInfo.endCursor, + ); + }, + ); it('should return cursor for each search edge', async () => { - const graphqlOperation = searchFactory({ + const response = await search({ searchInput: 'searchInput', excludedObjectNameSingulars: ['workspaceMember'], limit: 2, + expectToFail: false, }); - const response = await makeGraphqlAPIRequest(graphqlOperation); - const expectedResult = { edges: [ { @@ -979,17 +985,15 @@ describe('SearchResolver', () => { }; expect({ - ...response.body.data.search, - edges: response.body.data.search.edges.map( - (edge: SearchResultEdgeDTO) => ({ - cursor: edge.cursor, - }), - ), + ...response.data.search, + edges: response.data.search.edges.map((edge: SearchResultEdgeDTO) => ({ + cursor: edge.cursor, + })), }).toEqual(expectedResult); }); it('should return cursor for each search edge with after cursor input', async () => { - const graphqlOperation = searchFactory({ + const response = await search({ searchInput: 'searchInput', excludedObjectNameSingulars: ['workspaceMember'], limit: 2, @@ -999,10 +1003,9 @@ describe('SearchResolver', () => { person: searchInput2Person.id, }, }), + expectToFail: false, }); - const response = await makeGraphqlAPIRequest(graphqlOperation); - const expectedResult = { edges: [ { @@ -1036,12 +1039,10 @@ describe('SearchResolver', () => { }; expect({ - ...response.body.data.search, - edges: response.body.data.search.edges.map( - (edge: SearchResultEdgeDTO) => ({ - cursor: edge.cursor, - }), - ), + ...response.data.search, + edges: response.data.search.edges.map((edge: SearchResultEdgeDTO) => ({ + cursor: edge.cursor, + })), }).toEqual(expectedResult); }); }); diff --git a/packages/twenty-server/test/integration/graphql/suites/workspace/successful-user-and-workspace-creation.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/workspace/successful-user-and-workspace-creation.integration-spec.ts new file mode 100644 index 00000000000..7c6a388331d --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/workspace/successful-user-and-workspace-creation.integration-spec.ts @@ -0,0 +1,121 @@ +import { activateWorkspace } from 'test/integration/graphql/utils/activate-workspace.util'; +import { deleteUser } from 'test/integration/graphql/utils/delete-user.util'; +import { findManyApplications } from 'test/integration/graphql/utils/find-many-applications.util'; +import { getAuthTokensFromLoginToken } from 'test/integration/graphql/utils/get-auth-tokens-from-login-token.util'; +import { getCurrentUser } from 'test/integration/graphql/utils/get-current-user.util'; +import { signUpInNewWorkspace } from 'test/integration/graphql/utils/sign-up-in-new-workspace.util'; +import { signUp } from 'test/integration/graphql/utils/sign-up.util'; +import { jestExpectToBeDefined } from 'test/utils/jest-expect-to-be-defined.util.test'; +import { isDefined } from 'twenty-shared/utils'; +import { WorkspaceActivationStatus } from 'twenty-shared/workspace'; + +import { TWENTY_STANDARD_APPLICATION } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/twenty-standard-applications'; + +describe('Successful user and workspace creation', () => { + let createdUserAccessToken: string | undefined; + + afterEach(async () => { + if (!isDefined(createdUserAccessToken)) { + return; + } + + await deleteUser({ + accessToken: createdUserAccessToken, + expectToFail: false, + }); + }); + + it('should sign up a new user and create a new workspace successfully', async () => { + const { data } = await signUp({ + input: { + email: `test-1234@example.com`, + password: 'Test123!@#', + }, + + expectToFail: false, + }); + + createdUserAccessToken = + data.signUp.tokens.accessOrWorkspaceAgnosticToken.token; + + const { + data: { signUpInNewWorkspace: signUpInNewWorkspaceData }, + } = await signUpInNewWorkspace({ + accessToken: createdUserAccessToken, + expectToFail: false, + }); + + const { + data: { getAuthTokensFromLoginToken: authTokensData }, + } = await getAuthTokensFromLoginToken({ + origin: signUpInNewWorkspaceData.workspace.workspaceUrls.subdomainUrl, + loginToken: signUpInNewWorkspaceData.loginToken.token, + expectToFail: false, + }); + + const newWorkspaceAccessToken = + authTokensData.tokens.accessOrWorkspaceAgnosticToken.token; + + const { + data: { activateWorkspace: activateWorkspaceData }, + } = await activateWorkspace({ + accessToken: newWorkspaceAccessToken, + displayName: '42 answer', + expectToFail: false, + }); + + expect(activateWorkspaceData.activationStatus).toBe( + WorkspaceActivationStatus.ACTIVE, + ); + + const { + data: { currentUser }, + } = await getCurrentUser({ + accessToken: newWorkspaceAccessToken, + expectToFail: false, + }); + + jestExpectToBeDefined(currentUser.currentWorkspace); + const { inviteHash: _, ...expectedCurrentWorkspace } = + activateWorkspaceData; + + jestExpectToBeDefined(currentUser.currentWorkspace); + expect(currentUser.currentWorkspace).toMatchObject( + expectedCurrentWorkspace, + ); + + const { + data: { findManyApplications: findManyApplicationsData }, + } = await findManyApplications({ + accessToken: newWorkspaceAccessToken, + expectToFail: false, + }); + + expect(findManyApplicationsData.length).toBe(2); + const twentyStandardApp = findManyApplicationsData.find( + (application) => + application.universalIdentifier === + TWENTY_STANDARD_APPLICATION.universalIdentifier, + ); + + jestExpectToBeDefined(twentyStandardApp); + const { + sourcePath: _sourcePath, + sourceType: _sourceType, + ...expectedStandardTwentyApplication + } = TWENTY_STANDARD_APPLICATION; + + expect(twentyStandardApp).toMatchObject(expectedStandardTwentyApplication); + + const workpsaceCustomApplication = findManyApplicationsData.find( + (application) => + application.id === + currentUser.currentWorkspace?.workspaceCustomApplicationId, + ); + + jestExpectToBeDefined(workpsaceCustomApplication); + expect(workpsaceCustomApplication.universalIdentifier).toEqual( + workpsaceCustomApplication.id, + ); + }); +}); diff --git a/packages/twenty-server/test/integration/graphql/utils/activate-workspace.util.ts b/packages/twenty-server/test/integration/graphql/utils/activate-workspace.util.ts new file mode 100644 index 00000000000..0b70adc8464 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/activate-workspace.util.ts @@ -0,0 +1,62 @@ +import gql from 'graphql-tag'; +import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type'; +import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util'; +import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util'; +import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util'; + +import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; + +type ActivateWorkspaceUtilArgs = { + accessToken: string; + displayName: string; + expectToFail?: boolean; +}; + +export const activateWorkspace = async ({ + accessToken, + displayName, + expectToFail, +}: ActivateWorkspaceUtilArgs): CommonResponseBody<{ + activateWorkspace: WorkspaceEntity; +}> => { + const mutation = gql` + mutation ActivateWorkspace($input: ActivateWorkspaceInput!) { + activateWorkspace(data: $input) { + id + displayName + activationStatus + subdomain + inviteHash + logo + } + } + `; + + const response = await makeGraphqlAPIRequest( + { + query: mutation, + variables: { + input: { + displayName, + }, + }, + }, + accessToken, + ); + + if (expectToFail === true) { + warnIfNoErrorButExpectedToFail({ + response, + errorMessage: 'Activate workspace should have failed but did not', + }); + } + + if (expectToFail === false) { + warnIfErrorButNotExpectedToFail({ + response, + errorMessage: 'Activate workspace has failed but should not', + }); + } + + return { data: response.body.data, errors: response.body.errors }; +}; diff --git a/packages/twenty-server/test/integration/graphql/utils/delete-user.util.ts b/packages/twenty-server/test/integration/graphql/utils/delete-user.util.ts new file mode 100644 index 00000000000..876bebf7e5f --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/delete-user.util.ts @@ -0,0 +1,55 @@ +import gql from 'graphql-tag'; +import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type'; +import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util'; +import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util'; +import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util'; + +import { type UserEntity } from 'src/engine/core-modules/user/user.entity'; + +type DeleteUserUtilArgs = { + accessToken: string; + expectToFail?: boolean; +}; + +export const deleteUser = async ({ + accessToken, + expectToFail, +}: DeleteUserUtilArgs): CommonResponseBody<{ + deleteUser: UserEntity; +}> => { + const mutation = gql` + mutation DeleteUser { + deleteUser { + id + email + firstName + lastName + deletedAt + } + } + `; + + const response = await makeGraphqlAPIRequest( + { + query: mutation, + variables: {}, + }, + accessToken, + ); + + if (expectToFail === true) { + warnIfNoErrorButExpectedToFail({ + response, + errorMessage: 'Delete user should have failed but did not', + }); + } + + if (expectToFail === false) { + warnIfErrorButNotExpectedToFail({ + response, + errorMessage: 'Delete user has failed but should not', + }); + } + + return { data: response.body.data, errors: response.body.errors }; +}; diff --git a/packages/twenty-server/test/integration/graphql/utils/find-many-applications.util.ts b/packages/twenty-server/test/integration/graphql/utils/find-many-applications.util.ts new file mode 100644 index 00000000000..7d5097af756 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/find-many-applications.util.ts @@ -0,0 +1,57 @@ +import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util'; +import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type'; +import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util'; +import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util'; +import gql from 'graphql-tag'; + +import { type ApplicationDTO } from 'src/engine/core-modules/application/dtos/application.dto'; + +export const APPLICATION_GQL_FIELDS = ` + id + name + description + version + universalIdentifier +`; + +export const findManyApplications = async ({ + gqlFields = APPLICATION_GQL_FIELDS, + expectToFail, + accessToken, +}: { + gqlFields?: string; + expectToFail?: boolean; + accessToken?: string; +}): CommonResponseBody<{ + findManyApplications: ApplicationDTO[]; +}> => { + const response = await makeGraphqlAPIRequest( + { + query: gql` + query FindManyApplications { + findManyApplications { + ${gqlFields} + } + } + `, + variables: {}, + }, + accessToken, + ); + + if (expectToFail === true) { + warnIfNoErrorButExpectedToFail({ + response, + errorMessage: 'Application search should have failed but did not', + }); + } + + if (expectToFail === false) { + warnIfErrorButNotExpectedToFail({ + response, + errorMessage: 'Application search has failed but should not', + }); + } + + return { data: response.body.data, errors: response.body.errors }; +}; diff --git a/packages/twenty-server/test/integration/graphql/utils/get-auth-tokens-from-login-token.util.ts b/packages/twenty-server/test/integration/graphql/utils/get-auth-tokens-from-login-token.util.ts new file mode 100644 index 00000000000..8449a08e61a --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/get-auth-tokens-from-login-token.util.ts @@ -0,0 +1,70 @@ +import gql from 'graphql-tag'; +import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type'; +import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util'; +import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util'; +import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util'; + +import { type AuthTokens } from 'src/engine/core-modules/auth/dto/auth-tokens.dto'; + +type GetAuthTokensFromLoginTokenUtilArgs = { + loginToken: string; + origin?: string; + expectToFail?: boolean; +}; + +export const getAuthTokensFromLoginToken = async ({ + loginToken, + origin = 'http://localhost:3001', + expectToFail, +}: GetAuthTokensFromLoginTokenUtilArgs): CommonResponseBody<{ + getAuthTokensFromLoginToken: AuthTokens; +}> => { + const mutation = gql` + mutation GetAuthTokensFromLoginToken( + $loginToken: String! + $origin: String! + ) { + getAuthTokensFromLoginToken(loginToken: $loginToken, origin: $origin) { + tokens { + accessOrWorkspaceAgnosticToken { + token + expiresAt + } + refreshToken { + token + expiresAt + } + } + } + } + `; + + const response = await makeGraphqlAPIRequest( + { + query: mutation, + variables: { + loginToken, + origin, + }, + }, + undefined, // Public endpoint - no authentication required + ); + + if (expectToFail === true) { + warnIfNoErrorButExpectedToFail({ + response, + errorMessage: + 'Get auth tokens from login token should have failed but did not', + }); + } + + if (expectToFail === false) { + warnIfErrorButNotExpectedToFail({ + response, + errorMessage: + 'Get auth tokens from login token has failed but should not', + }); + } + + return { data: response.body.data, errors: response.body.errors }; +}; diff --git a/packages/twenty-server/test/integration/graphql/utils/get-current-user.util.ts b/packages/twenty-server/test/integration/graphql/utils/get-current-user.util.ts new file mode 100644 index 00000000000..4e2358644f0 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/get-current-user.util.ts @@ -0,0 +1,76 @@ +import gql from 'graphql-tag'; +import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util'; +import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type'; +import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util'; +import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util'; + +import { type UserEntity } from 'src/engine/core-modules/user/user.entity'; + +type CurrentUserUtilArgs = { + accessToken: string; + expectToFail?: boolean; +}; + +export const getCurrentUser = async ({ + accessToken, + expectToFail, +}: CurrentUserUtilArgs): CommonResponseBody<{ + currentUser: UserEntity; +}> => { + const query = gql` + query CurrentUser { + currentUser { + id + email + firstName + lastName + defaultAvatarUrl + isEmailVerified + disabled + canImpersonate + canAccessFullAdminPanel + locale + createdAt + updatedAt + deletedAt + currentWorkspace { + id + displayName + subdomain + activationStatus + logo + workspaceCustomApplicationId + } + currentUserWorkspace { + id + userId + workspaceId + } + } + } + `; + + const response = await makeGraphqlAPIRequest( + { + query, + variables: {}, + }, + accessToken, + ); + + if (expectToFail === true) { + warnIfNoErrorButExpectedToFail({ + response, + errorMessage: 'Get current user should have failed but did not', + }); + } + + if (expectToFail === false) { + warnIfErrorButNotExpectedToFail({ + response, + errorMessage: 'Get current user has failed but should not', + }); + } + + return { data: response.body.data, errors: response.body.errors }; +}; diff --git a/packages/twenty-server/test/integration/graphql/utils/make-graphql-api-request.util.ts b/packages/twenty-server/test/integration/graphql/utils/make-graphql-api-request.util.ts index e8b9d89e6c8..d08e4fcc9dc 100644 --- a/packages/twenty-server/test/integration/graphql/utils/make-graphql-api-request.util.ts +++ b/packages/twenty-server/test/integration/graphql/utils/make-graphql-api-request.util.ts @@ -1,19 +1,26 @@ import { type ASTNode, print } from 'graphql'; import request from 'supertest'; +import { isDefined } from 'twenty-shared/utils'; type GraphqlOperation = { query: ASTNode; variables?: Record; }; -export const makeGraphqlAPIRequest = (graphqlOperation: GraphqlOperation) => { +export const makeGraphqlAPIRequest = ( + graphqlOperation: GraphqlOperation, + token: string | undefined = APPLE_JANE_ADMIN_ACCESS_TOKEN, +) => { const client = request(`http://localhost:${APP_PORT}`); - return client - .post('/graphql') - .set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`) - .send({ - query: print(graphqlOperation.query), - variables: graphqlOperation.variables || {}, - }); + const clientInstance = client.post('/graphql'); + + if (isDefined(token)) { + clientInstance.set('Authorization', `Bearer ${token}`); + } + + return clientInstance.send({ + query: print(graphqlOperation.query), + variables: graphqlOperation.variables || {}, + }); }; diff --git a/packages/twenty-server/test/integration/graphql/utils/search.util.ts b/packages/twenty-server/test/integration/graphql/utils/search.util.ts new file mode 100644 index 00000000000..40e2c5503bc --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/search.util.ts @@ -0,0 +1,53 @@ +import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util'; +import { searchFactory } from 'test/integration/graphql/utils/search-factory.util'; +import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type'; +import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util'; +import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util'; + +import { type SearchArgs } from 'src/engine/core-modules/search/dtos/search-args'; +import { type SearchResultConnectionDTO } from 'src/engine/core-modules/search/dtos/search-result-connection.dto'; + +type SearchOperationArgs = SearchArgs & { + expectToFail?: boolean; + accessToken?: string; +}; + +export const search = async ({ + searchInput, + limit, + after, + includedObjectNameSingulars, + excludedObjectNameSingulars, + filter, + expectToFail, + accessToken, +}: SearchOperationArgs): CommonResponseBody<{ + search: SearchResultConnectionDTO; +}> => { + const graphqlOperation = searchFactory({ + searchInput, + limit, + after, + includedObjectNameSingulars, + excludedObjectNameSingulars, + filter, + }); + + const response = await makeGraphqlAPIRequest(graphqlOperation, accessToken); + + if (expectToFail === true) { + warnIfNoErrorButExpectedToFail({ + response, + errorMessage: 'Search should have failed but did not', + }); + } + + if (expectToFail === false) { + warnIfErrorButNotExpectedToFail({ + response, + errorMessage: 'Search has failed but should not', + }); + } + + return { data: response.body.data, errors: response.body.errors }; +}; diff --git a/packages/twenty-server/test/integration/graphql/utils/sign-up-in-new-workspace.util.ts b/packages/twenty-server/test/integration/graphql/utils/sign-up-in-new-workspace.util.ts new file mode 100644 index 00000000000..32c3d535310 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/sign-up-in-new-workspace.util.ts @@ -0,0 +1,61 @@ +import gql from 'graphql-tag'; +import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type'; +import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util'; +import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util'; +import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util'; + +import { type SignUpOutput } from 'src/engine/core-modules/auth/dto/sign-up.output'; + +type SignUpOnNewWorkspaceUtilArgs = { + accessToken: string; + expectToFail?: boolean; +}; + +export const signUpInNewWorkspace = async ({ + accessToken, + expectToFail, +}: SignUpOnNewWorkspaceUtilArgs): CommonResponseBody<{ + signUpInNewWorkspace: SignUpOutput; +}> => { + const mutation = gql` + mutation SignUpInNewWorkspace { + signUpInNewWorkspace { + loginToken { + token + expiresAt + } + workspace { + id + workspaceUrls { + customUrl + subdomainUrl + } + } + } + } + `; + + const response = await makeGraphqlAPIRequest( + { + query: mutation, + variables: {}, + }, + accessToken, + ); + + if (expectToFail === true) { + warnIfNoErrorButExpectedToFail({ + response, + errorMessage: 'Sign up on new workspace should have failed but did not', + }); + } + + if (expectToFail === false) { + warnIfErrorButNotExpectedToFail({ + response, + errorMessage: 'Sign up on new workspace has failed but should not', + }); + } + + return { data: response.body.data, errors: response.body.errors }; +}; diff --git a/packages/twenty-server/test/integration/graphql/utils/sign-up-operation-factory.util.ts b/packages/twenty-server/test/integration/graphql/utils/sign-up-operation-factory.util.ts index 10c35c1bb65..d4cd911a69d 100644 --- a/packages/twenty-server/test/integration/graphql/utils/sign-up-operation-factory.util.ts +++ b/packages/twenty-server/test/integration/graphql/utils/sign-up-operation-factory.util.ts @@ -1,4 +1,4 @@ -import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-workspaces.util'; +import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant'; export const signUpOperationFactory = ({ email, diff --git a/packages/twenty-server/test/integration/graphql/utils/sign-up.util.ts b/packages/twenty-server/test/integration/graphql/utils/sign-up.util.ts new file mode 100644 index 00000000000..fb019579262 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/sign-up.util.ts @@ -0,0 +1,87 @@ +import gql from 'graphql-tag'; +import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type'; +import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util'; +import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util'; +import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util'; + +import { type AvailableWorkspacesAndAccessTokensOutput } from 'src/engine/core-modules/auth/dto/available-workspaces-and-access-tokens.output'; +import { type UserCredentialsInput } from 'src/engine/core-modules/auth/dto/user-credentials.input'; + +type SignUpUtilArgs = { + input: UserCredentialsInput; + expectToFail?: boolean; +}; + +export const signUp = async ({ + input, + expectToFail, +}: SignUpUtilArgs): CommonResponseBody<{ + signUp: AvailableWorkspacesAndAccessTokensOutput; +}> => { + const mutation = gql` + mutation SignUp( + $email: String! + $password: String! + $captchaToken: String + ) { + signUp(email: $email, password: $password, captchaToken: $captchaToken) { + tokens { + accessOrWorkspaceAgnosticToken { + token + expiresAt + } + refreshToken { + token + expiresAt + } + } + availableWorkspaces { + availableWorkspacesForSignIn { + id + displayName + logo + workspaceUrls { + customUrl + subdomainUrl + } + } + availableWorkspacesForSignUp { + id + displayName + logo + workspaceUrls { + customUrl + subdomainUrl + } + } + } + } + } + `; + + const response = await makeGraphqlAPIRequest( + { + query: mutation, + variables: { + ...input, + }, + }, + undefined, // Public endpoint - no authentication required + ); + + if (expectToFail === true) { + warnIfNoErrorButExpectedToFail({ + response, + errorMessage: 'Sign up should have failed but did not', + }); + } + + if (expectToFail === false) { + warnIfErrorButNotExpectedToFail({ + response, + errorMessage: 'Sign up has failed but should not', + }); + } + + return { data: response.body.data, errors: response.body.errors }; +}; diff --git a/packages/twenty-server/test/integration/metadata/suites/object-metadata/failing-create-one-object-metadata.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/object-metadata/failing-create-one-object-metadata.integration-spec.ts index 590b3569d23..698a9d07da9 100644 --- a/packages/twenty-server/test/integration/metadata/suites/object-metadata/failing-create-one-object-metadata.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/object-metadata/failing-create-one-object-metadata.integration-spec.ts @@ -6,7 +6,7 @@ import { updateFeatureFlag } from 'test/integration/metadata/suites/utils/update import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; -import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-workspaces.util'; +import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant'; const allTestsUseCases = [ ...OBJECT_METADATA_NAMES_FAILING_TEST_CASES, diff --git a/packages/twenty-server/test/integration/metadata/suites/utils/update-feature-flag.util.ts b/packages/twenty-server/test/integration/metadata/suites/utils/update-feature-flag.util.ts index 4023e848a47..c698ea7e9e2 100644 --- a/packages/twenty-server/test/integration/metadata/suites/utils/update-feature-flag.util.ts +++ b/packages/twenty-server/test/integration/metadata/suites/utils/update-feature-flag.util.ts @@ -4,7 +4,7 @@ import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util'; import { type FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; -import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-workspaces.util'; +import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant'; export const updateFeatureFlag = async ({ featureFlag,