4586 fix workspace member feature (#4680)

* Fix import

* Handle delete workspace member consequences

* Add a patch to request deleted workspace member's userId

* Remove useless relations

* Handle delete workspace + refactor

* Add missing migration

* Fix test

* Code review returns

* Add missing operation in migration file

* Fix code review return update

* Fix workspaceMember<>ConnectedAccount relation
This commit is contained in:
martmull 2024-03-28 17:59:48 +01:00 committed by GitHub
parent 00eee3158e
commit 27fdb00d07
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 298 additions and 127 deletions

View File

@ -0,0 +1,102 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddMissingMigration1711557405330 implements MigrationInterface {
name = 'AddMissingMigration1711557405330';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."userWorkspace" DROP CONSTRAINT "FK_37fdc7357af701e595c5c3a9bd6"`,
);
await queryRunner.query(
`ALTER TABLE "core"."userWorkspace" DROP CONSTRAINT "FK_cb488f32c6a0827b938edadf221"`,
);
await queryRunner.query(
`ALTER TABLE "core"."userWorkspace" ALTER COLUMN "createdAt" TYPE TIMESTAMP WITH TIME ZONE USING "createdAt"::TIMESTAMP WITH TIME ZONE`,
);
await queryRunner.query(
`ALTER TABLE "core"."userWorkspace" ALTER COLUMN "createdAt" SET DEFAULT now()`,
);
await queryRunner.query(
`ALTER TABLE "core"."userWorkspace" ALTER COLUMN "createdAt" SET NOT NULL;`,
);
await queryRunner.query(
`ALTER TABLE "core"."userWorkspace" ALTER COLUMN "updatedAt" TYPE TIMESTAMP WITH TIME ZONE USING "updatedAt"::TIMESTAMP WITH TIME ZONE`,
);
await queryRunner.query(
`ALTER TABLE "core"."userWorkspace" ALTER COLUMN "updatedAt" SET DEFAULT now()`,
);
await queryRunner.query(
`ALTER TABLE "core"."userWorkspace" ALTER COLUMN "updatedAt" SET NOT NULL;`,
);
await queryRunner.query(
`ALTER TABLE "core"."userWorkspace" ALTER COLUMN "deletedAt" TYPE TIMESTAMP WITH TIME ZONE USING "deletedAt"::TIMESTAMP WITH TIME ZONE`,
);
await queryRunner.query(
`ALTER TABLE "core"."user" DROP CONSTRAINT "FK_2ec910029395fa7655621c88908"`,
);
await queryRunner.query(
`ALTER TABLE "core"."user" ALTER COLUMN "defaultWorkspaceId" SET NOT NULL`,
);
await queryRunner.query(
`ALTER TABLE "core"."userWorkspace" ADD CONSTRAINT "IndexOnUserIdAndWorkspaceIdUnique" UNIQUE ("userId", "workspaceId")`,
);
await queryRunner.query(
`ALTER TABLE "core"."userWorkspace" ADD CONSTRAINT "FK_a2da2ea7d6cd1e5a4c5cb1791f8" FOREIGN KEY ("userId") REFERENCES "core"."user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "core"."userWorkspace" ADD CONSTRAINT "FK_22f5e76f493c3fb20237cfc48b0" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "core"."user" ADD CONSTRAINT "FK_2ec910029395fa7655621c88908" FOREIGN KEY ("defaultWorkspaceId") REFERENCES "core"."workspace"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."user" DROP CONSTRAINT "FK_2ec910029395fa7655621c88908"`,
);
await queryRunner.query(
`ALTER TABLE "core"."userWorkspace" DROP CONSTRAINT "FK_22f5e76f493c3fb20237cfc48b0"`,
);
await queryRunner.query(
`ALTER TABLE "core"."userWorkspace" DROP CONSTRAINT "FK_a2da2ea7d6cd1e5a4c5cb1791f8"`,
);
await queryRunner.query(
`ALTER TABLE "core"."userWorkspace" DROP CONSTRAINT "IndexOnUserIdAndWorkspaceIdUnique"`,
);
await queryRunner.query(
`ALTER TABLE "core"."user" ALTER COLUMN "defaultWorkspaceId" DROP NOT NULL`,
);
await queryRunner.query(
`ALTER TABLE "core"."user" ADD CONSTRAINT "FK_2ec910029395fa7655621c88908" FOREIGN KEY ("defaultWorkspaceId") REFERENCES "core"."workspace"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "core"."userWorkspace" DROP COLUMN "deletedAt"`,
);
await queryRunner.query(
`ALTER TABLE "core"."userWorkspace" ADD "deletedAt" TIMESTAMP`,
);
await queryRunner.query(
`ALTER TABLE "core"."userWorkspace" DROP COLUMN "updatedAt"`,
);
await queryRunner.query(
`ALTER TABLE "core"."userWorkspace" ADD "updatedAt" TIMESTAMP NOT NULL DEFAULT now()`,
);
await queryRunner.query(
`ALTER TABLE "core"."userWorkspace" DROP COLUMN "createdAt"`,
);
await queryRunner.query(
`ALTER TABLE "core"."userWorkspace" ADD "createdAt" TIMESTAMP NOT NULL DEFAULT now()`,
);
await queryRunner.query(
`ALTER TABLE "core"."userWorkspace" ADD CONSTRAINT "FK_cb488f32c6a0827b938edadf221" FOREIGN KEY ("userId") REFERENCES "core"."user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "core"."userWorkspace" ADD CONSTRAINT "FK_37fdc7357af701e595c5c3a9bd6" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
}

View File

@ -19,9 +19,21 @@ export class UpdateRefreshTokenTable1711624086253
await queryRunner.query(
`ALTER TABLE "core"."appToken" ADD CONSTRAINT "FK_d6ae19a7aa2bbd4919053257772" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "core"."appToken" DROP CONSTRAINT "FK_7008a2b0fb083127f60b5f4448e"`,
);
await queryRunner.query(
`ALTER TABLE "core"."appToken" ADD CONSTRAINT "FK_8cd4819144baf069777b5729136" FOREIGN KEY ("userId") REFERENCES "core"."user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."appToken" ADD CONSTRAINT "FK_7008a2b0fb083127f60b5f4448e" FOREIGN KEY ("userId") REFERENCES "core"."user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "core"."appToken" DROP CONSTRAINT "FK_8cd4819144baf069777b5729136"`,
);
await queryRunner.query(
`ALTER TABLE "core"."appToken" DROP CONSTRAINT "FK_d6ae19a7aa2bbd4919053257772"`,
);

View File

@ -407,6 +407,15 @@ export class WorkspaceQueryRunnerService {
args,
options,
);
// TODO START: remove this awful patch and use our upcoming custom ORM is developed
const deletedWorkspaceMember = await this.handleDeleteWorkspaceMember(
args.id,
workspaceId,
objectMetadataItem,
);
// TODO END
const result = await this.execute(query, workspaceId);
const parsedResults = (
@ -429,7 +438,10 @@ export class WorkspaceQueryRunnerService {
recordId: args.id,
objectMetadata: objectMetadataItem,
details: {
before: this.removeNestedProperties(parsedResults?.[0]),
before: {
...(deletedWorkspaceMember ?? {}),
...this.removeNestedProperties(parsedResults?.[0]),
},
},
} satisfies ObjectRecordDeleteEvent<any>);
@ -555,4 +567,33 @@ export class WorkspaceQueryRunnerService {
);
});
}
async handleDeleteWorkspaceMember(
id: string,
workspaceId: string,
objectMetadataItem: ObjectMetadataInterface,
) {
if (objectMetadataItem.nameSingular !== 'workspaceMember') {
return;
}
const workspaceMemberResult = await this.executeAndParse<IRecord>(
`
query {
workspaceMemberCollection(filter: {id: {eq: "${id}"}}) {
edges {
node {
userId: userId
}
}
}
}
`,
objectMetadataItem,
'',
workspaceId,
);
return workspaceMemberResult.edges?.[0]?.node;
}
}

View File

@ -113,4 +113,46 @@ export class UserService extends TypeOrmQueryService<User> {
return user;
}
async handleRemoveWorkspaceMember(workspaceId: string, userId: string) {
await this.userWorkspaceRepository.delete({
userId,
workspaceId,
});
await this.reassignOrRemoveUserDefaultWorkspace(workspaceId, userId);
}
private async reassignOrRemoveUserDefaultWorkspace(
workspaceId: string,
userId: string,
) {
const userWorkspaces = await this.userWorkspaceRepository.find({
where: { userId: userId },
});
if (userWorkspaces.length === 0) {
await this.userRepository.delete({ id: userId });
return;
}
const user = await this.userRepository.findOne({
where: {
id: userId,
},
});
if (!user) {
throw new Error(`User ${userId} not found in workspace ${workspaceId}`);
}
if (user.defaultWorkspaceId === workspaceId) {
await this.userRepository.update(
{ id: userId },
{
defaultWorkspaceId: userWorkspaces[0].workspaceId,
},
);
}
}
}

View File

@ -0,0 +1,22 @@
import { Injectable } from '@nestjs/common';
import { MessageQueueJob } from 'src/engine/integrations/message-queue/interfaces/message-queue-job.interface';
import { UserService } from 'src/engine/core-modules/user/services/user.service';
export type HandleWorkspaceMemberDeletedJobData = {
workspaceId: string;
userId: string;
};
@Injectable()
export class HandleWorkspaceMemberDeletedJob
implements MessageQueueJob<HandleWorkspaceMemberDeletedJobData>
{
constructor(private readonly userService: UserService) {}
async handle(data: HandleWorkspaceMemberDeletedJobData): Promise<void> {
const { workspaceId, userId } = data;
await this.userService.handleRemoveWorkspaceMember(workspaceId, userId);
}
}

View File

@ -7,6 +7,7 @@ import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-works
import { User } from 'src/engine/core-modules/user/user.entity';
import { BillingService } from 'src/engine/core-modules/billing/billing.service';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { UserService } from 'src/engine/core-modules/user/services/user.service';
import { WorkspaceService } from './workspace.service';
@ -37,6 +38,10 @@ describe('WorkspaceService', () => {
provide: UserWorkspaceService,
useValue: {},
},
{
provide: UserService,
useValue: {},
},
{
provide: BillingService,
useValue: {},

View File

@ -13,6 +13,7 @@ import { ActivateWorkspaceInput } from 'src/engine/core-modules/workspace/dtos/a
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { BillingService } from 'src/engine/core-modules/billing/billing.service';
import { UserService } from 'src/engine/core-modules/user/services/user.service';
export class WorkspaceService extends TypeOrmQueryService<Workspace> {
constructor(
@ -20,11 +21,10 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
private readonly workspaceRepository: Repository<Workspace>,
@InjectRepository(UserWorkspace, 'core')
private readonly userWorkspaceRepository: Repository<UserWorkspace>,
@InjectRepository(User, 'core')
private readonly userRepository: Repository<User>,
private readonly workspaceManagerService: WorkspaceManagerService,
private readonly userWorkspaceService: UserWorkspaceService,
private readonly billingService: BillingService,
private readonly userService: UserService,
) {
super(workspaceRepository);
}
@ -49,7 +49,7 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
return await this.workspaceManagerService.doesDataSourceExist(id);
}
async deleteWorkspace(id: string, shouldDeleteCoreWorkspace = true) {
async solfDeleteWorkspace(id: string) {
const workspace = await this.workspaceRepository.findOneBy({ id });
assert(workspace, 'Workspace not found');
@ -58,9 +58,24 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
await this.billingService.deleteSubscription(workspace.id);
await this.workspaceManagerService.delete(id);
if (shouldDeleteCoreWorkspace) {
await this.workspaceRepository.delete(id);
return workspace;
}
async deleteWorkspace(id: string) {
const userWorkspaces = await this.userWorkspaceRepository.findBy({
workspaceId: id,
});
const workspace = await this.solfDeleteWorkspace(id);
for (const userWorkspace of userWorkspaces) {
await this.userService.handleRemoveWorkspaceMember(
id,
userWorkspace.userId,
);
}
await this.workspaceRepository.delete(id);
return workspace;
}
@ -70,118 +85,4 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
.find()
.then((workspaces) => workspaces.map((workspace) => workspace.id));
}
private async reassignDefaultWorkspace(
currentWorkspaceId: string,
user: User,
worskpaces: UserWorkspace[],
) {
// We'll filter all user workspaces without the one which its getting removed from
const filteredUserWorkspaces = worskpaces.filter(
(workspace) => workspace.workspaceId !== currentWorkspaceId,
);
// Loop over each workspace in the filteredUserWorkspaces array and check if it currently exists in
// the database
for (let index = 0; index < filteredUserWorkspaces.length; index++) {
const userWorkspace = filteredUserWorkspaces[index];
const nextWorkspace = await this.workspaceRepository.findOneBy({
id: userWorkspace.workspaceId,
});
if (nextWorkspace) {
await this.userRepository.save({
id: user.id,
defaultWorkspace: nextWorkspace,
updatedAt: new Date().toISOString(),
});
break;
}
// if no workspaces are valid then we delete the user
if (index === filteredUserWorkspaces.length - 1) {
await this.userRepository.delete({ id: user.id });
}
}
}
/*
async removeWorkspaceMember(workspaceId: string, memberId: string) {
const dataSourceMetadata =
await this.dataSourceService.getLastDataSourceMetadataFromWorkspaceIdOrFail(
workspaceId,
);
const workspaceDataSource =
await this.typeORMService.connectToDataSource(dataSourceMetadata);
// using "SELECT *" here because we will need the corresponding members userId later
const [workspaceMember] = await workspaceDataSource?.query(
`SELECT * FROM ${dataSourceMetadata.schema}."workspaceMember" WHERE "id" = '${memberId}'`,
);
if (!workspaceMember) {
throw new NotFoundException('Member not found.');
}
await workspaceDataSource?.query(
`DELETE FROM ${dataSourceMetadata.schema}."workspaceMember" WHERE "id" = '${memberId}'`,
);
const workspaceMemberUser = await this.userRepository.findOne({
where: {
id: workspaceMember.userId,
},
relations: ['defaultWorkspace'],
});
if (!workspaceMemberUser) {
throw new NotFoundException('User not found');
}
const userWorkspaces = await this.userWorkspaceRepository.find({
where: { userId: workspaceMemberUser.id },
relations: ['workspace'],
});
// We want to check if we the user has signed up to more than one workspace
if (userWorkspaces.length > 1) {
// We neeed to check if the workspace that its getting removed from is its default workspace, if it is then
// change the default workspace to point to the next workspace available.
if (workspaceMemberUser.defaultWorkspace.id === workspaceId) {
await this.reassignDefaultWorkspace(
workspaceId,
workspaceMemberUser,
userWorkspaces,
);
}
// if its not the default workspace then simply delete the user-workspace mapping
await this.userWorkspaceRepository.delete({
userId: workspaceMemberUser.id,
workspaceId,
});
} else {
await this.userWorkspaceRepository.delete({
userId: workspaceMemberUser.id,
});
// After deleting the user-workspace mapping, we have a condition where we have the users default workspace points to a
// workspace which it doesnt have access to. So we delete the user.
await this.userRepository.delete({ id: workspaceMemberUser.id });
}
const payload =
new ObjectRecordDeleteEvent<WorkspaceMemberObjectMetadata>();
payload.workspaceId = workspaceId;
payload.details = {
before: workspaceMember,
};
this.eventEmitter.emit('workspaceMember.deleted', payload);
return memberId;
}
*/
}

View File

@ -0,0 +1,35 @@
import { Inject, Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { MessageQueue } from 'src/engine/integrations/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/integrations/message-queue/services/message-queue.service';
import { WorkspaceMemberObjectMetadata } from 'src/modules/workspace-member/standard-objects/workspace-member.object-metadata';
import { ObjectRecordDeleteEvent } from 'src/engine/integrations/event-emitter/types/object-record-delete.event';
import {
HandleWorkspaceMemberDeletedJob,
HandleWorkspaceMemberDeletedJobData,
} from 'src/engine/core-modules/workspace/handle-workspace-member-deleted.job';
@Injectable()
export class WorkspaceWorkspaceMemberListener {
constructor(
@Inject(MessageQueue.workspaceQueue)
private readonly messageQueueService: MessageQueueService,
) {}
@OnEvent('workspaceMember.deleted')
async handleDeleteEvent(
payload: ObjectRecordDeleteEvent<WorkspaceMemberObjectMetadata>,
) {
const userId = payload.details.before.userId;
if (!userId) {
return;
}
await this.messageQueueService.add<HandleWorkspaceMemberDeletedJobData>(
HandleWorkspaceMemberDeletedJob.name,
{ workspaceId: payload.workspaceId, userId },
);
}
}

View File

@ -8,11 +8,12 @@ import { WorkspaceResolver } from 'src/engine/core-modules/workspace/workspace.r
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { User } from 'src/engine/core-modules/user/user.entity';
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
import { BillingModule } from 'src/engine/core-modules/billing/billing.module';
import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-upload.module';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { UserModule } from 'src/engine/core-modules/user/user.module';
import { WorkspaceWorkspaceMemberListener } from 'src/engine/core-modules/workspace/workspace-workspace-member.listener';
import { workspaceAutoResolverOpts } from './workspace.auto-resolver-opts';
import { Workspace } from './workspace.entity';
@ -27,19 +28,24 @@ import { WorkspaceService } from './services/workspace.service';
BillingModule,
FileUploadModule,
NestjsQueryTypeOrmModule.forFeature(
[User, Workspace, UserWorkspace, FeatureFlagEntity],
[Workspace, UserWorkspace, FeatureFlagEntity],
'core',
),
UserWorkspaceModule,
WorkspaceManagerModule,
DataSourceModule,
TypeORMModule,
UserModule,
],
services: [WorkspaceService],
resolvers: workspaceAutoResolverOpts,
}),
],
exports: [WorkspaceService],
providers: [WorkspaceResolver, WorkspaceService],
providers: [
WorkspaceResolver,
WorkspaceService,
WorkspaceWorkspaceMemberListener,
],
})
export class WorkspaceModule {}

View File

@ -47,6 +47,7 @@ import { MessageChannelObjectMetadata } from 'src/modules/messaging/standard-obj
import { SaveEventToDbJob } from 'src/engine/api/graphql/workspace-query-runner/jobs/save-event-to-db.job';
import { CreateCompanyAndContactJob } from 'src/modules/connected-account/auto-companies-and-contacts-creation/jobs/create-company-and-contact.job';
import { EventObjectMetadata } from 'src/modules/event/standard-objects/event.object-metadata';
import { HandleWorkspaceMemberDeletedJob } from 'src/engine/core-modules/workspace/handle-workspace-member-deleted.job';
import { GmailFullSynV2Module } from 'src/modules/messaging/services/gmail-full-sync-v2/gmail-full-sync.v2.module';
import { GmailFetchMessageContentFromCacheModule } from 'src/modules/messaging/services/gmail-fetch-message-content-from-cache/gmail-fetch-message-content-from-cache.module';
import { FetchAllMessagesFromCacheCronJob } from 'src/modules/messaging/commands/crons/fetch-all-messages-from-cache.cron-job';
@ -139,6 +140,10 @@ import { GmailPartialSyncV2Module } from 'src/modules/messaging/services/gmail-p
useClass: DeleteConnectedAccountAssociatedCalendarDataJob,
},
{ provide: UpdateSubscriptionJob.name, useClass: UpdateSubscriptionJob },
{
provide: HandleWorkspaceMemberDeletedJob.name,
useClass: HandleWorkspaceMemberDeletedJob,
},
{
provide: RecordPositionBackfillJob.name,
useClass: RecordPositionBackfillJob,

View File

@ -9,6 +9,7 @@ export enum MessageQueue {
calendarQueue = 'calendar-queue',
contactCreationQueue = 'contact-creation-queue',
billingQueue = 'billing-queue',
workspaceQueue = 'workspace-queue',
recordPositionBackfillQueue = 'record-position-backfill-queue',
entityEventsToDbQueue = 'entity-events-to-db-queue',
}

View File

@ -83,10 +83,7 @@ export class DeleteIncompleteWorkspacesCommand extends CommandRunner {
} name: '${incompleteWorkspace.displayName}'`,
);
if (!options.dryRun) {
await this.workspaceService.deleteWorkspace(
incompleteWorkspace.id,
false,
);
await this.workspaceService.solfDeleteWorkspace(incompleteWorkspace.id);
}
}
}

View File

@ -186,6 +186,7 @@ export class WorkspaceMemberObjectMetadata extends BaseObjectMetadata {
type: RelationMetadataType.ONE_TO_MANY,
inverseSideTarget: () => ConnectedAccountObjectMetadata,
inverseSideFieldKey: 'accountOwner',
onDelete: RelationOnDeleteAction.CASCADE,
})
connectedAccounts: ConnectedAccountObjectMetadata[];

View File

@ -25,6 +25,7 @@
"types": ["jest", "node"],
"paths": {
"src/*": ["packages/twenty-server/src/*"],
"test/*": ["packages/twenty-server/test/*"],
"twenty-emails": ["packages/twenty-emails/src/index.ts"]
}
},