Compare commits

...
Author SHA1 Message Date
Sonarly Claude Code 35d855705a Morph relation persist uses wrong FK naming, causing "unknown field" error
https://sonarly.com/issue/4056?type=bug

Editing a morph relation field (e.g., "client" on opportunity) via the record detail UI throws an error because usePersistField generates the wrong foreign key name (`clientId`) that computeOptimisticRecordFromInput doesn't recognize.

Fix: The morph relation persist code path in `usePersistField.ts` was using `getForeignKeyNameFromRelationFieldName(fieldName)` which simply appends `Id` to the field name (producing `clientId` from `client`). For morph relations, the correct naming convention uses target-specific names computed by `computeMorphRelationFieldName` (e.g., `clientCompanyId` or `clientPersonId`).

The fix replaces the incorrect one-liner FK generation with the proper morph relation pattern already used by `useMorphPersistManyToOne`:

1. Extract `morphRelations` and `relationType` from the typed field definition
2. Build a record resetting all morph FK IDs to null (to clear any stale target)
3. Handle the null case explicitly (clear all morph FKs)
4. For a non-null selection: use `valueToPersist.objectMetadataId` (provided by the picker) to find the correct `targetMorphRelation` in `morphRelations`
5. Call `computeMorphRelationFieldName` to get the target-specific field name (e.g., `clientCompany`)
6. Pass `${computedFieldName}Id` (e.g., `clientCompanyId`) in the update input — which `computeOptimisticRecordFromInput` can correctly recognize

```typescript file=packages/twenty-front/src/modules/object-record/record-field/ui/hooks/usePersistField.ts lines=223-288
if (fieldIsMorphRelationManyToOne) {
  if (valueToPersist?.id === currentValue?.id) {
    return;
  }

  const morphFieldDefinition =
    fieldDefinition as FieldDefinition<FieldMorphRelationMetadata>;
  const morphRelations = morphFieldDefinition.metadata.morphRelations;
  const relationType = morphFieldDefinition.metadata.relationType;

  const recordWithAllMorphObjectIdsToNull =
    buildRecordWithAllMorphObjectIdsToNull({
      morphRelations,
      fieldName,
      relationType,
    });

  if (!isDefined(valueToPersist)) {
    await updateOneRecord({
      objectNameSingular: objectMetadataItem.nameSingular,
      idToUpdate: recordId,
      updateOneRecordInput: recordWithAllMorphObjectIdsToNull,
    });
    return;
  }

  const targetMorphRelation = morphRelations.find(
    (morphRelation) =>
      morphRelation.targetObjectMetadata.id ===
      (valueToPersist as ObjectRecord).objectMetadataId,
  );

  if (!isDefined(targetMorphRelation)) {
    throw new Error(
      `Target morph relation not found for objectMetadataId ${(valueToPersist as ObjectRecord).objectMetadataId}`,
    );
  }

  const computedFieldName = computeMorphRelationFieldName({
    fieldName,
    relationType,
    targetObjectMetadataNameSingular:
      targetMorphRelation.targetObjectMetadata.nameSingular,
    targetObjectMetadataNamePlural:
      targetMorphRelation.targetObjectMetadata.namePlural,
  });

  const newRecord = await updateOneRecord({
    objectNameSingular: objectMetadataItem.nameSingular,
    idToUpdate: recordId,
    updateOneRecordInput: {
      ...recordWithAllMorphObjectIdsToNull,
      [`${computedFieldName}Id`]: valueToPersist?.id,
    },
  });

  upsertRecordsInStore({
    partialRecords: [
      getRecordFromRecordNode({
        recordNode: newRecord,
      }),
    ],
  });

  return;
}
```
2026-03-05 16:46:52 +00:00
Sonarly Claude Code c9bd3fa9f7 Address field persist fails: Zod schema expects number for lat/lng but receives strings
https://sonarly.com/issue/9931?type=bug

