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:
+16
@@ -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
|
||||
>[];
|
||||
}>;
|
||||
};
|
||||
+143
@@ -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);
|
||||
});
|
||||
});
|
||||
+79
@@ -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,
|
||||
}),
|
||||
};
|
||||
};
|
||||
-47
@@ -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);
|
||||
};
|
||||
-57
@@ -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);
|
||||
};
|
||||
+39
@@ -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,
|
||||
},
|
||||
);
|
||||
};
|
||||
+28
@@ -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,
|
||||
});
|
||||
};
|
||||
-46
@@ -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`,
|
||||
},
|
||||
);
|
||||
};
|
||||
+2
-12
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user