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
@@ -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 (