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:
Charles Bochet
2026-03-20 17:22:22 +01:00
committed by GitHub
parent a8625d8bfb
commit 9cb21e71fa
88 changed files with 3412 additions and 1233 deletions
@@ -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,
@@ -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;
}
}
@@ -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);
}
@@ -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);
@@ -59,9 +59,7 @@ export class CalendarChannelDTO {
@Field()
isSyncEnabled: boolean;
@IsString()
@IsOptional()
@Field(() => String, { nullable: true })
@HideField()
syncCursor: string | null;
@IsDateString()
@@ -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 })
@@ -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,
});
}
}
@@ -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;
};
@@ -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;
}
}
@@ -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);
}
@@ -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) {
@@ -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()
@@ -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;
}
@@ -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,
});
}
}
@@ -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);
}
@@ -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);
@@ -75,9 +75,7 @@ export class MessageChannelDTO {
@Field()
isSyncEnabled: boolean;
@IsString()
@IsOptional()
@Field(() => String, { nullable: true })
@HideField()
syncCursor: string | null;
@IsDateString()
@@ -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 })
@@ -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,
@@ -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;
}
}
@@ -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);
}
@@ -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,
});
}
}
@@ -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;
};
@@ -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}`,
@@ -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()
@@ -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;
}
@@ -41,6 +41,7 @@ export class MessageFolderEntity extends WorkspaceRelatedEntity {
type: 'enum',
enum: MessageFolderPendingSyncAction,
nullable: false,
default: MessageFolderPendingSyncAction.NONE,
})
pendingSyncAction: MessageFolderPendingSyncAction;
@@ -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,
@@ -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;
}
}
@@ -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);
}
@@ -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,
});
}
}
@@ -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;
};
@@ -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,
@@ -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> {
@@ -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 },