Compare commits

...
Author SHA1 Message Date
Sonarly Claude Code 5a0474750d chore: improve monitoring for PostgreSQL enum mismatch: chart filter value "WON_
## Monitoring: Properly classify filter validation errors in chart data services

All three chart data services (line, pie, bar) had catch blocks that wrapped ALL non-ChartDataException errors as `QUERY_EXECUTION_FAILED`, which maps to `InternalServerError` in Sentry. This means user input errors (like invalid filter values) were reported as internal server errors, creating false noise.

Added a check for `CommonQueryRunnerException` (which includes `INVALID_ARGS_FILTER`) before the generic catch. These are now classified as `INVALID_WIDGET_CONFIGURATION`, which maps to `UserInputError` — a handled, expected error that won't trigger Sentry alerts.

This change reduces Sentry noise by correctly categorizing errors that are caused by user-configured filters referencing stale/invalid enum values.
2026-03-12 15:15:42 +00:00
Sonarly Claude Code 71b09b0389 PostgreSQL enum mismatch: chart filter value "WON_SUBSCRIPTION" not in _lead_stage_enum
https://sonarly.com/issue/6393?type=bug

Dashboard LineChartData query fails when a SELECT field filter references an enum value that doesn't exist in the PostgreSQL enum type, producing an unhandled InternalServerError.

Fix: ## Fix: Validate SELECT/MULTI_SELECT filter values against field options

Added a new `validateSelectFieldOrThrow` validator that checks:
1. The filter value is a string (type check)
2. The filter value exists in the field's configured options (enum membership check)

This validator is called from `validateAndTransformValueByFieldType` for `SELECT` and `MULTI_SELECT` field types, which was previously falling through to the `default` case with no validation. This means invalid enum values like "WON_SUBSCRIPTION" were passed directly to PostgreSQL as SQL parameters, causing a database-level error.

With this fix, invalid enum values are caught before the SQL query is built, and a `CommonQueryRunnerException` with `INVALID_ARGS_FILTER` code is thrown. This is then classified as a `UserInputError` (not `InternalServerError`) by the chart data exception handler.

