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
This commit is contained in:
Paul Rastoin
2025-11-10 16:13:12 +01:00
committed by GitHub
parent 9a80164cf3
commit 7a1e699fc8
106 changed files with 2167 additions and 985 deletions
@@ -222,6 +222,7 @@ export type Application = {
name: Scalars['String'];
objects: Array<Object>;
serverlessFunctions: Array<ServerlessFunction>;
universalIdentifier: Scalars['String'];
version: Scalars['String'];
};
@@ -4802,6 +4803,7 @@ export type Workspace = {
viewGroups?: Maybe<Array<CoreViewGroup>>;
viewSorts?: Maybe<Array<CoreViewSort>>;
views?: Maybe<Array<CoreView>>;
workspaceCustomApplicationId?: Maybe<Scalars['String']>;
workspaceMembersCount?: Maybe<Scalars['Float']>;
workspaceUrls: WorkspaceUrls;
};
@@ -222,6 +222,7 @@ export type Application = {
name: Scalars['String'];
objects: Array<Object>;
serverlessFunctions: Array<ServerlessFunction>;
universalIdentifier: Scalars['String'];
version: Scalars['String'];
};
@@ -4630,6 +4631,7 @@ export type Workspace = {
viewGroups?: Maybe<Array<CoreViewGroup>>;
viewSorts?: Maybe<Array<CoreViewSort>>;
views?: Maybe<Array<CoreView>>;
workspaceCustomApplicationId?: Maybe<Scalars['String']>;
workspaceMembersCount?: Maybe<Scalars['Float']>;
workspaceUrls: WorkspaceUrls;
};
@@ -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<Application, 'objects'> & { objects: { id: string }[] };
application?: Omit<Application, 'objects' | 'universalIdentifier'> & {
objects: { id: string }[];
};
}) => {
const loading = !isDefined(application);
@@ -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<Application, 'objects'> & { objects: { id: string }[] };
application?: Omit<Application, 'objects' | 'universalIdentifier'> & {
objects: { id: string }[];
};
}) => {
if (!isDefined(application)) {
return null;
@@ -12,7 +12,9 @@ import { SettingsObjectTable } from '~/pages/settings/data-model/SettingsObjectT
export const SettingsApplicationDetailContentTab = ({
application,
}: {
application?: Omit<Application, 'objects'> & { objects: { id: string }[] };
application?: Omit<Application, 'objects' | 'universalIdentifier'> & {
objects: { id: string }[];
};
}) => {
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
@@ -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<Application, 'objects'> & { objects: { id: string }[] };
application?: Omit<Application, 'objects' | 'universalIdentifier'> & {
objects: { id: string }[];
};
}) => {
const { updateOneApplicationVariable } = useUpdateOneApplicationVariable();
+1 -1
View File
@@ -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
@@ -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) {
@@ -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<WorkspaceEntity>,
protected readonly twentyORMGlobalManager: TwentyORMGlobalManager,
@InjectRepository(ApplicationEntity)
private readonly applicationRepository: Repository<ApplicationEntity>,
private readonly applicationService: ApplicationService,
) {
super(workspaceRepository, twentyORMGlobalManager);
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
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);
}
}
}
@@ -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 {}
@@ -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,
@@ -0,0 +1,19 @@
import { type MigrationInterface, type QueryRunner } from 'typeorm';
export class NullableApplicationServerlessFunctionLayer1762333916255
implements MigrationInterface
{
name = 'NullableApplicationServerlessFunctionLayer1762333916255';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."application" ALTER COLUMN "serverlessFunctionLayerId" DROP NOT NULL`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."application" ALTER COLUMN "serverlessFunctionLayerId" SET NOT NULL`,
);
}
}
@@ -0,0 +1,29 @@
import { type MigrationInterface, type QueryRunner } from 'typeorm';
export class MakeApplicationWorkspaceFkDeferrable1762339932345
implements MigrationInterface
{
name = 'MakeApplicationWorkspaceFkDeferrable1762339932345';
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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`,
);
}
}
@@ -0,0 +1,37 @@
import { type MigrationInterface, type QueryRunner } from 'typeorm';
export class AddWorkspaceCustomApplicationIdColumn1762343994716
implements MigrationInterface
{
name = 'AddWorkspaceCustomApplicationIdColumn1762343994716';
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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"`,
);
}
}
@@ -0,0 +1,19 @@
import { type MigrationInterface, type QueryRunner } from 'typeorm';
export class WorkspaceCustomApplicationIdForeignKey1762437814771
implements MigrationInterface
{
name = 'WorkspaceCustomApplicationIdForeignKey1762437814771';
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
await queryRunner.query(
`ALTER TABLE "core"."workspace" DROP CONSTRAINT "FK_3b1acb13a5dac9956d1a4b32755"`,
);
}
}
@@ -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<ApplicationEntity> {
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)) {
@@ -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',
@@ -36,6 +36,7 @@ import { FieldMetadataModule } from 'src/engine/metadata-modules/field-metadata/
WorkspaceMigrationV2Module,
PermissionsModule,
],
exports: [ApplicationService],
providers: [ApplicationResolver, ApplicationService, ApplicationSyncService],
})
export class ApplicationModule {}
@@ -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<ApplicationEntity> {
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<ApplicationEntity> {
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<typeof this.applicationRepository.update>[1],
): Promise<ApplicationEntity> {
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`);
@@ -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;
}
@@ -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,
@@ -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<UserEntity>;
let WorkspaceRepository: Repository<WorkspaceEntity>;
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>(SignInUpService);
UserRepository = module.get(getRepositoryToken(UserEntity));
WorkspaceRepository = module.get(getRepositoryToken(WorkspaceEntity));
workspaceInvitationService = module.get<WorkspaceInvitationService>(
WorkspaceInvitationService,
);
userWorkspaceService =
module.get<UserWorkspaceService>(UserWorkspaceService);
twentyConfigService = module.get<TwentyConfigService>(TwentyConfigService);
subdomainManagerService = module.get<SubdomainManagerService>(
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();
});
});
@@ -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(
@@ -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<K extends keyof KeyValueTypesMap>({
userId,
workspaceId,
key,
value,
type,
}: {
userId?: string | null;
workspaceId?: string | null;
key: Extract<K, string>;
value: KeyValueTypesMap[K];
type: KeyValuePairType;
}) {
async set<K extends keyof KeyValueTypesMap>(
{
userId,
workspaceId,
key,
value,
type,
}: {
userId?: string | null;
workspaceId?: string | null;
key: Extract<K, string>;
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<keyof KeyValueTypesMap, string>;
}) {
await this.keyValuePairRepository.delete({
async delete(
{
userId,
workspaceId,
type,
key,
}: {
userId?: string | null;
workspaceId?: string | null;
type: KeyValuePairType;
key: Extract<keyof KeyValueTypesMap, string>;
},
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);
}
}
}
@@ -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({
@@ -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 () => {
@@ -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<UserWorkspaceEntit
super(userWorkspaceRepository);
}
async create({
userId,
workspaceId,
isExistingUser,
pictureUrl,
}: {
userId: string;
workspaceId: string;
isExistingUser: boolean;
pictureUrl?: string;
}): Promise<UserWorkspaceEntity> {
async create(
{
userId,
workspaceId,
isExistingUser,
pictureUrl,
}: {
userId: string;
workspaceId: string;
isExistingUser: boolean;
pictureUrl?: string;
},
queryRunner?: QueryRunner,
): Promise<UserWorkspaceEntity> {
const defaultAvatarUrl = await this.computeDefaultAvatarUrl(
userId,
workspaceId,
@@ -81,7 +84,9 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspaceEntit
defaultAvatarUrl,
});
return this.userWorkspaceRepository.save(userWorkspace);
return queryRunner
? queryRunner.manager.save(UserWorkspaceEntity, userWorkspace)
: this.userWorkspaceRepository.save(userWorkspace);
}
async createWorkspaceMember(workspaceId: string, user: UserEntity) {
@@ -126,6 +131,7 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspaceEntit
async addUserToWorkspaceIfUserNotInWorkspace(
user: UserEntity,
workspace: WorkspaceEntity,
queryRunner?: QueryRunner,
) {
let userWorkspace = await this.checkUserWorkspaceExists(
user.id,
@@ -133,11 +139,14 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspaceEntit
);
if (!userWorkspace) {
userWorkspace = await this.create({
userId: user.id,
workspaceId: workspace.id,
isExistingUser: true,
});
userWorkspace = await this.create(
{
userId: user.id,
workspaceId: workspace.id,
isExistingUser: true,
},
queryRunner,
);
await this.createWorkspaceMember(workspace.id, user);
@@ -150,15 +159,19 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspaceEntit
);
}
await this.userRoleService.assignRoleToUserWorkspace({
workspaceId: workspace.id,
userWorkspaceId: userWorkspace.id,
roleId: defaultRoleId,
});
await this.userRoleService.assignRoleToUserWorkspace(
{
workspaceId: workspace.id,
userWorkspaceId: userWorkspace.id,
roleId: defaultRoleId,
},
queryRunner,
);
await this.workspaceInvitationService.invalidateWorkspaceInvitation(
workspace.id,
user.email,
queryRunner,
);
}
}
@@ -4,6 +4,7 @@ import { getRepositoryToken } from '@nestjs/typeorm';
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import { type Repository, type UpdateResult } from 'typeorm';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { AuthException } from 'src/engine/core-modules/auth/auth.exception';
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';
@@ -43,6 +44,7 @@ describe('UserService', () => {
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();
@@ -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<UserEntity> {
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);
}
}
@@ -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<Extract<keyof KeyValueTypesMap, string>>(result);
}
set<K extends keyof KeyValueTypesMap>({
userId,
workspaceId,
key,
value,
}: {
userId?: string;
workspaceId?: string;
key: Extract<K, string>;
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<keyof KeyValueTypesMap, string>;
}) {
return this.keyValuePairService.delete({
set<K extends keyof KeyValueTypesMap>(
{
userId,
workspaceId,
key,
type: KeyValuePairType.USER_VARIABLE,
});
value,
}: {
userId?: string;
workspaceId?: string;
key: Extract<K, string>;
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<keyof KeyValueTypesMap, string>;
},
queryRunner?: QueryRunner,
) {
return this.keyValuePairService.delete(
{
userId,
workspaceId,
key,
type: KeyValuePairType.USER_VARIABLE,
},
queryRunner,
);
}
}
@@ -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);
}
}
@@ -247,7 +247,7 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
);
await this.workspaceManagerService.init({
workspaceId: workspace.id,
workspace,
userId: user.id,
});
await this.userWorkspaceService.createWorkspaceMember(workspace.id, user);
@@ -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<ApplicationEntity>;
@OneToMany(() => ApplicationEntity, (application) => application.workspace, {
onDelete: 'CASCADE',
})
applications: Relation<Application[]>;
}
@@ -292,6 +292,11 @@ export class WorkspaceResolver {
);
}
@ResolveField(() => String)
workspaceCustomApplicationId(@Parent() workspace: WorkspaceEntity) {
return workspace.workspaceCustomApplicationId;
}
@ResolveField(() => Boolean)
isMicrosoftAuthEnabled(@Parent() workspace: WorkspaceEntity) {
return (
@@ -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,
@@ -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<void> {
public async assignRoleToUserWorkspace(
{
workspaceId,
userWorkspaceId,
roleId,
}: {
workspaceId: string;
userWorkspaceId: string;
roleId: string;
},
queryRunner?: QueryRunner,
): Promise<void> {
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),
@@ -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: {},
@@ -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'])
@@ -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}`, [
@@ -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<CreateWorkspaceInput, 'version' | 'workspaceCustomApplicationId'>
>;
@@ -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';
@@ -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);
await seedChatMessages(dataSource, schemaName, workspaceId, threadId);
type SeedAgentsArgs = {
queryRunner: QueryRunner;
schemaName: string;
workspaceId: string;
};
export const seedAgents = async ({
queryRunner,
schemaName,
workspaceId,
}: SeedAgentsArgs) => {
const threadId = await seedChatThreads({
queryRunner,
schemaName,
workspaceId,
});
await seedChatMessages({
queryRunner,
schemaName,
workspaceId,
threadId,
});
};
@@ -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}`, [
@@ -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();
}
};
@@ -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}`)
@@ -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}`)
@@ -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}`, [
@@ -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();
};
@@ -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<string, WorkspaceSeederFields> = {
[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();
};
@@ -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;
@@ -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,
@@ -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';
@@ -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<void> {
public async seedDev(workspaceId: SeededWorkspacesIds): Promise<void> {
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,
@@ -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,
@@ -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<RoleEntity>,
@InjectRepository(RoleTargetsEntity)
private readonly roleTargetsRepository: Repository<RoleTargetsEntity>,
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<void> {
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,
@@ -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],
@@ -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'];
@@ -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<WorkspaceEntity, 'id' | 'displayName'>;
}) =>
({
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;
};
@@ -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,
@@ -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`,
@@ -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`,
@@ -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`,
@@ -69,6 +69,7 @@ registerEnumType(CalendarChannelContactAutoCreationPolicy, {
@WorkspaceEntity({
standardId: STANDARD_OBJECT_IDS.calendarChannel,
namePlural: 'calendarChannels',
labelSingular: msg`Calendar Channel`,
labelPlural: msg`Calendar Channels`,
@@ -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`,
@@ -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`,
@@ -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`,
@@ -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`,
@@ -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`,
@@ -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`,
@@ -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`,
@@ -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`,
@@ -98,6 +98,7 @@ registerEnumType(MessageChannelPendingGroupEmailsAction, {
@WorkspaceEntity({
standardId: STANDARD_OBJECT_IDS.messageChannel,
namePlural: 'messageChannels',
labelSingular: msg`Message Channel`,
labelPlural: msg`Message Channels`,
@@ -31,6 +31,7 @@ registerEnumType(MessageFolderPendingSyncAction, {
@WorkspaceEntity({
standardId: STANDARD_OBJECT_IDS.messageFolder,
namePlural: 'messageFolders',
labelSingular: msg`Message Folder`,
labelPlural: msg`Message Folders`,
@@ -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`,
@@ -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`,
@@ -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`,
@@ -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`,
@@ -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`,
@@ -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`,
@@ -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`,
@@ -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`,
@@ -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`,
@@ -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`,
@@ -24,6 +24,7 @@ export enum AutomatedTriggerType {
@WorkspaceEntity({
standardId: STANDARD_OBJECT_IDS.workflowAutomatedTrigger,
namePlural: 'workflowAutomatedTriggers',
labelSingular: msg`WorkflowAutomatedTrigger`,
labelPlural: msg`WorkflowAutomatedTriggers`,
@@ -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`,
@@ -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`,
@@ -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`,
@@ -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`,
@@ -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",
}
`;
@@ -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<String>,
"currentUserWorkspace": null,
"currentWorkspace": null,
"defaultAvatarUrl": null,
"deletedAt": null,
"disabled": false,
"email": "test-123@example.com",
"firstName": "",
"id": Any<String>,
"isEmailVerified": false,
"lastName": "",
"locale": "en",
"updatedAt": Any<String>,
}
`;
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<String>,
"currentUserWorkspace": null,
"currentWorkspace": null,
"defaultAvatarUrl": null,
"deletedAt": null,
"disabled": false,
"email": "test-123@example.com",
"firstName": "",
"id": Any<String>,
"isEmailVerified": false,
"lastName": "",
"locale": "en",
"updatedAt": Any<String>,
}
`;
@@ -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,
});
});
});
@@ -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 }),
);
});
});
@@ -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);
});
});
@@ -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,
);
});
});
@@ -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 };
};
@@ -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 };
};
@@ -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 };
};
@@ -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 };
};
@@ -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 };
};
@@ -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<string, unknown>;
};
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 || {},
});
};

Some files were not shown because too many files have changed in this diff Show More