Fix unclear metadata validation errors (#20234)

https://github.com/user-attachments/assets/8f8f1122-3de1-4a9b-8bb4-a3c8d31e47ae

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Abdul Rahman
2026-05-05 23:48:05 +05:30
committed by GitHub
parent a03c2647cf
commit e0563377b5
70 changed files with 935 additions and 771 deletions
@@ -32,6 +32,7 @@ import {
graphQLErrorCodesToFilter,
shouldCaptureException,
} from 'src/engine/utils/global-exception-handler.util';
import { translateUserFriendlyMessageDescriptors } from 'src/engine/core-modules/i18n/utils/translate-user-friendly-message-descriptors.util';
const DEFAULT_EVENT_ID_KEY = 'exceptionEventId';
const SCHEMA_VERSION_HEADER = 'x-schema-version';
@@ -223,13 +224,15 @@ export const useGraphQLErrorHandlerHook = <
error instanceof BaseGraphQLError
? {
...error,
extensions: {
...error.extensions,
userFriendlyMessage: i18n._(
error.extensions.userFriendlyMessage ??
extensions: translateUserFriendlyMessageDescriptors(
{
...error.extensions,
userFriendlyMessage:
error.extensions.userFriendlyMessage ??
defaultErrorMessage,
),
},
},
i18n,
),
}
: generateGraphQLErrorFromError(error, i18n);
@@ -0,0 +1,148 @@
import { type I18n, type MessageDescriptor } from '@lingui/core';
import { translateUserFriendlyMessageDescriptors } from 'src/engine/core-modules/i18n/utils/translate-user-friendly-message-descriptors.util';
const buildDescriptor = (id: string): MessageDescriptor => ({
id,
message: id,
});
const i18nMock = {
_: (descriptor: MessageDescriptor) => `tr:${descriptor.id}`,
} as unknown as I18n;
describe('translateUserFriendlyMessageDescriptors', () => {
it('translates a top-level userFriendlyMessage descriptor to a string', () => {
const result = translateUserFriendlyMessageDescriptors(
{ userFriendlyMessage: buildDescriptor('top-level') },
i18nMock,
);
expect(result).toEqual({ userFriendlyMessage: 'tr:top-level' });
});
it('leaves a userFriendlyMessage value untouched when it is already a string', () => {
const result = translateUserFriendlyMessageDescriptors(
{ userFriendlyMessage: 'already a string' },
i18nMock,
);
expect(result).toEqual({ userFriendlyMessage: 'already a string' });
});
it('does not translate descriptors stored under other keys', () => {
const descriptor = buildDescriptor('not-user-friendly');
const result = translateUserFriendlyMessageDescriptors(
{ someOtherKey: descriptor },
i18nMock,
);
expect(result).toEqual({
someOtherKey: { id: 'not-user-friendly', message: 'not-user-friendly' },
});
});
it('translates userFriendlyMessage descriptors nested inside arrays of objects', () => {
const result = translateUserFriendlyMessageDescriptors(
{
errors: [
{ code: 'A', userFriendlyMessage: buildDescriptor('first') },
{ code: 'B', userFriendlyMessage: buildDescriptor('second') },
],
},
i18nMock,
);
expect(result).toEqual({
errors: [
{ code: 'A', userFriendlyMessage: 'tr:first' },
{ code: 'B', userFriendlyMessage: 'tr:second' },
],
});
});
it('preserves primitives, null and undefined values found along the way', () => {
const result = translateUserFriendlyMessageDescriptors(
{
statusCode: 400,
message: 'Validation failed',
eventId: null,
traceId: undefined,
nested: { count: 0, flag: false },
},
i18nMock,
);
expect(result).toEqual({
statusCode: 400,
message: 'Validation failed',
eventId: null,
traceId: undefined,
nested: { count: 0, flag: false },
});
});
it('handles a realistic metadata-validation payload with both top-level and per-error descriptors', () => {
const result = translateUserFriendlyMessageDescriptors(
{
userFriendlyMessage: buildDescriptor('Field value is required'),
summary: { totalErrors: 1, viewGroup: 1 },
errors: {
viewGroup: [
{
status: 'fail',
type: 'create',
metadataName: 'viewGroup',
errors: [
{
code: 'INVALID_VIEW_DATA',
message: 'Field value is required',
userFriendlyMessage: buildDescriptor(
'Field value is required',
),
},
],
},
],
},
},
i18nMock,
);
expect(result).toEqual({
userFriendlyMessage: 'tr:Field value is required',
summary: { totalErrors: 1, viewGroup: 1 },
errors: {
viewGroup: [
{
status: 'fail',
type: 'create',
metadataName: 'viewGroup',
errors: [
{
code: 'INVALID_VIEW_DATA',
message: 'Field value is required',
userFriendlyMessage: 'tr:Field value is required',
},
],
},
],
},
});
});
it('does not mutate the input payload', () => {
const descriptor = buildDescriptor('mutation-check');
const input = { userFriendlyMessage: descriptor, nested: { value: 1 } };
const result = translateUserFriendlyMessageDescriptors(input, i18nMock);
expect(input.userFriendlyMessage).toBe(descriptor);
expect(result).not.toBe(input);
expect(result).toEqual({
userFriendlyMessage: 'tr:mutation-check',
nested: { value: 1 },
});
});
});
@@ -0,0 +1,40 @@
import { type I18n, type MessageDescriptor } from '@lingui/core';
import { isArray, isObject, isString } from '@sniptt/guards';
const USER_FRIENDLY_MESSAGE_KEY = 'userFriendlyMessage';
const isMessageDescriptor = (value: unknown): value is MessageDescriptor =>
isObject(value) && 'id' in value && isString(value.id);
const translateValueRecursively = (
value: unknown,
i18n: I18n,
parentKey?: string,
): unknown => {
if (parentKey === USER_FRIENDLY_MESSAGE_KEY && isMessageDescriptor(value)) {
return i18n._(value);
}
if (isArray(value)) {
return value.map((item) =>
translateValueRecursively(item, i18n, parentKey),
);
}
if (isObject(value)) {
return Object.fromEntries(
Object.entries(value).map(([key, nestedValue]) => [
key,
translateValueRecursively(nestedValue, i18n, key),
]),
);
}
return value;
};
export const translateUserFriendlyMessageDescriptors = (
payload: object,
i18n: I18n,
): Record<string, unknown> =>
translateValueRecursively(payload, i18n) as Record<string, unknown>;
@@ -95,7 +95,6 @@ export class FieldMetadataResolver {
async createOneField(
@Args('input') input: CreateOneFieldMetadataInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
@Context() context: I18nContext,
) {
try {
const flatFieldMetadata = await this.fieldMetadataService.createOneField({
@@ -105,10 +104,7 @@ export class FieldMetadataResolver {
return fromFlatFieldMetadataToFieldMetadataDto(flatFieldMetadata);
} catch (error) {
return fieldMetadataGraphqlApiExceptionHandler(
error,
this.i18nService.getI18nInstance(context.req.locale),
);
return fieldMetadataGraphqlApiExceptionHandler(error);
}
}
@@ -117,7 +113,6 @@ export class FieldMetadataResolver {
async updateOneField(
@Args('input') input: UpdateOneFieldMetadataInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
@Context() context: I18nContext,
) {
try {
const flatFieldMetadata = await this.fieldMetadataService.updateOneField({
@@ -127,10 +122,7 @@ export class FieldMetadataResolver {
return fromFlatFieldMetadataToFieldMetadataDto(flatFieldMetadata);
} catch (error) {
fieldMetadataGraphqlApiExceptionHandler(
error,
this.i18nService.getI18nInstance(context.req.locale),
);
fieldMetadataGraphqlApiExceptionHandler(error);
}
}
@@ -139,7 +131,6 @@ export class FieldMetadataResolver {
async deleteOneField(
@Args('input') deleteOneFieldInput: DeleteOneFieldInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
@Context() context: I18nContext,
) {
if (!isDefined(workspaceId)) {
throw new ForbiddenError('Could not retrieve workspace ID');
@@ -153,10 +144,7 @@ export class FieldMetadataResolver {
return fromFlatFieldMetadataToFieldMetadataDto(flatFieldMetadata);
} catch (error) {
fieldMetadataGraphqlApiExceptionHandler(
error,
this.i18nService.getI18nInstance(context.req.locale),
);
fieldMetadataGraphqlApiExceptionHandler(error);
}
}
@@ -168,7 +156,7 @@ export class FieldMetadataResolver {
id: fieldMetadataId,
objectMetadataId,
}: Pick<FieldMetadataDTO, 'id' | 'objectMetadataId'>,
@Context() context: { loaders: IDataloaders } & I18nContext,
@Context() context: { loaders: IDataloaders },
): Promise<RelationDTO | null> {
try {
return await context.loaders.relationLoader.load({
@@ -177,10 +165,7 @@ export class FieldMetadataResolver {
workspaceId: workspace.id,
});
} catch (error) {
return fieldMetadataGraphqlApiExceptionHandler(
error,
this.i18nService.getI18nInstance(context.req.locale),
);
return fieldMetadataGraphqlApiExceptionHandler(error);
}
}
@@ -192,7 +177,7 @@ export class FieldMetadataResolver {
id: fieldMetadataId,
objectMetadataId,
}: Pick<FieldMetadataDTO, 'id' | 'objectMetadataId'>,
@Context() context: { loaders: IDataloaders } & I18nContext,
@Context() context: { loaders: IDataloaders },
): Promise<RelationDTO[] | null> {
try {
return await context.loaders.morphRelationLoader.load({
@@ -201,10 +186,7 @@ export class FieldMetadataResolver {
workspaceId: workspace.id,
});
} catch (error) {
return fieldMetadataGraphqlApiExceptionHandler(
error,
this.i18nService.getI18nInstance(context.req.locale),
);
return fieldMetadataGraphqlApiExceptionHandler(error);
}
}
}
@@ -4,31 +4,19 @@ import {
Injectable,
type NestInterceptor,
} from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
import { type Observable, catchError } from 'rxjs';
import { SOURCE_LOCALE } from 'twenty-shared/translations';
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
import { fieldMetadataGraphqlApiExceptionHandler } from 'src/engine/metadata-modules/field-metadata/utils/field-metadata-graphql-api-exception-handler.util';
@Injectable()
export class FieldMetadataGraphqlApiExceptionInterceptor
implements NestInterceptor
{
constructor(private readonly i18nService: I18nService) {}
// oxlint-disable-next-line @typescripttypescript/no-explicit-any
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const gqlContext = GqlExecutionContext.create(context);
const ctx = gqlContext.getContext();
const locale = ctx.req?.locale ?? SOURCE_LOCALE;
const i18n = this.i18nService.getI18nInstance(locale);
intercept(_context: ExecutionContext, next: CallHandler): Observable<any> {
return next
.handle()
.pipe(
catchError((err) => fieldMetadataGraphqlApiExceptionHandler(err, i18n)),
);
.pipe(catchError((err) => fieldMetadataGraphqlApiExceptionHandler(err)));
}
}
@@ -1,4 +1,3 @@
import { type I18n } from '@lingui/core';
import { assertUnreachable } from 'twenty-shared/utils';
import {
@@ -13,14 +12,11 @@ import {
} from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
import { InvalidMetadataException } from 'src/engine/metadata-modules/utils/exceptions/invalid-metadata.exception';
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
import { workspaceMigrationBuilderExceptionFormatter } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-builder-exception-formatter';
import { workspaceMigrationBuilderGraphqlApiExceptionHandler } from 'src/engine/workspace-manager/workspace-migration/interceptors/utils/workspace-migration-builder-graphql-api-exception-handler.util';
export const fieldMetadataGraphqlApiExceptionHandler = (
error: Error,
i18n: I18n,
) => {
export const fieldMetadataGraphqlApiExceptionHandler = (error: Error) => {
if (error instanceof WorkspaceMigrationBuilderException) {
workspaceMigrationBuilderExceptionFormatter(error, i18n);
workspaceMigrationBuilderGraphqlApiExceptionHandler(error);
}
if (error instanceof InvalidMetadataException) {
@@ -1,14 +1,12 @@
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
import { Context, Parent, ResolveField } from '@nestjs/graphql';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
import { type I18nContext } from 'src/engine/core-modules/i18n/types/i18n-context.type';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { type IDataloaders } from 'src/engine/dataloaders/dataloader.interface';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { IndexFieldMetadataDTO } from 'src/engine/metadata-modules/index-metadata/dtos/index-field-metadata.dto';
import { IndexMetadataDTO } from 'src/engine/metadata-modules/index-metadata/dtos/index-metadata.dto';
@@ -23,13 +21,11 @@ import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-module
PermissionsGraphqlApiExceptionFilter,
)
export class IndexMetadataResolver {
constructor(private readonly i18nService: I18nService) {}
@ResolveField(() => [IndexFieldMetadataDTO], { nullable: false })
async indexFieldMetadataList(
@AuthWorkspace() workspace: WorkspaceEntity,
@Parent() indexMetadata: IndexMetadataDTO,
@Context() context: { loaders: IDataloaders } & I18nContext,
@Context() context: { loaders: IDataloaders },
): Promise<IndexFieldMetadataDTO[]> {
try {
const indexFieldMetadataItems =
@@ -41,10 +37,7 @@ export class IndexMetadataResolver {
return indexFieldMetadataItems;
} catch (error) {
objectMetadataGraphqlApiExceptionHandler(
error,
this.i18nService.getI18nInstance(context.req.locale),
);
objectMetadataGraphqlApiExceptionHandler(error);
return [];
}
@@ -4,33 +4,19 @@ import {
Injectable,
type NestInterceptor,
} from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
import { type Observable, catchError } from 'rxjs';
import { SOURCE_LOCALE } from 'twenty-shared/translations';
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
import { objectMetadataGraphqlApiExceptionHandler } from 'src/engine/metadata-modules/object-metadata/utils/object-metadata-graphql-api-exception-handler.util';
@Injectable()
export class ObjectMetadataGraphqlApiExceptionInterceptor
implements NestInterceptor
{
constructor(private readonly i18nService: I18nService) {}
// oxlint-disable-next-line @typescripttypescript/no-explicit-any
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const gqlContext = GqlExecutionContext.create(context);
const ctx = gqlContext.getContext();
const locale = ctx.req?.locale ?? SOURCE_LOCALE;
const i18n = this.i18nService.getI18nInstance(locale);
intercept(_context: ExecutionContext, next: CallHandler): Observable<any> {
return next
.handle()
.pipe(
catchError((err) =>
objectMetadataGraphqlApiExceptionHandler(err, i18n),
),
);
.pipe(catchError((err) => objectMetadataGraphqlApiExceptionHandler(err)));
}
}
@@ -137,7 +137,6 @@ export class ObjectMetadataResolver {
async createOneObject(
@Args('input') input: CreateOneObjectInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
@Context() context: I18nContext,
) {
try {
const flatobjectMetadata =
@@ -148,10 +147,7 @@ export class ObjectMetadataResolver {
return fromFlatObjectMetadataToObjectMetadataDto(flatobjectMetadata);
} catch (error) {
objectMetadataGraphqlApiExceptionHandler(
error,
this.i18nService.getI18nInstance(context.req.locale),
);
objectMetadataGraphqlApiExceptionHandler(error);
}
}
@@ -160,7 +156,6 @@ export class ObjectMetadataResolver {
async deleteOneObject(
@Args('input') deleteObjectInput: DeleteOneObjectInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
@Context() context: I18nContext,
) {
try {
const flatobjectMetadata =
@@ -171,10 +166,7 @@ export class ObjectMetadataResolver {
return fromFlatObjectMetadataToObjectMetadataDto(flatobjectMetadata);
} catch (error) {
objectMetadataGraphqlApiExceptionHandler(
error,
this.i18nService.getI18nInstance(context.req.locale),
);
objectMetadataGraphqlApiExceptionHandler(error);
}
}
@@ -183,7 +175,6 @@ export class ObjectMetadataResolver {
async updateOneObject(
@Args('input') updateObjectInput: UpdateOneObjectInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
@Context() context: I18nContext,
) {
try {
const flatobjectMetadata =
@@ -194,10 +185,7 @@ export class ObjectMetadataResolver {
return fromFlatObjectMetadataToObjectMetadataDto(flatobjectMetadata);
} catch (error) {
objectMetadataGraphqlApiExceptionHandler(
error,
this.i18nService.getI18nInstance(context.req.locale),
);
objectMetadataGraphqlApiExceptionHandler(error);
}
}
@@ -218,10 +206,7 @@ export class ObjectMetadataResolver {
return fieldMetadataItems;
} catch (error) {
objectMetadataGraphqlApiExceptionHandler(
error,
this.i18nService.getI18nInstance(context.req.locale),
);
objectMetadataGraphqlApiExceptionHandler(error);
return [];
}
@@ -231,7 +216,7 @@ export class ObjectMetadataResolver {
async indexMetadataList(
@AuthWorkspace() workspace: WorkspaceEntity,
@Parent() objectMetadata: ObjectMetadataDTO,
@Context() context: { loaders: IDataloaders } & I18nContext,
@Context() context: { loaders: IDataloaders },
): Promise<IndexMetadataDTO[]> {
try {
const indexMetadataItems = await context.loaders.indexMetadataLoader.load(
@@ -243,10 +228,7 @@ export class ObjectMetadataResolver {
return indexMetadataItems;
} catch (error) {
objectMetadataGraphqlApiExceptionHandler(
error,
this.i18nService.getI18nInstance(context.req.locale),
);
objectMetadataGraphqlApiExceptionHandler(error);
return [];
}
@@ -1,4 +1,3 @@
import { type I18n } from '@lingui/core';
import { assertUnreachable } from 'twenty-shared/utils';
import {
@@ -14,14 +13,11 @@ import {
} from 'src/engine/metadata-modules/object-metadata/object-metadata.exception';
import { InvalidMetadataException } from 'src/engine/metadata-modules/utils/exceptions/invalid-metadata.exception';
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
import { workspaceMigrationBuilderExceptionFormatter } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-builder-exception-formatter';
import { workspaceMigrationBuilderGraphqlApiExceptionHandler } from 'src/engine/workspace-manager/workspace-migration/interceptors/utils/workspace-migration-builder-graphql-api-exception-handler.util';
export const objectMetadataGraphqlApiExceptionHandler = (
error: Error,
i18n: I18n,
) => {
export const objectMetadataGraphqlApiExceptionHandler = (error: Error) => {
if (error instanceof WorkspaceMigrationBuilderException) {
workspaceMigrationBuilderExceptionFormatter(error, i18n);
workspaceMigrationBuilderGraphqlApiExceptionHandler(error);
}
if (error instanceof InvalidMetadataException) {
@@ -1,6 +1,7 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { msg } from '@lingui/core/macro';
import { type ObjectsPermissionsByRoleId } from 'twenty-shared/types';
import { type Repository } from 'typeorm';
@@ -755,7 +756,7 @@ describe('FieldPermissionService', () => {
{
code: PermissionsExceptionCode.ROLE_NOT_EDITABLE,
message: 'Role is not editable',
userFriendlyMessage: 'This role cannot be modified.',
userFriendlyMessage: msg`This role cannot be modified.`,
},
],
},
@@ -1,4 +1,3 @@
import { type I18n } from '@lingui/core';
import { assertUnreachable } from 'twenty-shared/utils';
import {
@@ -18,14 +17,11 @@ import {
PageLayoutExceptionCode,
} from 'src/engine/metadata-modules/page-layout/exceptions/page-layout.exception';
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
import { workspaceMigrationBuilderExceptionFormatter } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-builder-exception-formatter';
import { workspaceMigrationBuilderGraphqlApiExceptionHandler } from 'src/engine/workspace-manager/workspace-migration/interceptors/utils/workspace-migration-builder-graphql-api-exception-handler.util';
export const pageLayoutGraphqlApiExceptionHandler = (
error: Error,
i18n: I18n,
) => {
export const pageLayoutGraphqlApiExceptionHandler = (error: Error) => {
if (error instanceof WorkspaceMigrationBuilderException) {
return workspaceMigrationBuilderExceptionFormatter(error, i18n);
return workspaceMigrationBuilderGraphqlApiExceptionHandler(error);
}
if (error instanceof PageLayoutException) {
@@ -4,11 +4,7 @@ import {
type ExecutionContext,
Injectable,
} from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
import { SOURCE_LOCALE } from 'twenty-shared/translations';
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
import { PageLayoutTabException } from 'src/engine/metadata-modules/page-layout-tab/exceptions/page-layout-tab.exception';
import { PageLayoutWidgetException } from 'src/engine/metadata-modules/page-layout-widget/exceptions/page-layout-widget.exception';
import { PageLayoutException } from 'src/engine/metadata-modules/page-layout/exceptions/page-layout.exception';
@@ -23,21 +19,14 @@ import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager
)
@Injectable()
export class PageLayoutGraphqlApiExceptionFilter implements ExceptionFilter {
constructor(private readonly i18nService: I18nService) {}
catch(
exception:
| PageLayoutException
| PageLayoutTabException
| PageLayoutWidgetException
| WorkspaceMigrationBuilderException,
host: ExecutionContext,
_host: ExecutionContext,
) {
const gqlContext = GqlExecutionContext.create(host);
const ctx = gqlContext.getContext();
const userLocale = ctx.req?.locale ?? SOURCE_LOCALE;
const i18n = this.i18nService.getI18nInstance(userLocale);
return pageLayoutGraphqlApiExceptionHandler(exception, i18n);
return pageLayoutGraphqlApiExceptionHandler(exception);
}
}
@@ -16,7 +16,7 @@ import {
ViewFieldExceptionCode,
} from 'src/engine/metadata-modules/view-field/exceptions/view-field.exception';
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
import { fromWorkspaceMigrationBuilderExceptionToMetadataValidationResponseError } from 'src/engine/workspace-manager/workspace-migration/interceptors/utils/from-workspace-migration-builder-exception-to-metadata-validation-response-error.util';
import { workspaceMigrationBuilderRestApiExceptionHandler } from 'src/engine/workspace-manager/workspace-migration/interceptors/utils/workspace-migration-builder-rest-api-exception-handler.util';
import {
type CustomException,
UnknownException,
@@ -38,19 +38,10 @@ export class ViewFieldRestApiExceptionFilter implements ExceptionFilter {
const response = ctx.getResponse<Response>();
if (exception instanceof WorkspaceMigrationBuilderException) {
const i18n = this.i18nService.getI18nInstance(SOURCE_LOCALE);
const { errors, summary } =
fromWorkspaceMigrationBuilderExceptionToMetadataValidationResponseError(
exception,
i18n,
);
return response.status(400).json({
statusCode: 400,
error: 'METADATA_VALIDATION_ERROR',
message: exception.message || 'Validation failed',
errors,
summary,
return workspaceMigrationBuilderRestApiExceptionHandler({
exception,
response,
i18n: this.i18nService.getI18nInstance(SOURCE_LOCALE),
});
}
@@ -16,7 +16,7 @@ import {
ViewFilterGroupExceptionCode,
} from 'src/engine/metadata-modules/view-filter-group/exceptions/view-filter-group.exception';
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
import { fromWorkspaceMigrationBuilderExceptionToMetadataValidationResponseError } from 'src/engine/workspace-manager/workspace-migration/interceptors/utils/from-workspace-migration-builder-exception-to-metadata-validation-response-error.util';
import { workspaceMigrationBuilderRestApiExceptionHandler } from 'src/engine/workspace-manager/workspace-migration/interceptors/utils/workspace-migration-builder-rest-api-exception-handler.util';
import {
type CustomException,
UnknownException,
@@ -38,19 +38,10 @@ export class ViewFilterGroupRestApiExceptionFilter implements ExceptionFilter {
const response = ctx.getResponse<Response>();
if (exception instanceof WorkspaceMigrationBuilderException) {
const i18n = this.i18nService.getI18nInstance(SOURCE_LOCALE);
const { errors, summary } =
fromWorkspaceMigrationBuilderExceptionToMetadataValidationResponseError(
exception,
i18n,
);
return response.status(400).json({
statusCode: 400,
error: 'METADATA_VALIDATION_ERROR',
message: exception.message || 'Validation failed',
errors,
summary,
return workspaceMigrationBuilderRestApiExceptionHandler({
exception,
response,
i18n: this.i18nService.getI18nInstance(SOURCE_LOCALE),
});
}
@@ -16,7 +16,7 @@ import {
ViewFilterExceptionCode,
} from 'src/engine/metadata-modules/view-filter/exceptions/view-filter.exception';
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
import { fromWorkspaceMigrationBuilderExceptionToMetadataValidationResponseError } from 'src/engine/workspace-manager/workspace-migration/interceptors/utils/from-workspace-migration-builder-exception-to-metadata-validation-response-error.util';
import { workspaceMigrationBuilderRestApiExceptionHandler } from 'src/engine/workspace-manager/workspace-migration/interceptors/utils/workspace-migration-builder-rest-api-exception-handler.util';
import {
type CustomException,
UnknownException,
@@ -38,19 +38,10 @@ export class ViewFilterRestApiExceptionFilter implements ExceptionFilter {
const response = ctx.getResponse<Response>();
if (exception instanceof WorkspaceMigrationBuilderException) {
const i18n = this.i18nService.getI18nInstance(SOURCE_LOCALE);
const { errors, summary } =
fromWorkspaceMigrationBuilderExceptionToMetadataValidationResponseError(
exception,
i18n,
);
return response.status(400).json({
statusCode: 400,
error: 'METADATA_VALIDATION_ERROR',
message: exception.message || 'Validation failed',
errors,
summary,
return workspaceMigrationBuilderRestApiExceptionHandler({
exception,
response,
i18n: this.i18nService.getI18nInstance(SOURCE_LOCALE),
});
}
@@ -16,7 +16,7 @@ import {
ViewGroupExceptionCode,
} from 'src/engine/metadata-modules/view-group/exceptions/view-group.exception';
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
import { fromWorkspaceMigrationBuilderExceptionToMetadataValidationResponseError } from 'src/engine/workspace-manager/workspace-migration/interceptors/utils/from-workspace-migration-builder-exception-to-metadata-validation-response-error.util';
import { workspaceMigrationBuilderRestApiExceptionHandler } from 'src/engine/workspace-manager/workspace-migration/interceptors/utils/workspace-migration-builder-rest-api-exception-handler.util';
import {
type CustomException,
UnknownException,
@@ -38,19 +38,10 @@ export class ViewGroupRestApiExceptionFilter implements ExceptionFilter {
const response = ctx.getResponse<Response>();
if (exception instanceof WorkspaceMigrationBuilderException) {
const i18n = this.i18nService.getI18nInstance(SOURCE_LOCALE);
const { errors, summary } =
fromWorkspaceMigrationBuilderExceptionToMetadataValidationResponseError(
exception,
i18n,
);
return response.status(400).json({
statusCode: 400,
error: 'METADATA_VALIDATION_ERROR',
message: exception.message || 'Validation failed',
errors,
summary,
return workspaceMigrationBuilderRestApiExceptionHandler({
exception,
response,
i18n: this.i18nService.getI18nInstance(SOURCE_LOCALE),
});
}
@@ -1,5 +1,3 @@
import { type I18n } from '@lingui/core';
import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import {
ViewGroupException,
@@ -7,10 +5,7 @@ import {
} from 'src/engine/metadata-modules/view-group/exceptions/view-group.exception';
import { viewGraphqlApiExceptionHandler } from 'src/engine/metadata-modules/view/utils/view-graphql-api-exception-handler.util';
export const viewGroupGraphqlApiExceptionHandler = (
error: Error,
i18n: I18n,
) => {
export const viewGroupGraphqlApiExceptionHandler = (error: Error) => {
if (error instanceof ViewGroupException) {
if (
error.code ===
@@ -22,5 +17,5 @@ export const viewGroupGraphqlApiExceptionHandler = (
}
}
return viewGraphqlApiExceptionHandler(error, i18n);
return viewGraphqlApiExceptionHandler(error);
};
@@ -4,11 +4,7 @@ import {
type ExecutionContext,
Injectable,
} from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
import { SOURCE_LOCALE } from 'twenty-shared/translations';
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
import { ViewGroupException } from 'src/engine/metadata-modules/view-group/exceptions/view-group.exception';
import { viewGroupGraphqlApiExceptionHandler } from 'src/engine/metadata-modules/view-group/utils/view-group-graphql-api-exception-handler.util';
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
@@ -16,17 +12,10 @@ import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager
@Catch(ViewGroupException, WorkspaceMigrationBuilderException)
@Injectable()
export class ViewGroupGraphqlApiExceptionFilter implements ExceptionFilter {
constructor(private readonly i18nService: I18nService) {}
catch(
exception: ViewGroupException | WorkspaceMigrationBuilderException,
host: ExecutionContext,
_host: ExecutionContext,
) {
const gqlContext = GqlExecutionContext.create(host);
const ctx = gqlContext.getContext();
const userLocale = ctx.req?.locale ?? SOURCE_LOCALE;
const i18n = this.i18nService.getI18nInstance(userLocale);
return viewGroupGraphqlApiExceptionHandler(exception, i18n);
return viewGroupGraphqlApiExceptionHandler(exception);
}
}
@@ -16,7 +16,7 @@ import {
ViewSortExceptionCode,
} from 'src/engine/metadata-modules/view-sort/exceptions/view-sort.exception';
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
import { fromWorkspaceMigrationBuilderExceptionToMetadataValidationResponseError } from 'src/engine/workspace-manager/workspace-migration/interceptors/utils/from-workspace-migration-builder-exception-to-metadata-validation-response-error.util';
import { workspaceMigrationBuilderRestApiExceptionHandler } from 'src/engine/workspace-manager/workspace-migration/interceptors/utils/workspace-migration-builder-rest-api-exception-handler.util';
import {
type CustomException,
UnknownException,
@@ -38,19 +38,10 @@ export class ViewSortRestApiExceptionFilter implements ExceptionFilter {
const response = ctx.getResponse<Response>();
if (exception instanceof WorkspaceMigrationBuilderException) {
const i18n = this.i18nService.getI18nInstance(SOURCE_LOCALE);
const { errors, summary } =
fromWorkspaceMigrationBuilderExceptionToMetadataValidationResponseError(
exception,
i18n,
);
return response.status(400).json({
statusCode: 400,
error: 'METADATA_VALIDATION_ERROR',
message: exception.message || 'Validation failed',
errors,
summary,
return workspaceMigrationBuilderRestApiExceptionHandler({
exception,
response,
i18n: this.i18nService.getI18nInstance(SOURCE_LOCALE),
});
}
@@ -16,7 +16,7 @@ import {
ViewExceptionCode,
} from 'src/engine/metadata-modules/view/exceptions/view.exception';
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
import { fromWorkspaceMigrationBuilderExceptionToMetadataValidationResponseError } from 'src/engine/workspace-manager/workspace-migration/interceptors/utils/from-workspace-migration-builder-exception-to-metadata-validation-response-error.util';
import { workspaceMigrationBuilderRestApiExceptionHandler } from 'src/engine/workspace-manager/workspace-migration/interceptors/utils/workspace-migration-builder-rest-api-exception-handler.util';
import {
type CustomException,
UnknownException,
@@ -38,19 +38,10 @@ export class ViewRestApiExceptionFilter implements ExceptionFilter {
const response = ctx.getResponse<Response>();
if (exception instanceof WorkspaceMigrationBuilderException) {
const i18n = this.i18nService.getI18nInstance(SOURCE_LOCALE);
const { errors, summary } =
fromWorkspaceMigrationBuilderExceptionToMetadataValidationResponseError(
exception,
i18n,
);
return response.status(400).json({
statusCode: 400,
error: 'METADATA_VALIDATION_ERROR',
message: exception.message || 'Validation failed',
errors,
summary,
return workspaceMigrationBuilderRestApiExceptionHandler({
exception,
response,
i18n: this.i18nService.getI18nInstance(SOURCE_LOCALE),
});
}
@@ -1,4 +1,3 @@
import { type I18n } from '@lingui/core';
import { assertUnreachable } from 'twenty-shared/utils';
import {
@@ -36,11 +35,11 @@ import {
ViewExceptionCode,
} from 'src/engine/metadata-modules/view/exceptions/view.exception';
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
import { workspaceMigrationBuilderExceptionFormatter } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-builder-exception-formatter';
import { workspaceMigrationBuilderGraphqlApiExceptionHandler } from 'src/engine/workspace-manager/workspace-migration/interceptors/utils/workspace-migration-builder-graphql-api-exception-handler.util';
export const viewGraphqlApiExceptionHandler = (error: Error, i18n: I18n) => {
export const viewGraphqlApiExceptionHandler = (error: Error) => {
if (error instanceof WorkspaceMigrationBuilderException) {
return workspaceMigrationBuilderExceptionFormatter(error, i18n);
return workspaceMigrationBuilderGraphqlApiExceptionHandler(error);
}
if (error instanceof ViewException) {
@@ -4,11 +4,7 @@ import {
type ExecutionContext,
Injectable,
} from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
import { SOURCE_LOCALE } from 'twenty-shared/translations';
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
import { ViewFieldGroupException } from 'src/engine/metadata-modules/view-field-group/exceptions/view-field-group.exception';
import { ViewFieldException } from 'src/engine/metadata-modules/view-field/exceptions/view-field.exception';
import { ViewFilterGroupException } from 'src/engine/metadata-modules/view-filter-group/exceptions/view-filter-group.exception';
@@ -30,8 +26,6 @@ import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager
)
@Injectable()
export class ViewGraphqlApiExceptionFilter implements ExceptionFilter {
constructor(private readonly i18nService: I18nService) {}
catch(
exception:
| ViewException
@@ -42,13 +36,8 @@ export class ViewGraphqlApiExceptionFilter implements ExceptionFilter {
| ViewGroupException
| ViewSortException
| WorkspaceMigrationBuilderException,
host: ExecutionContext,
_host: ExecutionContext,
) {
const gqlContext = GqlExecutionContext.create(host);
const ctx = gqlContext.getContext();
const userLocale = ctx.req?.locale ?? SOURCE_LOCALE;
const i18n = this.i18nService.getI18nInstance(userLocale);
return viewGraphqlApiExceptionHandler(exception, i18n);
return viewGraphqlApiExceptionHandler(exception);
}
}
@@ -0,0 +1,16 @@
import { type AllMetadataName } from 'twenty-shared/metadata';
import { type WorkspaceMigrationActionType } from 'src/engine/metadata-modules/flat-entity/types/metadata-workspace-migration-action.type';
import { type FailedFlatEntityValidation } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/types/failed-flat-entity-validation.type';
export type MetadataValidationErrorResponseDescriptor = {
summary: {
totalErrors: number;
} & Partial<Record<AllMetadataName, number>>;
errors: Partial<{
[P in AllMetadataName]: FailedFlatEntityValidation<
P,
WorkspaceMigrationActionType
>[];
}>;
};
@@ -0,0 +1,143 @@
import { type MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { EMPTY_ORCHESTRATOR_FAILURE_REPORT } from 'src/engine/workspace-manager/workspace-migration/constant/empty-orchestrator-failure-report.constant';
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
import { buildMetadataValidationErrorPayload } from 'src/engine/workspace-manager/workspace-migration/interceptors/utils/build-metadata-validation-error-payload.util';
import { type OrchestratorFailureReport } from 'src/engine/workspace-manager/workspace-migration/types/workspace-migration-orchestrator.type';
const buildFailedValidation = (
errors: Array<{
code: string;
message: string;
userFriendlyMessage?: MessageDescriptor;
}>,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): any => ({
type: 'create',
errors,
flatEntityMinimalInformation: {},
});
const buildException = (
partialReport: Partial<OrchestratorFailureReport>,
): WorkspaceMigrationBuilderException =>
new WorkspaceMigrationBuilderException({
status: 'fail',
report: { ...EMPTY_ORCHESTRATOR_FAILURE_REPORT(), ...partialReport },
});
describe('buildMetadataValidationErrorPayload', () => {
it('returns an empty errors record and a totalErrors-of-0 summary when no metadata has failures', () => {
const payload = buildMetadataValidationErrorPayload(buildException({}));
expect(payload.errors).toEqual({});
expect(payload.summary).toEqual({ totalErrors: 0 });
expect(payload.userFriendlyMessage).toEqual(
msg`Metadata validation failed`,
);
});
it('aggregates per-metadata failure counts into the summary and only exposes metadata buckets that have failures', () => {
const payload = buildMetadataValidationErrorPayload(
buildException({
view: [
buildFailedValidation([{ code: 'A', message: 'a' }]),
buildFailedValidation([{ code: 'B', message: 'b' }]),
],
viewGroup: [buildFailedValidation([{ code: 'C', message: 'c' }])],
}),
);
expect(payload.summary).toEqual({
totalErrors: 3,
view: 2,
viewGroup: 1,
});
expect(Object.keys(payload.errors).sort()).toEqual(['view', 'viewGroup']);
});
it('returns the generic "Many validation errors" descriptor when more than one failed validation is reported', () => {
const payload = buildMetadataValidationErrorPayload(
buildException({
role: [
buildFailedValidation([
{
code: 'ERR',
message: 'x',
userFriendlyMessage: msg`Only error`,
},
]),
buildFailedValidation([
{
code: 'ERR2',
message: 'y',
userFriendlyMessage: msg`Other error`,
},
]),
],
}),
);
expect(payload.userFriendlyMessage).toEqual(msg`Many validation errors`);
});
it("returns the only failure's userFriendlyMessage when exactly one failed validation is reported", () => {
const userFriendlyMessage = msg`Role name is invalid`;
const payload = buildMetadataValidationErrorPayload(
buildException({
role: [
buildFailedValidation([
{
code: 'ROLE_ERROR',
message: 'invalid',
userFriendlyMessage,
},
]),
],
}),
);
expect(payload.userFriendlyMessage).toBe(userFriendlyMessage);
});
it('returns the fallback descriptor when the only reported failure has no userFriendlyMessage on any of its errors', () => {
const payload = buildMetadataValidationErrorPayload(
buildException({
view: [
buildFailedValidation([
{ code: 'X', message: 'y' },
{ code: 'Z', message: 'z' },
]),
],
}),
);
expect(payload.userFriendlyMessage).toEqual(
msg`Metadata validation failed`,
);
});
it("returns the first error's userFriendlyMessage and skips earlier errors that do not provide one", () => {
const userFriendlyMessage = msg`First friendly message`;
const payload = buildMetadataValidationErrorPayload(
buildException({
fieldMetadata: [
buildFailedValidation([
{ code: 'A', message: 'first' },
{ code: 'B', message: 'second', userFriendlyMessage },
{
code: 'C',
message: 'third',
userFriendlyMessage: msg`Would not reach this`,
},
]),
],
}),
);
expect(payload.userFriendlyMessage).toBe(userFriendlyMessage);
});
});
@@ -0,0 +1,79 @@
import { type MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { ALL_METADATA_NAME } from 'twenty-shared/metadata';
import { isDefined } from 'twenty-shared/utils';
import { type WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
import { type MetadataValidationErrorResponseDescriptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/types/metadata-validation-error-response-descriptor.type';
export type MetadataValidationErrorPayloadDescriptor =
MetadataValidationErrorResponseDescriptor & {
userFriendlyMessage: MessageDescriptor;
};
const MANY_VALIDATION_ERRORS_MESSAGE = msg`Many validation errors`;
const METADATA_VALIDATION_FAILED_MESSAGE = msg`Metadata validation failed`;
const getMetadataValidationUserFriendlyMessage = (
metadataValidation: MetadataValidationErrorResponseDescriptor,
): MessageDescriptor => {
if (metadataValidation.summary.totalErrors > 1) {
return MANY_VALIDATION_ERRORS_MESSAGE;
}
for (const metadataName of Object.values(ALL_METADATA_NAME)) {
const failedValidations = metadataValidation.errors[metadataName];
if (!isDefined(failedValidations) || failedValidations.length === 0) {
continue;
}
for (const failedValidation of failedValidations) {
for (const validationError of failedValidation.errors) {
if (isDefined(validationError.userFriendlyMessage)) {
return validationError.userFriendlyMessage;
}
}
}
}
return METADATA_VALIDATION_FAILED_MESSAGE;
};
export const buildMetadataValidationErrorPayload = (
exception: WorkspaceMigrationBuilderException,
): MetadataValidationErrorPayloadDescriptor => {
const { report } = exception.failedWorkspaceMigrationBuildResult;
const { errors, summary } = (
Object.keys(report) as (keyof typeof report)[]
).reduce<MetadataValidationErrorResponseDescriptor>(
(acc, metadataName) => {
const failedMetadataValidation = report[metadataName];
if (failedMetadataValidation.length === 0) {
return acc;
}
return {
errors: { ...acc.errors, [metadataName]: failedMetadataValidation },
summary: {
...acc.summary,
totalErrors:
acc.summary.totalErrors + failedMetadataValidation.length,
[metadataName]: failedMetadataValidation.length,
},
};
},
{ errors: {}, summary: { totalErrors: 0 } },
);
return {
errors,
summary,
userFriendlyMessage: getMetadataValidationUserFriendlyMessage({
errors,
summary,
}),
};
};
@@ -1,47 +0,0 @@
import { type I18n } from '@lingui/core';
import { type MetadataValidationErrorResponse } from 'twenty-shared/metadata';
import { type WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
import { translateOrchestratorFailureReportErrors } from 'src/engine/workspace-manager/workspace-migration/interceptors/utils/translate-orchestrator-failure-report-errors.util';
export const fromWorkspaceMigrationBuilderExceptionToMetadataValidationResponseError =
(
workspaceMigrationBuilderException: WorkspaceMigrationBuilderException,
i18n: I18n,
): MetadataValidationErrorResponse => {
const translatedReport = translateOrchestratorFailureReportErrors(
workspaceMigrationBuilderException.failedWorkspaceMigrationBuildResult
.report,
i18n,
);
const initialAccumulator: MetadataValidationErrorResponse = {
errors: {},
summary: {
totalErrors: 0,
},
};
return (
Object.keys(translatedReport) as (keyof typeof translatedReport)[]
).reduce((acc, metadataName) => {
const failedMetadataValidation = translatedReport[metadataName];
if (failedMetadataValidation.length === 0) {
return acc;
}
return {
errors: {
...acc.errors,
[metadataName]: failedMetadataValidation,
},
summary: {
...acc.summary,
totalErrors:
acc.summary.totalErrors + failedMetadataValidation.length,
[metadataName]: failedMetadataValidation.length,
},
} satisfies MetadataValidationErrorResponse;
}, initialAccumulator);
};
@@ -1,57 +0,0 @@
import { type I18n, type MessageDescriptor } from '@lingui/core';
import { isDefined } from 'twenty-shared/utils';
import { type OrchestratorFailureReport } from 'src/engine/workspace-manager/workspace-migration/types/workspace-migration-orchestrator.type';
import { type FlatEntityValidationError } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/types/failed-flat-entity-validation.type';
const isMessageDescriptor = (obj: unknown): obj is MessageDescriptor =>
isDefined(obj) &&
typeof obj === 'object' &&
Object.prototype.hasOwnProperty.call(obj, 'id') &&
Object.prototype.hasOwnProperty.call(obj, 'message') &&
typeof (obj as MessageDescriptor).id === 'string';
const translateUserFriendlyMessageInFlatEntityValidationError = <T>(
obj: T,
i18n: I18n,
parentKey?: string,
): T => {
if (!obj || typeof obj !== 'object') {
return obj;
}
if (
isMessageDescriptor(obj) &&
isDefined(parentKey) &&
parentKey ===
('userFriendlyMessage' as const satisfies keyof FlatEntityValidationError)
) {
return i18n._(obj) as T;
}
if (Array.isArray(obj)) {
return obj.map((item) =>
translateUserFriendlyMessageInFlatEntityValidationError(
item,
i18n,
parentKey,
),
) as T;
}
const result = {} as T;
for (const [key, value] of Object.entries(obj)) {
(result as Record<string, unknown>)[key] =
translateUserFriendlyMessageInFlatEntityValidationError(value, i18n, key);
}
return result;
};
export const translateOrchestratorFailureReportErrors = (
report: OrchestratorFailureReport,
i18n: I18n,
) => {
return translateUserFriendlyMessageInFlatEntityValidationError(report, i18n);
};
@@ -0,0 +1,39 @@
import { ALL_METADATA_NAME } from 'twenty-shared/metadata';
import { isDefined } from 'twenty-shared/utils';
import {
BaseGraphQLError,
ErrorCode,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { type WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
import { buildMetadataValidationErrorPayload } from 'src/engine/workspace-manager/workspace-migration/interceptors/utils/build-metadata-validation-error-payload.util';
export const workspaceMigrationBuilderGraphqlApiExceptionHandler = (
exception: WorkspaceMigrationBuilderException,
) => {
const payload = buildMetadataValidationErrorPayload(exception);
const validationSummaryMessage = `Validation failed for ${Object.values(
ALL_METADATA_NAME,
)
.flatMap((metadataName) => {
const count = payload.summary[metadataName];
if (!isDefined(count) || count === 0) {
return [];
}
return [`${count} ${metadataName}${count > 1 ? 's' : ''}`];
})
.join(', ')}`;
throw new BaseGraphQLError(
exception.message,
ErrorCode.METADATA_VALIDATION_FAILED,
{
code: 'METADATA_VALIDATION_ERROR',
...payload,
message: validationSummaryMessage,
},
);
};
@@ -0,0 +1,28 @@
import { type I18n } from '@lingui/core';
import { type Response } from 'express';
import { type WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
import { buildMetadataValidationErrorPayload } from 'src/engine/workspace-manager/workspace-migration/interceptors/utils/build-metadata-validation-error-payload.util';
import { translateUserFriendlyMessageDescriptors } from 'src/engine/core-modules/i18n/utils/translate-user-friendly-message-descriptors.util';
export const workspaceMigrationBuilderRestApiExceptionHandler = ({
exception,
response,
i18n,
}: {
exception: WorkspaceMigrationBuilderException;
response: Response;
i18n: I18n;
}): Response => {
const payload = translateUserFriendlyMessageDescriptors(
buildMetadataValidationErrorPayload(exception),
i18n,
);
return response.status(400).json({
statusCode: 400,
error: 'METADATA_VALIDATION_ERROR',
message: exception.message || 'Validation failed',
...payload,
});
};
@@ -1,46 +0,0 @@
import { type I18n } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { ALL_METADATA_NAME } from 'twenty-shared/metadata';
import { isDefined } from 'twenty-shared/utils';
import {
BaseGraphQLError,
ErrorCode,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { type WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
import { fromWorkspaceMigrationBuilderExceptionToMetadataValidationResponseError } from 'src/engine/workspace-manager/workspace-migration/interceptors/utils/from-workspace-migration-builder-exception-to-metadata-validation-response-error.util';
export const workspaceMigrationBuilderExceptionFormatter = (
error: WorkspaceMigrationBuilderException,
i18n: I18n,
) => {
const { errors, summary } =
fromWorkspaceMigrationBuilderExceptionToMetadataValidationResponseError(
error,
i18n,
);
const message = `Validation failed for ${Object.values(ALL_METADATA_NAME)
.flatMap((metadataName) => {
const count = summary[metadataName];
if (!isDefined(count) || count === 0) {
return [];
}
return [`${count} ${metadataName}${count > 1 ? 's' : ''}`];
})
.join(', ')}`;
throw new BaseGraphQLError(
error.message,
ErrorCode.METADATA_VALIDATION_FAILED,
{
code: 'METADATA_VALIDATION_ERROR',
errors,
summary,
message,
userFriendlyMessage: msg`Metadata validation failed`,
},
);
};
@@ -4,19 +4,16 @@ import {
Injectable,
type NestInterceptor,
} from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
import { type Observable, catchError } from 'rxjs';
import { SOURCE_LOCALE } from 'twenty-shared/translations';
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
import { NotFoundError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import {
FlatEntityMapsException,
FlatEntityMapsExceptionCode,
} from 'src/engine/metadata-modules/flat-entity/exceptions/flat-entity-maps.exception';
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
import { workspaceMigrationBuilderExceptionFormatter } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-builder-exception-formatter';
import { workspaceMigrationBuilderGraphqlApiExceptionHandler } from 'src/engine/workspace-manager/workspace-migration/interceptors/utils/workspace-migration-builder-graphql-api-exception-handler.util';
import { workspaceMigrationRunnerExceptionFormatter } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-runner-exception-formatter';
import { WorkspaceMigrationRunnerException } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/exceptions/workspace-migration-runner.exception';
@@ -24,14 +21,7 @@ import { WorkspaceMigrationRunnerException } from 'src/engine/workspace-manager/
export class WorkspaceMigrationGraphqlApiExceptionInterceptor
implements NestInterceptor
{
constructor(private readonly i18nService: I18nService) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const gqlContext = GqlExecutionContext.create(context);
const ctx = gqlContext.getContext();
const locale = ctx.req?.locale ?? SOURCE_LOCALE;
const i18n = this.i18nService.getI18nInstance(locale);
return next.handle().pipe(
catchError((error) => {
if (error instanceof FlatEntityMapsException) {
@@ -46,7 +36,7 @@ export class WorkspaceMigrationGraphqlApiExceptionInterceptor
}
if (error instanceof WorkspaceMigrationBuilderException) {
workspaceMigrationBuilderExceptionFormatter(error, i18n);
workspaceMigrationBuilderGraphqlApiExceptionHandler(error);
}
if (error instanceof WorkspaceMigrationRunnerException) {