### Files changed:
- **New**: `validator-utils/validate-select-field-or-throw.util.ts` — SELECT field validator following the same pattern as `validate-boolean-field-or-throw.util.ts`
- **Modified**: `utils/validate-and-transform-value-by-field-type.util.ts` — Added `SELECT`/`MULTI_SELECT` cases to the switch statement
- **New**: `validator-utils/__tests__/validate-select-field-or-throw.util.spec.ts` — Unit tests for the new validator
2026-03-12 15:15:42 +00:00
6 changed files with 135 additions and 0 deletions
@@ -5,6 +5,7 @@ import { validateBooleanFieldOrThrow } from 'src/engine/api/common/common-args-p
import { validateDateFieldOrThrow } from 'src/engine/api/common/common-args-processors/filter-arg-processor/validator-utils/validate-date-field-or-throw.util';
import { validateDateTimeFieldOrThrow } from 'src/engine/api/common/common-args-processors/filter-arg-processor/validator-utils/validate-date-time-field-or-throw.util';
import { validateNumberFieldOrThrow } from 'src/engine/api/common/common-args-processors/filter-arg-processor/validator-utils/validate-number-field-or-throw.util';
import { validateSelectFieldOrThrow } from 'src/engine/api/common/common-args-processors/filter-arg-processor/validator-utils/validate-select-field-or-throw.util';
import { validateUUIDFieldOrThrow } from 'src/engine/api/common/common-args-processors/filter-arg-processor/validator-utils/validate-uuid-field-or-throw.util';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
@@ -56,6 +57,12 @@ export const validateAndTransformValueByFieldType = (
return value;
case FieldMetadataType.SELECT:
case FieldMetadataType.MULTI_SELECT:
validateSelectFieldOrThrow(value, fieldMetadata, fieldName);
return value;
default:
return value;
}
@@ -0,0 +1,58 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { validateSelectFieldOrThrow } from 'src/engine/api/common/common-args-processors/filter-arg-processor/validator-utils/validate-select-field-or-throw.util';
import { CommonQueryRunnerException } from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
const createSelectFieldMetadata = (
options: { value: string }[],
): FlatFieldMetadata =>
({
type: FieldMetadataType.SELECT,
name: 'stage',
options,
}) as unknown as FlatFieldMetadata;
describe('validateSelectFieldOrThrow', () => {
it('should accept a valid option value', () => {
const fieldMetadata = createSelectFieldMetadata([
{ value: 'OPEN' },
{ value: 'CLOSED' },
]);
expect(validateSelectFieldOrThrow('OPEN', fieldMetadata, 'stage')).toBe(
'OPEN',
);
});
it('should throw for a value not in the options', () => {
const fieldMetadata = createSelectFieldMetadata([
{ value: 'OPEN' },
{ value: 'CLOSED' },
]);
expect(() =>
validateSelectFieldOrThrow(
'WON_SUBSCRIPTION',
fieldMetadata,
'stage',
),
).toThrow(CommonQueryRunnerException);
});
it('should throw for a non-string value', () => {
const fieldMetadata = createSelectFieldMetadata([{ value: 'OPEN' }]);
expect(() =>
validateSelectFieldOrThrow(123, fieldMetadata, 'stage'),
).toThrow(CommonQueryRunnerException);
});
it('should accept any string value when field has no options', () => {
const fieldMetadata = createSelectFieldMetadata([]);
expect(
validateSelectFieldOrThrow('ANYTHING', fieldMetadata, 'stage'),
).toBe('ANYTHING');
});
});
@@ -0,0 +1,37 @@
import { msg } from '@lingui/core/macro';
import {
CommonQueryRunnerException,
CommonQueryRunnerExceptionCode,
} from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
export const validateSelectFieldOrThrow = (
value: unknown,
fieldMetadata: FlatFieldMetadata,
fieldName: string,
): string => {
if (typeof value !== 'string') {
throw new CommonQueryRunnerException(
`Invalid select value for field "${fieldName}": expected string, got ${typeof value}`,
CommonQueryRunnerExceptionCode.INVALID_ARGS_FILTER,
{
userFriendlyMessage: msg`Invalid filter value for field "${fieldName}"`,
},
);
}
const validOptions = fieldMetadata.options?.map((option) => option.value);
if (validOptions && validOptions.length > 0 && !validOptions.includes(value)) {
throw new CommonQueryRunnerException(
`Invalid select value "${value}" for field "${fieldName}". Valid options: ${validOptions.join(', ')}`,
CommonQueryRunnerExceptionCode.INVALID_ARGS_FILTER,
{
userFriendlyMessage: msg`Invalid filter value "${value}" for field "${fieldName}"`,
},
);
}
return value;
};
@@ -8,6 +8,7 @@ import {
isDefined,
} from 'twenty-shared/utils';
import { CommonQueryRunnerException } from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception';
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
@@ -194,6 +195,16 @@ export class BarChartDataService {
throw error;
}
if (error instanceof CommonQueryRunnerException) {
throw new ChartDataException(
generateChartDataExceptionMessage(
ChartDataExceptionCode.INVALID_WIDGET_CONFIGURATION,
`Bar chart filter is invalid: ${error.message}`,
),
ChartDataExceptionCode.INVALID_WIDGET_CONFIGURATION,
);
}
throw new ChartDataException(
generateChartDataExceptionMessage(
ChartDataExceptionCode.QUERY_EXECUTION_FAILED,
@@ -7,6 +7,7 @@ import {
isDefined,
} from 'twenty-shared/utils';
import { CommonQueryRunnerException } from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception';
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
@@ -202,6 +203,16 @@ export class LineChartDataService {
throw error;
}
if (error instanceof CommonQueryRunnerException) {
throw new ChartDataException(
generateChartDataExceptionMessage(
ChartDataExceptionCode.INVALID_WIDGET_CONFIGURATION,
`Line chart filter is invalid: ${error.message}`,
),
ChartDataExceptionCode.INVALID_WIDGET_CONFIGURATION,
);
}
throw new ChartDataException(
generateChartDataExceptionMessage(
ChartDataExceptionCode.QUERY_EXECUTION_FAILED,
@@ -7,6 +7,7 @@ import {
isDefined,
} from 'twenty-shared/utils';
import { CommonQueryRunnerException } from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception';
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
@@ -147,6 +148,16 @@ export class PieChartDataService {
throw error;
}
if (error instanceof CommonQueryRunnerException) {
throw new ChartDataException(
generateChartDataExceptionMessage(
ChartDataExceptionCode.INVALID_WIDGET_CONFIGURATION,
`Pie chart filter is invalid: ${error.message}`,
),
ChartDataExceptionCode.INVALID_WIDGET_CONFIGURATION,
);
}
throw new ChartDataException(
generateChartDataExceptionMessage(
ChartDataExceptionCode.QUERY_EXECUTION_FAILED,