Validate timezone input in group-by date queries (#17777)

## Summary

- Validates that the timezone parameter in group-by date expressions is
a recognized IANA timezone
- Adds SQL string literal escaping as a defense-in-depth measure for the
timezone value interpolated into SQL expressions
- Moves the `IANA_TIME_ZONES` constant to `twenty-shared` so it can be
reused across frontend and server packages
- Adds `INVALID_TIMEZONE` error code mapped to 400 Bad Request in both
GraphQL and REST API exception handlers

## Test plan

- [x] Unit tests for `validateIanaTimeZone` (valid IANA zones, fixed
offsets, rejects invalid strings)
- [x] Unit tests for `escapeSqlStringLiteral`
- [x] Integration tests for `getGroupByExpression` covering timezone
validation and granularity handling


Made with [Cursor](https://cursor.com)

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
This commit is contained in:
Félix Malfait
2026-02-08 17:06:19 +01:00
committed by GitHub
parent cc86b9acae
commit c44d61f324
23 changed files with 181 additions and 33 deletions
@@ -20,8 +20,8 @@ import {
type GroupByDateField,
type GroupByField,
type GroupByRegularField,
} from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types';
import { getGroupByOrderExpression } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/get-group-by-order-expression.util';
} from 'src/engine/api/common/common-query-runners/types/group-by-field.types';
import { getGroupByOrderExpression } from 'src/engine/api/common/common-query-runners/utils/get-group-by-order-expression.util';
import { ProcessAggregateHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-aggregate.helper';
import {
type AggregationField,
@@ -5,8 +5,8 @@ import { isDefined } from 'twenty-shared/utils';
import {
type GroupByField,
type GroupByRelationField,
} from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types';
import { isGroupByRelationField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-relation-field.util';
} from 'src/engine/api/common/common-query-runners/types/group-by-field.types';
import { isGroupByRelationField } from 'src/engine/api/common/common-query-runners/utils/is-group-by-relation-field.util';
import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
@@ -18,7 +18,7 @@ import {
GraphqlQuerySelectedFieldsParser,
type GraphqlQuerySelectedFieldsResult,
} from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-selected-fields/graphql-selected-fields.parser';
import { type GroupByField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types';
import { type GroupByField } from 'src/engine/api/common/common-query-runners/types/group-by-field.types';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
@@ -1,8 +0,0 @@
import { type ObjectRecordGroupByDateGranularity } from 'twenty-shared/types';
export type GroupByDefinition = {
columnNameWithQuotes: string;
expression: string;
alias: string;
dateGranularity?: ObjectRecordGroupByDateGranularity;
};
@@ -1,31 +0,0 @@
import { type ObjectRecordGroupByDateGranularity } from 'twenty-shared/types';
import { type FirstDayOfTheWeek } from 'twenty-shared/utils';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
export type GroupByRegularField = {
fieldMetadata: FlatFieldMetadata;
subFieldName?: string;
};
export type GroupByDateField = {
fieldMetadata: FlatFieldMetadata;
subFieldName?: string;
dateGranularity: ObjectRecordGroupByDateGranularity;
weekStartDay?: FirstDayOfTheWeek;
timeZone?: string;
};
export type GroupByRelationField = {
fieldMetadata: FlatFieldMetadata;
nestedFieldMetadata: FlatFieldMetadata;
nestedSubFieldName?: string;
dateGranularity?: ObjectRecordGroupByDateGranularity;
weekStartDay?: FirstDayOfTheWeek;
timeZone?: string;
};
export type GroupByField =
| GroupByRegularField
| GroupByDateField
| GroupByRelationField;
@@ -6,7 +6,7 @@ import {
import { isDefined } from 'twenty-shared/utils';
import { type CommonGroupByOutputItem } from 'src/engine/api/common/types/common-group-by-output-item.type';
import { type GroupByDefinition } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-definition.type';
import { type GroupByDefinition } from 'src/engine/api/common/common-query-runners/types/group-by-definition.type';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
@@ -1,91 +0,0 @@
import { isNonEmptyString } from '@sniptt/guards';
import { GROUP_BY_DATE_GRANULARITY_THAT_REQUIRE_TIME_ZONE } from 'twenty-shared/constants';
import {
FieldMetadataType,
ObjectRecordGroupByDateGranularity,
} from 'twenty-shared/types';
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
import {
CommonQueryRunnerException,
CommonQueryRunnerExceptionCode,
} from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception';
import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant';
import { type GroupByField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types';
import { isGroupByDateField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-date-field.util';
import { isGroupByRelationField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-relation-field.util';
export const getGroupByExpression = ({
groupByField,
columnNameWithQuotes,
}: {
groupByField: GroupByField;
columnNameWithQuotes: string;
}) => {
if (
!(isGroupByDateField(groupByField) || isGroupByRelationField(groupByField))
) {
return columnNameWithQuotes;
}
const dateGranularity = groupByField.dateGranularity;
if (!isDefined(dateGranularity)) {
return columnNameWithQuotes;
}
const shouldUseTimeZone =
GROUP_BY_DATE_GRANULARITY_THAT_REQUIRE_TIME_ZONE.includes(
dateGranularity,
) && groupByField.fieldMetadata.type === FieldMetadataType.DATE_TIME;
const timeZoneIsNotProvided = !isNonEmptyString(groupByField.timeZone);
if (shouldUseTimeZone && timeZoneIsNotProvided) {
throw new CommonQueryRunnerException(
'Time zone should be specified for a group by date on Day, Week, Month, Quarter or Year',
CommonQueryRunnerExceptionCode.MISSING_TIMEZONE_FOR_DATE_GROUP_BY,
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
);
}
const timeZoneAsDateTruncParameter = shouldUseTimeZone
? `, '${groupByField.timeZone}'`
: '';
const timeZoneAsToCharParameter = shouldUseTimeZone
? ` AT TIME ZONE '${groupByField.timeZone}'`
: '';
switch (dateGranularity) {
case ObjectRecordGroupByDateGranularity.NONE:
return columnNameWithQuotes;
case ObjectRecordGroupByDateGranularity.DAY_OF_THE_WEEK:
return `TRIM(TO_CHAR(${columnNameWithQuotes}, 'TMDay'))`;
case ObjectRecordGroupByDateGranularity.MONTH_OF_THE_YEAR:
return `TRIM(TO_CHAR(${columnNameWithQuotes}, 'TMMonth'))`;
case ObjectRecordGroupByDateGranularity.QUARTER_OF_THE_YEAR:
return `TRIM(TO_CHAR(${columnNameWithQuotes}, '"Q"Q'))`;
case ObjectRecordGroupByDateGranularity.WEEK: {
const weekStartDay = groupByField.weekStartDay;
let shiftedExpression = `DATE_TRUNC('week', ${columnNameWithQuotes}${timeZoneAsDateTruncParameter})`;
if (isDefined(weekStartDay)) {
if (weekStartDay === 'SUNDAY') {
shiftedExpression = `(DATE_TRUNC('week', ${columnNameWithQuotes} + INTERVAL '1 day'${timeZoneAsDateTruncParameter}) - INTERVAL '1 day')`;
} else if (weekStartDay === 'SATURDAY') {
shiftedExpression = `(DATE_TRUNC('week', ${columnNameWithQuotes} + INTERVAL '2 days'${timeZoneAsDateTruncParameter}) - INTERVAL '2 days')`;
}
}
return `TO_CHAR(${shiftedExpression}${timeZoneAsToCharParameter}, 'YYYY-MM-DD')`;
}
case ObjectRecordGroupByDateGranularity.DAY:
case ObjectRecordGroupByDateGranularity.MONTH:
case ObjectRecordGroupByDateGranularity.QUARTER:
case ObjectRecordGroupByDateGranularity.YEAR:
return `TO_CHAR(DATE_TRUNC('${dateGranularity}', ${columnNameWithQuotes}${timeZoneAsDateTruncParameter})${timeZoneAsToCharParameter}, 'YYYY-MM-DD')`;
default:
assertUnreachable(dateGranularity);
}
};
@@ -1,82 +0,0 @@
import {
FirstDayOfTheWeek,
ObjectRecordGroupByDateGranularity,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type GroupByField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types';
import { getGroupByExpression } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/get-group-by-expression.util';
import { isGroupByDateField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-date-field.util';
import { isGroupByRelationField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-relation-field.util';
const DAYS_OF_WEEK = [
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
'Sunday',
] as const;
const getDayOfWeekOrderExpression = (
groupByExpression: string,
weekStartDay?: FirstDayOfTheWeek,
): string => {
const startDay = weekStartDay ?? FirstDayOfTheWeek.MONDAY;
const startIndex =
startDay === FirstDayOfTheWeek.SUNDAY
? 6
: startDay === FirstDayOfTheWeek.SATURDAY
? 5
: 0;
const orderedDays = [
...DAYS_OF_WEEK.slice(startIndex),
...DAYS_OF_WEEK.slice(0, startIndex),
];
const caseConditions = orderedDays
.map((day, index) => `WHEN '${day}' THEN ${index + 1}`)
.join(' ');
return `CASE ${groupByExpression} ${caseConditions} END`;
};
export const getGroupByOrderExpression = ({
groupByField,
columnNameWithQuotes,
}: {
groupByField: GroupByField;
columnNameWithQuotes: string;
}): string => {
if (
!(isGroupByDateField(groupByField) || isGroupByRelationField(groupByField))
) {
return getGroupByExpression({ groupByField, columnNameWithQuotes });
}
const dateGranularity = groupByField.dateGranularity;
if (!isDefined(dateGranularity)) {
return getGroupByExpression({ groupByField, columnNameWithQuotes });
}
const groupByExpression = getGroupByExpression({
groupByField,
columnNameWithQuotes,
});
switch (dateGranularity) {
case ObjectRecordGroupByDateGranularity.DAY_OF_THE_WEEK:
return getDayOfWeekOrderExpression(
groupByExpression,
groupByField.weekStartDay,
);
case ObjectRecordGroupByDateGranularity.MONTH_OF_THE_YEAR:
return `CASE ${groupByExpression} WHEN 'January' THEN 1 WHEN 'February' THEN 2 WHEN 'March' THEN 3 WHEN 'April' THEN 4 WHEN 'May' THEN 5 WHEN 'June' THEN 6 WHEN 'July' THEN 7 WHEN 'August' THEN 8 WHEN 'September' THEN 9 WHEN 'October' THEN 10 WHEN 'November' THEN 11 WHEN 'December' THEN 12 END`;
default:
return groupByExpression;
}
};
@@ -1,13 +0,0 @@
import {
type GroupByDateField,
type GroupByField,
} from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types';
export const isGroupByDateField = (
groupByField: GroupByField,
): groupByField is GroupByDateField => {
return (
'dateGranularity' in groupByField &&
!('nestedFieldMetadata' in groupByField)
);
};
@@ -1,10 +0,0 @@
import {
type GroupByField,
type GroupByRelationField,
} from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types';
export const isGroupByRelationField = (
groupByField: GroupByField,
): groupByField is GroupByRelationField => {
return 'nestedFieldMetadata' in groupByField;
};
@@ -3,7 +3,7 @@ import { isDefined } from 'twenty-shared/utils';
import { type GroupByResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { type GroupByField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types';
import { type GroupByField } from 'src/engine/api/common/common-query-runners/types/group-by-field.types';
import { isGroupByDateFieldDefinition } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-date-field-definition.util';
import { parseGroupByRelationField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/parse-group-by-relation-field.util';
import { validateSingleKeyForGroupByOrThrow } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/validate-single-key-for-group-by-or-throw.util';
@@ -8,7 +8,7 @@ import {
} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
import { type CompositeFieldGroupByDefinition } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/composite-field-group-by-definition.type';
import { type DateFieldGroupByDefinition } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/date-field-group-by-definition.type';
import { type GroupByField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types';
import { type GroupByField } from 'src/engine/api/common/common-query-runners/types/group-by-field.types';
import { isGroupByDateFieldDefinition } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-date-field-definition.util';
import { validateSingleKeyForGroupByOrThrow } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/validate-single-key-for-group-by-or-throw.util';
import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
@@ -1,9 +0,0 @@
const removeQuotes = (string: string): string => {
return string.replace(/["']/g, '');
};
export const formatColumnNameAsAlias = (
columnNameWithQuotes: string,
): string => {
return removeQuotes(columnNameWithQuotes).replace(/\./g, '_');
};
@@ -14,7 +14,7 @@ import { CommonExtendedQueryRunnerContext } from 'src/engine/api/common/types/co
import { type CommonGroupByOutputItem } from 'src/engine/api/common/types/common-group-by-output-item.type';
import { CommonSelectedFieldsResult } from 'src/engine/api/common/types/common-selected-fields-result.type';
import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser';
import { type GroupByDefinition } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-definition.type';
import { type GroupByDefinition } from 'src/engine/api/common/common-query-runners/types/group-by-definition.type';
import { formatResultWithGroupByDimensionValues } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/format-result-with-group-by-dimension-values.util';
import { getGroupLimit } from 'src/engine/api/graphql/graphql-query-runner/group-by/utils/get-group-limit.util';
import { ProcessNestedRelationsHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations.helper';