feat: secure and user-scope metadata resolvers for messaging infrastructure (#18787)
## Summary Builds on the messaging infrastructure migration (#18784) by securing and user-scoping all 4 metadata resolvers: ### DTOs secured - **ConnectedAccountDTO**: `@HideField()` on `accessToken`, `refreshToken`, `connectionParameters`, `oidcTokenClaims` - **MessageChannelDTO / CalendarChannelDTO**: `@HideField()` on `syncCursor` - **MessageFolderDTO**: `@HideField()` on `syncCursor`, `externalId` - **UpdateMessageFolderInputUpdates**: stripped to only `isSynced` (removed `name`, `syncCursor`, `pendingSyncAction`) ### Resolvers user-scoped via `@AuthUserWorkspaceId()` - `myConnectedAccounts` — returns only the calling user's accounts (no permission guard) - `myMessageChannels(connectedAccountId?)` — returns channels for the user's connected accounts - `myCalendarChannels(connectedAccountId?)` — same pattern - `myMessageFolders(messageChannelId?)` — returns folders through the ownership chain ### Admin-only listing with permission guard - `connectedAccounts` query retained with `SettingsPermissionGuard(CONNECTED_ACCOUNTS)` for admin listing of all workspace accounts ### Unsafe mutations removed - Removed `createConnectedAccount`, `updateConnectedAccount` (OAuth/IMAP flows create/refresh tokens server-side) - Removed `create*`/`delete*` mutations from MessageChannel, CalendarChannel, MessageFolder (managed by sync engine) ### Update mutations restricted with ownership verification - `deleteConnectedAccount(id)` — verifies `entity.userWorkspaceId === currentUserWorkspaceId` - `updateMessageChannel` / `updateCalendarChannel` / `updateMessageFolder` — verify ownership through connected account chain - New `OWNERSHIP_VIOLATION` exception codes map to `ForbiddenError` in GraphQL ### `@AuthUserWorkspaceId` decorator hardened - Added `allowUndefined` option (default: `false`) — throws `ForbiddenException` if `userWorkspaceId` is undefined (e.g. API key auth) - Existing callers updated to `@AuthUserWorkspaceId({ allowUndefined: true })` where needed - New user-scoped resolvers enforce non-undefined `userWorkspaceId` at decorator level ### Exception handler chaining - `MessageFolderGraphqlApiExceptionInterceptor`, `MessageChannelGraphqlApiExceptionInterceptor`, `CalendarChannelGraphqlApiExceptionInterceptor` chain upstream exception handling (ConnectedAccountException, MessageChannelException) for correct `ForbiddenError` propagation ### Metadata services enhanced - `findByUserWorkspaceId()`, `getUserConnectedAccountIds()`, `findByConnectedAccountIds()`, `findByMessageChannelIds()` - `findBy*ForUser()` methods encapsulate ownership checks before querying - `verifyOwnership()` on all 4 services with proper chain validation - Named parameters throughout for clarity ### Dev seeds for both schemas - Added JANE to connected account, message channel, calendar channel workspace seeds - Created message folder workspace seeds (TIM, JONY, JANE) - New `seed-metadata-entities.util.ts` seeds core schema tables (connectedAccount, messageChannel, calendarChannel, messageFolder) with same IDs as workspace seeds, mapping `accountOwnerId` → `userWorkspaceId` ### Integration tests (using seeds, not raw SQL) - 4 test suites (`connected-account`, `message-channel`, `calendar-channel`, `message-folder`) - Tests use seeded data IDs from seed constants — no raw SQL inserts/deletes - Tests read via GraphQL resolvers - Tests cover: user scoping, admin permission checks, sensitive field exclusion, ownership enforcement on mutations ### Frontend migration - Feature-flag-gated hooks (`useMyConnectedAccounts`, `useMyMessageChannels`, `useMyCalendarChannels`, `useMyMessageFolders`) - When `IS_CONNECTED_ACCOUNT_MIGRATED` is on: hooks use metadata API (`POST /metadata`) - When flag is off: hooks use existing workspace API (`POST /graphql`, current behavior) - Settings account pages updated to use new hooks - `useEffect` extracted to `SettingsAccountsSelectedMessageChannelEffect` component per project conventions - Error messages translated with Lingui ## Test plan - [x] Server typecheck passes - [x] Server lint passes - [x] Server unit tests pass (477 suites, 4269 tests) - [x] Frontend typecheck passes - [x] Frontend lint passes - [x] Integration tests verify user-scoping, ownership enforcement, hidden fields - [ ] CI green --------- Co-authored-by: neo773 <neo773@protonmail.com>
This commit is contained in:
+2
@@ -7,6 +7,7 @@ import { CalendarChannelMetadataService } from 'src/engine/metadata-modules/cale
|
||||
import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
|
||||
import { CalendarChannelGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/calendar-channel/interceptors/calendar-channel-graphql-api-exception.interceptor';
|
||||
import { CalendarChannelResolver } from 'src/engine/metadata-modules/calendar-channel/resolvers/calendar-channel.resolver';
|
||||
import { ConnectedAccountMetadataModule } from 'src/engine/metadata-modules/connected-account/connected-account-metadata.module';
|
||||
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
|
||||
|
||||
@Module({
|
||||
@@ -15,6 +16,7 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
|
||||
AuthModule,
|
||||
PermissionsModule,
|
||||
FeatureFlagModule,
|
||||
ConnectedAccountMetadataModule,
|
||||
],
|
||||
providers: [
|
||||
CalendarChannelMetadataService,
|
||||
|
||||
+128
-17
@@ -1,43 +1,144 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { Repository } from 'typeorm';
|
||||
import { In, Repository } from 'typeorm';
|
||||
|
||||
import {
|
||||
CalendarChannelSyncStage,
|
||||
CalendarChannelVisibility,
|
||||
} from 'twenty-shared/types';
|
||||
|
||||
import {
|
||||
CalendarChannelException,
|
||||
CalendarChannelExceptionCode,
|
||||
} from 'src/engine/metadata-modules/calendar-channel/calendar-channel.exception';
|
||||
import { CalendarChannelDTO } from 'src/engine/metadata-modules/calendar-channel/dtos/calendar-channel.dto';
|
||||
import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
|
||||
import { ConnectedAccountMetadataService } from 'src/engine/metadata-modules/connected-account/connected-account-metadata.service';
|
||||
|
||||
@Injectable()
|
||||
export class CalendarChannelMetadataService {
|
||||
constructor(
|
||||
@InjectRepository(CalendarChannelEntity)
|
||||
private readonly repository: Repository<CalendarChannelEntity>,
|
||||
private readonly connectedAccountMetadataService: ConnectedAccountMetadataService,
|
||||
) {}
|
||||
|
||||
async findAll(workspaceId: string): Promise<CalendarChannelDTO[]> {
|
||||
return this.repository.find({ where: { workspaceId } });
|
||||
}
|
||||
|
||||
async findByConnectedAccountId(
|
||||
connectedAccountId: string,
|
||||
workspaceId: string,
|
||||
): Promise<CalendarChannelDTO[]> {
|
||||
async findByUserWorkspaceId({
|
||||
userWorkspaceId,
|
||||
workspaceId,
|
||||
}: {
|
||||
userWorkspaceId: string;
|
||||
workspaceId: string;
|
||||
}): Promise<CalendarChannelDTO[]> {
|
||||
const userAccountIds =
|
||||
await this.connectedAccountMetadataService.getUserConnectedAccountIds({
|
||||
userWorkspaceId,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return this.findByConnectedAccountIds({
|
||||
connectedAccountIds: userAccountIds,
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
async findByConnectedAccountIdForUser({
|
||||
connectedAccountId,
|
||||
userWorkspaceId,
|
||||
workspaceId,
|
||||
}: {
|
||||
connectedAccountId: string;
|
||||
userWorkspaceId: string;
|
||||
workspaceId: string;
|
||||
}): Promise<CalendarChannelDTO[]> {
|
||||
await this.connectedAccountMetadataService.verifyOwnership({
|
||||
id: connectedAccountId,
|
||||
userWorkspaceId,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return this.findByConnectedAccountId({ connectedAccountId, workspaceId });
|
||||
}
|
||||
|
||||
async findByConnectedAccountId({
|
||||
connectedAccountId,
|
||||
workspaceId,
|
||||
}: {
|
||||
connectedAccountId: string;
|
||||
workspaceId: string;
|
||||
}): Promise<CalendarChannelDTO[]> {
|
||||
return this.repository.find({
|
||||
where: { connectedAccountId, workspaceId },
|
||||
});
|
||||
}
|
||||
|
||||
async findById(
|
||||
id: string,
|
||||
workspaceId: string,
|
||||
): Promise<CalendarChannelDTO | null> {
|
||||
async findByConnectedAccountIds({
|
||||
connectedAccountIds,
|
||||
workspaceId,
|
||||
}: {
|
||||
connectedAccountIds: string[];
|
||||
workspaceId: string;
|
||||
}): Promise<CalendarChannelDTO[]> {
|
||||
if (connectedAccountIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.repository.find({
|
||||
where: { connectedAccountId: In(connectedAccountIds), workspaceId },
|
||||
});
|
||||
}
|
||||
|
||||
async findById({
|
||||
id,
|
||||
workspaceId,
|
||||
}: {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
}): Promise<CalendarChannelDTO | null> {
|
||||
return this.repository.findOne({ where: { id, workspaceId } });
|
||||
}
|
||||
|
||||
async verifyOwnership({
|
||||
id,
|
||||
userWorkspaceId,
|
||||
workspaceId,
|
||||
}: {
|
||||
id: string;
|
||||
userWorkspaceId: string;
|
||||
workspaceId: string;
|
||||
}): Promise<CalendarChannelEntity> {
|
||||
const calendarChannel = await this.repository.findOne({
|
||||
where: { id, workspaceId },
|
||||
});
|
||||
|
||||
if (!calendarChannel) {
|
||||
throw new CalendarChannelException(
|
||||
`Calendar channel ${id} not found`,
|
||||
CalendarChannelExceptionCode.CALENDAR_CHANNEL_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
const userAccountIds =
|
||||
await this.connectedAccountMetadataService.getUserConnectedAccountIds({
|
||||
userWorkspaceId,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
if (!userAccountIds.includes(calendarChannel.connectedAccountId)) {
|
||||
throw new CalendarChannelException(
|
||||
`Calendar channel ${id} does not belong to user workspace ${userWorkspaceId}`,
|
||||
CalendarChannelExceptionCode.CALENDAR_CHANNEL_OWNERSHIP_VIOLATION,
|
||||
);
|
||||
}
|
||||
|
||||
return calendarChannel;
|
||||
}
|
||||
|
||||
async create(
|
||||
data: Partial<CalendarChannelEntity> & {
|
||||
workspaceId: string;
|
||||
@@ -52,11 +153,15 @@ export class CalendarChannelMetadataService {
|
||||
return this.repository.save(entity);
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
workspaceId: string,
|
||||
data: Partial<CalendarChannelEntity>,
|
||||
): Promise<CalendarChannelDTO> {
|
||||
async update({
|
||||
id,
|
||||
workspaceId,
|
||||
data,
|
||||
}: {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
data: Partial<CalendarChannelEntity>;
|
||||
}): Promise<CalendarChannelDTO> {
|
||||
await this.repository.update(
|
||||
{ id, workspaceId },
|
||||
data as Record<string, unknown>,
|
||||
@@ -65,13 +170,19 @@ export class CalendarChannelMetadataService {
|
||||
return this.repository.findOneOrFail({ where: { id, workspaceId } });
|
||||
}
|
||||
|
||||
async delete(id: string, workspaceId: string): Promise<CalendarChannelDTO> {
|
||||
const entity = await this.repository.findOneOrFail({
|
||||
async delete({
|
||||
id,
|
||||
workspaceId,
|
||||
}: {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
}): Promise<CalendarChannelDTO> {
|
||||
const calendarChannel = await this.repository.findOneOrFail({
|
||||
where: { id, workspaceId },
|
||||
});
|
||||
|
||||
await this.repository.delete({ id, workspaceId });
|
||||
|
||||
return entity;
|
||||
return calendarChannel;
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -7,6 +7,7 @@ import { CustomException } from 'src/utils/custom-exception';
|
||||
export enum CalendarChannelExceptionCode {
|
||||
CALENDAR_CHANNEL_NOT_FOUND = 'CALENDAR_CHANNEL_NOT_FOUND',
|
||||
INVALID_CALENDAR_CHANNEL_INPUT = 'INVALID_CALENDAR_CHANNEL_INPUT',
|
||||
CALENDAR_CHANNEL_OWNERSHIP_VIOLATION = 'CALENDAR_CHANNEL_OWNERSHIP_VIOLATION',
|
||||
}
|
||||
|
||||
const getCalendarChannelExceptionUserFriendlyMessage = (
|
||||
@@ -17,6 +18,8 @@ const getCalendarChannelExceptionUserFriendlyMessage = (
|
||||
return msg`Calendar channel not found.`;
|
||||
case CalendarChannelExceptionCode.INVALID_CALENDAR_CHANNEL_INPUT:
|
||||
return msg`Invalid calendar channel input.`;
|
||||
case CalendarChannelExceptionCode.CALENDAR_CHANNEL_OWNERSHIP_VIOLATION:
|
||||
return msg`You do not have access to this calendar channel.`;
|
||||
default:
|
||||
assertUnreachable(code);
|
||||
}
|
||||
|
||||
+33
-2
@@ -96,10 +96,41 @@ export class CalendarChannelDataAccessService {
|
||||
)
|
||||
: await this.toCoreWhere(workspaceId, where);
|
||||
|
||||
return this.coreRepository.findOne({
|
||||
const requestedRelations =
|
||||
(options.relations as string[] | undefined)?.slice() ?? [];
|
||||
|
||||
const needsConnectedAccount =
|
||||
requestedRelations.includes('connectedAccount');
|
||||
|
||||
const coreRelations = requestedRelations.filter(
|
||||
(r) => r !== 'connectedAccount',
|
||||
);
|
||||
|
||||
const result = await this.coreRepository.findOne({
|
||||
...options,
|
||||
where: coreWhere,
|
||||
} as FindOneOptions<CalendarChannelEntity>) as unknown as Promise<CalendarChannelWorkspaceEntity | null>;
|
||||
relations: coreRelations,
|
||||
} as FindOneOptions<CalendarChannelEntity>);
|
||||
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const workspaceResult =
|
||||
result as unknown as CalendarChannelWorkspaceEntity;
|
||||
|
||||
if (needsConnectedAccount) {
|
||||
const connectedAccount =
|
||||
await this.connectedAccountDataAccessService.findOne(workspaceId, {
|
||||
where: { id: result.connectedAccountId },
|
||||
});
|
||||
|
||||
if (connectedAccount) {
|
||||
workspaceResult.connectedAccount = connectedAccount;
|
||||
}
|
||||
}
|
||||
|
||||
return workspaceResult;
|
||||
}
|
||||
|
||||
const workspaceRepository = await this.getWorkspaceRepository(workspaceId);
|
||||
|
||||
+1
-3
@@ -59,9 +59,7 @@ export class CalendarChannelDTO {
|
||||
@Field()
|
||||
isSyncEnabled: boolean;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@Field(() => String, { nullable: true })
|
||||
@HideField()
|
||||
syncCursor: string | null;
|
||||
|
||||
@IsDateString()
|
||||
|
||||
+4
-2
@@ -49,17 +49,19 @@ export class CalendarChannelEntity extends WorkspaceRelatedEntity {
|
||||
})
|
||||
visibility: CalendarChannelVisibility;
|
||||
|
||||
@Column({ type: 'boolean', nullable: false })
|
||||
@Column({ type: 'boolean', nullable: false, default: true })
|
||||
isContactAutoCreationEnabled: boolean;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: CalendarChannelContactAutoCreationPolicy,
|
||||
nullable: false,
|
||||
default:
|
||||
CalendarChannelContactAutoCreationPolicy.AS_PARTICIPANT_AND_ORGANIZER,
|
||||
})
|
||||
contactAutoCreationPolicy: CalendarChannelContactAutoCreationPolicy;
|
||||
|
||||
@Column({ type: 'boolean', nullable: false })
|
||||
@Column({ type: 'boolean', nullable: false, default: true })
|
||||
isSyncEnabled: boolean;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
|
||||
+25
-45
@@ -1,22 +1,21 @@
|
||||
import { UseGuards, UseInterceptors } from '@nestjs/common';
|
||||
import { Args, Mutation, Query } from '@nestjs/graphql';
|
||||
|
||||
import { PermissionFlagType } from 'twenty-shared/constants';
|
||||
import { FeatureFlagKey } from 'twenty-shared/types';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import {
|
||||
FeatureFlagGuard,
|
||||
RequireFeatureFlag,
|
||||
} from 'src/engine/guards/feature-flag.guard';
|
||||
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
|
||||
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import { CalendarChannelMetadataService } from 'src/engine/metadata-modules/calendar-channel/calendar-channel-metadata.service';
|
||||
import { CalendarChannelDTO } from 'src/engine/metadata-modules/calendar-channel/dtos/calendar-channel.dto';
|
||||
import { CreateCalendarChannelInput } from 'src/engine/metadata-modules/calendar-channel/dtos/create-calendar-channel.input';
|
||||
import { UpdateCalendarChannelInput } from 'src/engine/metadata-modules/calendar-channel/dtos/update-calendar-channel.input';
|
||||
import { CalendarChannelGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/calendar-channel/interceptors/calendar-channel-graphql-api-exception.interceptor';
|
||||
|
||||
@@ -29,10 +28,11 @@ export class CalendarChannelResolver {
|
||||
) {}
|
||||
|
||||
@Query(() => [CalendarChannelDTO])
|
||||
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
|
||||
@UseGuards(NoPermissionGuard)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
|
||||
async calendarChannels(
|
||||
async myCalendarChannels(
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
@AuthUserWorkspaceId() userWorkspaceId: string,
|
||||
@Args('connectedAccountId', {
|
||||
type: () => UUIDScalarType,
|
||||
nullable: true,
|
||||
@@ -40,59 +40,39 @@ export class CalendarChannelResolver {
|
||||
connectedAccountId?: string,
|
||||
): Promise<CalendarChannelDTO[]> {
|
||||
if (connectedAccountId) {
|
||||
return this.calendarChannelMetadataService.findByConnectedAccountId(
|
||||
connectedAccountId,
|
||||
workspace.id,
|
||||
return this.calendarChannelMetadataService.findByConnectedAccountIdForUser(
|
||||
{
|
||||
connectedAccountId,
|
||||
userWorkspaceId,
|
||||
workspaceId: workspace.id,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return this.calendarChannelMetadataService.findAll(workspace.id);
|
||||
}
|
||||
|
||||
@Query(() => CalendarChannelDTO, { nullable: true })
|
||||
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
|
||||
async calendarChannel(
|
||||
@Args('id', { type: () => UUIDScalarType }) id: string,
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
): Promise<CalendarChannelDTO | null> {
|
||||
return this.calendarChannelMetadataService.findById(id, workspace.id);
|
||||
}
|
||||
|
||||
@Mutation(() => CalendarChannelDTO)
|
||||
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
|
||||
async createCalendarChannel(
|
||||
@Args('input') input: CreateCalendarChannelInput,
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
): Promise<CalendarChannelDTO> {
|
||||
return this.calendarChannelMetadataService.create({
|
||||
...input,
|
||||
return this.calendarChannelMetadataService.findByUserWorkspaceId({
|
||||
userWorkspaceId,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
}
|
||||
|
||||
@Mutation(() => CalendarChannelDTO)
|
||||
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
|
||||
@UseGuards(NoPermissionGuard)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
|
||||
async updateCalendarChannel(
|
||||
@Args('input') input: UpdateCalendarChannelInput,
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
@AuthUserWorkspaceId() userWorkspaceId: string,
|
||||
): Promise<CalendarChannelDTO> {
|
||||
return this.calendarChannelMetadataService.update(
|
||||
input.id,
|
||||
workspace.id,
|
||||
input.update,
|
||||
);
|
||||
}
|
||||
await this.calendarChannelMetadataService.verifyOwnership({
|
||||
id: input.id,
|
||||
userWorkspaceId,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
|
||||
@Mutation(() => CalendarChannelDTO)
|
||||
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
|
||||
async deleteCalendarChannel(
|
||||
@Args('id', { type: () => UUIDScalarType }) id: string,
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
): Promise<CalendarChannelDTO> {
|
||||
return this.calendarChannelMetadataService.delete(id, workspace.id);
|
||||
return this.calendarChannelMetadataService.update({
|
||||
id: input.id,
|
||||
workspaceId: workspace.id,
|
||||
data: input.update,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+17
@@ -1,6 +1,7 @@
|
||||
import { assertUnreachable } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
ForbiddenError,
|
||||
NotFoundError,
|
||||
UserInputError,
|
||||
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
|
||||
@@ -8,6 +9,10 @@ import {
|
||||
CalendarChannelException,
|
||||
CalendarChannelExceptionCode,
|
||||
} from 'src/engine/metadata-modules/calendar-channel/calendar-channel.exception';
|
||||
import {
|
||||
ConnectedAccountException,
|
||||
ConnectedAccountExceptionCode,
|
||||
} from 'src/engine/metadata-modules/connected-account/connected-account.exception';
|
||||
|
||||
export const calendarChannelGraphqlApiExceptionHandler = (error: Error) => {
|
||||
if (error instanceof CalendarChannelException) {
|
||||
@@ -16,11 +21,23 @@ export const calendarChannelGraphqlApiExceptionHandler = (error: Error) => {
|
||||
throw new NotFoundError(error);
|
||||
case CalendarChannelExceptionCode.INVALID_CALENDAR_CHANNEL_INPUT:
|
||||
throw new UserInputError(error);
|
||||
case CalendarChannelExceptionCode.CALENDAR_CHANNEL_OWNERSHIP_VIOLATION:
|
||||
throw new ForbiddenError(error);
|
||||
default: {
|
||||
return assertUnreachable(error.code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (error instanceof ConnectedAccountException) {
|
||||
switch (error.code) {
|
||||
case ConnectedAccountExceptionCode.CONNECTED_ACCOUNT_OWNERSHIP_VIOLATION:
|
||||
case ConnectedAccountExceptionCode.CONNECTED_ACCOUNT_NOT_FOUND:
|
||||
throw new ForbiddenError(error);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
};
|
||||
|
||||
+99
-13
@@ -1,8 +1,12 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { Repository } from 'typeorm';
|
||||
import { In, Repository } from 'typeorm';
|
||||
|
||||
import {
|
||||
ConnectedAccountException,
|
||||
ConnectedAccountExceptionCode,
|
||||
} from 'src/engine/metadata-modules/connected-account/connected-account.exception';
|
||||
import { ConnectedAccountDTO } from 'src/engine/metadata-modules/connected-account/dtos/connected-account.dto';
|
||||
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
|
||||
|
||||
@@ -17,13 +21,85 @@ export class ConnectedAccountMetadataService {
|
||||
return this.repository.find({ where: { workspaceId } });
|
||||
}
|
||||
|
||||
async findById(
|
||||
id: string,
|
||||
workspaceId: string,
|
||||
): Promise<ConnectedAccountDTO | null> {
|
||||
async findByUserWorkspaceId({
|
||||
userWorkspaceId,
|
||||
workspaceId,
|
||||
}: {
|
||||
userWorkspaceId: string;
|
||||
workspaceId: string;
|
||||
}): Promise<ConnectedAccountDTO[]> {
|
||||
return this.repository.find({
|
||||
where: { userWorkspaceId, workspaceId },
|
||||
});
|
||||
}
|
||||
|
||||
async findById({
|
||||
id,
|
||||
workspaceId,
|
||||
}: {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
}): Promise<ConnectedAccountDTO | null> {
|
||||
return this.repository.findOne({ where: { id, workspaceId } });
|
||||
}
|
||||
|
||||
async findByIds({
|
||||
ids,
|
||||
workspaceId,
|
||||
}: {
|
||||
ids: string[];
|
||||
workspaceId: string;
|
||||
}): Promise<ConnectedAccountDTO[]> {
|
||||
return this.repository.find({
|
||||
where: { id: In(ids), workspaceId },
|
||||
});
|
||||
}
|
||||
|
||||
async verifyOwnership({
|
||||
id,
|
||||
userWorkspaceId,
|
||||
workspaceId,
|
||||
}: {
|
||||
id: string;
|
||||
userWorkspaceId: string;
|
||||
workspaceId: string;
|
||||
}): Promise<ConnectedAccountEntity> {
|
||||
const connectedAccount = await this.repository.findOne({
|
||||
where: { id, workspaceId },
|
||||
});
|
||||
|
||||
if (!connectedAccount) {
|
||||
throw new ConnectedAccountException(
|
||||
`Connected account ${id} not found`,
|
||||
ConnectedAccountExceptionCode.CONNECTED_ACCOUNT_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
if (connectedAccount.userWorkspaceId !== userWorkspaceId) {
|
||||
throw new ConnectedAccountException(
|
||||
`Connected account ${id} does not belong to user workspace ${userWorkspaceId}`,
|
||||
ConnectedAccountExceptionCode.CONNECTED_ACCOUNT_OWNERSHIP_VIOLATION,
|
||||
);
|
||||
}
|
||||
|
||||
return connectedAccount;
|
||||
}
|
||||
|
||||
async getUserConnectedAccountIds({
|
||||
userWorkspaceId,
|
||||
workspaceId,
|
||||
}: {
|
||||
userWorkspaceId: string;
|
||||
workspaceId: string;
|
||||
}): Promise<string[]> {
|
||||
const accounts = await this.repository.find({
|
||||
where: { userWorkspaceId, workspaceId },
|
||||
select: ['id'],
|
||||
});
|
||||
|
||||
return accounts.map((account) => account.id);
|
||||
}
|
||||
|
||||
async create(
|
||||
data: Partial<ConnectedAccountEntity> & {
|
||||
workspaceId: string;
|
||||
@@ -37,11 +113,15 @@ export class ConnectedAccountMetadataService {
|
||||
return this.repository.save(entity);
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
workspaceId: string,
|
||||
data: Partial<ConnectedAccountEntity>,
|
||||
): Promise<ConnectedAccountDTO> {
|
||||
async update({
|
||||
id,
|
||||
workspaceId,
|
||||
data,
|
||||
}: {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
data: Partial<ConnectedAccountEntity>;
|
||||
}): Promise<ConnectedAccountDTO> {
|
||||
await this.repository.update(
|
||||
{ id, workspaceId },
|
||||
data as Record<string, unknown>,
|
||||
@@ -50,13 +130,19 @@ export class ConnectedAccountMetadataService {
|
||||
return this.repository.findOneOrFail({ where: { id, workspaceId } });
|
||||
}
|
||||
|
||||
async delete(id: string, workspaceId: string): Promise<ConnectedAccountDTO> {
|
||||
const entity = await this.repository.findOneOrFail({
|
||||
async delete({
|
||||
id,
|
||||
workspaceId,
|
||||
}: {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
}): Promise<ConnectedAccountDTO> {
|
||||
const connectedAccount = await this.repository.findOneOrFail({
|
||||
where: { id, workspaceId },
|
||||
});
|
||||
|
||||
await this.repository.delete({ id, workspaceId });
|
||||
|
||||
return entity;
|
||||
return connectedAccount;
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -7,6 +7,7 @@ import { CustomException } from 'src/utils/custom-exception';
|
||||
export enum ConnectedAccountExceptionCode {
|
||||
CONNECTED_ACCOUNT_NOT_FOUND = 'CONNECTED_ACCOUNT_NOT_FOUND',
|
||||
INVALID_CONNECTED_ACCOUNT_INPUT = 'INVALID_CONNECTED_ACCOUNT_INPUT',
|
||||
CONNECTED_ACCOUNT_OWNERSHIP_VIOLATION = 'CONNECTED_ACCOUNT_OWNERSHIP_VIOLATION',
|
||||
}
|
||||
|
||||
const getConnectedAccountExceptionUserFriendlyMessage = (
|
||||
@@ -17,6 +18,8 @@ const getConnectedAccountExceptionUserFriendlyMessage = (
|
||||
return msg`Connected account not found.`;
|
||||
case ConnectedAccountExceptionCode.INVALID_CONNECTED_ACCOUNT_INPUT:
|
||||
return msg`Invalid connected account input.`;
|
||||
case ConnectedAccountExceptionCode.CONNECTED_ACCOUNT_OWNERSHIP_VIOLATION:
|
||||
return msg`You do not have access to this connected account.`;
|
||||
default:
|
||||
assertUnreachable(code);
|
||||
}
|
||||
|
||||
+9
-3
@@ -79,9 +79,15 @@ export class ConnectedAccountDataAccessService {
|
||||
const coreData: Record<string, unknown> = { ...rest };
|
||||
|
||||
if (handleAliases !== undefined) {
|
||||
coreData.handleAliases = isNonEmptyString(handleAliases)
|
||||
? handleAliases.split(',').map((alias: string) => alias.trim())
|
||||
: null;
|
||||
if (Array.isArray(handleAliases)) {
|
||||
coreData.handleAliases = handleAliases;
|
||||
} else if (isNonEmptyString(handleAliases)) {
|
||||
coreData.handleAliases = handleAliases
|
||||
.split(',')
|
||||
.map((alias: string) => alias.trim());
|
||||
} else {
|
||||
coreData.handleAliases = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (accountOwnerId !== undefined) {
|
||||
|
||||
+4
-11
@@ -8,7 +8,6 @@ import {
|
||||
IsString,
|
||||
IsUUID,
|
||||
} from 'class-validator';
|
||||
import GraphQLJSON from 'graphql-type-json';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
|
||||
@@ -29,14 +28,10 @@ export class ConnectedAccountDTO {
|
||||
@Field()
|
||||
provider: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@Field(() => String, { nullable: true })
|
||||
@HideField()
|
||||
accessToken: string | null;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@Field(() => String, { nullable: true })
|
||||
@HideField()
|
||||
refreshToken: string | null;
|
||||
|
||||
@IsDateString()
|
||||
@@ -59,8 +54,7 @@ export class ConnectedAccountDTO {
|
||||
@Field(() => [String], { nullable: true })
|
||||
scopes: string[] | null;
|
||||
|
||||
@IsOptional()
|
||||
@Field(() => GraphQLJSON, { nullable: true })
|
||||
@HideField()
|
||||
connectionParameters: Record<string, unknown> | null;
|
||||
|
||||
@IsDateString()
|
||||
@@ -68,8 +62,7 @@ export class ConnectedAccountDTO {
|
||||
@Field(() => Date, { nullable: true })
|
||||
lastSignedInAt: Date | null;
|
||||
|
||||
@IsOptional()
|
||||
@Field(() => GraphQLJSON, { nullable: true })
|
||||
@HideField()
|
||||
oidcTokenClaims: Record<string, unknown> | null;
|
||||
|
||||
@IsUUID()
|
||||
|
||||
-49
@@ -1,49 +0,0 @@
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
|
||||
@InputType()
|
||||
export class UpdateConnectedAccountInputUpdates {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Field({ nullable: true })
|
||||
accessToken?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Field({ nullable: true })
|
||||
refreshToken?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@Field(() => [String], { nullable: true })
|
||||
handleAliases?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@Field(() => [String], { nullable: true })
|
||||
scopes?: string[];
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class UpdateConnectedAccountInput {
|
||||
@IsUUID()
|
||||
@IsNotEmpty()
|
||||
@Field(() => UUIDScalarType)
|
||||
id: string;
|
||||
|
||||
@Type(() => UpdateConnectedAccountInputUpdates)
|
||||
@ValidateNested()
|
||||
@Field(() => UpdateConnectedAccountInputUpdates)
|
||||
update: UpdateConnectedAccountInputUpdates;
|
||||
}
|
||||
+27
-41
@@ -7,17 +7,17 @@ import { FeatureFlagKey } from 'twenty-shared/types';
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import {
|
||||
FeatureFlagGuard,
|
||||
RequireFeatureFlag,
|
||||
} from 'src/engine/guards/feature-flag.guard';
|
||||
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
|
||||
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import { ConnectedAccountMetadataService } from 'src/engine/metadata-modules/connected-account/connected-account-metadata.service';
|
||||
import { ConnectedAccountDTO } from 'src/engine/metadata-modules/connected-account/dtos/connected-account.dto';
|
||||
import { CreateConnectedAccountInput } from 'src/engine/metadata-modules/connected-account/dtos/create-connected-account.input';
|
||||
import { UpdateConnectedAccountInput } from 'src/engine/metadata-modules/connected-account/dtos/update-connected-account.input';
|
||||
import { ConnectedAccountGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/connected-account/interceptors/connected-account-graphql-api-exception.interceptor';
|
||||
|
||||
@UseGuards(WorkspaceAuthGuard, FeatureFlagGuard)
|
||||
@@ -28,6 +28,19 @@ export class ConnectedAccountResolver {
|
||||
private readonly connectedAccountMetadataService: ConnectedAccountMetadataService,
|
||||
) {}
|
||||
|
||||
@Query(() => [ConnectedAccountDTO])
|
||||
@UseGuards(NoPermissionGuard)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
|
||||
async myConnectedAccounts(
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
@AuthUserWorkspaceId() userWorkspaceId: string,
|
||||
): Promise<ConnectedAccountDTO[]> {
|
||||
return this.connectedAccountMetadataService.findByUserWorkspaceId({
|
||||
userWorkspaceId,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
}
|
||||
|
||||
@Query(() => [ConnectedAccountDTO])
|
||||
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
|
||||
@@ -37,50 +50,23 @@ export class ConnectedAccountResolver {
|
||||
return this.connectedAccountMetadataService.findAll(workspace.id);
|
||||
}
|
||||
|
||||
@Query(() => ConnectedAccountDTO, { nullable: true })
|
||||
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
|
||||
async connectedAccount(
|
||||
@Args('id', { type: () => UUIDScalarType }) id: string,
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
): Promise<ConnectedAccountDTO | null> {
|
||||
return this.connectedAccountMetadataService.findById(id, workspace.id);
|
||||
}
|
||||
|
||||
@Mutation(() => ConnectedAccountDTO)
|
||||
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
|
||||
async createConnectedAccount(
|
||||
@Args('input') input: CreateConnectedAccountInput,
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
): Promise<ConnectedAccountDTO> {
|
||||
return this.connectedAccountMetadataService.create({
|
||||
...input,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
}
|
||||
|
||||
@Mutation(() => ConnectedAccountDTO)
|
||||
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
|
||||
async updateConnectedAccount(
|
||||
@Args('input') input: UpdateConnectedAccountInput,
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
): Promise<ConnectedAccountDTO> {
|
||||
return this.connectedAccountMetadataService.update(
|
||||
input.id,
|
||||
workspace.id,
|
||||
input.update,
|
||||
);
|
||||
}
|
||||
|
||||
@Mutation(() => ConnectedAccountDTO)
|
||||
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
|
||||
@UseGuards(NoPermissionGuard)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
|
||||
async deleteConnectedAccount(
|
||||
@Args('id', { type: () => UUIDScalarType }) id: string,
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
@AuthUserWorkspaceId() userWorkspaceId: string,
|
||||
): Promise<ConnectedAccountDTO> {
|
||||
return this.connectedAccountMetadataService.delete(id, workspace.id);
|
||||
await this.connectedAccountMetadataService.verifyOwnership({
|
||||
id,
|
||||
userWorkspaceId,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
|
||||
return this.connectedAccountMetadataService.delete({
|
||||
id,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -1,6 +1,7 @@
|
||||
import { assertUnreachable } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
ForbiddenError,
|
||||
NotFoundError,
|
||||
UserInputError,
|
||||
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
|
||||
@@ -16,6 +17,8 @@ export const connectedAccountGraphqlApiExceptionHandler = (error: Error) => {
|
||||
throw new NotFoundError(error);
|
||||
case ConnectedAccountExceptionCode.INVALID_CONNECTED_ACCOUNT_INPUT:
|
||||
throw new UserInputError(error);
|
||||
case ConnectedAccountExceptionCode.CONNECTED_ACCOUNT_OWNERSHIP_VIOLATION:
|
||||
throw new ForbiddenError(error);
|
||||
default: {
|
||||
return assertUnreachable(error.code);
|
||||
}
|
||||
|
||||
+47
-2
@@ -96,10 +96,55 @@ export class MessageChannelDataAccessService {
|
||||
)
|
||||
: await this.toCoreWhere(workspaceId, where);
|
||||
|
||||
return this.coreRepository.findOne({
|
||||
const requestedRelations =
|
||||
(options.relations as string[] | undefined)?.slice() ?? [];
|
||||
|
||||
const needsConnectedAccount =
|
||||
requestedRelations.includes('connectedAccount');
|
||||
|
||||
const needsMessageFolders = requestedRelations.includes('messageFolders');
|
||||
|
||||
const coreRelations = requestedRelations.filter(
|
||||
(r) => r !== 'connectedAccount' && r !== 'messageFolders',
|
||||
);
|
||||
|
||||
const result = await this.coreRepository.findOne({
|
||||
...options,
|
||||
where: coreWhere,
|
||||
} as FindOneOptions<MessageChannelEntity>) as unknown as Promise<MessageChannelWorkspaceEntity | null>;
|
||||
relations: coreRelations,
|
||||
} as FindOneOptions<MessageChannelEntity>);
|
||||
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const workspaceResult =
|
||||
result as unknown as MessageChannelWorkspaceEntity;
|
||||
|
||||
if (needsConnectedAccount) {
|
||||
const connectedAccount =
|
||||
await this.connectedAccountDataAccessService.findOne(workspaceId, {
|
||||
where: { id: result.connectedAccountId },
|
||||
});
|
||||
|
||||
if (connectedAccount) {
|
||||
workspaceResult.connectedAccount = connectedAccount;
|
||||
}
|
||||
}
|
||||
|
||||
if (needsMessageFolders) {
|
||||
const workspaceRepository =
|
||||
await this.getWorkspaceRepository(workspaceId);
|
||||
|
||||
const workspaceChannel = await workspaceRepository.findOne({
|
||||
where: { id: result.id },
|
||||
relations: ['messageFolders'],
|
||||
});
|
||||
|
||||
workspaceResult.messageFolders = workspaceChannel?.messageFolders ?? [];
|
||||
}
|
||||
|
||||
return workspaceResult;
|
||||
}
|
||||
|
||||
const workspaceRepository = await this.getWorkspaceRepository(workspaceId);
|
||||
|
||||
+1
-3
@@ -75,9 +75,7 @@ export class MessageChannelDTO {
|
||||
@Field()
|
||||
isSyncEnabled: boolean;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@Field(() => String, { nullable: true })
|
||||
@HideField()
|
||||
syncCursor: string | null;
|
||||
|
||||
@IsDateString()
|
||||
|
||||
+6
-4
@@ -46,13 +46,14 @@ export class MessageChannelEntity extends WorkspaceRelatedEntity {
|
||||
})
|
||||
type: MessageChannelType;
|
||||
|
||||
@Column({ type: 'boolean', nullable: false })
|
||||
@Column({ type: 'boolean', nullable: false, default: true })
|
||||
isContactAutoCreationEnabled: boolean;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: MessageChannelContactAutoCreationPolicy,
|
||||
nullable: false,
|
||||
default: MessageChannelContactAutoCreationPolicy.SENT,
|
||||
})
|
||||
contactAutoCreationPolicy: MessageChannelContactAutoCreationPolicy;
|
||||
|
||||
@@ -60,13 +61,14 @@ export class MessageChannelEntity extends WorkspaceRelatedEntity {
|
||||
type: 'enum',
|
||||
enum: MessageFolderImportPolicy,
|
||||
nullable: false,
|
||||
default: MessageFolderImportPolicy.ALL_FOLDERS,
|
||||
})
|
||||
messageFolderImportPolicy: MessageFolderImportPolicy;
|
||||
|
||||
@Column({ type: 'boolean', nullable: false })
|
||||
@Column({ type: 'boolean', nullable: false, default: true })
|
||||
excludeNonProfessionalEmails: boolean;
|
||||
|
||||
@Column({ type: 'boolean', nullable: false })
|
||||
@Column({ type: 'boolean', nullable: false, default: true })
|
||||
excludeGroupEmails: boolean;
|
||||
|
||||
@Column({
|
||||
@@ -76,7 +78,7 @@ export class MessageChannelEntity extends WorkspaceRelatedEntity {
|
||||
})
|
||||
pendingGroupEmailsAction: MessageChannelPendingGroupEmailsAction;
|
||||
|
||||
@Column({ type: 'boolean', nullable: false })
|
||||
@Column({ type: 'boolean', nullable: false, default: true })
|
||||
isSyncEnabled: boolean;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
|
||||
+6
@@ -3,11 +3,14 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { ConnectedAccountMetadataModule } from 'src/engine/metadata-modules/connected-account/connected-account-metadata.module';
|
||||
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
|
||||
import { MessageChannelGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/message-channel/interceptors/message-channel-graphql-api-exception.interceptor';
|
||||
import { MessageChannelMetadataService } from 'src/engine/metadata-modules/message-channel/message-channel-metadata.service';
|
||||
import { MessageChannelResolver } from 'src/engine/metadata-modules/message-channel/resolvers/message-channel.resolver';
|
||||
import { MessageFolderDataAccessModule } from 'src/engine/metadata-modules/message-folder/data-access/message-folder-data-access.module';
|
||||
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
|
||||
import { MessagingImportManagerModule } from 'src/modules/messaging/message-import-manager/messaging-import-manager.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -15,6 +18,9 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
|
||||
AuthModule,
|
||||
PermissionsModule,
|
||||
FeatureFlagModule,
|
||||
ConnectedAccountMetadataModule,
|
||||
MessageFolderDataAccessModule,
|
||||
MessagingImportManagerModule,
|
||||
],
|
||||
providers: [
|
||||
MessageChannelMetadataService,
|
||||
|
||||
+128
-17
@@ -1,7 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { Repository } from 'typeorm';
|
||||
import { In, Repository } from 'typeorm';
|
||||
|
||||
import {
|
||||
MessageChannelSyncStage,
|
||||
@@ -9,36 +9,137 @@ import {
|
||||
MessageChannelVisibility,
|
||||
} from 'twenty-shared/types';
|
||||
|
||||
import { ConnectedAccountMetadataService } from 'src/engine/metadata-modules/connected-account/connected-account-metadata.service';
|
||||
import { MessageChannelDTO } from 'src/engine/metadata-modules/message-channel/dtos/message-channel.dto';
|
||||
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
|
||||
import {
|
||||
MessageChannelException,
|
||||
MessageChannelExceptionCode,
|
||||
} from 'src/engine/metadata-modules/message-channel/message-channel.exception';
|
||||
|
||||
@Injectable()
|
||||
export class MessageChannelMetadataService {
|
||||
constructor(
|
||||
@InjectRepository(MessageChannelEntity)
|
||||
private readonly repository: Repository<MessageChannelEntity>,
|
||||
private readonly connectedAccountMetadataService: ConnectedAccountMetadataService,
|
||||
) {}
|
||||
|
||||
async findAll(workspaceId: string): Promise<MessageChannelDTO[]> {
|
||||
return this.repository.find({ where: { workspaceId } });
|
||||
}
|
||||
|
||||
async findByConnectedAccountId(
|
||||
connectedAccountId: string,
|
||||
workspaceId: string,
|
||||
): Promise<MessageChannelDTO[]> {
|
||||
async findByUserWorkspaceId({
|
||||
userWorkspaceId,
|
||||
workspaceId,
|
||||
}: {
|
||||
userWorkspaceId: string;
|
||||
workspaceId: string;
|
||||
}): Promise<MessageChannelDTO[]> {
|
||||
const userAccountIds =
|
||||
await this.connectedAccountMetadataService.getUserConnectedAccountIds({
|
||||
userWorkspaceId,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return this.findByConnectedAccountIds({
|
||||
connectedAccountIds: userAccountIds,
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
async findByConnectedAccountIdForUser({
|
||||
connectedAccountId,
|
||||
userWorkspaceId,
|
||||
workspaceId,
|
||||
}: {
|
||||
connectedAccountId: string;
|
||||
userWorkspaceId: string;
|
||||
workspaceId: string;
|
||||
}): Promise<MessageChannelDTO[]> {
|
||||
await this.connectedAccountMetadataService.verifyOwnership({
|
||||
id: connectedAccountId,
|
||||
userWorkspaceId,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return this.findByConnectedAccountId({ connectedAccountId, workspaceId });
|
||||
}
|
||||
|
||||
async findByConnectedAccountId({
|
||||
connectedAccountId,
|
||||
workspaceId,
|
||||
}: {
|
||||
connectedAccountId: string;
|
||||
workspaceId: string;
|
||||
}): Promise<MessageChannelDTO[]> {
|
||||
return this.repository.find({
|
||||
where: { connectedAccountId, workspaceId },
|
||||
});
|
||||
}
|
||||
|
||||
async findById(
|
||||
id: string,
|
||||
workspaceId: string,
|
||||
): Promise<MessageChannelDTO | null> {
|
||||
async findByConnectedAccountIds({
|
||||
connectedAccountIds,
|
||||
workspaceId,
|
||||
}: {
|
||||
connectedAccountIds: string[];
|
||||
workspaceId: string;
|
||||
}): Promise<MessageChannelDTO[]> {
|
||||
if (connectedAccountIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.repository.find({
|
||||
where: { connectedAccountId: In(connectedAccountIds), workspaceId },
|
||||
});
|
||||
}
|
||||
|
||||
async findById({
|
||||
id,
|
||||
workspaceId,
|
||||
}: {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
}): Promise<MessageChannelDTO | null> {
|
||||
return this.repository.findOne({ where: { id, workspaceId } });
|
||||
}
|
||||
|
||||
async verifyOwnership({
|
||||
id,
|
||||
userWorkspaceId,
|
||||
workspaceId,
|
||||
}: {
|
||||
id: string;
|
||||
userWorkspaceId: string;
|
||||
workspaceId: string;
|
||||
}): Promise<MessageChannelEntity> {
|
||||
const messageChannel = await this.repository.findOne({
|
||||
where: { id, workspaceId },
|
||||
});
|
||||
|
||||
if (!messageChannel) {
|
||||
throw new MessageChannelException(
|
||||
`Message channel ${id} not found`,
|
||||
MessageChannelExceptionCode.MESSAGE_CHANNEL_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
const userAccountIds =
|
||||
await this.connectedAccountMetadataService.getUserConnectedAccountIds({
|
||||
userWorkspaceId,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
if (!userAccountIds.includes(messageChannel.connectedAccountId)) {
|
||||
throw new MessageChannelException(
|
||||
`Message channel ${id} does not belong to user workspace ${userWorkspaceId}`,
|
||||
MessageChannelExceptionCode.MESSAGE_CHANNEL_OWNERSHIP_VIOLATION,
|
||||
);
|
||||
}
|
||||
|
||||
return messageChannel;
|
||||
}
|
||||
|
||||
async create(
|
||||
data: Partial<MessageChannelEntity> & {
|
||||
workspaceId: string;
|
||||
@@ -54,11 +155,15 @@ export class MessageChannelMetadataService {
|
||||
return this.repository.save(entity);
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
workspaceId: string,
|
||||
data: Partial<MessageChannelEntity>,
|
||||
): Promise<MessageChannelDTO> {
|
||||
async update({
|
||||
id,
|
||||
workspaceId,
|
||||
data,
|
||||
}: {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
data: Partial<MessageChannelEntity>;
|
||||
}): Promise<MessageChannelDTO> {
|
||||
await this.repository.update(
|
||||
{ id, workspaceId },
|
||||
data as Record<string, unknown>,
|
||||
@@ -67,13 +172,19 @@ export class MessageChannelMetadataService {
|
||||
return this.repository.findOneOrFail({ where: { id, workspaceId } });
|
||||
}
|
||||
|
||||
async delete(id: string, workspaceId: string): Promise<MessageChannelDTO> {
|
||||
const entity = await this.repository.findOneOrFail({
|
||||
async delete({
|
||||
id,
|
||||
workspaceId,
|
||||
}: {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
}): Promise<MessageChannelDTO> {
|
||||
const messageChannel = await this.repository.findOneOrFail({
|
||||
where: { id, workspaceId },
|
||||
});
|
||||
|
||||
await this.repository.delete({ id, workspaceId });
|
||||
|
||||
return entity;
|
||||
return messageChannel;
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -7,6 +7,7 @@ import { CustomException } from 'src/utils/custom-exception';
|
||||
export enum MessageChannelExceptionCode {
|
||||
MESSAGE_CHANNEL_NOT_FOUND = 'MESSAGE_CHANNEL_NOT_FOUND',
|
||||
INVALID_MESSAGE_CHANNEL_INPUT = 'INVALID_MESSAGE_CHANNEL_INPUT',
|
||||
MESSAGE_CHANNEL_OWNERSHIP_VIOLATION = 'MESSAGE_CHANNEL_OWNERSHIP_VIOLATION',
|
||||
}
|
||||
|
||||
const getMessageChannelExceptionUserFriendlyMessage = (
|
||||
@@ -17,6 +18,8 @@ const getMessageChannelExceptionUserFriendlyMessage = (
|
||||
return msg`Message channel not found.`;
|
||||
case MessageChannelExceptionCode.INVALID_MESSAGE_CHANNEL_INPUT:
|
||||
return msg`Invalid message channel input.`;
|
||||
case MessageChannelExceptionCode.MESSAGE_CHANNEL_OWNERSHIP_VIOLATION:
|
||||
return msg`You do not have access to this message channel.`;
|
||||
default:
|
||||
assertUnreachable(code);
|
||||
}
|
||||
|
||||
+82
-45
@@ -1,24 +1,37 @@
|
||||
import { UseGuards, UseInterceptors } from '@nestjs/common';
|
||||
import { Args, Mutation, Query } from '@nestjs/graphql';
|
||||
|
||||
import { PermissionFlagType } from 'twenty-shared/constants';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { FeatureFlagKey } from 'twenty-shared/types';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import {
|
||||
FeatureFlagGuard,
|
||||
RequireFeatureFlag,
|
||||
} from 'src/engine/guards/feature-flag.guard';
|
||||
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
|
||||
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import { CreateMessageChannelInput } from 'src/engine/metadata-modules/message-channel/dtos/create-message-channel.input';
|
||||
import { MessageChannelDTO } from 'src/engine/metadata-modules/message-channel/dtos/message-channel.dto';
|
||||
import { UpdateMessageChannelInput } from 'src/engine/metadata-modules/message-channel/dtos/update-message-channel.input';
|
||||
import {
|
||||
MessageChannelException,
|
||||
MessageChannelExceptionCode,
|
||||
} from 'src/engine/metadata-modules/message-channel/message-channel.exception';
|
||||
import { MessageChannelGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/message-channel/interceptors/message-channel-graphql-api-exception.interceptor';
|
||||
import { MessageChannelMetadataService } from 'src/engine/metadata-modules/message-channel/message-channel-metadata.service';
|
||||
import {
|
||||
MessageChannelPendingGroupEmailsAction,
|
||||
MessageChannelSyncStage,
|
||||
type MessageChannelWorkspaceEntity,
|
||||
} from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
|
||||
import { MessageFolderPendingSyncAction } from 'src/modules/messaging/common/standard-objects/message-folder.workspace-entity';
|
||||
import { MessageFolderDataAccessService } from 'src/engine/metadata-modules/message-folder/data-access/services/message-folder-data-access.service';
|
||||
import { MessagingProcessGroupEmailActionsService } from 'src/modules/messaging/message-import-manager/services/messaging-process-group-email-actions.service';
|
||||
import { Not } from 'typeorm';
|
||||
|
||||
@UseGuards(WorkspaceAuthGuard, FeatureFlagGuard)
|
||||
@UseInterceptors(MessageChannelGraphqlApiExceptionInterceptor)
|
||||
@@ -26,13 +39,16 @@ import { MessageChannelMetadataService } from 'src/engine/metadata-modules/messa
|
||||
export class MessageChannelResolver {
|
||||
constructor(
|
||||
private readonly messageChannelMetadataService: MessageChannelMetadataService,
|
||||
private readonly messageFolderDataAccessService: MessageFolderDataAccessService,
|
||||
private readonly messagingProcessGroupEmailActionsService: MessagingProcessGroupEmailActionsService,
|
||||
) {}
|
||||
|
||||
@Query(() => [MessageChannelDTO])
|
||||
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
|
||||
@UseGuards(NoPermissionGuard)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
|
||||
async messageChannels(
|
||||
async myMessageChannels(
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
@AuthUserWorkspaceId() userWorkspaceId: string,
|
||||
@Args('connectedAccountId', {
|
||||
type: () => UUIDScalarType,
|
||||
nullable: true,
|
||||
@@ -40,59 +56,80 @@ export class MessageChannelResolver {
|
||||
connectedAccountId?: string,
|
||||
): Promise<MessageChannelDTO[]> {
|
||||
if (connectedAccountId) {
|
||||
return this.messageChannelMetadataService.findByConnectedAccountId(
|
||||
connectedAccountId,
|
||||
workspace.id,
|
||||
return this.messageChannelMetadataService.findByConnectedAccountIdForUser(
|
||||
{
|
||||
connectedAccountId,
|
||||
userWorkspaceId,
|
||||
workspaceId: workspace.id,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return this.messageChannelMetadataService.findAll(workspace.id);
|
||||
}
|
||||
|
||||
@Query(() => MessageChannelDTO, { nullable: true })
|
||||
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
|
||||
async messageChannel(
|
||||
@Args('id', { type: () => UUIDScalarType }) id: string,
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
): Promise<MessageChannelDTO | null> {
|
||||
return this.messageChannelMetadataService.findById(id, workspace.id);
|
||||
}
|
||||
|
||||
@Mutation(() => MessageChannelDTO)
|
||||
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
|
||||
async createMessageChannel(
|
||||
@Args('input') input: CreateMessageChannelInput,
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
): Promise<MessageChannelDTO> {
|
||||
return this.messageChannelMetadataService.create({
|
||||
...input,
|
||||
return this.messageChannelMetadataService.findByUserWorkspaceId({
|
||||
userWorkspaceId,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
}
|
||||
|
||||
@Mutation(() => MessageChannelDTO)
|
||||
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
|
||||
@UseGuards(NoPermissionGuard)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
|
||||
async updateMessageChannel(
|
||||
@Args('input') input: UpdateMessageChannelInput,
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
@AuthUserWorkspaceId() userWorkspaceId: string,
|
||||
): Promise<MessageChannelDTO> {
|
||||
return this.messageChannelMetadataService.update(
|
||||
input.id,
|
||||
workspace.id,
|
||||
input.update,
|
||||
);
|
||||
}
|
||||
const messageChannel =
|
||||
await this.messageChannelMetadataService.verifyOwnership({
|
||||
id: input.id,
|
||||
userWorkspaceId,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
|
||||
@Mutation(() => MessageChannelDTO)
|
||||
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
|
||||
async deleteMessageChannel(
|
||||
@Args('id', { type: () => UUIDScalarType }) id: string,
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
): Promise<MessageChannelDTO> {
|
||||
return this.messageChannelMetadataService.delete(id, workspace.id);
|
||||
const isSyncOngoing =
|
||||
messageChannel.syncStage ===
|
||||
MessageChannelSyncStage.MESSAGE_LIST_FETCH_ONGOING;
|
||||
|
||||
const foldersWithPendingAction =
|
||||
await this.messageFolderDataAccessService.find(workspace.id, {
|
||||
messageChannelId: messageChannel.id,
|
||||
pendingSyncAction: Not(MessageFolderPendingSyncAction.NONE),
|
||||
});
|
||||
|
||||
const hasPendingGroupEmailsAction =
|
||||
messageChannel.pendingGroupEmailsAction !==
|
||||
MessageChannelPendingGroupEmailsAction.NONE;
|
||||
|
||||
if (
|
||||
isSyncOngoing &&
|
||||
(foldersWithPendingAction.length > 0 || hasPendingGroupEmailsAction)
|
||||
) {
|
||||
throw new MessageChannelException(
|
||||
'Cannot update message channel while sync is ongoing with pending actions',
|
||||
MessageChannelExceptionCode.INVALID_MESSAGE_CHANNEL_INPUT,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
messageChannel.syncStage !==
|
||||
MessageChannelSyncStage.PENDING_CONFIGURATION &&
|
||||
isDefined(input.update.excludeGroupEmails) &&
|
||||
input.update.excludeGroupEmails !== messageChannel.excludeGroupEmails
|
||||
) {
|
||||
// Service expects WorkspaceEntity type but only reads .id
|
||||
await this.messagingProcessGroupEmailActionsService.markMessageChannelAsPendingGroupEmailsAction(
|
||||
messageChannel as unknown as MessageChannelWorkspaceEntity,
|
||||
workspace.id,
|
||||
input.update.excludeGroupEmails
|
||||
? MessageChannelPendingGroupEmailsAction.GROUP_EMAILS_DELETION
|
||||
: MessageChannelPendingGroupEmailsAction.GROUP_EMAILS_IMPORT,
|
||||
);
|
||||
}
|
||||
|
||||
return this.messageChannelMetadataService.update({
|
||||
id: input.id,
|
||||
workspaceId: workspace.id,
|
||||
data: input.update,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+17
@@ -1,9 +1,14 @@
|
||||
import { assertUnreachable } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
ForbiddenError,
|
||||
NotFoundError,
|
||||
UserInputError,
|
||||
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
|
||||
import {
|
||||
ConnectedAccountException,
|
||||
ConnectedAccountExceptionCode,
|
||||
} from 'src/engine/metadata-modules/connected-account/connected-account.exception';
|
||||
import {
|
||||
MessageChannelException,
|
||||
MessageChannelExceptionCode,
|
||||
@@ -16,11 +21,23 @@ export const messageChannelGraphqlApiExceptionHandler = (error: Error) => {
|
||||
throw new NotFoundError(error);
|
||||
case MessageChannelExceptionCode.INVALID_MESSAGE_CHANNEL_INPUT:
|
||||
throw new UserInputError(error);
|
||||
case MessageChannelExceptionCode.MESSAGE_CHANNEL_OWNERSHIP_VIOLATION:
|
||||
throw new ForbiddenError(error);
|
||||
default: {
|
||||
return assertUnreachable(error.code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (error instanceof ConnectedAccountException) {
|
||||
switch (error.code) {
|
||||
case ConnectedAccountExceptionCode.CONNECTED_ACCOUNT_OWNERSHIP_VIOLATION:
|
||||
case ConnectedAccountExceptionCode.CONNECTED_ACCOUNT_NOT_FOUND:
|
||||
throw new ForbiddenError(error);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
};
|
||||
|
||||
+31
-4
@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { FeatureFlagKey } from 'twenty-shared/types';
|
||||
import { validate as uuidValidate } from 'uuid';
|
||||
import {
|
||||
type FindOneOptions,
|
||||
type FindOptionsWhere,
|
||||
@@ -32,6 +33,31 @@ export class MessageFolderDataAccessService {
|
||||
);
|
||||
}
|
||||
|
||||
// Workspace stores parentFolderId as an externalId (text),
|
||||
// core stores it as a uuid FK. Resolve during dual-write.
|
||||
private async toCore(
|
||||
workspaceId: string,
|
||||
data: Partial<MessageFolderWorkspaceEntity>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const coreData: Record<string, unknown> = { ...data, workspaceId };
|
||||
const parentFolderId = coreData.parentFolderId as string | null;
|
||||
|
||||
if (parentFolderId && !uuidValidate(parentFolderId)) {
|
||||
const parentFolder = await this.coreRepository.findOne({
|
||||
where: {
|
||||
workspaceId,
|
||||
messageChannelId: coreData.messageChannelId as string,
|
||||
externalId: parentFolderId,
|
||||
},
|
||||
select: ['id'],
|
||||
});
|
||||
|
||||
coreData.parentFolderId = parentFolder?.id ?? null;
|
||||
}
|
||||
|
||||
return coreData;
|
||||
}
|
||||
|
||||
async getWorkspaceRepository(workspaceId: string) {
|
||||
return this.globalWorkspaceOrmManager.getRepository<MessageFolderWorkspaceEntity>(
|
||||
workspaceId,
|
||||
@@ -94,10 +120,11 @@ export class MessageFolderDataAccessService {
|
||||
|
||||
if (await this.isMigrated(workspaceId)) {
|
||||
try {
|
||||
await this.coreRepository.save({
|
||||
...data,
|
||||
workspaceId,
|
||||
} as unknown as MessageFolderEntity);
|
||||
const coreData = await this.toCore(workspaceId, data);
|
||||
|
||||
await this.coreRepository.save(
|
||||
coreData as unknown as MessageFolderEntity,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to dual-write messageFolder to core: ${error}`,
|
||||
|
||||
+1
-3
@@ -25,9 +25,7 @@ export class MessageFolderDTO {
|
||||
@Field(() => String, { nullable: true })
|
||||
name: string | null;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@Field(() => String, { nullable: true })
|
||||
@HideField()
|
||||
syncCursor: string | null;
|
||||
|
||||
@IsBoolean()
|
||||
|
||||
+13
-18
@@ -3,38 +3,20 @@ import { Field, InputType } from '@nestjs/graphql';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { MessageFolderPendingSyncAction } from 'twenty-shared/types';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
|
||||
@InputType()
|
||||
export class UpdateMessageFolderInputUpdates {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Field({ nullable: true })
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Field({ nullable: true })
|
||||
syncCursor?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@Field({ nullable: true })
|
||||
isSynced?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(MessageFolderPendingSyncAction)
|
||||
@Field(() => MessageFolderPendingSyncAction, { nullable: true })
|
||||
pendingSyncAction?: MessageFolderPendingSyncAction;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
@@ -49,3 +31,16 @@ export class UpdateMessageFolderInput {
|
||||
@Field(() => UpdateMessageFolderInputUpdates)
|
||||
update: UpdateMessageFolderInputUpdates;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class UpdateMessageFoldersInput {
|
||||
@IsUUID('4', { each: true })
|
||||
@IsNotEmpty({ each: true })
|
||||
@Field(() => [UUIDScalarType])
|
||||
ids: string[];
|
||||
|
||||
@Type(() => UpdateMessageFolderInputUpdates)
|
||||
@ValidateNested()
|
||||
@Field(() => UpdateMessageFolderInputUpdates)
|
||||
update: UpdateMessageFolderInputUpdates;
|
||||
}
|
||||
|
||||
+1
@@ -41,6 +41,7 @@ export class MessageFolderEntity extends WorkspaceRelatedEntity {
|
||||
type: 'enum',
|
||||
enum: MessageFolderPendingSyncAction,
|
||||
nullable: false,
|
||||
default: MessageFolderPendingSyncAction.NONE,
|
||||
})
|
||||
pendingSyncAction: MessageFolderPendingSyncAction;
|
||||
|
||||
|
||||
+4
@@ -3,6 +3,8 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { ConnectedAccountMetadataModule } from 'src/engine/metadata-modules/connected-account/connected-account-metadata.module';
|
||||
import { MessageChannelMetadataModule } from 'src/engine/metadata-modules/message-channel/message-channel-metadata.module';
|
||||
import { MessageFolderEntity } from 'src/engine/metadata-modules/message-folder/entities/message-folder.entity';
|
||||
import { MessageFolderGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/message-folder/interceptors/message-folder-graphql-api-exception.interceptor';
|
||||
import { MessageFolderMetadataService } from 'src/engine/metadata-modules/message-folder/message-folder-metadata.service';
|
||||
@@ -15,6 +17,8 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
|
||||
AuthModule,
|
||||
PermissionsModule,
|
||||
FeatureFlagModule,
|
||||
ConnectedAccountMetadataModule,
|
||||
MessageChannelMetadataModule,
|
||||
],
|
||||
providers: [
|
||||
MessageFolderMetadataService,
|
||||
|
||||
+163
-17
@@ -1,40 +1,159 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { Repository } from 'typeorm';
|
||||
import { In, Repository } from 'typeorm';
|
||||
|
||||
import { MessageFolderPendingSyncAction } from 'twenty-shared/types';
|
||||
|
||||
import { ConnectedAccountMetadataService } from 'src/engine/metadata-modules/connected-account/connected-account-metadata.service';
|
||||
import { MessageFolderDTO } from 'src/engine/metadata-modules/message-folder/dtos/message-folder.dto';
|
||||
import { MessageFolderEntity } from 'src/engine/metadata-modules/message-folder/entities/message-folder.entity';
|
||||
import {
|
||||
MessageFolderException,
|
||||
MessageFolderExceptionCode,
|
||||
} from 'src/engine/metadata-modules/message-folder/message-folder.exception';
|
||||
import { MessageChannelMetadataService } from 'src/engine/metadata-modules/message-channel/message-channel-metadata.service';
|
||||
|
||||
@Injectable()
|
||||
export class MessageFolderMetadataService {
|
||||
constructor(
|
||||
@InjectRepository(MessageFolderEntity)
|
||||
private readonly repository: Repository<MessageFolderEntity>,
|
||||
private readonly messageChannelMetadataService: MessageChannelMetadataService,
|
||||
private readonly connectedAccountMetadataService: ConnectedAccountMetadataService,
|
||||
) {}
|
||||
|
||||
async findAll(workspaceId: string): Promise<MessageFolderDTO[]> {
|
||||
return this.repository.find({ where: { workspaceId } });
|
||||
}
|
||||
|
||||
async findByMessageChannelId(
|
||||
messageChannelId: string,
|
||||
workspaceId: string,
|
||||
): Promise<MessageFolderDTO[]> {
|
||||
async findByUserWorkspaceId({
|
||||
userWorkspaceId,
|
||||
workspaceId,
|
||||
}: {
|
||||
userWorkspaceId: string;
|
||||
workspaceId: string;
|
||||
}): Promise<MessageFolderDTO[]> {
|
||||
const userAccountIds =
|
||||
await this.connectedAccountMetadataService.getUserConnectedAccountIds({
|
||||
userWorkspaceId,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
const userChannels =
|
||||
await this.messageChannelMetadataService.findByConnectedAccountIds({
|
||||
connectedAccountIds: userAccountIds,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
const userChannelIds = userChannels.map((channel) => channel.id);
|
||||
|
||||
return this.findByMessageChannelIds({
|
||||
messageChannelIds: userChannelIds,
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
async findByMessageChannelIdForUser({
|
||||
messageChannelId,
|
||||
userWorkspaceId,
|
||||
workspaceId,
|
||||
}: {
|
||||
messageChannelId: string;
|
||||
userWorkspaceId: string;
|
||||
workspaceId: string;
|
||||
}): Promise<MessageFolderDTO[]> {
|
||||
await this.messageChannelMetadataService.verifyOwnership({
|
||||
id: messageChannelId,
|
||||
userWorkspaceId,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return this.findByMessageChannelId({ messageChannelId, workspaceId });
|
||||
}
|
||||
|
||||
async findByMessageChannelId({
|
||||
messageChannelId,
|
||||
workspaceId,
|
||||
}: {
|
||||
messageChannelId: string;
|
||||
workspaceId: string;
|
||||
}): Promise<MessageFolderDTO[]> {
|
||||
return this.repository.find({
|
||||
where: { messageChannelId, workspaceId },
|
||||
});
|
||||
}
|
||||
|
||||
async findById(
|
||||
id: string,
|
||||
workspaceId: string,
|
||||
): Promise<MessageFolderDTO | null> {
|
||||
async findByMessageChannelIds({
|
||||
messageChannelIds,
|
||||
workspaceId,
|
||||
}: {
|
||||
messageChannelIds: string[];
|
||||
workspaceId: string;
|
||||
}): Promise<MessageFolderDTO[]> {
|
||||
if (messageChannelIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.repository.find({
|
||||
where: { messageChannelId: In(messageChannelIds), workspaceId },
|
||||
});
|
||||
}
|
||||
|
||||
async findById({
|
||||
id,
|
||||
workspaceId,
|
||||
}: {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
}): Promise<MessageFolderDTO | null> {
|
||||
return this.repository.findOne({ where: { id, workspaceId } });
|
||||
}
|
||||
|
||||
async verifyOwnership({
|
||||
id,
|
||||
userWorkspaceId,
|
||||
workspaceId,
|
||||
}: {
|
||||
id: string;
|
||||
userWorkspaceId: string;
|
||||
workspaceId: string;
|
||||
}): Promise<MessageFolderEntity> {
|
||||
const messageFolder = await this.repository.findOne({
|
||||
where: { id, workspaceId },
|
||||
});
|
||||
|
||||
if (!messageFolder) {
|
||||
throw new MessageFolderException(
|
||||
`Message folder ${id} not found`,
|
||||
MessageFolderExceptionCode.MESSAGE_FOLDER_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
const userAccountIds =
|
||||
await this.connectedAccountMetadataService.getUserConnectedAccountIds({
|
||||
userWorkspaceId,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
const messageChannel = await this.messageChannelMetadataService.findById({
|
||||
id: messageFolder.messageChannelId,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
if (
|
||||
!messageChannel ||
|
||||
!userAccountIds.includes(messageChannel.connectedAccountId)
|
||||
) {
|
||||
throw new MessageFolderException(
|
||||
`Message folder ${id} does not belong to user workspace ${userWorkspaceId}`,
|
||||
MessageFolderExceptionCode.MESSAGE_FOLDER_OWNERSHIP_VIOLATION,
|
||||
);
|
||||
}
|
||||
|
||||
return messageFolder;
|
||||
}
|
||||
|
||||
async create(
|
||||
data: Partial<MessageFolderEntity> & {
|
||||
workspaceId: string;
|
||||
@@ -47,11 +166,15 @@ export class MessageFolderMetadataService {
|
||||
return this.repository.save(entity);
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
workspaceId: string,
|
||||
data: Partial<MessageFolderEntity>,
|
||||
): Promise<MessageFolderDTO> {
|
||||
async update({
|
||||
id,
|
||||
workspaceId,
|
||||
data,
|
||||
}: {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
data: Partial<MessageFolderEntity>;
|
||||
}): Promise<MessageFolderDTO> {
|
||||
await this.repository.update(
|
||||
{ id, workspaceId },
|
||||
data as Record<string, unknown>,
|
||||
@@ -60,13 +183,36 @@ export class MessageFolderMetadataService {
|
||||
return this.repository.findOneOrFail({ where: { id, workspaceId } });
|
||||
}
|
||||
|
||||
async delete(id: string, workspaceId: string): Promise<MessageFolderDTO> {
|
||||
const entity = await this.repository.findOneOrFail({
|
||||
async updateMany({
|
||||
ids,
|
||||
workspaceId,
|
||||
data,
|
||||
}: {
|
||||
ids: string[];
|
||||
workspaceId: string;
|
||||
data: Partial<MessageFolderEntity>;
|
||||
}): Promise<MessageFolderDTO[]> {
|
||||
await this.repository.update(
|
||||
{ id: In(ids), workspaceId },
|
||||
data as Record<string, unknown>,
|
||||
);
|
||||
|
||||
return this.repository.find({ where: { id: In(ids), workspaceId } });
|
||||
}
|
||||
|
||||
async delete({
|
||||
id,
|
||||
workspaceId,
|
||||
}: {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
}): Promise<MessageFolderDTO> {
|
||||
const messageFolder = await this.repository.findOneOrFail({
|
||||
where: { id, workspaceId },
|
||||
});
|
||||
|
||||
await this.repository.delete({ id, workspaceId });
|
||||
|
||||
return entity;
|
||||
return messageFolder;
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -7,6 +7,7 @@ import { CustomException } from 'src/utils/custom-exception';
|
||||
export enum MessageFolderExceptionCode {
|
||||
MESSAGE_FOLDER_NOT_FOUND = 'MESSAGE_FOLDER_NOT_FOUND',
|
||||
INVALID_MESSAGE_FOLDER_INPUT = 'INVALID_MESSAGE_FOLDER_INPUT',
|
||||
MESSAGE_FOLDER_OWNERSHIP_VIOLATION = 'MESSAGE_FOLDER_OWNERSHIP_VIOLATION',
|
||||
}
|
||||
|
||||
const getMessageFolderExceptionUserFriendlyMessage = (
|
||||
@@ -17,6 +18,8 @@ const getMessageFolderExceptionUserFriendlyMessage = (
|
||||
return msg`Message folder not found.`;
|
||||
case MessageFolderExceptionCode.INVALID_MESSAGE_FOLDER_INPUT:
|
||||
return msg`Invalid message folder input.`;
|
||||
case MessageFolderExceptionCode.MESSAGE_FOLDER_OWNERSHIP_VIOLATION:
|
||||
return msg`You do not have access to this message folder.`;
|
||||
default:
|
||||
assertUnreachable(code);
|
||||
}
|
||||
|
||||
+49
-43
@@ -1,22 +1,24 @@
|
||||
import { UseGuards, UseInterceptors } from '@nestjs/common';
|
||||
import { Args, Mutation, Query } from '@nestjs/graphql';
|
||||
|
||||
import { PermissionFlagType } from 'twenty-shared/constants';
|
||||
import { FeatureFlagKey } from 'twenty-shared/types';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import {
|
||||
FeatureFlagGuard,
|
||||
RequireFeatureFlag,
|
||||
} from 'src/engine/guards/feature-flag.guard';
|
||||
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
|
||||
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import { CreateMessageFolderInput } from 'src/engine/metadata-modules/message-folder/dtos/create-message-folder.input';
|
||||
import { MessageFolderDTO } from 'src/engine/metadata-modules/message-folder/dtos/message-folder.dto';
|
||||
import { UpdateMessageFolderInput } from 'src/engine/metadata-modules/message-folder/dtos/update-message-folder.input';
|
||||
import {
|
||||
UpdateMessageFolderInput,
|
||||
UpdateMessageFoldersInput,
|
||||
} from 'src/engine/metadata-modules/message-folder/dtos/update-message-folder.input';
|
||||
import { MessageFolderGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/message-folder/interceptors/message-folder-graphql-api-exception.interceptor';
|
||||
import { MessageFolderMetadataService } from 'src/engine/metadata-modules/message-folder/message-folder-metadata.service';
|
||||
|
||||
@@ -29,10 +31,11 @@ export class MessageFolderResolver {
|
||||
) {}
|
||||
|
||||
@Query(() => [MessageFolderDTO])
|
||||
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
|
||||
@UseGuards(NoPermissionGuard)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
|
||||
async messageFolders(
|
||||
async myMessageFolders(
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
@AuthUserWorkspaceId() userWorkspaceId: string,
|
||||
@Args('messageChannelId', {
|
||||
type: () => UUIDScalarType,
|
||||
nullable: true,
|
||||
@@ -40,59 +43,62 @@ export class MessageFolderResolver {
|
||||
messageChannelId?: string,
|
||||
): Promise<MessageFolderDTO[]> {
|
||||
if (messageChannelId) {
|
||||
return this.messageFolderMetadataService.findByMessageChannelId(
|
||||
return this.messageFolderMetadataService.findByMessageChannelIdForUser({
|
||||
messageChannelId,
|
||||
workspace.id,
|
||||
);
|
||||
userWorkspaceId,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
}
|
||||
|
||||
return this.messageFolderMetadataService.findAll(workspace.id);
|
||||
}
|
||||
|
||||
@Query(() => MessageFolderDTO, { nullable: true })
|
||||
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
|
||||
async messageFolder(
|
||||
@Args('id', { type: () => UUIDScalarType }) id: string,
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
): Promise<MessageFolderDTO | null> {
|
||||
return this.messageFolderMetadataService.findById(id, workspace.id);
|
||||
}
|
||||
|
||||
@Mutation(() => MessageFolderDTO)
|
||||
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
|
||||
async createMessageFolder(
|
||||
@Args('input') input: CreateMessageFolderInput,
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
): Promise<MessageFolderDTO> {
|
||||
return this.messageFolderMetadataService.create({
|
||||
...input,
|
||||
return this.messageFolderMetadataService.findByUserWorkspaceId({
|
||||
userWorkspaceId,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
}
|
||||
|
||||
@Mutation(() => MessageFolderDTO)
|
||||
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
|
||||
@UseGuards(NoPermissionGuard)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
|
||||
async updateMessageFolder(
|
||||
@Args('input') input: UpdateMessageFolderInput,
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
@AuthUserWorkspaceId() userWorkspaceId: string,
|
||||
): Promise<MessageFolderDTO> {
|
||||
return this.messageFolderMetadataService.update(
|
||||
input.id,
|
||||
workspace.id,
|
||||
input.update,
|
||||
);
|
||||
await this.messageFolderMetadataService.verifyOwnership({
|
||||
id: input.id,
|
||||
userWorkspaceId,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
|
||||
return this.messageFolderMetadataService.update({
|
||||
id: input.id,
|
||||
workspaceId: workspace.id,
|
||||
data: input.update,
|
||||
});
|
||||
}
|
||||
|
||||
@Mutation(() => MessageFolderDTO)
|
||||
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
|
||||
@Mutation(() => [MessageFolderDTO])
|
||||
@UseGuards(NoPermissionGuard)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
|
||||
async deleteMessageFolder(
|
||||
@Args('id', { type: () => UUIDScalarType }) id: string,
|
||||
async updateMessageFolders(
|
||||
@Args('input') input: UpdateMessageFoldersInput,
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
): Promise<MessageFolderDTO> {
|
||||
return this.messageFolderMetadataService.delete(id, workspace.id);
|
||||
@AuthUserWorkspaceId() userWorkspaceId: string,
|
||||
): Promise<MessageFolderDTO[]> {
|
||||
await Promise.all(
|
||||
input.ids.map((id) =>
|
||||
this.messageFolderMetadataService.verifyOwnership({
|
||||
id,
|
||||
userWorkspaceId,
|
||||
workspaceId: workspace.id,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
return this.messageFolderMetadataService.updateMany({
|
||||
ids: input.ids,
|
||||
workspaceId: workspace.id,
|
||||
data: input.update,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+31
@@ -1,9 +1,18 @@
|
||||
import { assertUnreachable } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
ForbiddenError,
|
||||
NotFoundError,
|
||||
UserInputError,
|
||||
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
|
||||
import {
|
||||
ConnectedAccountException,
|
||||
ConnectedAccountExceptionCode,
|
||||
} from 'src/engine/metadata-modules/connected-account/connected-account.exception';
|
||||
import {
|
||||
MessageChannelException,
|
||||
MessageChannelExceptionCode,
|
||||
} from 'src/engine/metadata-modules/message-channel/message-channel.exception';
|
||||
import {
|
||||
MessageFolderException,
|
||||
MessageFolderExceptionCode,
|
||||
@@ -16,11 +25,33 @@ export const messageFolderGraphqlApiExceptionHandler = (error: Error) => {
|
||||
throw new NotFoundError(error);
|
||||
case MessageFolderExceptionCode.INVALID_MESSAGE_FOLDER_INPUT:
|
||||
throw new UserInputError(error);
|
||||
case MessageFolderExceptionCode.MESSAGE_FOLDER_OWNERSHIP_VIOLATION:
|
||||
throw new ForbiddenError(error);
|
||||
default: {
|
||||
return assertUnreachable(error.code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (error instanceof MessageChannelException) {
|
||||
switch (error.code) {
|
||||
case MessageChannelExceptionCode.MESSAGE_CHANNEL_OWNERSHIP_VIOLATION:
|
||||
case MessageChannelExceptionCode.MESSAGE_CHANNEL_NOT_FOUND:
|
||||
throw new ForbiddenError(error);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (error instanceof ConnectedAccountException) {
|
||||
switch (error.code) {
|
||||
case ConnectedAccountExceptionCode.CONNECTED_ACCOUNT_OWNERSHIP_VIOLATION:
|
||||
case ConnectedAccountExceptionCode.CONNECTED_ACCOUNT_NOT_FOUND:
|
||||
throw new ForbiddenError(error);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
};
|
||||
|
||||
+2
-1
@@ -20,7 +20,8 @@ export class MinimalMetadataResolver {
|
||||
@Query(() => MinimalMetadataDTO)
|
||||
async minimalMetadata(
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
@AuthUserWorkspaceId() userWorkspaceId: string | undefined,
|
||||
@AuthUserWorkspaceId({ allowUndefined: true })
|
||||
userWorkspaceId: string | undefined,
|
||||
): Promise<MinimalMetadataDTO> {
|
||||
return this.minimalMetadataService.getMinimalMetadata(
|
||||
workspace.id,
|
||||
|
||||
+8
-4
@@ -44,7 +44,8 @@ export class NavigationMenuItemResolver {
|
||||
@UseGuards(NoPermissionGuard)
|
||||
async navigationMenuItems(
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
@AuthUserWorkspaceId() userWorkspaceId: string | undefined,
|
||||
@AuthUserWorkspaceId({ allowUndefined: true })
|
||||
userWorkspaceId: string | undefined,
|
||||
): Promise<NavigationMenuItemDTO[]> {
|
||||
return await this.navigationMenuItemService.findAll({
|
||||
workspaceId: workspace.id,
|
||||
@@ -69,7 +70,8 @@ export class NavigationMenuItemResolver {
|
||||
async createNavigationMenuItem(
|
||||
@Args('input') input: CreateNavigationMenuItemInput,
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
@AuthUserWorkspaceId() userWorkspaceId: string | undefined,
|
||||
@AuthUserWorkspaceId({ allowUndefined: true })
|
||||
userWorkspaceId: string | undefined,
|
||||
@AuthApiKey() apiKey: ApiKeyEntity | undefined,
|
||||
@Context() context: { req: { application?: ApplicationEntity } },
|
||||
): Promise<NavigationMenuItemDTO> {
|
||||
@@ -87,7 +89,8 @@ export class NavigationMenuItemResolver {
|
||||
async updateNavigationMenuItem(
|
||||
@Args('input') input: UpdateOneNavigationMenuItemInput,
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
@AuthUserWorkspaceId() userWorkspaceId: string | undefined,
|
||||
@AuthUserWorkspaceId({ allowUndefined: true })
|
||||
userWorkspaceId: string | undefined,
|
||||
@AuthApiKey() apiKey: ApiKeyEntity | undefined,
|
||||
@Context() context: { req: { application?: ApplicationEntity } },
|
||||
): Promise<NavigationMenuItemDTO> {
|
||||
@@ -105,7 +108,8 @@ export class NavigationMenuItemResolver {
|
||||
async deleteNavigationMenuItem(
|
||||
@Args('id', { type: () => UUIDScalarType }) id: string,
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
@AuthUserWorkspaceId() userWorkspaceId: string | undefined,
|
||||
@AuthUserWorkspaceId({ allowUndefined: true })
|
||||
userWorkspaceId: string | undefined,
|
||||
@AuthApiKey() apiKey: ApiKeyEntity | undefined,
|
||||
@Context() context: { req: { application?: ApplicationEntity } },
|
||||
): Promise<NavigationMenuItemDTO> {
|
||||
|
||||
+4
-2
@@ -56,7 +56,8 @@ export class ViewController {
|
||||
async findMany(
|
||||
@RequestLocale() locale: keyof typeof APP_LOCALES | undefined,
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
@AuthUserWorkspaceId() userWorkspaceId: string | undefined,
|
||||
@AuthUserWorkspaceId({ allowUndefined: true })
|
||||
userWorkspaceId: string | undefined,
|
||||
@Query('objectMetadataId') objectMetadataId?: string,
|
||||
): Promise<ViewDTO[]> {
|
||||
const views = objectMetadataId
|
||||
@@ -134,7 +135,8 @@ export class ViewController {
|
||||
@Body() input: UpdateViewInput,
|
||||
@RequestLocale() locale: keyof typeof APP_LOCALES | undefined,
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
@AuthUserWorkspaceId() userWorkspaceId: string | undefined,
|
||||
@AuthUserWorkspaceId({ allowUndefined: true })
|
||||
userWorkspaceId: string | undefined,
|
||||
): Promise<ViewDTO> {
|
||||
const updatedView = await this.viewService.updateOne({
|
||||
updateViewInput: {
|
||||
|
||||
@@ -96,7 +96,8 @@ export class ViewResolver {
|
||||
@UseGuards(CustomPermissionGuard)
|
||||
async getViews(
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
@AuthUserWorkspaceId() userWorkspaceId: string | undefined,
|
||||
@AuthUserWorkspaceId({ allowUndefined: true })
|
||||
userWorkspaceId: string | undefined,
|
||||
@Args('objectMetadataId', { type: () => String, nullable: true })
|
||||
objectMetadataId?: string,
|
||||
@Args('viewTypes', { type: () => [ViewType], nullable: true })
|
||||
@@ -138,7 +139,8 @@ export class ViewResolver {
|
||||
async createView(
|
||||
@Args('input') input: CreateViewInput,
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
@AuthUserWorkspaceId() userWorkspaceId: string | undefined,
|
||||
@AuthUserWorkspaceId({ allowUndefined: true })
|
||||
userWorkspaceId: string | undefined,
|
||||
): Promise<ViewDTO> {
|
||||
const visibility = input.visibility ?? ViewVisibility.WORKSPACE;
|
||||
|
||||
@@ -157,7 +159,8 @@ export class ViewResolver {
|
||||
@Args('id', { type: () => String }) id: string,
|
||||
@Args('input') input: UpdateViewInput,
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
@AuthUserWorkspaceId() userWorkspaceId: string | undefined,
|
||||
@AuthUserWorkspaceId({ allowUndefined: true })
|
||||
userWorkspaceId: string | undefined,
|
||||
): Promise<ViewDTO> {
|
||||
return await this.viewService.updateOne({
|
||||
updateViewInput: { ...input, id },
|
||||
|
||||
Reference in New Issue
Block a user