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
@@ -48,7 +48,8 @@ export class McpCoreController {
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthApiKey() apiKey: ApiKeyEntity | undefined,
@AuthUser({ allowUndefined: true }) user: UserEntity | undefined,
@AuthUserWorkspaceId() userWorkspaceId: string | undefined,
@AuthUserWorkspaceId({ allowUndefined: true })
userWorkspaceId: string | undefined,
@Res({ passthrough: true }) res: Response,
) {
const result = await this.mcpProtocolService.handleMCPCoreQuery(body, {
@@ -112,7 +112,7 @@ export class ApplicationDevelopmentResolver {
@Args() { applicationId }: GenerateApplicationTokenInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
@AuthUser({ allowUndefined: true }) user?: { id: string },
@AuthUserWorkspaceId() userWorkspaceId?: string,
@AuthUserWorkspaceId({ allowUndefined: true }) userWorkspaceId?: string,
): Promise<ApplicationTokenPairDTO> {
return this.applicationTokenService.generateApplicationTokenPair({
workspaceId,
@@ -37,7 +37,8 @@ export class SearchResolver {
@Query(() => SearchResultConnectionDTO)
async search(
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthUserWorkspaceId() userWorkspaceId: string | undefined,
@AuthUserWorkspaceId({ allowUndefined: true })
userWorkspaceId: string | undefined,
@AuthApiKey() apiKey: ApiKeyEntity | undefined,
@Args()
{
@@ -362,7 +362,8 @@ export class WorkspaceResolver {
@ResolveField(() => [ViewDTO])
async views(
@Parent() workspace: WorkspaceEntity,
@AuthUserWorkspaceId() userWorkspaceId: string | undefined,
@AuthUserWorkspaceId({ allowUndefined: true })
userWorkspaceId: string | undefined,
): Promise<ViewDTO[]> {
return this.viewService.findByWorkspaceId(workspace.id, userWorkspaceId);
}
@@ -1,11 +1,25 @@
import { type ExecutionContext, createParamDecorator } from '@nestjs/common';
import {
type ExecutionContext,
ForbiddenException,
createParamDecorator,
} from '@nestjs/common';
import { getRequest } from 'src/utils/extract-request';
interface DecoratorOptions {
allowUndefined?: boolean;
}
export const AuthUserWorkspaceId = createParamDecorator(
(_data: unknown, ctx: ExecutionContext) => {
(options: DecoratorOptions | undefined, ctx: ExecutionContext) => {
const request = getRequest(ctx);
if (!options?.allowUndefined && !request.userWorkspaceId) {
throw new ForbiddenException(
'This endpoint requires a user context. API keys are not supported.',
);
}
return request.userWorkspaceId;
},
);
@@ -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 },
@@ -58,7 +58,8 @@ export class EventStreamResolver {
@Args('eventStreamId') eventStreamId: string,
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthUser({ allowUndefined: true }) user: AuthContextUser | undefined,
@AuthUserWorkspaceId() userWorkspaceId: string | undefined,
@AuthUserWorkspaceId({ allowUndefined: true })
userWorkspaceId: string | undefined,
@AuthApiKey() apiKey: ApiKeyEntity | undefined,
) {
const eventStreamChannelId = eventStreamIdToChannelId(eventStreamId);
@@ -124,7 +125,8 @@ export class EventStreamResolver {
@Args('input') input: AddQuerySubscriptionInput,
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthUser({ allowUndefined: true }) user: AuthContextUser | undefined,
@AuthUserWorkspaceId() userWorkspaceId: string | undefined,
@AuthUserWorkspaceId({ allowUndefined: true })
userWorkspaceId: string | undefined,
@AuthApiKey() apiKey: ApiKeyEntity | undefined,
): Promise<boolean> {
const eventStreamChannelId = eventStreamIdToChannelId(input.eventStreamId);
@@ -168,7 +170,8 @@ export class EventStreamResolver {
@Args('input') input: RemoveQueryFromEventStreamInput,
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthUser({ allowUndefined: true }) user: AuthContextUser | undefined,
@AuthUserWorkspaceId() userWorkspaceId: string | undefined,
@AuthUserWorkspaceId({ allowUndefined: true })
userWorkspaceId: string | undefined,
@AuthApiKey() apiKey: ApiKeyEntity | undefined,
): Promise<boolean> {
const eventStreamChannelId = eventStreamIdToChannelId(input.eventStreamId);
@@ -12,6 +12,7 @@ import { seedAgents } from 'src/engine/workspace-manager/dev-seeder/core/utils/s
import { seedApiKeys } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-api-keys.util';
import { seedFeatureFlags } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util';
import { seedMetadataEntities } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-metadata-entities.util';
import { seedServerId } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-server-id.util';
import { seedUserWorkspaces } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-user-workspaces.util';
import { seedUsers } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-users.util';
@@ -93,6 +94,8 @@ export const seedCoreSchema = async ({
await seedBillingSubscriptions({ queryRunner, schemaName, workspaceId });
}
await seedMetadataEntities({ queryRunner, schemaName, workspaceId });
await queryRunner.commitTransaction();
} catch (error) {
await queryRunner.rollbackTransaction();
@@ -0,0 +1,306 @@
import { type QueryRunner } from 'typeorm';
import {
CalendarChannelVisibility,
MessageChannelSyncStage,
MessageChannelType,
MessageChannelVisibility,
MessageFolderPendingSyncAction,
} from 'twenty-shared/types';
import {
SEED_APPLE_WORKSPACE_ID,
SEED_YCOMBINATOR_WORKSPACE_ID,
} from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
import { USER_WORKSPACE_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-user-workspaces.util';
import { CALENDAR_CHANNEL_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/calendar-channel-data-seeds.constant';
import { CONNECTED_ACCOUNT_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/connected-account-data-seeds.constant';
import { MESSAGE_CHANNEL_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/message-channel-data-seeds.constant';
import { MESSAGE_FOLDER_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/message-folder-data-seeds.constant';
type SeedMetadataEntitiesArgs = {
queryRunner: QueryRunner;
schemaName: string;
workspaceId: string;
};
export const seedMetadataEntities = async ({
queryRunner,
schemaName,
workspaceId,
}: SeedMetadataEntitiesArgs) => {
if (
workspaceId !== SEED_APPLE_WORKSPACE_ID &&
workspaceId !== SEED_YCOMBINATOR_WORKSPACE_ID
) {
return;
}
if (workspaceId === SEED_YCOMBINATOR_WORKSPACE_ID) {
return;
}
await seedConnectedAccounts({ queryRunner, schemaName, workspaceId });
await seedMessageChannels({ queryRunner, schemaName, workspaceId });
await seedCalendarChannels({ queryRunner, schemaName, workspaceId });
await seedMessageFolders({ queryRunner, schemaName, workspaceId });
};
const seedConnectedAccounts = async ({
queryRunner,
schemaName,
workspaceId,
}: SeedMetadataEntitiesArgs) => {
const connectedAccounts = [
{
id: CONNECTED_ACCOUNT_DATA_SEED_IDS.TIM,
handle: 'tim@apple.dev',
provider: 'google',
userWorkspaceId: USER_WORKSPACE_DATA_SEED_IDS.TIM,
workspaceId,
},
{
id: CONNECTED_ACCOUNT_DATA_SEED_IDS.JONY,
handle: 'jony.ive@apple.dev',
provider: 'google',
userWorkspaceId: USER_WORKSPACE_DATA_SEED_IDS.JONY,
workspaceId,
},
{
id: CONNECTED_ACCOUNT_DATA_SEED_IDS.PHIL,
handle: 'phil.schiler@apple.dev',
provider: 'google',
userWorkspaceId: USER_WORKSPACE_DATA_SEED_IDS.PHIL,
workspaceId,
},
{
id: CONNECTED_ACCOUNT_DATA_SEED_IDS.JANE,
handle: 'jane.austen@apple.dev',
provider: 'google',
userWorkspaceId: USER_WORKSPACE_DATA_SEED_IDS.JANE,
workspaceId,
},
{
id: CONNECTED_ACCOUNT_DATA_SEED_IDS.JANE_DELETABLE,
handle: 'jane-deletable@apple.dev',
provider: 'google',
userWorkspaceId: USER_WORKSPACE_DATA_SEED_IDS.JANE,
workspaceId,
},
];
await queryRunner.manager
.createQueryBuilder()
.insert()
.into(`${schemaName}.connectedAccount`, [
'id',
'handle',
'provider',
'userWorkspaceId',
'workspaceId',
])
.orIgnore()
.values(connectedAccounts)
.execute();
};
const seedMessageChannels = async ({
queryRunner,
schemaName,
workspaceId,
}: SeedMetadataEntitiesArgs) => {
const messageChannels = [
{
id: MESSAGE_CHANNEL_DATA_SEED_IDS.TIM,
handle: 'tim@apple.dev',
visibility: MessageChannelVisibility.SHARE_EVERYTHING,
type: MessageChannelType.EMAIL,
syncStage: MessageChannelSyncStage.MESSAGE_LIST_FETCH_PENDING,
isContactAutoCreationEnabled: true,
contactAutoCreationPolicy: 'SENT_AND_RECEIVED',
messageFolderImportPolicy: 'ALL_FOLDERS',
excludeNonProfessionalEmails: false,
excludeGroupEmails: false,
pendingGroupEmailsAction: 'NONE',
isSyncEnabled: true,
connectedAccountId: CONNECTED_ACCOUNT_DATA_SEED_IDS.TIM,
workspaceId,
},
{
id: MESSAGE_CHANNEL_DATA_SEED_IDS.JONY,
handle: 'jony.ive@apple.dev',
visibility: MessageChannelVisibility.SHARE_EVERYTHING,
type: MessageChannelType.EMAIL,
syncStage: MessageChannelSyncStage.MESSAGE_LIST_FETCH_PENDING,
isContactAutoCreationEnabled: true,
contactAutoCreationPolicy: 'SENT_AND_RECEIVED',
messageFolderImportPolicy: 'ALL_FOLDERS',
excludeNonProfessionalEmails: false,
excludeGroupEmails: false,
pendingGroupEmailsAction: 'NONE',
isSyncEnabled: true,
connectedAccountId: CONNECTED_ACCOUNT_DATA_SEED_IDS.JONY,
workspaceId,
},
{
id: MESSAGE_CHANNEL_DATA_SEED_IDS.JANE,
handle: 'jane.austen@apple.dev',
visibility: MessageChannelVisibility.SHARE_EVERYTHING,
type: MessageChannelType.EMAIL,
syncStage: MessageChannelSyncStage.MESSAGE_LIST_FETCH_PENDING,
isContactAutoCreationEnabled: true,
contactAutoCreationPolicy: 'SENT_AND_RECEIVED',
messageFolderImportPolicy: 'ALL_FOLDERS',
excludeNonProfessionalEmails: false,
excludeGroupEmails: false,
pendingGroupEmailsAction: 'NONE',
isSyncEnabled: true,
connectedAccountId: CONNECTED_ACCOUNT_DATA_SEED_IDS.JANE,
workspaceId,
},
];
await queryRunner.manager
.createQueryBuilder()
.insert()
.into(`${schemaName}.messageChannel`, [
'id',
'handle',
'visibility',
'type',
'syncStage',
'isContactAutoCreationEnabled',
'contactAutoCreationPolicy',
'messageFolderImportPolicy',
'excludeNonProfessionalEmails',
'excludeGroupEmails',
'pendingGroupEmailsAction',
'isSyncEnabled',
'connectedAccountId',
'workspaceId',
])
.orIgnore()
.values(messageChannels)
.execute();
};
const seedCalendarChannels = async ({
queryRunner,
schemaName,
workspaceId,
}: SeedMetadataEntitiesArgs) => {
const calendarChannels = [
{
id: CALENDAR_CHANNEL_DATA_SEED_IDS.TIM,
handle: 'tim@apple.dev',
visibility: CalendarChannelVisibility.METADATA,
syncStage: 'CALENDAR_EVENT_LIST_FETCH_PENDING',
isContactAutoCreationEnabled: true,
contactAutoCreationPolicy: 'NONE',
isSyncEnabled: true,
connectedAccountId: CONNECTED_ACCOUNT_DATA_SEED_IDS.TIM,
workspaceId,
},
{
id: CALENDAR_CHANNEL_DATA_SEED_IDS.JONY,
handle: 'jony@apple.dev',
visibility: CalendarChannelVisibility.SHARE_EVERYTHING,
syncStage: 'CALENDAR_EVENT_LIST_FETCH_PENDING',
isContactAutoCreationEnabled: true,
contactAutoCreationPolicy: 'NONE',
isSyncEnabled: true,
connectedAccountId: CONNECTED_ACCOUNT_DATA_SEED_IDS.JONY,
workspaceId,
},
{
id: CALENDAR_CHANNEL_DATA_SEED_IDS.JANE,
handle: 'jane.austen@apple.dev',
visibility: CalendarChannelVisibility.SHARE_EVERYTHING,
syncStage: 'CALENDAR_EVENT_LIST_FETCH_PENDING',
isContactAutoCreationEnabled: true,
contactAutoCreationPolicy: 'NONE',
isSyncEnabled: true,
connectedAccountId: CONNECTED_ACCOUNT_DATA_SEED_IDS.JANE,
workspaceId,
},
];
await queryRunner.manager
.createQueryBuilder()
.insert()
.into(`${schemaName}.calendarChannel`, [
'id',
'handle',
'visibility',
'syncStage',
'isContactAutoCreationEnabled',
'contactAutoCreationPolicy',
'isSyncEnabled',
'connectedAccountId',
'workspaceId',
])
.orIgnore()
.values(calendarChannels)
.execute();
};
const seedMessageFolders = async ({
queryRunner,
schemaName,
workspaceId,
}: SeedMetadataEntitiesArgs) => {
const messageFolders = [
{
id: MESSAGE_FOLDER_DATA_SEED_IDS.TIM_INBOX,
name: 'INBOX',
isSynced: true,
isSentFolder: false,
messageChannelId: MESSAGE_CHANNEL_DATA_SEED_IDS.TIM,
workspaceId,
pendingSyncAction: MessageFolderPendingSyncAction.NONE,
},
{
id: MESSAGE_FOLDER_DATA_SEED_IDS.JONY_INBOX,
name: 'INBOX',
isSynced: true,
isSentFolder: false,
messageChannelId: MESSAGE_CHANNEL_DATA_SEED_IDS.JONY,
workspaceId,
pendingSyncAction: MessageFolderPendingSyncAction.NONE,
},
{
id: MESSAGE_FOLDER_DATA_SEED_IDS.JANE_INBOX,
name: 'INBOX',
isSynced: true,
isSentFolder: false,
messageChannelId: MESSAGE_CHANNEL_DATA_SEED_IDS.JANE,
workspaceId,
pendingSyncAction: MessageFolderPendingSyncAction.NONE,
},
{
id: MESSAGE_FOLDER_DATA_SEED_IDS.JANE_SENT,
name: 'Sent',
isSynced: true,
isSentFolder: true,
messageChannelId: MESSAGE_CHANNEL_DATA_SEED_IDS.JANE,
workspaceId,
pendingSyncAction: MessageFolderPendingSyncAction.NONE,
},
];
await queryRunner.manager
.createQueryBuilder()
.insert()
.into(`${schemaName}.messageFolder`, [
'id',
'name',
'isSynced',
'isSentFolder',
'messageChannelId',
'workspaceId',
'pendingSyncAction',
])
.orIgnore()
.values(messageFolders)
.execute();
};
@@ -26,6 +26,7 @@ const GENERATE_CALENDAR_CHANNEL_IDS = (): Record<string, string> => {
CHANNEL_IDS['TIM'] = '20202020-a40f-4faf-bb9f-c6f9945b8203';
CHANNEL_IDS['JONY'] = '20202020-a40f-4faf-bb9f-c6f9945b8204';
CHANNEL_IDS['PHIL'] = '20202020-a40f-4faf-bb9f-c6f9945b8205';
CHANNEL_IDS['JANE'] = '20202020-a40f-4faf-bb9f-c6f9945b8208';
CHANNEL_IDS['COMPANY_MAIN'] = '20202020-a40f-4faf-bb9f-c6f9945b8206';
CHANNEL_IDS['TEAM_CALENDAR'] = '20202020-a40f-4faf-bb9f-c6f9945b8207';
@@ -59,6 +60,14 @@ export const CALENDAR_CHANNEL_DATA_SEEDS: CalendarChannelDataSeed[] = [
isContactAutoCreationEnabled: true,
isSyncEnabled: true,
},
{
id: CALENDAR_CHANNEL_DATA_SEED_IDS.JANE,
connectedAccountId: CONNECTED_ACCOUNT_DATA_SEED_IDS.JANE,
handle: 'jane.austen@apple.dev',
visibility: CalendarChannelVisibility.SHARE_EVERYTHING,
isContactAutoCreationEnabled: true,
isSyncEnabled: true,
},
{
id: CALENDAR_CHANNEL_DATA_SEED_IDS.COMPANY_MAIN,
connectedAccountId: CONNECTED_ACCOUNT_DATA_SEED_IDS.TIM,
@@ -31,6 +31,8 @@ export const CONNECTED_ACCOUNT_DATA_SEED_IDS = {
TIM: '20202020-9ac0-4390-9a1a-ab4d2c4e1bb7',
JONY: '20202020-0cc8-4d60-a3a4-803245698908',
PHIL: '20202020-cafc-4323-908d-e5b42ad69fdf',
JANE: '20202020-b5c7-46f0-bf5c-3f4e4b3f7c1a',
JANE_DELETABLE: '20202020-d1e5-4a8f-9c3b-7f6d5e4c3b2a',
};
export const CONNECTED_ACCOUNT_DATA_SEEDS: ConnectedAccountDataSeed[] = [
@@ -70,4 +72,28 @@ export const CONNECTED_ACCOUNT_DATA_SEEDS: ConnectedAccountDataSeed[] = [
provider: 'google',
handle: 'phil.schiler@apple.dev',
},
{
id: CONNECTED_ACCOUNT_DATA_SEED_IDS.JANE,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
lastSyncHistoryId: 'exampleLastSyncHistory',
accountOwnerId: WORKSPACE_MEMBER_DATA_SEED_IDS.JANE,
refreshToken: 'exampleRefreshToken',
accessToken: 'exampleAccessToken',
provider: 'google',
handle: 'jane.austen@apple.dev',
},
{
id: CONNECTED_ACCOUNT_DATA_SEED_IDS.JANE_DELETABLE,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
lastSyncHistoryId: 'exampleLastSyncHistory',
accountOwnerId: WORKSPACE_MEMBER_DATA_SEED_IDS.JANE,
refreshToken: 'exampleRefreshToken',
accessToken: 'exampleAccessToken',
provider: 'google',
handle: 'jane-deletable@apple.dev',
},
];
@@ -40,6 +40,7 @@ const GENERATE_MESSAGE_CHANNEL_IDS = (): Record<string, string> => {
CHANNEL_IDS['TIM'] = '20202020-9b80-4c2c-a597-383db48de1d6';
CHANNEL_IDS['JONY'] = '20202020-5ffe-4b32-814a-983d5e4911cd';
CHANNEL_IDS['PHIL'] = '20202020-e2f1-49b5-85d2-5d3a3386990c';
CHANNEL_IDS['JANE'] = '20202020-8c4d-4e71-a672-2e6a8c9f1b3d';
CHANNEL_IDS['SUPPORT'] = '20202020-e2f1-49b5-85d2-5d3a3386990d';
CHANNEL_IDS['SALES'] = '20202020-e2f1-49b5-85d2-5d3a3386990e';
@@ -88,6 +89,19 @@ export const MESSAGE_CHANNEL_DATA_SEEDS: MessageChannelDataSeed[] = [
visibility: MessageChannelVisibility.SHARE_EVERYTHING,
syncStage: MessageChannelSyncStage.MESSAGE_LIST_FETCH_PENDING,
},
{
id: MESSAGE_CHANNEL_DATA_SEED_IDS.JANE,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
isContactAutoCreationEnabled: true,
type: MessageChannelType.EMAIL,
connectedAccountId: CONNECTED_ACCOUNT_DATA_SEED_IDS.JANE,
handle: 'jane.austen@apple.dev',
isSyncEnabled: true,
visibility: MessageChannelVisibility.SHARE_EVERYTHING,
syncStage: MessageChannelSyncStage.MESSAGE_LIST_FETCH_PENDING,
},
{
id: MESSAGE_CHANNEL_DATA_SEED_IDS.SUPPORT,
createdAt: new Date(),
@@ -0,0 +1,94 @@
import { MessageFolderPendingSyncAction } from 'twenty-shared/types';
import { MESSAGE_CHANNEL_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/message-channel-data-seeds.constant';
type MessageFolderDataSeed = {
id: string;
createdAt: Date;
updatedAt: Date;
deletedAt: Date | null;
name: string;
isSynced: boolean;
isSentFolder: boolean;
messageChannelId: string;
pendingSyncAction: MessageFolderPendingSyncAction;
};
export const MESSAGE_FOLDER_DATA_SEED_COLUMNS: (keyof MessageFolderDataSeed)[] =
[
'id',
'createdAt',
'updatedAt',
'deletedAt',
'name',
'isSynced',
'isSentFolder',
'messageChannelId',
'pendingSyncAction',
];
export const MESSAGE_FOLDER_DATA_SEED_IDS = {
TIM_INBOX: '20202020-f1a2-4b3c-8d4e-5f6a7b8c9d0e',
TIM_SENT: '20202020-f1a2-4b3c-8d4e-5f6a7b8c9d1e',
JONY_INBOX: '20202020-f1a2-4b3c-8d4e-5f6a7b8c9d2e',
JANE_INBOX: '20202020-f1a2-4b3c-8d4e-5f6a7b8c9d3e',
JANE_SENT: '20202020-f1a2-4b3c-8d4e-5f6a7b8c9d4e',
};
export const MESSAGE_FOLDER_DATA_SEEDS: MessageFolderDataSeed[] = [
{
id: MESSAGE_FOLDER_DATA_SEED_IDS.TIM_INBOX,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
name: 'INBOX',
isSynced: true,
isSentFolder: false,
messageChannelId: MESSAGE_CHANNEL_DATA_SEED_IDS.TIM,
pendingSyncAction: MessageFolderPendingSyncAction.NONE,
},
{
id: MESSAGE_FOLDER_DATA_SEED_IDS.TIM_SENT,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
name: 'Sent',
isSynced: true,
isSentFolder: true,
messageChannelId: MESSAGE_CHANNEL_DATA_SEED_IDS.TIM,
pendingSyncAction: MessageFolderPendingSyncAction.NONE,
},
{
id: MESSAGE_FOLDER_DATA_SEED_IDS.JONY_INBOX,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
name: 'INBOX',
isSynced: true,
isSentFolder: false,
messageChannelId: MESSAGE_CHANNEL_DATA_SEED_IDS.JONY,
pendingSyncAction: MessageFolderPendingSyncAction.NONE,
},
{
id: MESSAGE_FOLDER_DATA_SEED_IDS.JANE_INBOX,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
name: 'INBOX',
isSynced: true,
isSentFolder: false,
messageChannelId: MESSAGE_CHANNEL_DATA_SEED_IDS.JANE,
pendingSyncAction: MessageFolderPendingSyncAction.NONE,
},
{
id: MESSAGE_FOLDER_DATA_SEED_IDS.JANE_SENT,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
name: 'Sent',
isSynced: true,
isSentFolder: true,
messageChannelId: MESSAGE_CHANNEL_DATA_SEED_IDS.JANE,
pendingSyncAction: MessageFolderPendingSyncAction.NONE,
},
];
@@ -56,6 +56,10 @@ import {
MESSAGE_CHANNEL_DATA_SEED_COLUMNS,
MESSAGE_CHANNEL_DATA_SEEDS,
} from 'src/engine/workspace-manager/dev-seeder/data/constants/message-channel-data-seeds.constant';
import {
MESSAGE_FOLDER_DATA_SEED_COLUMNS,
MESSAGE_FOLDER_DATA_SEEDS,
} from 'src/engine/workspace-manager/dev-seeder/data/constants/message-folder-data-seeds.constant';
import {
MESSAGE_CHANNEL_MESSAGE_ASSOCIATION_DATA_SEED_COLUMNS,
MESSAGE_CHANNEL_MESSAGE_ASSOCIATION_DATA_SEEDS,
@@ -194,8 +198,13 @@ const getRecordSeedsBatches = (
},
];
// Batch 4: Depends on person/company or independent
// Batch 4: Depends on person/company/messageChannel or independent
const batch4: RecordSeedConfig[] = [
{
tableName: 'messageFolder',
pgColumns: MESSAGE_FOLDER_DATA_SEED_COLUMNS,
recordSeeds: MESSAGE_FOLDER_DATA_SEEDS,
},
{
tableName: 'opportunity',
pgColumns: OPPORTUNITY_DATA_SEED_COLUMNS,