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:
+18
-3
@@ -2,15 +2,30 @@ import { IsNotEmpty, IsNumber, IsOptional, IsString } from 'class-validator';
|
||||
import { IsValidGraphQLEnumName } from 'twenty-shared/types';
|
||||
|
||||
export type TagColor =
|
||||
| 'red'
|
||||
| 'ruby'
|
||||
| 'crimson'
|
||||
| 'tomato'
|
||||
| 'orange'
|
||||
| 'amber'
|
||||
| 'yellow'
|
||||
| 'lime'
|
||||
| 'grass'
|
||||
| 'green'
|
||||
| 'jade'
|
||||
| 'mint'
|
||||
| 'turquoise'
|
||||
| 'cyan'
|
||||
| 'sky'
|
||||
| 'blue'
|
||||
| 'iris'
|
||||
| 'violet'
|
||||
| 'purple'
|
||||
| 'plum'
|
||||
| 'pink'
|
||||
| 'red'
|
||||
| 'orange'
|
||||
| 'yellow'
|
||||
| 'bronze'
|
||||
| 'gold'
|
||||
| 'brown'
|
||||
| 'gray';
|
||||
|
||||
export class FieldMetadataDefaultOption {
|
||||
|
||||
+5
-80
@@ -61,29 +61,6 @@ export const COMPANY_FLAT_FIELDS_MOCK = {
|
||||
relationTargetObjectMetadataId: null,
|
||||
universalIdentifier: '20202020-0c28-43d8-8ba5-3659924d3489',
|
||||
}),
|
||||
employees: getFlatFieldMetadataMock({
|
||||
id: '721d26f0-238d-4d55-a1a2-62f915631c4d',
|
||||
objectMetadataId: '7f5c2c7a-bb23-46fb-b59d-9b7a52a8d1cc',
|
||||
type: FieldMetadataType.NUMBER,
|
||||
name: 'employees',
|
||||
label: 'Employees',
|
||||
defaultValue: null,
|
||||
description: 'Number of employees in the company',
|
||||
icon: 'IconUsers',
|
||||
standardOverrides: null,
|
||||
options: null,
|
||||
settings: null,
|
||||
isCustom: false,
|
||||
isActive: true,
|
||||
isSystem: false,
|
||||
isNullable: true,
|
||||
isUnique: false,
|
||||
workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
|
||||
isLabelSyncedWithName: true,
|
||||
relationTargetFieldMetadataId: null,
|
||||
relationTargetObjectMetadataId: null,
|
||||
universalIdentifier: '20202020-8965-464a-8a75-74bafc152a0b',
|
||||
}),
|
||||
linkedinLink: getFlatFieldMetadataMock({
|
||||
id: '6acb80a0-dab8-4d97-a0c8-15e4a495fb54',
|
||||
objectMetadataId: '7f5c2c7a-bb23-46fb-b59d-9b7a52a8d1cc',
|
||||
@@ -111,42 +88,14 @@ export const COMPANY_FLAT_FIELDS_MOCK = {
|
||||
relationTargetObjectMetadataId: null,
|
||||
universalIdentifier: '20202020-ebeb-4beb-b9ad-6848036fb451',
|
||||
}),
|
||||
xLink: getFlatFieldMetadataMock({
|
||||
id: 'fc630bbb-aa2c-4554-aa4f-9afc0b0567f9',
|
||||
objectMetadataId: '7f5c2c7a-bb23-46fb-b59d-9b7a52a8d1cc',
|
||||
type: FieldMetadataType.LINKS,
|
||||
name: 'xLink',
|
||||
label: 'X',
|
||||
defaultValue: {
|
||||
primaryLinkUrl: "''",
|
||||
secondaryLinks: [],
|
||||
primaryLinkLabel: "''",
|
||||
},
|
||||
description: 'The company Twitter/X account',
|
||||
icon: 'IconBrandX',
|
||||
standardOverrides: null,
|
||||
options: null,
|
||||
settings: null,
|
||||
isCustom: false,
|
||||
isActive: true,
|
||||
isSystem: false,
|
||||
isNullable: true,
|
||||
isUnique: false,
|
||||
workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
|
||||
isLabelSyncedWithName: false,
|
||||
relationTargetFieldMetadataId: null,
|
||||
relationTargetObjectMetadataId: null,
|
||||
universalIdentifier: '20202020-6f64-4fd9-9580-9c1991c7d8c3',
|
||||
}),
|
||||
annualRecurringRevenue: getFlatFieldMetadataMock({
|
||||
annualRevenue: getFlatFieldMetadataMock({
|
||||
id: '1e34d871-3e1a-4903-850e-1e4bc6b1d72b',
|
||||
objectMetadataId: '7f5c2c7a-bb23-46fb-b59d-9b7a52a8d1cc',
|
||||
type: FieldMetadataType.CURRENCY,
|
||||
name: 'annualRecurringRevenue',
|
||||
label: 'ARR',
|
||||
name: 'annualRevenue',
|
||||
label: 'Annual Revenue',
|
||||
defaultValue: { amountMicros: null, currencyCode: "''" },
|
||||
description:
|
||||
'Annual Recurring Revenue: The actual or estimated annual revenue of the company',
|
||||
description: "The company's total annual revenue",
|
||||
icon: 'IconMoneybag',
|
||||
standardOverrides: null,
|
||||
options: null,
|
||||
@@ -160,7 +109,7 @@ export const COMPANY_FLAT_FIELDS_MOCK = {
|
||||
isLabelSyncedWithName: false,
|
||||
relationTargetFieldMetadataId: null,
|
||||
relationTargetObjectMetadataId: null,
|
||||
universalIdentifier: '20202020-602a-495c-9776-f5d5b11d227b',
|
||||
universalIdentifier: '60f533b7-2166-4071-a767-ceb0286822fd',
|
||||
}),
|
||||
address: getFlatFieldMetadataMock({
|
||||
id: '1bd4dc61-e644-4a9e-8899-cacdf43571a1',
|
||||
@@ -194,30 +143,6 @@ export const COMPANY_FLAT_FIELDS_MOCK = {
|
||||
relationTargetObjectMetadataId: null,
|
||||
universalIdentifier: '20202020-c5ce-4adc-b7b6-9c0979fc55e7',
|
||||
}),
|
||||
idealCustomerProfile: getFlatFieldMetadataMock({
|
||||
id: '8b75a4f1-dc7d-4fa3-b778-616ab9128c31',
|
||||
objectMetadataId: '7f5c2c7a-bb23-46fb-b59d-9b7a52a8d1cc',
|
||||
type: FieldMetadataType.BOOLEAN,
|
||||
name: 'idealCustomerProfile',
|
||||
label: 'ICP',
|
||||
defaultValue: false,
|
||||
description:
|
||||
'Ideal Customer Profile: Indicates whether the company is the most suitable and valuable customer for you',
|
||||
icon: 'IconTarget',
|
||||
standardOverrides: null,
|
||||
options: null,
|
||||
settings: null,
|
||||
isCustom: false,
|
||||
isActive: true,
|
||||
isSystem: false,
|
||||
isNullable: false,
|
||||
isUnique: false,
|
||||
workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
|
||||
isLabelSyncedWithName: false,
|
||||
relationTargetFieldMetadataId: null,
|
||||
relationTargetObjectMetadataId: null,
|
||||
universalIdentifier: '20202020-ba6b-438a-8213-2c5ba28d76a2',
|
||||
}),
|
||||
position: getFlatFieldMetadataMock({
|
||||
id: 'ed081dac-ff61-4fe1-bc2c-073644b61c28',
|
||||
objectMetadataId: '7f5c2c7a-bb23-46fb-b59d-9b7a52a8d1cc',
|
||||
|
||||
-50
@@ -83,33 +83,6 @@ export const PERSON_FLAT_FIELDS_MOCK = {
|
||||
relationTargetObjectMetadataId: null,
|
||||
universalIdentifier: '20202020-f1af-48f7-893b-2007a73dd508',
|
||||
}),
|
||||
xLink: getFlatFieldMetadataMock({
|
||||
id: 'c9d36790-a449-4108-91e1-c6ad6e3379fb',
|
||||
objectMetadataId: '843e0b67-9619-4628-91c4-2fa62256a611',
|
||||
type: FieldMetadataType.LINKS,
|
||||
name: 'xLink',
|
||||
label: 'X',
|
||||
defaultValue: {
|
||||
primaryLinkUrl: "''",
|
||||
secondaryLinks: [],
|
||||
primaryLinkLabel: "''",
|
||||
},
|
||||
description: 'Contact’s X/Twitter account',
|
||||
icon: 'IconBrandX',
|
||||
standardOverrides: null,
|
||||
options: null,
|
||||
settings: null,
|
||||
isCustom: false,
|
||||
isActive: true,
|
||||
isSystem: false,
|
||||
isNullable: true,
|
||||
isUnique: false,
|
||||
workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
|
||||
isLabelSyncedWithName: false,
|
||||
relationTargetFieldMetadataId: null,
|
||||
relationTargetObjectMetadataId: null,
|
||||
universalIdentifier: '20202020-8fc2-487c-b84a-55a99b145cfd',
|
||||
}),
|
||||
jobTitle: getFlatFieldMetadataMock({
|
||||
id: 'ab82c659-b553-4317-9708-998f6884c72e',
|
||||
objectMetadataId: '843e0b67-9619-4628-91c4-2fa62256a611',
|
||||
@@ -161,29 +134,6 @@ export const PERSON_FLAT_FIELDS_MOCK = {
|
||||
relationTargetObjectMetadataId: null,
|
||||
universalIdentifier: '20202020-0638-448e-8825-439134618022',
|
||||
}),
|
||||
city: getFlatFieldMetadataMock({
|
||||
id: '61ad7592-6465-4a24-a863-0320cf7830a6',
|
||||
objectMetadataId: '843e0b67-9619-4628-91c4-2fa62256a611',
|
||||
type: FieldMetadataType.TEXT,
|
||||
name: 'city',
|
||||
label: 'City',
|
||||
defaultValue: "''",
|
||||
description: 'Contact’s city',
|
||||
icon: 'IconMap',
|
||||
standardOverrides: null,
|
||||
options: null,
|
||||
settings: null,
|
||||
isCustom: false,
|
||||
isActive: true,
|
||||
isSystem: false,
|
||||
isNullable: false,
|
||||
isUnique: false,
|
||||
workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
|
||||
isLabelSyncedWithName: true,
|
||||
relationTargetFieldMetadataId: null,
|
||||
relationTargetObjectMetadataId: null,
|
||||
universalIdentifier: '20202020-5243-4ffb-afc5-2c675da41346',
|
||||
}),
|
||||
avatarUrl: getFlatFieldMetadataMock({
|
||||
id: '05e46775-93e9-46c4-8b6f-94829045b1ad',
|
||||
objectMetadataId: '843e0b67-9619-4628-91c4-2fa62256a611',
|
||||
|
||||
+2
-2
@@ -239,7 +239,7 @@ describe('IndexMetadataService', () => {
|
||||
{
|
||||
id: 'field-currency',
|
||||
universalIdentifier: 'field-currency-universal',
|
||||
name: 'annualRecurringRevenue',
|
||||
name: 'annualRevenue',
|
||||
type: FieldMetadataType.CURRENCY,
|
||||
},
|
||||
],
|
||||
@@ -269,7 +269,7 @@ describe('IndexMetadataService', () => {
|
||||
{
|
||||
id: 'field-currency',
|
||||
universalIdentifier: 'field-currency-universal',
|
||||
name: 'annualRecurringRevenue',
|
||||
name: 'annualRevenue',
|
||||
type: FieldMetadataType.CURRENCY,
|
||||
},
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user