41d5d80a65
# 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.
525 lines
15 KiB
TypeScript
525 lines
15 KiB
TypeScript
import gql from 'graphql-tag';
|
|
import { TEST_COMPANY_1_ID } from 'test/integration/constants/test-company-ids.constants';
|
|
import { TEST_PERSON_1_ID } from 'test/integration/constants/test-person-ids.constants';
|
|
import { TEST_PRIMARY_LINK_URL } from 'test/integration/constants/test-primary-link-url.constant';
|
|
import { upsertFieldPermissions } from 'test/integration/graphql/utils/upsert-field-permissions.util';
|
|
import { upsertRowLevelPermissionPredicates } from 'test/integration/metadata/suites/row-level-permission-predicate/utils/upsert-row-level-permission-predicates.util';
|
|
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
|
|
import { makeRestAPIRequest } from 'test/integration/rest/utils/make-rest-api-request.util';
|
|
import { generateRecordName } from 'test/integration/utils/generate-record-name';
|
|
import { RowLevelPermissionPredicateOperand } from 'twenty-shared/types';
|
|
|
|
describe('Restricted fields', () => {
|
|
let personJobTitle: string;
|
|
let memberRoleId: string;
|
|
let personObjectId: string;
|
|
let emailsFieldId: string;
|
|
let phonesFieldId: string;
|
|
|
|
beforeAll(async () => {
|
|
personJobTitle = generateRecordName(TEST_PERSON_1_ID);
|
|
|
|
await makeRestAPIRequest({
|
|
method: 'post',
|
|
path: '/companies',
|
|
body: {
|
|
id: TEST_COMPANY_1_ID,
|
|
domainName: {
|
|
primaryLinkUrl: TEST_PRIMARY_LINK_URL,
|
|
},
|
|
},
|
|
});
|
|
|
|
await makeRestAPIRequest({
|
|
method: 'post',
|
|
path: '/people',
|
|
body: {
|
|
id: TEST_PERSON_1_ID,
|
|
jobTitle: personJobTitle,
|
|
emails: {
|
|
primaryEmail: 'test@test.com',
|
|
},
|
|
phones: {
|
|
primaryPhoneNumber: '123456789',
|
|
primaryPhoneCountryCode: 'US',
|
|
primaryPhoneCallingCode: '+1',
|
|
},
|
|
},
|
|
});
|
|
|
|
// Get object metadata IDs for Person and Company
|
|
const getObjectMetadataOperation = {
|
|
query: gql`
|
|
query {
|
|
objects(paging: { first: 1000 }) {
|
|
edges {
|
|
node {
|
|
id
|
|
nameSingular
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`,
|
|
};
|
|
|
|
const objectMetadataResponse = await makeMetadataAPIRequest(
|
|
getObjectMetadataOperation,
|
|
);
|
|
const objects = objectMetadataResponse.body.data.objects.edges;
|
|
|
|
personObjectId = objects.find(
|
|
(obj: any) => obj.node.nameSingular === 'person',
|
|
)?.node.id;
|
|
|
|
// Get field metadata ID for email field
|
|
const getFieldMetadataOperation = {
|
|
query: gql`
|
|
query {
|
|
fields(paging: { first: 1000 }) {
|
|
edges {
|
|
node {
|
|
id
|
|
name
|
|
object {
|
|
nameSingular
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`,
|
|
};
|
|
|
|
const fieldMetadataResponse = await makeMetadataAPIRequest(
|
|
getFieldMetadataOperation,
|
|
);
|
|
const fields = fieldMetadataResponse.body.data.fields.edges;
|
|
|
|
emailsFieldId = fields.find(
|
|
(field: any) =>
|
|
field.node.name === 'emails' &&
|
|
field.node.object.nameSingular === 'person',
|
|
).node.id;
|
|
|
|
phonesFieldId = fields.find(
|
|
(field: any) =>
|
|
field.node.name === 'phones' &&
|
|
field.node.object.nameSingular === 'person',
|
|
).node.id;
|
|
|
|
// Get member role ID
|
|
const getRolesOperation = {
|
|
query: gql`
|
|
query {
|
|
getRoles {
|
|
id
|
|
label
|
|
}
|
|
}
|
|
`,
|
|
};
|
|
|
|
const rolesResponse = await makeMetadataAPIRequest(getRolesOperation);
|
|
|
|
memberRoleId = rolesResponse.body.data.getRoles.find(
|
|
(role: any) => role.label === 'Member',
|
|
)?.id;
|
|
|
|
// Create field permission restricting read access to email field
|
|
await upsertFieldPermissions({
|
|
roleId: memberRoleId,
|
|
fieldPermissions: [
|
|
{
|
|
objectMetadataId: personObjectId,
|
|
fieldMetadataId: emailsFieldId,
|
|
canReadFieldValue: false,
|
|
canUpdateFieldValue: null,
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it('should hide fields when user has restricted read permissions - findOne', async () => {
|
|
await makeRestAPIRequest({
|
|
method: 'get',
|
|
path: `/people/${TEST_PERSON_1_ID}`,
|
|
bearer: APPLE_JONY_MEMBER_ACCESS_TOKEN,
|
|
})
|
|
.expect(200)
|
|
.expect((res) => {
|
|
const person = res.body.data.person;
|
|
|
|
expect(person).toBeDefined();
|
|
expect(person.id).toBeDefined();
|
|
expect(person.emails).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('updateOne', () => {
|
|
it('should hide fields in the response when user has restricted read permissions', async () => {
|
|
// Create field permission restricting update access to phones field
|
|
await upsertFieldPermissions({
|
|
roleId: memberRoleId,
|
|
fieldPermissions: [
|
|
{
|
|
objectMetadataId: personObjectId,
|
|
fieldMetadataId: phonesFieldId,
|
|
canReadFieldValue: false,
|
|
canUpdateFieldValue: null,
|
|
},
|
|
],
|
|
});
|
|
|
|
await makeRestAPIRequest({
|
|
method: 'patch',
|
|
path: `/people/${TEST_PERSON_1_ID}`,
|
|
bearer: APPLE_JONY_MEMBER_ACCESS_TOKEN,
|
|
body: {
|
|
name: {
|
|
firstName: 'John',
|
|
},
|
|
},
|
|
})
|
|
.expect(200)
|
|
.expect((res) => {
|
|
const updatedPerson = res.body.data.updatePerson;
|
|
|
|
expect(updatedPerson.name.firstName).toBe('John');
|
|
expect(updatedPerson.phones).toBeUndefined();
|
|
});
|
|
});
|
|
it('should block update when user tries to update non-updatable field', async () => {
|
|
// Create field permission restricting update access to phones field
|
|
await upsertFieldPermissions({
|
|
roleId: memberRoleId,
|
|
fieldPermissions: [
|
|
{
|
|
objectMetadataId: personObjectId,
|
|
fieldMetadataId: phonesFieldId,
|
|
canReadFieldValue: null,
|
|
canUpdateFieldValue: false,
|
|
},
|
|
],
|
|
});
|
|
|
|
await makeRestAPIRequest({
|
|
method: 'patch',
|
|
path: `/people/${TEST_PERSON_1_ID}`,
|
|
bearer: APPLE_JONY_MEMBER_ACCESS_TOKEN,
|
|
body: {
|
|
phones: {
|
|
primaryPhoneNumber: '987654321',
|
|
primaryPhoneCountryCode: 'FR',
|
|
primaryPhoneCallingCode: '+33',
|
|
},
|
|
},
|
|
})
|
|
.expect(400)
|
|
.expect((res) => {
|
|
expect(res.body.messages[0]).toContain(
|
|
'Entity performing the request does not have permission',
|
|
);
|
|
});
|
|
});
|
|
|
|
it('should allow update when user has no restricted update permissions', async () => {
|
|
// Remove field permission restrictions
|
|
await upsertFieldPermissions({
|
|
roleId: memberRoleId,
|
|
fieldPermissions: [
|
|
{
|
|
objectMetadataId: personObjectId,
|
|
fieldMetadataId: phonesFieldId,
|
|
canReadFieldValue: null,
|
|
canUpdateFieldValue: null,
|
|
},
|
|
],
|
|
});
|
|
|
|
await makeRestAPIRequest({
|
|
method: 'patch',
|
|
path: `/people/${TEST_PERSON_1_ID}`,
|
|
bearer: APPLE_JONY_MEMBER_ACCESS_TOKEN,
|
|
body: {
|
|
jobTitle: 'Updated City',
|
|
},
|
|
})
|
|
.expect(200)
|
|
.expect((res) => {
|
|
const updatedPerson = res.body.data.updatePerson;
|
|
|
|
expect(updatedPerson.jobTitle).toBe('Updated City');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('createOne', () => {
|
|
it('should block create when restricted field is not in any RLS predicate', async () => {
|
|
await upsertFieldPermissions({
|
|
roleId: memberRoleId,
|
|
fieldPermissions: [
|
|
{
|
|
objectMetadataId: personObjectId,
|
|
fieldMetadataId: phonesFieldId,
|
|
canReadFieldValue: null,
|
|
canUpdateFieldValue: false,
|
|
},
|
|
],
|
|
});
|
|
|
|
await makeRestAPIRequest({
|
|
method: 'post',
|
|
path: `/people`,
|
|
bearer: APPLE_JONY_MEMBER_ACCESS_TOKEN,
|
|
body: {
|
|
phones: {
|
|
primaryPhoneNumber: '555123456',
|
|
primaryPhoneCountryCode: 'US',
|
|
primaryPhoneCallingCode: '+1',
|
|
},
|
|
},
|
|
})
|
|
.expect(400)
|
|
.expect((res) => {
|
|
expect(res.body.messages[0]).toContain(
|
|
'Entity performing the request does not have permission',
|
|
);
|
|
});
|
|
});
|
|
|
|
it('should allow create when restricted field is referenced in an RLS predicate', async () => {
|
|
await upsertFieldPermissions({
|
|
roleId: memberRoleId,
|
|
fieldPermissions: [
|
|
{
|
|
objectMetadataId: personObjectId,
|
|
fieldMetadataId: phonesFieldId,
|
|
canReadFieldValue: null,
|
|
canUpdateFieldValue: false,
|
|
},
|
|
],
|
|
});
|
|
|
|
await upsertRowLevelPermissionPredicates({
|
|
input: {
|
|
roleId: memberRoleId,
|
|
objectMetadataId: personObjectId,
|
|
predicates: [
|
|
{
|
|
fieldMetadataId: phonesFieldId,
|
|
operand: RowLevelPermissionPredicateOperand.IS_NOT_EMPTY,
|
|
},
|
|
],
|
|
predicateGroups: [],
|
|
},
|
|
});
|
|
|
|
await makeRestAPIRequest({
|
|
method: 'post',
|
|
path: `/people`,
|
|
bearer: APPLE_JONY_MEMBER_ACCESS_TOKEN,
|
|
body: {
|
|
phones: {
|
|
primaryPhoneNumber: '555123456',
|
|
primaryPhoneCountryCode: 'US',
|
|
primaryPhoneCallingCode: '+1',
|
|
},
|
|
},
|
|
})
|
|
.expect(201)
|
|
.expect((res) => {
|
|
const createdPerson = res.body.data.createPerson;
|
|
|
|
expect(createdPerson).toBeDefined();
|
|
expect(createdPerson.phones.primaryPhoneNumber).toBe('555123456');
|
|
});
|
|
|
|
await upsertRowLevelPermissionPredicates({
|
|
input: {
|
|
roleId: memberRoleId,
|
|
objectMetadataId: personObjectId,
|
|
predicates: [],
|
|
predicateGroups: [],
|
|
},
|
|
});
|
|
});
|
|
|
|
it('should allow create when user has no restricted update permissions', async () => {
|
|
await upsertFieldPermissions({
|
|
roleId: memberRoleId,
|
|
fieldPermissions: [
|
|
{
|
|
objectMetadataId: personObjectId,
|
|
fieldMetadataId: phonesFieldId,
|
|
canReadFieldValue: null,
|
|
canUpdateFieldValue: null,
|
|
},
|
|
{
|
|
objectMetadataId: personObjectId,
|
|
fieldMetadataId: emailsFieldId,
|
|
canReadFieldValue: false,
|
|
canUpdateFieldValue: null,
|
|
},
|
|
],
|
|
});
|
|
|
|
await makeRestAPIRequest({
|
|
method: 'post',
|
|
path: `/people`,
|
|
bearer: APPLE_JONY_MEMBER_ACCESS_TOKEN,
|
|
body: {
|
|
jobTitle: 'New City',
|
|
},
|
|
})
|
|
.expect(201)
|
|
.expect((res) => {
|
|
const createdPerson = res.body.data.createPerson;
|
|
|
|
expect(createdPerson.jobTitle).toBe('New City');
|
|
expect(createdPerson.emails).toBeUndefined();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('createMany', () => {
|
|
it('should block createMany when restricted field is not in any RLS predicate', async () => {
|
|
await upsertFieldPermissions({
|
|
roleId: memberRoleId,
|
|
fieldPermissions: [
|
|
{
|
|
objectMetadataId: personObjectId,
|
|
fieldMetadataId: phonesFieldId,
|
|
canReadFieldValue: null,
|
|
canUpdateFieldValue: false,
|
|
},
|
|
],
|
|
});
|
|
|
|
await makeRestAPIRequest({
|
|
method: 'post',
|
|
path: `/batch/people`,
|
|
bearer: APPLE_JONY_MEMBER_ACCESS_TOKEN,
|
|
body: [
|
|
{
|
|
phones: {
|
|
primaryPhoneNumber: '555123456',
|
|
primaryPhoneCountryCode: 'US',
|
|
primaryPhoneCallingCode: '+1',
|
|
},
|
|
},
|
|
],
|
|
})
|
|
.expect(400)
|
|
.expect((res) => {
|
|
expect(res.body.messages[0]).toContain(
|
|
'Entity performing the request does not have permission',
|
|
);
|
|
});
|
|
});
|
|
|
|
it('should allow createMany when restricted field is referenced in an RLS predicate', async () => {
|
|
await upsertFieldPermissions({
|
|
roleId: memberRoleId,
|
|
fieldPermissions: [
|
|
{
|
|
objectMetadataId: personObjectId,
|
|
fieldMetadataId: phonesFieldId,
|
|
canReadFieldValue: null,
|
|
canUpdateFieldValue: false,
|
|
},
|
|
],
|
|
});
|
|
|
|
await upsertRowLevelPermissionPredicates({
|
|
input: {
|
|
roleId: memberRoleId,
|
|
objectMetadataId: personObjectId,
|
|
predicates: [
|
|
{
|
|
fieldMetadataId: phonesFieldId,
|
|
operand: RowLevelPermissionPredicateOperand.IS_NOT_EMPTY,
|
|
},
|
|
],
|
|
predicateGroups: [],
|
|
},
|
|
});
|
|
|
|
await makeRestAPIRequest({
|
|
method: 'post',
|
|
path: `/batch/people`,
|
|
bearer: APPLE_JONY_MEMBER_ACCESS_TOKEN,
|
|
body: [
|
|
{
|
|
phones: {
|
|
primaryPhoneNumber: '555123456',
|
|
primaryPhoneCountryCode: 'US',
|
|
primaryPhoneCallingCode: '+1',
|
|
},
|
|
},
|
|
],
|
|
})
|
|
.expect(201)
|
|
.expect((res) => {
|
|
const createdPeople = res.body.data.createPeople;
|
|
|
|
expect(createdPeople).toHaveLength(1);
|
|
expect(createdPeople[0].phones.primaryPhoneNumber).toBe('555123456');
|
|
});
|
|
|
|
await upsertRowLevelPermissionPredicates({
|
|
input: {
|
|
roleId: memberRoleId,
|
|
objectMetadataId: personObjectId,
|
|
predicates: [],
|
|
predicateGroups: [],
|
|
},
|
|
});
|
|
});
|
|
|
|
it('should allow createMany when user has no restricted update permissions', async () => {
|
|
await upsertFieldPermissions({
|
|
roleId: memberRoleId,
|
|
fieldPermissions: [
|
|
{
|
|
objectMetadataId: personObjectId,
|
|
fieldMetadataId: phonesFieldId,
|
|
canReadFieldValue: null,
|
|
canUpdateFieldValue: null,
|
|
},
|
|
{
|
|
objectMetadataId: personObjectId,
|
|
fieldMetadataId: emailsFieldId,
|
|
canReadFieldValue: false,
|
|
canUpdateFieldValue: null,
|
|
},
|
|
],
|
|
});
|
|
|
|
await makeRestAPIRequest({
|
|
method: 'post',
|
|
path: `/batch/people`,
|
|
bearer: APPLE_JONY_MEMBER_ACCESS_TOKEN,
|
|
body: [
|
|
{
|
|
jobTitle: 'Batch City 1',
|
|
},
|
|
{
|
|
jobTitle: 'Batch City 2',
|
|
},
|
|
],
|
|
})
|
|
.expect(201)
|
|
.expect((res) => {
|
|
const createdPeople = res.body.data.createPeople;
|
|
|
|
expect(createdPeople).toHaveLength(2);
|
|
expect(createdPeople[0].jobTitle).toBe('Batch City 1');
|
|
expect(createdPeople[0].emails).toBeUndefined();
|
|
expect(createdPeople[1].jobTitle).toBe('Batch City 2');
|
|
expect(createdPeople[1].emails).toBeUndefined();
|
|
});
|
|
});
|
|
});
|
|
});
|