Migrate Company and Person standard fields in preparation for the enrichment app (#21171)

# Migrate Company and Person standard fields in preparation for the
enrichment app

## Why

Our standard `Person`/`Company` objects accumulated fields that aren't
generic to every
business, while missing a more universal revenue field that essentially
every CRM ships.
This PR makes the **Standard application** hold a tighter, more
universal set of fields,
and sets the stage for a follow-up PR that introduces a **People Data
Labs enrichment app**
to populate them.

## What changes

### Standard fields

**Demoted (Standard → Workspace Custom application)** — not generic
enough to ship as standard:

| Object  | Field                          | Type     |
| ------- | ------------------------------ | -------- |
| Company | annualRecurringRevenue (ARR)   | CURRENCY |
| Company | employees                      | NUMBER   |
| Company | idealCustomerProfile (ICP)     | BOOLEAN  |
| Company | xLink (X/Twitter)              | LINKS    |
| Person  | xLink (X/Twitter)              | LINKS    |
| Person  | city                           | TEXT     |

**Added (new generic Standard field)** — present in
Salesforce/HubSpot/Zoho, PDL-populatable:

| Object | Field | Type |
| ------- | ------------- |
-------------------------------------------------------- |
| Company | annualRevenue | CURRENCY (generic total revenue; replaces
the niche ARR) |

### Behavior by workspace

* **New workspaces:** demoted fields are gone; `annualRevenue` is
**active**.
* **Existing workspaces:** demoted fields are **preserved as active
custom fields, data intact**;
`annualRevenue` is created **inactive (opt-in)** with its column ready,
so a later activation
  is a metadata-only toggle.

### Upgrade commands (v2.9)

Three idempotent, per-workspace commands, run in timestamp order:

1. **`upgrade:2-9:move-demoted-standard-fields-to-custom-application`**
(1799000040000) —
re-owns the 6 demoted fields to the workspace custom application
(`isCustom = true`,
new `applicationId` + fresh `universalIdentifier`), keeping their data
and active state.
2. **`upgrade:2-9:rename-conflicting-custom-fields`** (1799000045000) —
if a workspace already
has a *custom* field named `annualRevenue`, renames it to
`annualRevenueCustom`
(data preserved via column rename) so the standard field can be added.
Skips non-custom matches.
3. **`upgrade:2-9:add-inactive-generic-standard-fields`**
(1799000050000) — creates
`Company.annualRevenue` on existing workspaces as inactive, guarded to
skip workspaces
   missing the target object or where the name is still taken.

**Failure model:** the workspace iterator isolates failures per
workspace (one workspace failing
never affects others); within a workspace the runner records per-command
status and resumes on the
next run, and every command is idempotent, so partial runs self-heal.

### Supporting changes

* **Field-option color palette:** widened the `TagColor` union
(`twenty-shared` `FieldMetadataOptions`
+ the field-metadata `options.input` DTO) from 10 colors to the full
theme palette, benefiting any
  future SELECT/MULTI_SELECT field.
* **Dev seeder:**
* The default "Annual Recurring Revenue" dashboard widget now points at
the generic
    `annualRevenue` field (renamed to "Annual Revenue").
* Removed the "Companies by Size (Stacked by City)" widget (relied on
the demoted `employees`).
* `employees` is dropped from company data seeds and re-added as a
**custom** field seed, so dev
workspaces still get an `employees` column matching the demoted
behavior.

### Cleanup

Front-end record types (`Company.ts`/`Person.ts`), the
`getDisplayNameFromParticipant` test mock,
metadata integration specs, the Zapier `crud_record` test, and the
regenerated
`get-standard-object-metadata-related-entity-ids` snapshot.

## ⚠️ Breaking change (intentional)

Removes standard fields `Company.annualRecurringRevenue`,
`Company.employees`,
`Company.idealCustomerProfile`, `Company.xLink`, `Person.xLink`, and
`Person.city` from the core
GraphQL schema (replaced by `Company.annualRevenue`).

This is why the breaking-changes check reports a large number of
removals — `graphql-inspector`
flags any removed object field plus its derived
aggregate/order-by/filter/update types.

**Mitigation:** the
`upgrade:2-9:move-demoted-standard-fields-to-custom-application` command
re-owns these fields as custom fields per workspace, preserving their
name and data, so existing
tenants keep working. New workspaces won't have them.
This commit is contained in:
Raphaël Bosi
2026-06-04 17:54:04 +02:00
committed by GitHub
parent e0d42323af
commit 41d5d80a65
56 changed files with 1798 additions and 3544 deletions
@@ -270,12 +270,12 @@ export class DevSeederPermissionsService {
},
});
const personCityFieldMetadata = personObjectMetadata.fields.find(
(field) => field.name === 'city',
const personJobTitleFieldMetadata = personObjectMetadata.fields.find(
(field) => field.name === 'jobTitle',
);
if (!personCityFieldMetadata) {
throw new Error('Person city field metadata not found');
if (!personJobTitleFieldMetadata) {
throw new Error('Person jobTitle field metadata not found');
}
const companyLinkedinLinkFieldMetadata = companyObjectMetadata.fields.find(
@@ -286,9 +286,9 @@ export class DevSeederPermissionsService {
throw new Error('Company linkedin link field metadata not found');
}
const readOnlyOnPersonCityFieldPermission = {
const readOnlyOnPersonJobTitleFieldPermission = {
objectMetadataId: personObjectMetadata.id,
fieldMetadataId: personCityFieldMetadata.id,
fieldMetadataId: personJobTitleFieldMetadata.id,
canReadFieldValue: null,
canUpdateFieldValue: false,
};
@@ -305,7 +305,7 @@ export class DevSeederPermissionsService {
input: {
roleId: customRole.id,
fieldPermissions: [
readOnlyOnPersonCityFieldPermission,
readOnlyOnPersonJobTitleFieldPermission,
noReadOnCompanyLinkedinLinkFieldPermission,
],
},
@@ -49,7 +49,10 @@ export const getPageLayoutWidgetDataSeedsV2 = (
const companyIdFieldId = getFieldId(companyObject, 'id');
const companyCreatedAtFieldId = getFieldId(companyObject, 'createdAt');
const companyArrFieldId = getFieldId(companyObject, 'annualRecurringRevenue');
const companyAnnualRevenueFieldId = getFieldId(
companyObject,
'annualRevenue',
);
const companyNameFieldId = getFieldId(companyObject, 'name');
const companyLinkedinLinkFieldId = getFieldId(companyObject, 'linkedinLink');
@@ -133,7 +136,7 @@ export const getPageLayoutWidgetDataSeedsV2 = (
: null,
// PIE chart: Revenue Distribution (Customer Analytics)
isDefined(companyArrFieldId) && isDefined(companyNameFieldId)
isDefined(companyAnnualRevenueFieldId) && isDefined(companyNameFieldId)
? ({
id: generateSeedId(
workspaceId,
@@ -155,7 +158,7 @@ export const getPageLayoutWidgetDataSeedsV2 = (
},
configuration: {
configurationType: WidgetConfigurationType.PIE_CHART,
aggregateFieldMetadataId: companyArrFieldId,
aggregateFieldMetadataId: companyAnnualRevenueFieldId,
aggregateOperation: AggregateOperations.SUM,
groupByFieldMetadataId: companyNameFieldId,
orderBy: GraphOrderBy.VALUE_DESC,
@@ -12,7 +12,6 @@ import { fromPageLayoutWidgetConfigurationToUniversalConfiguration } from 'src/e
import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { AxisNameDisplay } from 'src/engine/metadata-modules/page-layout-widget/enums/axis-name-display.enum';
import { BarChartLayout } from 'src/engine/metadata-modules/page-layout-widget/enums/bar-chart-layout.enum';
import { ObjectRecordGroupByDateGranularity } from 'src/engine/metadata-modules/page-layout-widget/enums/date-granularity.enum';
import { GraphOrderBy } from 'src/engine/metadata-modules/page-layout-widget/enums/graph-order-by.enum';
import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type';
import { WidgetType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-type.enum';
@@ -133,13 +132,13 @@ export const getPageLayoutWidgetDataSeeds = (
const opportunityStageFieldId = getFieldId(opportunityObject, 'stage');
const companyIdFieldId = getFieldId(companyObject, 'id');
const companyEmployeesFieldId = getFieldId(companyObject, 'employees');
const companyArrFieldId = getFieldId(companyObject, 'annualRecurringRevenue');
const companyAnnualRevenueFieldId = getFieldId(
companyObject,
'annualRevenue',
);
const companyLinkedinLinkFieldId = getFieldId(companyObject, 'linkedinLink');
const companyAddressFieldId = getFieldId(companyObject, 'address');
const personIdFieldId = getFieldId(personObject, 'id');
const personCityFieldId = getFieldId(personObject, 'city');
const opportunityIdFieldId = getFieldId(opportunityObject, 'id');
@@ -359,52 +358,8 @@ export const getPageLayoutWidgetDataSeeds = (
overrides: null,
} satisfies SeederFlatPageLayoutWidget)
: null,
isDefined(companyIdFieldId) &&
isDefined(companyEmployeesFieldId) &&
isDefined(companyAddressFieldId)
? ({
id: generateSeedId(
workspaceId,
PAGE_LAYOUT_WIDGET_SEEDS.CUSTOMER_COMPANIES_BY_SIZE,
),
pageLayoutTabId: generateSeedId(
workspaceId,
PAGE_LAYOUT_TAB_SEEDS.CUSTOMER_OVERVIEW,
),
title: 'Companies by Size (Stacked by City)',
type: WidgetType.GRAPH,
gridPosition: { row: 0, column: 6, rowSpan: 10, columnSpan: 6 },
position: {
layoutMode: PageLayoutTabLayoutMode.GRID,
row: 0,
column: 6,
rowSpan: 10,
columnSpan: 6,
},
configuration: {
configurationType: WidgetConfigurationType.BAR_CHART,
aggregateFieldMetadataId: companyIdFieldId,
aggregateOperation: AggregateOperations.COUNT,
primaryAxisGroupByFieldMetadataId: companyEmployeesFieldId,
secondaryAxisGroupByFieldMetadataId: companyAddressFieldId,
secondaryAxisGroupBySubFieldName: 'addressCity',
secondaryAxisGroupByDateGranularity:
ObjectRecordGroupByDateGranularity.DAY,
primaryAxisOrderBy: GraphOrderBy.FIELD_ASC,
axisNameDisplay: AxisNameDisplay.NONE,
displayDataLabel: false,
color: 'auto',
layout: BarChartLayout.VERTICAL,
timezone: 'UTC',
firstDayOfTheWeek: CalendarStartDay.MONDAY,
},
objectMetadataId: companyObject?.id ?? null,
overrides: null,
} satisfies SeederFlatPageLayoutWidget)
: null,
// Customer Analytics Tab Widgets
isDefined(companyArrFieldId)
isDefined(companyAnnualRevenueFieldId)
? ({
id: generateSeedId(
workspaceId,
@@ -414,7 +369,7 @@ export const getPageLayoutWidgetDataSeeds = (
workspaceId,
PAGE_LAYOUT_TAB_SEEDS.CUSTOMER_ANALYTICS,
),
title: 'Annual Recurring Revenue',
title: 'Annual Revenue',
type: WidgetType.GRAPH,
gridPosition: { row: 0, column: 0, rowSpan: 4, columnSpan: 4 },
position: {
@@ -426,7 +381,7 @@ export const getPageLayoutWidgetDataSeeds = (
},
configuration: {
configurationType: WidgetConfigurationType.AGGREGATE_CHART,
aggregateFieldMetadataId: companyArrFieldId,
aggregateFieldMetadataId: companyAnnualRevenueFieldId,
aggregateOperation: AggregateOperations.SUM,
displayDataLabel: true,
timezone: 'UTC',
@@ -499,44 +454,6 @@ export const getPageLayoutWidgetDataSeeds = (
overrides: null,
} satisfies SeederFlatPageLayoutWidget)
: null,
isDefined(personIdFieldId) && isDefined(personCityFieldId)
? ({
id: generateSeedId(
workspaceId,
PAGE_LAYOUT_WIDGET_SEEDS.TEAM_GEOGRAPHIC_DISTRIBUTION,
),
pageLayoutTabId: generateSeedId(
workspaceId,
PAGE_LAYOUT_TAB_SEEDS.TEAM_OVERVIEW,
),
title: 'Geographic Distribution',
type: WidgetType.GRAPH,
gridPosition: { row: 0, column: 6, rowSpan: 5, columnSpan: 6 },
position: {
layoutMode: PageLayoutTabLayoutMode.GRID,
row: 0,
column: 6,
rowSpan: 5,
columnSpan: 6,
},
configuration: {
configurationType: WidgetConfigurationType.BAR_CHART,
aggregateFieldMetadataId: personIdFieldId,
aggregateOperation: AggregateOperations.COUNT,
primaryAxisGroupByFieldMetadataId: personCityFieldId,
primaryAxisOrderBy: GraphOrderBy.VALUE_DESC,
axisNameDisplay: AxisNameDisplay.NONE,
displayDataLabel: false,
color: 'auto',
layout: BarChartLayout.VERTICAL,
timezone: 'UTC',
firstDayOfTheWeek: CalendarStartDay.MONDAY,
},
objectMetadataId: personObject?.id ?? null,
overrides: null,
} satisfies SeederFlatPageLayoutWidget)
: null,
// Team Metrics Tab Widgets
isDefined(taskIdFieldId)
? ({
@@ -341,7 +341,6 @@ export class TimelineActivitySeederService {
...commonProperties,
name: recordSeed.name,
domainName: recordSeed.domainNamePrimaryLinkUrl,
employees: recordSeed.employees,
city: recordSeed.addressAddressCity,
}),
person: () => ({
@@ -3,6 +3,16 @@ import { FieldMetadataType } from 'twenty-shared/types';
import { type FieldMetadataSeed } from 'src/engine/workspace-manager/dev-seeder/metadata/types/field-metadata-seed.type';
export const COMPANY_CUSTOM_FIELD_SEEDS: FieldMetadataSeed[] = [
{
type: FieldMetadataType.NUMBER,
name: 'employees',
label: 'Employees',
description: 'Number of employees in the company',
icon: 'IconUsers',
isActive: true,
isNullable: true,
isUnique: false,
},
{
type: FieldMetadataType.TEXT,
name: 'tagline',