Leverage workspace activationStatus to decide if a workspace is activated or not (#6497)

An ACTIVE workspace is a workspace that has a complete workspaceSchema
and is authorized to be browsed by users.

In this PR, I'm:
- introducing a new activationStatus: PENDING_CREATION (existing ACTIVE
/ INACTIVE)
- removing workspaceService.isWorkspaceActivated (based on
workspaceSchema existence which is not robust and checking
activationStatus.ACTIVE instead)
- removing dynamic activationStatus field on worksapce resolver (we can
use the postgres column directly now that data has been migrated)
- on user sign up creating the workspace in PENDING_CREATION, and on
workspace activation setting it to ACTIVE
- only re-activating a workspace if the current activationStatus is
INACTIVE through billing webhooks (a PENDING_CREATION should stay
PENDING and ACTIVE should stay ACTIVE)
This commit is contained in:
Charles Bochet
2024-08-01 17:05:15 +02:00
committed by GitHub
parent 1a90df8961
commit 5c92ab937e
12 changed files with 217 additions and 50 deletions

View File

@ -1054,7 +1054,7 @@ export type ServerlessFunctionExecutionResult = {
status: ServerlessFunctionExecutionStatus;
};
/** Status of the table */
/** Status of the serverless function execution */
export enum ServerlessFunctionExecutionStatus {
Error = 'ERROR',
Success = 'SUCCESS'
@ -1397,7 +1397,8 @@ export type WorkspaceFeatureFlagsArgs = {
export enum WorkspaceActivationStatus {
Active = 'ACTIVE',
Inactive = 'INACTIVE'
Inactive = 'INACTIVE',
PendingCreation = 'PENDING_CREATION'
}
export type WorkspaceEdge = {

View File

@ -162,6 +162,17 @@ export type ClientConfig = {
telemetry: Telemetry;
};
export type CreateServerlessFunctionFromFileInput = {
description?: InputMaybe<Scalars['String']>;
name: Scalars['String'];
};
export type CreateServerlessFunctionInput = {
code: Scalars['String'];
description?: InputMaybe<Scalars['String']>;
name: Scalars['String'];
};
export type CursorPaging = {
/** Paginate after opaque cursor */
after?: InputMaybe<Scalars['ConnectionCursor']>;
@ -178,6 +189,11 @@ export type DeleteOneObjectInput = {
id: Scalars['UUID'];
};
export type DeleteServerlessFunctionInput = {
/** The id of the function. */
id: Scalars['ID'];
};
/** Schema update on a table */
export enum DistantTableUpdate {
ColumnsAdded = 'COLUMNS_ADDED',
@ -310,14 +326,18 @@ export type Mutation = {
checkoutSession: SessionEntity;
createOneAppToken: AppToken;
createOneObject: Object;
createOneServerlessFunction: ServerlessFunction;
createOneServerlessFunctionFromFile: ServerlessFunction;
deleteCurrentWorkspace: Workspace;
deleteOneObject: Object;
deleteOneServerlessFunction: ServerlessFunction;
deleteUser: User;
disablePostgresProxy: PostgresCredentials;
emailPasswordResetLink: EmailPasswordResetLink;
enablePostgresProxy: PostgresCredentials;
enableWorkflowTrigger: Scalars['Boolean'];
exchangeAuthorizationCode: ExchangeAuthCode;
executeOneServerlessFunction: ServerlessFunctionExecutionResult;
generateApiKeyToken: ApiKeyToken;
generateJWT: AuthTokens;
generateTransientToken: TransientToken;
@ -327,8 +347,10 @@ export type Mutation = {
signUp: LoginToken;
skipSyncEmailOnboardingStep: OnboardingStepSuccess;
track: Analytics;
triggerWorkflow: WorkflowTriggerResult;
updateBillingSubscription: UpdateBillingEntity;
updateOneObject: Object;
updateOneServerlessFunction: ServerlessFunction;
updatePasswordViaResetToken: InvalidatePassword;
updateWorkspace: Workspace;
uploadFile: Scalars['String'];
@ -369,11 +391,27 @@ export type MutationCheckoutSessionArgs = {
};
export type MutationCreateOneServerlessFunctionArgs = {
input: CreateServerlessFunctionInput;
};
export type MutationCreateOneServerlessFunctionFromFileArgs = {
file: Scalars['Upload'];
input: CreateServerlessFunctionFromFileInput;
};
export type MutationDeleteOneObjectArgs = {
input: DeleteOneObjectInput;
};
export type MutationDeleteOneServerlessFunctionArgs = {
input: DeleteServerlessFunctionInput;
};
export type MutationEmailPasswordResetLinkArgs = {
email: Scalars['String'];
};
@ -391,6 +429,12 @@ export type MutationExchangeAuthorizationCodeArgs = {
};
export type MutationExecuteOneServerlessFunctionArgs = {
id: Scalars['UUID'];
payload?: InputMaybe<Scalars['JSON']>;
};
export type MutationGenerateApiKeyTokenArgs = {
apiKeyId: Scalars['String'];
expiresAt: Scalars['String'];
@ -431,11 +475,21 @@ export type MutationTrackArgs = {
};
export type MutationTriggerWorkflowArgs = {
workflowVersionId: Scalars['String'];
};
export type MutationUpdateOneObjectArgs = {
input: UpdateOneObjectInput;
};
export type MutationUpdateOneServerlessFunctionArgs = {
input: UpdateServerlessFunctionInput;
};
export type MutationUpdatePasswordViaResetTokenArgs = {
newPassword: Scalars['String'];
passwordResetToken: Scalars['String'];
@ -557,6 +611,8 @@ export type Query = {
getTimelineThreadsFromPersonId: TimelineThreadsWithTotal;
object: Object;
objects: ObjectConnection;
serverlessFunction: ServerlessFunction;
serverlessFunctions: ServerlessFunctionConnection;
validatePasswordResetToken: ValidatePasswordResetToken;
};
@ -731,9 +787,21 @@ export type ServerlessFunctionEdge = {
export type ServerlessFunctionExecutionResult = {
__typename?: 'ServerlessFunctionExecutionResult';
/** Execution result in JSON format */
result: Scalars['JSON'];
data?: Maybe<Scalars['JSON']>;
/** Execution duration in milliseconds */
duration: Scalars['Float'];
/** Execution error in JSON format */
error?: Maybe<Scalars['JSON']>;
/** Execution status */
status: ServerlessFunctionExecutionStatus;
};
/** Status of the serverless function execution */
export enum ServerlessFunctionExecutionStatus {
Error = 'ERROR',
Success = 'SUCCESS'
}
/** SyncStatus of the serverlessFunction */
export enum ServerlessFunctionSyncStatus {
NotReady = 'NOT_READY',
@ -896,6 +964,14 @@ export type UpdateOneObjectInput = {
update: UpdateObjectPayload;
};
export type UpdateServerlessFunctionInput = {
code: Scalars['String'];
description?: InputMaybe<Scalars['String']>;
/** Id of the serverless function to execute */
id: Scalars['UUID'];
name: Scalars['String'];
};
export type UpdateWorkspaceInput = {
allowImpersonation?: InputMaybe<Scalars['Boolean']>;
displayName?: InputMaybe<Scalars['String']>;
@ -969,6 +1045,12 @@ export type Verify = {
user: User;
};
export type WorkflowTriggerResult = {
__typename?: 'WorkflowTriggerResult';
/** Execution result in JSON format */
result?: Maybe<Scalars['JSON']>;
};
export type Workspace = {
__typename?: 'Workspace';
activationStatus: WorkspaceActivationStatus;
@ -1002,7 +1084,8 @@ export type WorkspaceFeatureFlagsArgs = {
export enum WorkspaceActivationStatus {
Active = 'ACTIVE',
Inactive = 'INACTIVE'
Inactive = 'INACTIVE',
PendingCreation = 'PENDING_CREATION'
}
export type WorkspaceEdge = {

View File

@ -128,6 +128,18 @@
"parallel": false
}
},
"database:migrate:revert": {
"executor": "nx:run-commands",
"dependsOn": ["build"],
"options": {
"cwd": "packages/twenty-server",
"commands": [
"nx typeorm -- migration:revert -d src/database/typeorm/metadata/metadata.datasource",
"nx typeorm -- migration:revert -d src/database/typeorm/core/core.datasource"
],
"parallel": false
}
},
"database:reset": {
"executor": "nx:run-commands",
"dependsOn": ["build"],

View File

@ -0,0 +1,67 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class UpdateActivationStatus1722256203540 implements MigrationInterface {
name = 'UpdateActivationStatus1722256203540';
public async up(queryRunner: QueryRunner): Promise<void> {
// Set current column as text
await queryRunner.query(
`ALTER TABLE "core"."workspace" ALTER COLUMN "activationStatus" SET DATA TYPE text USING "activationStatus"::text`,
);
// Drop default value
await queryRunner.query(
`ALTER TABLE "core"."workspace" ALTER COLUMN "activationStatus" DROP DEFAULT`,
);
// Drop the old enum type
await queryRunner.query(
`DROP TYPE "core"."workspace_activationstatus_enum"`,
);
await queryRunner.query(
`CREATE TYPE "core"."workspace_activationStatus_enum" AS ENUM('PENDING_CREATION', 'ACTIVE', 'INACTIVE')`,
);
// Re-apply the enum type
await queryRunner.query(
`ALTER TABLE "core"."workspace" ALTER COLUMN "activationStatus" SET DATA TYPE "core"."workspace_activationStatus_enum" USING "activationStatus"::"core"."workspace_activationStatus_enum"`,
);
// Update default value
await queryRunner.query(
`ALTER TABLE "core"."workspace" ALTER COLUMN "activationStatus" SET DEFAULT 'INACTIVE'`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Set current column as text
await queryRunner.query(
`ALTER TABLE "core"."workspace" ALTER COLUMN "activationStatus" SET DATA TYPE text USING "activationStatus"::text`,
);
// Drop default value
await queryRunner.query(
`ALTER TABLE "core"."workspace" ALTER COLUMN "activationStatus" DROP DEFAULT`,
);
// Drop the old enum type
await queryRunner.query(
`DROP TYPE "core"."workspace_activationStatus_enum"`,
);
await queryRunner.query(
`CREATE TYPE "core"."workspace_activationstatus_enum" AS ENUM('ACTIVE', 'INACTIVE')`,
);
// Re-apply the enum type
await queryRunner.query(
`ALTER TABLE "core"."workspace" ALTER COLUMN "activationStatus" SET DATA TYPE "core"."workspace_activationstatus_enum" USING "activationStatus"::"core"."workspace_activationstatus_enum"`,
);
// Update default value
await queryRunner.query(
`ALTER TABLE "core"."workspace" ALTER COLUMN "activationStatus" SET DEFAULT 'INACTIVE'`,
);
}
}

View File

@ -21,5 +21,13 @@ export class AddRuntimeColumnToServerlessFunction1721309629608
await queryRunner.query(
`ALTER TABLE "metadata"."serverlessFunction" DROP COLUMN "runtime"`,
);
await queryRunner.query(
`ALTER TABLE "metadata"."serverlessFunction" DROP COLUMN "description"`,
);
await queryRunner.query(
`ALTER TABLE "metadata"."serverlessFunction" DROP COLUMN "sourceCodeFullPath"`,
);
}
}

View File

@ -1,30 +1,33 @@
import { HttpService } from '@nestjs/axios';
import {
BadRequestException,
ForbiddenException,
Injectable,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { HttpService } from '@nestjs/axios';
import FileType from 'file-type';
import { Repository } from 'typeorm';
import { v4 } from 'uuid';
import FileType from 'file-type';
import { FileFolder } from 'src/engine/core-modules/file/interfaces/file-folder.interface';
import { assert } from 'src/utils/assert';
import {
PASSWORD_REGEX,
hashPassword,
compareHash,
hashPassword,
} from 'src/engine/core-modules/auth/auth.util';
import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { FileUploadService } from 'src/engine/core-modules/file/file-upload/services/file-upload.service';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
import { getImageBufferFromUrl } from 'src/utils/image';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service';
import {
Workspace,
WorkspaceActivationStatus,
} from 'src/engine/core-modules/workspace/workspace.entity';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
import { assert } from 'src/utils/assert';
import { getImageBufferFromUrl } from 'src/utils/image';
export type SignInUpServiceInput = {
email: string;
@ -144,11 +147,8 @@ export class SignInUpService {
ForbiddenException,
);
const isWorkspaceActivated =
await this.workspaceService.isWorkspaceActivated(workspace.id);
assert(
isWorkspaceActivated,
workspace.activationStatus === WorkspaceActivationStatus.ACTIVE,
'Workspace is not ready to welcome new members',
ForbiddenException,
);
@ -203,6 +203,7 @@ export class SignInUpService {
displayName: '',
domainName: '',
inviteHash: v4(),
activationStatus: WorkspaceActivationStatus.PENDING_CREATION,
});
const workspace = await this.workspaceRepository.save(workspaceToCreate);

View File

@ -284,7 +284,7 @@ export class BillingWorkspaceService {
| Stripe.CustomerSubscriptionCreatedEvent.Data
| Stripe.CustomerSubscriptionDeletedEvent.Data,
) {
const workspace = this.workspaceRepository.find({
const workspace = await this.workspaceRepository.findOne({
where: { id: workspaceId },
});
@ -341,9 +341,10 @@ export class BillingWorkspaceService {
}
if (
data.object.status === SubscriptionStatus.Active ||
data.object.status === SubscriptionStatus.Trialing ||
data.object.status === SubscriptionStatus.PastDue
(data.object.status === SubscriptionStatus.Active ||
data.object.status === SubscriptionStatus.Trialing ||
data.object.status === SubscriptionStatus.PastDue) &&
workspace.activationStatus == WorkspaceActivationStatus.INACTIVE
) {
await this.workspaceRepository.update(workspaceId, {
activationStatus: WorkspaceActivationStatus.ACTIVE,

View File

@ -45,23 +45,20 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
if (!data.displayName || !data.displayName.length) {
throw new BadRequestException("'displayName' not provided");
}
await this.workspaceRepository.update(user.defaultWorkspace.id, {
displayName: data.displayName,
activationStatus: WorkspaceActivationStatus.ACTIVE,
});
await this.workspaceManagerService.init(user.defaultWorkspace.id);
await this.userWorkspaceService.createWorkspaceMember(
user.defaultWorkspace.id,
user,
);
await this.workspaceRepository.update(user.defaultWorkspace.id, {
displayName: data.displayName,
activationStatus: WorkspaceActivationStatus.ACTIVE,
});
return user.defaultWorkspace;
}
async isWorkspaceActivated(id: string): Promise<boolean> {
return await this.workspaceManagerService.doesDataSourceExist(id);
}
async softDeleteWorkspace(id: string) {
const workspace = await this.workspaceRepository.findOneBy({ id });

View File

@ -21,6 +21,7 @@ import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-works
import { User } from 'src/engine/core-modules/user/user.entity';
export enum WorkspaceActivationStatus {
PENDING_CREATION = 'PENDING_CREATION',
ACTIVE = 'ACTIVE',
INACTIVE = 'INACTIVE',
}

View File

@ -29,7 +29,7 @@ import { WorkspaceCacheVersionService } from 'src/engine/metadata-modules/worksp
import { assert } from 'src/utils/assert';
import { streamToBuffer } from 'src/utils/stream-to-buffer';
import { Workspace, WorkspaceActivationStatus } from './workspace.entity';
import { Workspace } from './workspace.entity';
import { WorkspaceService } from './services/workspace.service';
@ -100,21 +100,6 @@ export class WorkspaceResolver {
return this.workspaceService.deleteWorkspace(id);
}
@ResolveField(() => WorkspaceActivationStatus)
async activationStatus(
@Parent() workspace: Workspace,
): Promise<WorkspaceActivationStatus> {
if (workspace.activationStatus) {
return workspace.activationStatus;
}
if (await this.workspaceService.isWorkspaceActivated(workspace.id)) {
return WorkspaceActivationStatus.ACTIVE;
}
return WorkspaceActivationStatus.INACTIVE;
}
@ResolveField(() => String, { nullable: true })
async currentCacheVersion(
@Parent() workspace: Workspace,

View File

@ -1,10 +1,12 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ObjectMetadataRepositoryModule } from 'src/engine/object-metadata-repository/object-metadata-repository.module';
import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module';
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
import { CalendarCreateCompanyAndContactAfterSyncJob } from 'src/modules/calendar/calendar-event-participant-manager/jobs/calendar-create-company-and-contact-after-sync.job';
@ -17,18 +19,17 @@ import { CalendarEventParticipantService } from 'src/modules/calendar/calendar-e
import { CalendarEventParticipantWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-event-participant.workspace-entity';
import { ContactCreationManagerModule } from 'src/modules/contact-creation-manager/contact-creation-manager.module';
import { MatchParticipantModule } from 'src/modules/match-participant/match-participant.module';
import { PersonWorkspaceEntity } from 'src/modules/person/standard-objects/person.workspace-entity';
@Module({
imports: [
WorkspaceDataSourceModule,
WorkspaceModule,
TwentyORMModule.forFeature([CalendarEventParticipantWorkspaceEntity]),
ObjectMetadataRepositoryModule.forFeature([PersonWorkspaceEntity]),
TypeOrmModule.forFeature(
[ObjectMetadataEntity, FieldMetadataEntity],
'metadata',
),
NestjsQueryTypeOrmModule.forFeature([Workspace], 'core'),
ContactCreationManagerModule,
MatchParticipantModule,
],

View File

@ -1,6 +1,9 @@
import { Scope } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service';
import { Repository } from 'typeorm';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { Process } from 'src/engine/integrations/message-queue/decorators/process.decorator';
import { Processor } from 'src/engine/integrations/message-queue/decorators/processor.decorator';
import { MessageQueue } from 'src/engine/integrations/message-queue/message-queue.constants';
@ -20,7 +23,8 @@ export type CalendarEventParticipantMatchParticipantJobData = {
})
export class CalendarEventParticipantMatchParticipantJob {
constructor(
private readonly workspaceService: WorkspaceService,
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
private readonly matchParticipantService: MatchParticipantService<CalendarEventParticipantWorkspaceEntity>,
) {}
@ -30,7 +34,13 @@ export class CalendarEventParticipantMatchParticipantJob {
): Promise<void> {
const { workspaceId, email, personId, workspaceMemberId } = data;
if (!this.workspaceService.isWorkspaceActivated(workspaceId)) {
const workspace = await this.workspaceRepository.findOne({
where: {
id: workspaceId,
},
});
if (workspace?.activationStatus !== 'ACTIVE') {
return;
}