feat(auth): collect the workspace logo on the sign-up creation step (#21723)
## What & why A single, consistent **workspace-creation step** for both multi-workspace and single-workspace self-host — collecting **name + logo** (and the **subdomain** in multi-workspace) — which **removes the duplicate name/logo prompt** that previously reappeared on the workspace subdomain (reported after #21641). ## Changes **One creation form for both modes** - With 0 workspaces, both multi-workspace and single-workspace route to the shared `SignInUpWorkspaceCreationForm`; `SignInUp` renders it for the `WorkspaceCreation` step regardless of domain/scope. - The subdomain field shows only in multi-workspace; single-workspace keeps its fixed address. **Logo on the creation step** - New scoped `uploadNewWorkspaceLogo(workspaceId, file)` mutation: the creator sets a logo on their just-created `PENDING_CREATION` workspace via the workspace-agnostic token (membership enforced — only the creator is a member at that point), reusing `uploadWorkspacePicture`. Upload size is capped via `settings.storage.maxFileSize` (also applied to the existing logo / profile-picture uploads). - The picked file is held locally (object-URL preview, revoked on unmount) and uploaded right after creation (non-fatal on failure). **Onboarding step → pure activation loader** - The old "Create your workspace" form (name + logo) is removed. The onboarding step now activates the pending workspace on mount and shows the loader, with a **Retry** action on failure. ## Testing - typecheck (front + server) ✅; oxlint + oxfmt clean on changed files ✅ - Unit tests: `auth.resolver.spec`, `useWorkspaceSubdomainField`, `SignInUpWorkspaceCreationForm` (multi + single-workspace), `useAuth` ✅ - Metadata GraphQL + `twenty-client-sdk` schema regenerated. Follow-up to #21641. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Xw37hR5seiCyWnppG9z4op --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -320,6 +320,15 @@ export class ApplicationService {
|
||||
},
|
||||
queryRunner?: QueryRunner,
|
||||
) {
|
||||
const existingApplication = await this.findByUniversalIdentifier({
|
||||
universalIdentifier: TWENTY_STANDARD_APPLICATION.universalIdentifier,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
if (isDefined(existingApplication)) {
|
||||
return existingApplication;
|
||||
}
|
||||
|
||||
const defaultPackageFields = await getDefaultApplicationPackageFields();
|
||||
|
||||
const twentyStandardApplication = await this.create(
|
||||
|
||||
@@ -15,6 +15,7 @@ import { SubdomainManagerService } from 'src/engine/core-modules/domain/subdomai
|
||||
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
|
||||
import { EmailVerificationService } from 'src/engine/core-modules/email-verification/services/email-verification.service';
|
||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
import { FileCorePictureService } from 'src/engine/core-modules/file/file-core-picture/services/file-core-picture.service';
|
||||
import { SSOService } from 'src/engine/core-modules/sso/services/sso.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { TwoFactorAuthenticationService } from 'src/engine/core-modules/two-factor-authentication/two-factor-authentication.service';
|
||||
@@ -77,6 +78,10 @@ describe('AuthResolver', () => {
|
||||
provide: SubdomainManagerService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: FileCorePictureService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: UserWorkspaceService,
|
||||
useValue: {},
|
||||
|
||||
@@ -2,6 +2,8 @@ import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
|
||||
import { Args, Context, Mutation, Query } from '@nestjs/graphql';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import bytes from 'bytes';
|
||||
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
|
||||
import omit from 'lodash.omit';
|
||||
import { PermissionFlagType } from 'twenty-shared/constants';
|
||||
import { SOURCE_LOCALE } from 'twenty-shared/translations';
|
||||
@@ -9,7 +11,10 @@ import { TwoFactorAuthenticationStrategy } from 'twenty-shared/types';
|
||||
import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import type { FileUpload } from 'graphql-upload/processRequest.mjs';
|
||||
|
||||
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
|
||||
import { settings } from 'src/engine/constants/settings';
|
||||
import { ApiKeyService } from 'src/engine/core-modules/api-key/services/api-key.service';
|
||||
import { AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
import { EventLogEmitterService } from 'src/engine/core-modules/event-logs/emit/event-log-emitter.service';
|
||||
@@ -57,6 +62,8 @@ import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspac
|
||||
import { EmailVerificationExceptionFilter } from 'src/engine/core-modules/email-verification/email-verification-exception-filter.util';
|
||||
import { EmailVerificationTrigger } from 'src/engine/core-modules/email-verification/email-verification.constants';
|
||||
import { EmailVerificationService } from 'src/engine/core-modules/email-verification/services/email-verification.service';
|
||||
import { FileWithSignedUrlDTO } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto';
|
||||
import { FileCorePictureService } from 'src/engine/core-modules/file/file-core-picture/services/file-core-picture.service';
|
||||
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
|
||||
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
|
||||
import { I18nContext } from 'src/engine/core-modules/i18n/types/i18n-context.type';
|
||||
@@ -84,6 +91,7 @@ import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.g
|
||||
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter';
|
||||
import { streamToBuffer } from 'src/utils/stream-to-buffer';
|
||||
|
||||
import { ApiKeyToken } from './dto/api-key-token.dto';
|
||||
import { AuthToken } from './dto/auth-token.dto';
|
||||
@@ -136,6 +144,7 @@ export class AuthResolver {
|
||||
private readonly eventLogEmitterService: EventLogEmitterService,
|
||||
private readonly impersonationAuthorizationService: ImpersonationAuthorizationService,
|
||||
private readonly subdomainManagerService: SubdomainManagerService,
|
||||
private readonly fileCorePictureService: FileCorePictureService,
|
||||
) {}
|
||||
|
||||
@UseGuards(CaptchaGuard, PublicEndpointGuard, NoPermissionGuard)
|
||||
@@ -557,6 +566,34 @@ export class AuthResolver {
|
||||
};
|
||||
}
|
||||
|
||||
@Mutation(() => FileWithSignedUrlDTO)
|
||||
@UseGuards(UserAuthGuard, NoPermissionGuard)
|
||||
async uploadNewWorkspaceLogo(
|
||||
@AuthUser() currentUser: AuthContextUser,
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args({ name: 'file', type: () => GraphQLUpload })
|
||||
{ createReadStream, filename }: FileUpload,
|
||||
): Promise<FileWithSignedUrlDTO> {
|
||||
const workspace =
|
||||
await this.fileCorePictureService.getPendingWorkspaceForLogoUploadOrThrow(
|
||||
{
|
||||
userId: currentUser.id,
|
||||
workspaceId,
|
||||
},
|
||||
);
|
||||
|
||||
const buffer = await streamToBuffer(
|
||||
createReadStream(),
|
||||
bytes(settings.storage.maxFileSize) ?? undefined,
|
||||
);
|
||||
|
||||
return this.fileCorePictureService.uploadWorkspacePicture({
|
||||
file: buffer,
|
||||
filename,
|
||||
workspace,
|
||||
});
|
||||
}
|
||||
|
||||
@Mutation(() => TransientTokenDTO)
|
||||
@UseGuards(UserAuthGuard, NoPermissionGuard)
|
||||
async generateTransientToken(
|
||||
|
||||
@@ -951,7 +951,6 @@ export class AuthService {
|
||||
workspaceInviteHash,
|
||||
workspaceId,
|
||||
billingCheckoutSessionState,
|
||||
action,
|
||||
locale,
|
||||
returnToPath,
|
||||
}: MicrosoftRequest['user'] | GoogleRequest['user'],
|
||||
@@ -964,11 +963,7 @@ export class AuthService {
|
||||
|
||||
// Route SSO sign-ins through the same create-or-select flow as credentials
|
||||
// instead of landing straight on a workspace subdomain.
|
||||
if (
|
||||
!workspaceId &&
|
||||
!workspaceInviteHash &&
|
||||
action === 'list-available-workspaces'
|
||||
) {
|
||||
if (!workspaceId && !workspaceInviteHash) {
|
||||
const user =
|
||||
existingUser ??
|
||||
(await this.signInUpService.signUpWithoutWorkspace(
|
||||
@@ -1010,15 +1005,12 @@ export class AuthService {
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
const currentWorkspace =
|
||||
action === 'create-new-workspace'
|
||||
? undefined
|
||||
: await this.findWorkspaceForSignInUp({
|
||||
workspaceId,
|
||||
workspaceInviteHash,
|
||||
email,
|
||||
authProvider,
|
||||
});
|
||||
const currentWorkspace = await this.findWorkspaceForSignInUp({
|
||||
workspaceId,
|
||||
workspaceInviteHash,
|
||||
email,
|
||||
authProvider,
|
||||
});
|
||||
|
||||
try {
|
||||
const invitation =
|
||||
|
||||
@@ -516,6 +516,18 @@ export class SignInUpService {
|
||||
|
||||
await this.assertWorkspaceCreationAllowed(userData);
|
||||
|
||||
const displayName = options?.displayName?.trim();
|
||||
|
||||
if (!displayName) {
|
||||
throw new AuthException(
|
||||
'Workspace name is required',
|
||||
AuthExceptionCode.INVALID_INPUT,
|
||||
{
|
||||
userFriendlyMessage: msg`Workspace name is required`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const requestedSubdomain = options?.subdomain;
|
||||
|
||||
if (isDefined(requestedSubdomain)) {
|
||||
@@ -544,7 +556,7 @@ export class SignInUpService {
|
||||
isWorkEmailFound ? { userEmail: email } : {},
|
||||
),
|
||||
workspaceCustomApplicationId,
|
||||
displayName: options?.displayName ?? '',
|
||||
displayName,
|
||||
inviteHash: v4(),
|
||||
activationStatus: WorkspaceActivationStatus.PENDING_CREATION,
|
||||
});
|
||||
|
||||
+6
-1
@@ -8,13 +8,18 @@ import { FileCorePictureService } from 'src/engine/core-modules/file/file-core-p
|
||||
import { FileUrlModule } from 'src/engine/core-modules/file/file-url/file-url.module';
|
||||
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
|
||||
import { SecureHttpClientModule } from 'src/engine/core-modules/secure-http-client/secure-http-client.module';
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
|
||||
import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/provide-workspace-scoped-repository';
|
||||
@Module({
|
||||
imports: [
|
||||
JwtModule,
|
||||
TypeOrmModule.forFeature([FileEntity, WorkspaceEntity]),
|
||||
TypeOrmModule.forFeature([
|
||||
FileEntity,
|
||||
WorkspaceEntity,
|
||||
UserWorkspaceEntity,
|
||||
]),
|
||||
PermissionsModule,
|
||||
FileStorageModule,
|
||||
FileUrlModule,
|
||||
|
||||
+10
-2
@@ -1,12 +1,14 @@
|
||||
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
|
||||
import { Args, Mutation } from '@nestjs/graphql';
|
||||
|
||||
import bytes from 'bytes';
|
||||
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
|
||||
import { PermissionFlagType } from 'twenty-shared/constants';
|
||||
|
||||
import type { FileUpload } from 'graphql-upload/processRequest.mjs';
|
||||
|
||||
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
|
||||
import { settings } from 'src/engine/constants/settings';
|
||||
import { FileWithSignedUrlDTO } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto';
|
||||
import { FileCorePictureService } from 'src/engine/core-modules/file/file-core-picture/services/file-core-picture.service';
|
||||
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
|
||||
@@ -41,7 +43,10 @@ export class FileCorePictureResolver {
|
||||
@Args({ name: 'file', type: () => GraphQLUpload })
|
||||
{ createReadStream, filename }: FileUpload,
|
||||
): Promise<FileWithSignedUrlDTO> {
|
||||
const buffer = await streamToBuffer(createReadStream());
|
||||
const buffer = await streamToBuffer(
|
||||
createReadStream(),
|
||||
bytes(settings.storage.maxFileSize) ?? undefined,
|
||||
);
|
||||
|
||||
return await this.fileCorePictureService.uploadWorkspacePicture({
|
||||
file: buffer,
|
||||
@@ -57,7 +62,10 @@ export class FileCorePictureResolver {
|
||||
@Args({ name: 'file', type: () => GraphQLUpload })
|
||||
{ createReadStream, filename }: FileUpload,
|
||||
): Promise<FileWithSignedUrlDTO> {
|
||||
const buffer = await streamToBuffer(createReadStream());
|
||||
const buffer = await streamToBuffer(
|
||||
createReadStream(),
|
||||
bytes(settings.storage.maxFileSize) ?? undefined,
|
||||
);
|
||||
|
||||
return await this.fileCorePictureService.uploadWorkspaceMemberProfilePicture(
|
||||
{
|
||||
|
||||
+37
@@ -8,6 +8,7 @@ import { FileTypeParser } from 'file-type';
|
||||
import { detectPdf } from '@file-type/pdf';
|
||||
import { FileFolder } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
|
||||
import { Like, type QueryRunner, Repository } from 'typeorm';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
@@ -15,6 +16,10 @@ import {
|
||||
ApplicationException,
|
||||
ApplicationExceptionCode,
|
||||
} from 'src/engine/core-modules/application/application.exception';
|
||||
import {
|
||||
AuthException,
|
||||
AuthExceptionCode,
|
||||
} from 'src/engine/core-modules/auth/auth.exception';
|
||||
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
|
||||
import { FileWithSignedUrlDTO } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto';
|
||||
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
|
||||
@@ -22,6 +27,7 @@ import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.s
|
||||
import { extractFileInfoOrThrow } from 'src/engine/core-modules/file/utils/extract-file-info-or-throw.utils';
|
||||
import { removeFileFolderFromFileEntityPath } from 'src/engine/core-modules/file/utils/remove-file-folder-from-file-entity-path.utils';
|
||||
import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service';
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator';
|
||||
import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository';
|
||||
@@ -35,6 +41,8 @@ export class FileCorePictureService {
|
||||
private readonly fileStorageService: FileStorageService,
|
||||
@InjectRepository(WorkspaceEntity)
|
||||
private readonly workspaceRepository: Repository<WorkspaceEntity>,
|
||||
@InjectRepository(UserWorkspaceEntity)
|
||||
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
|
||||
@InjectWorkspaceScopedRepository(FileEntity)
|
||||
private readonly fileRepository: WorkspaceScopedRepository<FileEntity>,
|
||||
private readonly fileUrlService: FileUrlService,
|
||||
@@ -137,6 +145,35 @@ export class FileCorePictureService {
|
||||
};
|
||||
}
|
||||
|
||||
async getPendingWorkspaceForLogoUploadOrThrow({
|
||||
userId,
|
||||
workspaceId,
|
||||
}: {
|
||||
userId: string;
|
||||
workspaceId: string;
|
||||
}): Promise<WorkspaceEntity> {
|
||||
const workspace = await this.workspaceRepository.findOne({
|
||||
where: { id: workspaceId },
|
||||
});
|
||||
|
||||
const userWorkspace = await this.userWorkspaceRepository.findOne({
|
||||
where: { userId, workspaceId },
|
||||
});
|
||||
|
||||
if (
|
||||
!isDefined(workspace) ||
|
||||
!isDefined(userWorkspace) ||
|
||||
workspace.activationStatus !== WorkspaceActivationStatus.PENDING_CREATION
|
||||
) {
|
||||
throw new AuthException(
|
||||
'Cannot set a logo for this workspace',
|
||||
AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
);
|
||||
}
|
||||
|
||||
return workspace;
|
||||
}
|
||||
|
||||
async uploadWorkspaceMemberProfilePicture({
|
||||
file,
|
||||
filename,
|
||||
|
||||
@@ -39,7 +39,9 @@ export class OnboardingService {
|
||||
|
||||
private isWorkspaceActivationPending(workspace: WorkspaceEntity) {
|
||||
return (
|
||||
workspace.activationStatus === WorkspaceActivationStatus.PENDING_CREATION
|
||||
workspace.activationStatus ===
|
||||
WorkspaceActivationStatus.PENDING_CREATION ||
|
||||
workspace.activationStatus === WorkspaceActivationStatus.ONGOING_CREATION
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+8
@@ -166,6 +166,14 @@ export class UpgradeMigrationService {
|
||||
? queryRunner.manager.getRepository(UpgradeMigrationEntity)
|
||||
: this.upgradeMigrationRepository;
|
||||
|
||||
const existingInitialMigration = await repository.findOne({
|
||||
where: { name, attempt: 1, workspaceId, isInitial: true },
|
||||
});
|
||||
|
||||
if (isDefined(existingInitialMigration)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await repository.save({
|
||||
name,
|
||||
status,
|
||||
|
||||
+29
-1
@@ -275,7 +275,10 @@ describe('UserWorkspaceService', () => {
|
||||
];
|
||||
const workspaceMemberRepository = {
|
||||
insert: jest.fn(),
|
||||
find: jest.fn().mockResolvedValue(workspaceMember),
|
||||
find: jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValue(workspaceMember),
|
||||
};
|
||||
|
||||
jest
|
||||
@@ -304,6 +307,31 @@ describe('UserWorkspaceService', () => {
|
||||
avatarUrl: 'userWorkspace-avatar-url',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not create a workspace member when one already exists', async () => {
|
||||
const workspaceId = 'workspace-id';
|
||||
const user = {
|
||||
id: 'user-id',
|
||||
email: 'test@example.com',
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
locale: 'en',
|
||||
} as unknown as AuthContextUser;
|
||||
const workspaceMemberRepository = {
|
||||
insert: jest.fn(),
|
||||
find: jest
|
||||
.fn()
|
||||
.mockResolvedValue([{ id: 'existing-member-id', userId: 'user-id' }]),
|
||||
};
|
||||
|
||||
jest
|
||||
.spyOn(globalWorkspaceOrmManager, 'getRepository')
|
||||
.mockResolvedValue(workspaceMemberRepository as any);
|
||||
|
||||
await service.createWorkspaceMember(workspaceId, user);
|
||||
|
||||
expect(workspaceMemberRepository.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('addUserToWorkspaceIfUserNotInWorkspace', () => {
|
||||
|
||||
+8
@@ -149,6 +149,14 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspaceEntit
|
||||
{ shouldBypassPermissionChecks: true },
|
||||
);
|
||||
|
||||
const existingWorkspaceMembers = await workspaceMemberRepository.find({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
|
||||
if (existingWorkspaceMembers.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userWorkspace = await this.userWorkspaceRepository.findOneOrFail({
|
||||
where: {
|
||||
userId: user.id,
|
||||
|
||||
+7
-1
@@ -4,7 +4,13 @@ import { IsOptional, IsString } from 'class-validator';
|
||||
|
||||
@InputType()
|
||||
export class ActivateWorkspaceInput {
|
||||
@Field({ nullable: true })
|
||||
// The workspace name is set at creation (signUpInNewWorkspace). This field is
|
||||
// ignored during activation and kept only for backward compatibility.
|
||||
@Field({
|
||||
nullable: true,
|
||||
description:
|
||||
'Deprecated: the workspace name is set at creation (signUpInNewWorkspace) and this field is ignored during activation. Kept for backward compatibility.',
|
||||
})
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
displayName?: string;
|
||||
|
||||
+78
-43
@@ -1,4 +1,4 @@
|
||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import assert from 'assert';
|
||||
@@ -8,7 +8,7 @@ import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
|
||||
import { PermissionFlagType } from 'twenty-shared/constants';
|
||||
import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
|
||||
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
|
||||
import { DataSource, QueryRunner, Repository } from 'typeorm';
|
||||
import { DataSource, LessThan, QueryRunner, Repository } from 'typeorm';
|
||||
|
||||
import { CoreEntityCacheService } from 'src/engine/core-entity-cache/services/core-entity-cache.service';
|
||||
import { ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
|
||||
@@ -42,7 +42,6 @@ import { UpgradeSequenceReaderService } from 'src/engine/core-modules/upgrade/se
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
|
||||
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
|
||||
import { type ActivateWorkspaceInput } from 'src/engine/core-modules/workspace/dtos/activate-workspace-input';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import {
|
||||
WorkspaceException,
|
||||
@@ -76,6 +75,12 @@ import { WorkspaceManagerService } from 'src/engine/workspace-manager/workspace-
|
||||
import { DEFAULT_FEATURE_FLAGS } from 'src/engine/workspace-manager/workspace-migration/constant/default-feature-flags';
|
||||
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
|
||||
|
||||
// A workspace stuck in ONGOING_CREATION for longer than this is treated as a
|
||||
// crashed activation (the process died before the catch block could reset it to
|
||||
// PENDING_CREATION) and may be retried. It is far longer than a real activation
|
||||
// takes, so a genuinely in-progress activation is never reclaimed.
|
||||
const WORKSPACE_ACTIVATION_STALE_LOCK_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
|
||||
@Injectable()
|
||||
// oxlint-disable-next-line twenty/inject-workspace-repository
|
||||
export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
|
||||
@@ -324,57 +329,90 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
|
||||
return updatedWorkspace;
|
||||
}
|
||||
|
||||
async activateWorkspace(
|
||||
user: AuthContextUser,
|
||||
workspace: WorkspaceEntity,
|
||||
data: ActivateWorkspaceInput,
|
||||
) {
|
||||
if (!data.displayName || !data.displayName.length) {
|
||||
throw new BadRequestException("'displayName' not provided");
|
||||
async activateWorkspace(user: AuthContextUser, workspace: WorkspaceEntity) {
|
||||
// Acquire the activation lock by atomically moving the workspace to
|
||||
// ONGOING_CREATION. First try the normal case (PENDING_CREATION). If nothing
|
||||
// matches, the workspace may be stuck in ONGOING_CREATION from a prior
|
||||
// attempt that was killed before the catch block could reset it — reclaim it,
|
||||
// but only once the lock is stale, so a genuinely concurrent activation is
|
||||
// never interrupted. Postgres row locking serializes concurrent reclaims, and
|
||||
// repository.update bumps updatedAt, so a reclaimed lock is immediately fresh.
|
||||
let activationLockResult = await this.workspaceRepository.update(
|
||||
{
|
||||
id: workspace.id,
|
||||
activationStatus: WorkspaceActivationStatus.PENDING_CREATION,
|
||||
},
|
||||
{ activationStatus: WorkspaceActivationStatus.ONGOING_CREATION },
|
||||
);
|
||||
|
||||
if ((activationLockResult.affected ?? 0) === 0) {
|
||||
activationLockResult = await this.workspaceRepository.update(
|
||||
{
|
||||
id: workspace.id,
|
||||
activationStatus: WorkspaceActivationStatus.ONGOING_CREATION,
|
||||
updatedAt: LessThan(
|
||||
new Date(Date.now() - WORKSPACE_ACTIVATION_STALE_LOCK_TIMEOUT_MS),
|
||||
),
|
||||
},
|
||||
{ activationStatus: WorkspaceActivationStatus.ONGOING_CREATION },
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
workspace.activationStatus === WorkspaceActivationStatus.ONGOING_CREATION
|
||||
) {
|
||||
if ((activationLockResult.affected ?? 0) === 0) {
|
||||
// Activation is idempotent for the terminal state: if a prior attempt
|
||||
// already completed (e.g. the client lost the response and retried),
|
||||
// return the active workspace instead of failing. Otherwise another
|
||||
// activation is genuinely in progress and must not be interrupted.
|
||||
const existingWorkspace = await this.workspaceRepository.findOneBy({
|
||||
id: workspace.id,
|
||||
});
|
||||
|
||||
if (
|
||||
existingWorkspace?.activationStatus === WorkspaceActivationStatus.ACTIVE
|
||||
) {
|
||||
return existingWorkspace;
|
||||
}
|
||||
|
||||
throw new Error('Workspace is already being created');
|
||||
}
|
||||
|
||||
if (
|
||||
workspace.activationStatus !== WorkspaceActivationStatus.PENDING_CREATION
|
||||
) {
|
||||
throw new Error('Workspace is not pending creation');
|
||||
}
|
||||
|
||||
await this.workspaceRepository.update(workspace.id, {
|
||||
activationStatus: WorkspaceActivationStatus.ONGOING_CREATION,
|
||||
});
|
||||
|
||||
await this.coreEntityCacheService.invalidate(
|
||||
'workspaceEntity',
|
||||
workspace.id,
|
||||
);
|
||||
|
||||
await this.workspaceManagerService.init({
|
||||
workspace,
|
||||
userId: user.id,
|
||||
});
|
||||
try {
|
||||
await this.workspaceManagerService.init({
|
||||
workspace,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
await this.featureFlagService.enableFeatureFlags(
|
||||
DEFAULT_FEATURE_FLAGS,
|
||||
workspace.id,
|
||||
);
|
||||
await this.featureFlagService.enableFeatureFlags(
|
||||
DEFAULT_FEATURE_FLAGS,
|
||||
workspace.id,
|
||||
);
|
||||
|
||||
await this.userWorkspaceService.createWorkspaceMember(workspace.id, user);
|
||||
await this.userWorkspaceService.createWorkspaceMember(workspace.id, user);
|
||||
|
||||
await this.prefillCreatedWorkspaceRecords({
|
||||
workspaceId: workspace.id,
|
||||
schemaName: getWorkspaceSchemaName(workspace.id),
|
||||
});
|
||||
await this.prefillCreatedWorkspaceRecords({
|
||||
workspaceId: workspace.id,
|
||||
schemaName: getWorkspaceSchemaName(workspace.id),
|
||||
});
|
||||
|
||||
await this.activateAndInitializeUpgradeState({
|
||||
workspaceId: workspace.id,
|
||||
displayName: data.displayName,
|
||||
});
|
||||
await this.activateAndInitializeUpgradeState({
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
} catch (error) {
|
||||
await this.workspaceRepository.update(workspace.id, {
|
||||
activationStatus: WorkspaceActivationStatus.PENDING_CREATION,
|
||||
});
|
||||
await this.coreEntityCacheService.invalidate(
|
||||
'workspaceEntity',
|
||||
workspace.id,
|
||||
);
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.sdkClientGenerationService.enqueueSdkClientGenerationForWorkspace(
|
||||
@@ -399,11 +437,9 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
|
||||
}
|
||||
|
||||
private async activateAndInitializeUpgradeState({
|
||||
displayName,
|
||||
workspaceId,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
displayName: string;
|
||||
}): Promise<void> {
|
||||
const lastAttemptedInstanceCommand =
|
||||
await this.upgradeMigrationService.getLastAttemptedInstanceCommandOrThrow();
|
||||
@@ -423,7 +459,6 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
|
||||
|
||||
try {
|
||||
await queryRunner.manager.update(WorkspaceEntity, workspaceId, {
|
||||
displayName,
|
||||
activationStatus: WorkspaceActivationStatus.ACTIVE,
|
||||
});
|
||||
|
||||
|
||||
@@ -115,11 +115,14 @@ export class WorkspaceResolver {
|
||||
@Mutation(() => WorkspaceEntity)
|
||||
@UseGuards(UserAuthGuard, WorkspaceAuthGuard, NoPermissionGuard)
|
||||
async activateWorkspace(
|
||||
@Args('data') data: ActivateWorkspaceInput,
|
||||
// Deprecated: the workspace name is set at creation. This argument is kept
|
||||
// for backward compatibility (removing it would be a breaking schema change)
|
||||
// but is ignored.
|
||||
@Args('data') _data: ActivateWorkspaceInput,
|
||||
@AuthUser() user: AuthContextUser,
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
) {
|
||||
return await this.workspaceService.activateWorkspace(user, workspace, data);
|
||||
return await this.workspaceService.activateWorkspace(user, workspace);
|
||||
}
|
||||
|
||||
@Mutation(() => WorkspaceEntity)
|
||||
|
||||
Reference in New Issue
Block a user