When users edit an address field on opportunities and click outside to save, the `isFieldAddressValue` Zod validation rejects the value because `addressLat` and `addressLng` arrive as strings from the PostgreSQL `numeric` column type, but the schema expects `z.number().nullable()`.

Fix: The fix changes `z.number().nullable()` to `z.coerce.number().nullable()` for the `addressLat` and `addressLng` fields in the `addressSchema` Zod validator.

`z.coerce.number()` calls `Number()` on the incoming value before validation, so string-encoded numbers like `"34.18247960000001"` and `"-118.1752679"` (returned as strings by PostgreSQL's `numeric` column type via the node-pg driver) are coerced to JavaScript `number` values before the type check runs. This makes `isFieldAddressValue()` return `true` for these values, allowing `usePersistField` to save the address field correctly.

```typescript file=packages/twenty-front/src/modules/object-record/record-field/ui/types/guards/isFieldAddressValue.ts lines=13-14
  addressLat: z.coerce.number().nullable(),
  addressLng: z.coerce.number().nullable(),
```

This is a minimal, targeted fix: it applies coercion only at the validation boundary where the type mismatch manifests, without changing any shared utilities, server code, or other callers. `z.coerce.number()` safely handles `null` values as well (coercion runs before the `.nullable()` check, and `Number(null)` returns `0`, but Zod's `.nullable()` short-circuits for `null` before coercion is applied).
2026-03-05 16:37:15 +00:00
@@ -53,8 +53,11 @@ import { isFieldRichTextValue } from '@/object-record/record-field/ui/types/guar
import { isFieldRichTextV2Value } from '@/object-record/record-field/ui/types/guards/isFieldRichTextValueV2';
import { isFieldText } from '@/object-record/record-field/ui/types/guards/isFieldText';
import { isFieldTextValue } from '@/object-record/record-field/ui/types/guards/isFieldTextValue';
import { buildRecordWithAllMorphObjectIdsToNull } from '@/object-record/record-field/ui/meta-types/input/utils/buildRecordWithAllMorphObjectIdsToNull';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
import { getForeignKeyNameFromRelationFieldName } from '@/object-record/utils/getForeignKeyNameFromRelationFieldName';
import { computeMorphRelationFieldName, isDefined } from 'twenty-shared/utils';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
export const usePersistField = ({
@@ -222,12 +225,54 @@ export const usePersistField = ({
return;
}
const morphFieldDefinition =
fieldDefinition as FieldDefinition<FieldMorphRelationMetadata>;
const morphRelations = morphFieldDefinition.metadata.morphRelations;
const relationType = morphFieldDefinition.metadata.relationType;
const recordWithAllMorphObjectIdsToNull =
buildRecordWithAllMorphObjectIdsToNull({
morphRelations,
fieldName,
relationType,
});
if (!isDefined(valueToPersist)) {
await updateOneRecord({
objectNameSingular: objectMetadataItem.nameSingular,
idToUpdate: recordId,
updateOneRecordInput: recordWithAllMorphObjectIdsToNull,
});
return;
}
const targetMorphRelation = morphRelations.find(
(morphRelation) =>
morphRelation.targetObjectMetadata.id ===
(valueToPersist as ObjectRecord).objectMetadataId,
);
if (!isDefined(targetMorphRelation)) {
throw new Error(
`Target morph relation not found for objectMetadataId ${(valueToPersist as ObjectRecord).objectMetadataId}`,
);
}
const computedFieldName = computeMorphRelationFieldName({
fieldName,
relationType,
targetObjectMetadataNameSingular:
targetMorphRelation.targetObjectMetadata.nameSingular,
targetObjectMetadataNamePlural:
targetMorphRelation.targetObjectMetadata.namePlural,
});
const newRecord = await updateOneRecord({
objectNameSingular: objectMetadataItem.nameSingular,
idToUpdate: recordId,
updateOneRecordInput: {
[getForeignKeyNameFromRelationFieldName(fieldName)]:
valueToPersist?.id ?? null,
...recordWithAllMorphObjectIdsToNull,
[`${computedFieldName}Id`]: valueToPersist?.id,
},
});