Files
twenty/packages/twenty-server/src/engine/twenty-orm/utils/format-result.util.ts
T
01e0502fcf feat: add updatedBy field to track last record modifier (#16807)
<!-- CURSOR_SUMMARY -->
> [!NOTE]
> Implements last-modifier tracking and unified actor injection.
> 
> - New `ActorFromAuthContextService` replaces createdBy-only logic;
pre-query hooks now inject both `createdBy` and `updatedBy` on create
and `updatedBy` on update
> - Add `updatedBy` standard field to core/custom objects (e.g.,
`person`, `company`, `task`, `note`, `attachment`, `dashboard`,
`workflow`, `workflowRun`) with IDs, metadata builders, and ORM entity
fields
> - Record CRUD: `create` now sets both `createdBy` and `updatedBy`;
`update` sets `updatedBy`; workflow actions use a shared workflow actor
builder; REST base handler uses the new actor service
> - Seeder data and snapshots updated to include `updatedBy`; GraphQL
result formatting adds defaults/handling for empty composite fields
(incl. actor)
> - Frontend `useUpdateOneRecord` upserts returned record into the local
store after mutation
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
1f283778e9. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Félix Malfait <felix@twenty.com>
2025-12-29 09:05:17 +00:00

343 lines
10 KiB
TypeScript

import { isPlainObject } from '@nestjs/common/utils/shared.utils';
import { isNull } from '@sniptt/guards';
import {
FieldActorSource,
FieldMetadataType,
compositeTypeDefinitions,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import {
DEFAULT_ARRAY_FIELD_NULL_EQUIVALENT_VALUE,
DEFAULT_COMPOSITE_FIELDS_NULL_EQUIVALENT_VALUE,
DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE,
} from 'src/engine/api/common/common-args-processors/data-arg-processor/constants/null-equivalent-values.constant';
import { getFlatFieldsFromFlatObjectMetadata } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-flat-fields-for-flat-object-metadata.util';
import { computeCompositeColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util';
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 {
buildFieldMapsFromFlatObjectMetadata,
type FieldMapsForObject,
} from 'src/engine/metadata-modules/flat-field-metadata/utils/build-field-maps-from-flat-object-metadata.util';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { getCompositeFieldMetadataCollection } from 'src/engine/twenty-orm/utils/get-composite-field-metadata-collection';
import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
export function formatResult<T>(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data: any,
flatObjectMetadata: FlatObjectMetadata | undefined,
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>,
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>,
fieldMapsForObject?: FieldMapsForObject,
): T {
if (!isDefined(data)) {
return data;
}
if (!isPlainObject(data)) {
if (Array.isArray(data)) {
return data.map((item) =>
formatResult(
item,
flatObjectMetadata,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
fieldMapsForObject,
),
) as T;
}
return data;
}
if (!flatObjectMetadata) {
throw new Error('Object metadata is missing');
}
const fieldMaps =
fieldMapsForObject ??
buildFieldMapsFromFlatObjectMetadata(
flatFieldMetadataMaps,
flatObjectMetadata,
);
const { fieldIdByName } = fieldMaps;
const compositeFieldMetadataMap = getCompositeFieldMetadataMap(
flatObjectMetadata,
flatFieldMetadataMaps,
);
const newData: object = {};
for (const [key, value] of Object.entries(data)) {
const compositePropertyArgs = compositeFieldMetadataMap.get(key);
const fieldMetadataId =
fieldIdByName[key] ||
fieldIdByName[compositePropertyArgs?.parentField ?? ''];
const fieldMetadata = flatFieldMetadataMaps.byId[fieldMetadataId] as
| FlatFieldMetadata<FieldMetadataType>
| undefined;
const isRelation = fieldMetadata
? isFieldMetadataEntityOfType(fieldMetadata, FieldMetadataType.RELATION)
: false;
if (!compositePropertyArgs && !isRelation) {
if (isPlainObject(value)) {
// @ts-expect-error legacy noImplicitAny
newData[key] = formatResult(
value,
flatObjectMetadata,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
fieldMaps,
);
} else if (fieldMetadata) {
// @ts-expect-error legacy noImplicitAny
newData[key] = formatFieldMetadataValue(value, fieldMetadata.type);
} else {
// @ts-expect-error legacy noImplicitAny
newData[key] = value;
}
continue;
}
if (isRelation) {
if (!isDefined(fieldMetadata?.relationTargetObjectMetadataId)) {
throw new Error(
`Relation target object metadata ID is missing for field "${key}"`,
);
}
const targetObjectMetadata =
flatObjectMetadataMaps.byId[
fieldMetadata.relationTargetObjectMetadataId
];
if (!targetObjectMetadata) {
throw new Error(
`Object metadata for object metadataId "${fieldMetadata.relationTargetObjectMetadataId}" is missing`,
);
}
// @ts-expect-error legacy noImplicitAny
newData[key] = formatResult(
value,
targetObjectMetadata,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
);
}
if (!compositePropertyArgs || !isDefined(fieldMetadata)) {
continue;
}
const { parentField, ...compositeProperty } = compositePropertyArgs;
// @ts-expect-error legacy noImplicitAny
if (!newData[parentField]) {
// @ts-expect-error legacy noImplicitAny
newData[parentField] = {};
}
// @ts-expect-error legacy noImplicitAny
newData[parentField][compositeProperty.name] = isNull(value)
? transformCompositeFieldNullValue(
value,
compositeProperty.name,
fieldMetadata,
)
: value;
}
// After assembling composite fields, handle those with missing required subfields
handleEmptyCompositeFields(
newData,
flatObjectMetadata,
flatFieldMetadataMaps,
);
const fieldMetadataItemsOfTypeDateOnly = getFlatFieldsFromFlatObjectMetadata(
flatObjectMetadata,
flatFieldMetadataMaps,
).filter((field) => field.type === FieldMetadataType.DATE);
for (const dateField of fieldMetadataItemsOfTypeDateOnly) {
// @ts-expect-error legacy noImplicitAny
const rawUpdatedDate = newData[dateField.name] as string | null | undefined;
if (!isDefined(rawUpdatedDate)) {
continue;
}
// @ts-expect-error legacy noImplicitAny
newData[dateField.name] = rawUpdatedDate;
}
return newData as T;
}
export function getCompositeFieldMetadataMap(
flatObjectMetadata: FlatObjectMetadata,
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>,
) {
const compositeFieldMetadataCollection = getCompositeFieldMetadataCollection(
flatObjectMetadata,
flatFieldMetadataMaps,
);
return new Map(
compositeFieldMetadataCollection.flatMap((fieldMetadata) => {
const compositeType = compositeTypeDefinitions.get(fieldMetadata.type);
if (!compositeType) return [];
// Map each composite property to a [key, value] pair
return compositeType.properties.map((compositeProperty) => [
computeCompositeColumnName(fieldMetadata.name, compositeProperty),
{
parentField: fieldMetadata.name,
...compositeProperty,
},
]);
}),
);
}
function formatFieldMetadataValue(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
value: any,
fieldMetadataType: FieldMetadataType,
) {
if (
typeof value === 'string' &&
(fieldMetadataType === FieldMetadataType.MULTI_SELECT ||
fieldMetadataType === FieldMetadataType.ARRAY)
) {
const cleanedValue = value.replace(/{|}/g, '').trim();
return cleanedValue ? cleanedValue.split(',') : [];
}
if (isNull(value)) {
if (
fieldMetadataType === FieldMetadataType.MULTI_SELECT ||
fieldMetadataType === FieldMetadataType.ARRAY
) {
return DEFAULT_ARRAY_FIELD_NULL_EQUIVALENT_VALUE;
}
if (fieldMetadataType === FieldMetadataType.TEXT) {
return DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE;
}
return value;
}
return value;
}
function transformCompositeFieldNullValue(
value: unknown,
compositePropertyName: string,
fieldMetadata: FlatFieldMetadata,
) {
if (!isNull(value)) return value;
return (
DEFAULT_COMPOSITE_FIELDS_NULL_EQUIVALENT_VALUE[fieldMetadata.type]?.[
compositePropertyName
] ?? value
);
}
/**
* Handles composite fields with missing required subfields.
* - For nullable fields: sets to null if all required subfields are null
* - For non-nullable fields: provides a default value to prevent GraphQL errors
*
* This handles existing records that were created before the field was added
* or records with incomplete data.
*/
function handleEmptyCompositeFields(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data: Record<string, any>,
flatObjectMetadata: FlatObjectMetadata,
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>,
) {
const compositeFieldMetadataCollection = getCompositeFieldMetadataCollection(
flatObjectMetadata,
flatFieldMetadataMaps,
);
for (const fieldMetadata of compositeFieldMetadataCollection) {
const fieldValue = data[fieldMetadata.name];
if (!isDefined(fieldValue) || !isPlainObject(fieldValue)) {
continue;
}
const compositeType = compositeTypeDefinitions.get(fieldMetadata.type);
if (!compositeType) {
continue;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const typedFieldValue = fieldValue as Record<string, any>;
// Check if all required properties are null/undefined
const requiredProperties = compositeType.properties.filter(
(prop) => prop.isRequired,
);
const allRequiredPropertiesAreNull = requiredProperties.every(
(prop) =>
!isDefined(typedFieldValue[prop.name]) ||
isNull(typedFieldValue[prop.name]),
);
if (allRequiredPropertiesAreNull && requiredProperties.length > 0) {
if (fieldMetadata.isNullable) {
// Field is nullable, set to null
data[fieldMetadata.name] = null;
} else {
// Field is non-nullable, provide a default value
data[fieldMetadata.name] = getDefaultCompositeFieldValue(
fieldMetadata.type,
);
}
}
}
}
/**
* Returns a default value for non-nullable composite fields.
*/
function getDefaultCompositeFieldValue(
fieldType: FieldMetadataType,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): Record<string, any> | null {
switch (fieldType) {
case FieldMetadataType.ACTOR:
return {
source: FieldActorSource.MANUAL,
name: '',
workspaceMemberId: null,
context: {},
};
default:
// For other composite types, return null and let GraphQL handle the error
// This should be extended as needed for other non-nullable composite fields
return null;
}
}