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:
+55
-53
@@ -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,
|
||||
|
||||
-409
@@ -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();
|
||||
});
|
||||
});
|
||||
+114
-46
@@ -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(
|
||||
|
||||
+54
-31
@@ -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({
|
||||
|
||||
+18
-11
@@ -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 () => {
|
||||
|
||||
+36
-23
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+46
-32
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+16
-4
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user