4002 prevent user from creating twice the same blocklist item (#5213)

Closes #4002
This commit is contained in:
bosiraphael
2024-04-30 14:36:33 +02:00
committed by GitHub
parent 3a61c922f1
commit 7c605fc2f9
12 changed files with 297 additions and 53 deletions

View File

@ -21,33 +21,42 @@ const StyledLinkContainer = styled.div`
type SettingsAccountsEmailsBlocklistInputProps = {
updateBlockedEmailList: (email: string) => void;
blockedEmailOrDomainList: string[];
};
const validationSchema = z
.object({
emailOrDomain: z
.string()
.trim()
.email('Invalid email or domain')
.or(
z
.string()
.refine(
(value) => value.startsWith('@') && isDomain(value.slice(1)),
'Invalid email or domain',
),
),
})
.required();
const validationSchema = (blockedEmailOrDomainList: string[]) =>
z
.object({
emailOrDomain: z
.string()
.trim()
.email('Invalid email or domain')
.or(
z
.string()
.refine(
(value) => value.startsWith('@') && isDomain(value.slice(1)),
'Invalid email or domain',
),
)
.refine(
(value) => !blockedEmailOrDomainList.includes(value),
'Email or domain is already in blocklist',
),
})
.required();
type FormInput = z.infer<typeof validationSchema>;
type FormInput = {
emailOrDomain: string;
};
export const SettingsAccountsEmailsBlocklistInput = ({
updateBlockedEmailList,
blockedEmailOrDomainList,
}: SettingsAccountsEmailsBlocklistInputProps) => {
const { reset, handleSubmit, control, formState } = useForm<FormInput>({
mode: 'onSubmit',
resolver: zodResolver(validationSchema),
resolver: zodResolver(validationSchema(blockedEmailOrDomainList)),
defaultValues: {
emailOrDomain: '',
},

View File

@ -45,6 +45,7 @@ export const SettingsAccountsEmailsBlocklistSection = () => {
description="Exclude the following people and domains from my email sync"
/>
<SettingsAccountsEmailsBlocklistInput
blockedEmailOrDomainList={blocklist.map((item) => item.handle)}
updateBlockedEmailList={updateBlockedEmailList}
/>
<SettingsAccountsEmailsBlocklistTable

View File

@ -4,6 +4,8 @@ import { WorkspaceQueryHook } from 'src/engine/api/graphql/workspace-query-runne
import { CalendarEventFindManyPreQueryHook } from 'src/modules/calendar/query-hooks/calendar-event/calendar-event-find-many.pre-query.hook';
import { CalendarEventFindOnePreQueryHook } from 'src/modules/calendar/query-hooks/calendar-event/calendar-event-find-one.pre-query-hook';
import { BlocklistCreateManyPreQueryHook } from 'src/modules/connected-account/query-hooks/blocklist/blocklist-create-many.pre-query.hook';
import { BlocklistUpdateManyPreQueryHook } from 'src/modules/connected-account/query-hooks/blocklist/blocklist-update-many.pre-query.hook';
import { BlocklistUpdateOnePreQueryHook } from 'src/modules/connected-account/query-hooks/blocklist/blocklist-update-one.pre-query.hook';
// TODO: move to a decorator
export const workspacePreQueryHooks: WorkspaceQueryHook = {
@ -17,5 +19,7 @@ export const workspacePreQueryHooks: WorkspaceQueryHook = {
},
blocklist: {
createMany: [BlocklistCreateManyPreQueryHook.name],
updateMany: [BlocklistUpdateManyPreQueryHook.name],
updateOne: [BlocklistUpdateOnePreQueryHook.name],
},
};

View File

@ -328,6 +328,14 @@ export class WorkspaceQueryRunnerService {
options,
);
await this.workspacePreQueryHookService.executePreHooks(
userId,
workspaceId,
objectMetadataItem.nameSingular,
'updateOne',
args,
);
const result = await this.execute(query, workspaceId);
const parsedResults = (
@ -363,7 +371,7 @@ export class WorkspaceQueryRunnerService {
args: UpdateManyResolverArgs<Record>,
options: WorkspaceQueryRunnerOptions,
): Promise<Record[] | undefined> {
const { workspaceId, objectMetadataItem } = options;
const { userId, workspaceId, objectMetadataItem } = options;
assertMutationNotOnRemoteObject(objectMetadataItem);
assertIsValidUuid(args.data.id);
@ -376,6 +384,14 @@ export class WorkspaceQueryRunnerService {
atMost: maximumRecordAffected,
});
await this.workspacePreQueryHookService.executePreHooks(
userId,
workspaceId,
objectMetadataItem.nameSingular,
'updateMany',
args,
);
const result = await this.execute(query, workspaceId);
const parsedResults = (

View File

@ -1,46 +1,28 @@
import { Injectable } from '@nestjs/common';
import z from 'zod';
import { WorkspacePreQueryHook } from 'src/engine/api/graphql/workspace-query-runner/workspace-pre-query-hook/interfaces/workspace-pre-query-hook.interface';
import { CreateManyResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { isDomain } from 'src/engine/utils/is-domain';
import { BlocklistObjectMetadata } from 'src/modules/connected-account/standard-objects/blocklist.object-metadata';
import {
BlocklistItem,
BlocklistValidationService,
} from 'src/modules/connected-account/services/blocklist/blocklist-validation.service';
@Injectable()
export class BlocklistCreateManyPreQueryHook implements WorkspacePreQueryHook {
constructor() {}
constructor(
private readonly blocklistValidationService: BlocklistValidationService,
) {}
async execute(
userId: string,
workspaceId: string,
payload: CreateManyResolverArgs<
Omit<BlocklistObjectMetadata, 'createdAt' | 'updatedAt'> & {
createdAt: string;
updatedAt: string;
}
>,
payload: CreateManyResolverArgs<BlocklistItem>,
): Promise<void> {
const emailOrDomainSchema = z
.string()
.trim()
.email('Invalid email or domain')
.or(
z
.string()
.refine(
(value) => value.startsWith('@') && isDomain(value.slice(1)),
'Invalid email or domain',
),
);
for (const { handle } of payload.data) {
if (!handle) {
throw new Error('Handle is required');
}
emailOrDomainSchema.parse(handle);
}
await this.blocklistValidationService.validateBlocklistForCreateMany(
payload,
userId,
workspaceId,
);
}
}

View File

@ -0,0 +1,12 @@
import { Injectable } from '@nestjs/common';
import { WorkspacePreQueryHook } from 'src/engine/api/graphql/workspace-query-runner/workspace-pre-query-hook/interfaces/workspace-pre-query-hook.interface';
@Injectable()
export class BlocklistUpdateManyPreQueryHook implements WorkspacePreQueryHook {
constructor() {}
async execute(): Promise<void> {
throw new Error('Method not implemented.');
}
}

View File

@ -0,0 +1,28 @@
import { Injectable } from '@nestjs/common';
import { WorkspacePreQueryHook } from 'src/engine/api/graphql/workspace-query-runner/workspace-pre-query-hook/interfaces/workspace-pre-query-hook.interface';
import { UpdateOneResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import {
BlocklistItem,
BlocklistValidationService,
} from 'src/modules/connected-account/services/blocklist/blocklist-validation.service';
@Injectable()
export class BlocklistUpdateOnePreQueryHook implements WorkspacePreQueryHook {
constructor(
private readonly blocklistValidationService: BlocklistValidationService,
) {}
async execute(
userId: string,
workspaceId: string,
payload: UpdateOneResolverArgs<BlocklistItem>,
): Promise<void> {
await this.blocklistValidationService.validateBlocklistForUpdateOne(
payload,
userId,
workspaceId,
);
}
}

View File

@ -1,14 +1,25 @@
import { Module } from '@nestjs/common';
import { BlocklistCreateManyPreQueryHook } from 'src/modules/connected-account/query-hooks/blocklist/blocklist-create-many.pre-query.hook';
import { BlocklistUpdateManyPreQueryHook } from 'src/modules/connected-account/query-hooks/blocklist/blocklist-update-many.pre-query.hook';
import { BlocklistUpdateOnePreQueryHook } from 'src/modules/connected-account/query-hooks/blocklist/blocklist-update-one.pre-query.hook';
import { BlocklistValidationModule } from 'src/modules/connected-account/services/blocklist/blocklist-validation.module';
@Module({
imports: [],
imports: [BlocklistValidationModule],
providers: [
{
provide: BlocklistCreateManyPreQueryHook.name,
useClass: BlocklistCreateManyPreQueryHook,
},
{
provide: BlocklistUpdateManyPreQueryHook.name,
useClass: BlocklistUpdateManyPreQueryHook,
},
{
provide: BlocklistUpdateOnePreQueryHook.name,
useClass: BlocklistUpdateOnePreQueryHook,
},
],
})
export class ConnectedAccountQueryHookModule {}

View File

@ -50,4 +50,21 @@ export class BlocklistRepository {
transactionManager,
);
}
public async getByWorkspaceMemberIdAndHandle(
workspaceMemberId: string,
handle: string,
workspaceId: string,
transactionManager?: EntityManager,
): Promise<ObjectRecord<BlocklistObjectMetadata>[]> {
const dataSourceSchema =
this.workspaceDataSourceService.getSchemaName(workspaceId);
return await this.workspaceDataSourceService.executeRawQuery(
`SELECT * FROM ${dataSourceSchema}."blocklist" WHERE "workspaceMemberId" = $1 AND "handle" = $2`,
[workspaceMemberId, handle],
workspaceId,
transactionManager,
);
}
}

View File

@ -0,0 +1,18 @@
import { Module } from '@nestjs/common';
import { ObjectMetadataRepositoryModule } from 'src/engine/object-metadata-repository/object-metadata-repository.module';
import { BlocklistValidationService } from 'src/modules/connected-account/services/blocklist/blocklist-validation.service';
import { BlocklistObjectMetadata } from 'src/modules/connected-account/standard-objects/blocklist.object-metadata';
import { WorkspaceMemberObjectMetadata } from 'src/modules/workspace-member/standard-objects/workspace-member.object-metadata';
@Module({
imports: [
ObjectMetadataRepositoryModule.forFeature([
BlocklistObjectMetadata,
WorkspaceMemberObjectMetadata,
]),
],
providers: [BlocklistValidationService],
exports: [BlocklistValidationService],
})
export class BlocklistValidationModule {}

View File

@ -0,0 +1,146 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { z } from 'zod';
import {
CreateManyResolverArgs,
UpdateOneResolverArgs,
} from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { InjectObjectMetadataRepository } from 'src/engine/object-metadata-repository/object-metadata-repository.decorator';
import { isDomain } from 'src/engine/utils/is-domain';
import { BlocklistRepository } from 'src/modules/connected-account/repositories/blocklist.repository';
import { BlocklistObjectMetadata } from 'src/modules/connected-account/standard-objects/blocklist.object-metadata';
import { WorkspaceMemberRepository } from 'src/modules/workspace-member/repositories/workspace-member.repository';
import { WorkspaceMemberObjectMetadata } from 'src/modules/workspace-member/standard-objects/workspace-member.object-metadata';
export type BlocklistItem = Omit<
BlocklistObjectMetadata,
'createdAt' | 'updatedAt' | 'workspaceMember'
> & {
createdAt: string;
updatedAt: string;
workspaceMemberId: string;
};
@Injectable()
export class BlocklistValidationService {
constructor(
@InjectObjectMetadataRepository(BlocklistObjectMetadata)
private readonly blocklistRepository: BlocklistRepository,
@InjectObjectMetadataRepository(WorkspaceMemberObjectMetadata)
private readonly workspaceMemberRepository: WorkspaceMemberRepository,
) {}
public async validateBlocklistForCreateMany(
payload: CreateManyResolverArgs<BlocklistItem>,
userId: string,
workspaceId: string,
) {
await this.validateSchema(payload.data);
await this.validateUniquenessForCreateMany(payload, userId, workspaceId);
}
public async validateBlocklistForUpdateOne(
payload: UpdateOneResolverArgs<BlocklistItem>,
userId: string,
workspaceId: string,
) {
if (payload.data.handle) {
await this.validateSchema([payload.data]);
}
await this.validateUniquenessForUpdateOne(payload, userId, workspaceId);
}
public async validateSchema(blocklist: BlocklistItem[]) {
const emailOrDomainSchema = z
.string()
.trim()
.email('Invalid email or domain')
.or(
z
.string()
.refine(
(value) => value.startsWith('@') && isDomain(value.slice(1)),
'Invalid email or domain',
),
);
for (const handle of blocklist.map((item) => item.handle)) {
if (!handle) {
throw new BadRequestException('Blocklist handle is required');
}
const result = emailOrDomainSchema.safeParse(handle);
if (!result.success) {
throw new BadRequestException(result.error.errors[0].message);
}
}
}
public async validateUniquenessForCreateMany(
payload: CreateManyResolverArgs<BlocklistItem>,
userId: string,
workspaceId: string,
) {
const currentWorkspaceMember =
await this.workspaceMemberRepository.getByIdOrFail(userId, workspaceId);
const currentBlocklist =
await this.blocklistRepository.getByWorkspaceMemberId(
currentWorkspaceMember.id,
workspaceId,
);
const currentBlocklistHandles = currentBlocklist.map(
(blocklist) => blocklist.handle,
);
if (
payload.data.some((item) => currentBlocklistHandles.includes(item.handle))
) {
throw new BadRequestException('Blocklist handle already exists');
}
}
public async validateUniquenessForUpdateOne(
payload: UpdateOneResolverArgs<BlocklistItem>,
userId: string,
workspaceId: string,
) {
const existingRecord = await this.blocklistRepository.getById(
payload.id,
workspaceId,
);
if (!existingRecord) {
throw new BadRequestException('Blocklist item not found');
}
if (existingRecord.workspaceMemberId !== payload.data.workspaceMemberId) {
throw new BadRequestException('Workspace member cannot be updated');
}
if (existingRecord.handle === payload.data.handle) {
return;
}
const currentWorkspaceMember =
await this.workspaceMemberRepository.getByIdOrFail(userId, workspaceId);
const currentBlocklist =
await this.blocklistRepository.getByWorkspaceMemberId(
currentWorkspaceMember.id,
workspaceId,
);
const currentBlocklistHandles = currentBlocklist
.filter((blocklist) => blocklist.id !== payload.id)
.map((blocklist) => blocklist.handle);
if (currentBlocklistHandles.includes(payload.data.handle)) {
throw new BadRequestException('Blocklist handle already exists');
}
}
}

View File

@ -32,7 +32,7 @@ export class MessageFindManyPreQueryHook implements WorkspacePreQueryHook {
@InjectObjectMetadataRepository(ConnectedAccountObjectMetadata)
private readonly connectedAccountRepository: ConnectedAccountRepository,
@InjectObjectMetadataRepository(WorkspaceMemberObjectMetadata)
private readonly workspaceMemberService: WorkspaceMemberRepository,
private readonly workspaceMemberRepository: WorkspaceMemberRepository,
) {}
async execute(
@ -83,7 +83,7 @@ export class MessageFindManyPreQueryHook implements WorkspacePreQueryHook {
}
const currentWorkspaceMember =
await this.workspaceMemberService.getByIdOrFail(userId, workspaceId);
await this.workspaceMemberRepository.getByIdOrFail(userId, workspaceId);
const messageChannelsConnectedAccounts =
await this.connectedAccountRepository.getByIds(