From 27fd124c2ea721d9e5ca531cd6e4d68892749925 Mon Sep 17 00:00:00 2001 From: Weiko Date: Wed, 13 May 2026 14:31:16 +0200 Subject: [PATCH] Dedicated REST controllers for object & field metadata (#20364) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Replace the dynamic `RestApiMetadataController` (which parsed `/rest/metadata/*path` and proxied to internal GraphQL) with two dedicated controllers: `ObjectMetadataController` and `FieldMetadataController`. - Drop the GraphQL hop: reads hit Postgres directly via TypeORM repositories; writes call the existing `{create,update,delete}One{Object,Field}` service methods. - Introduce a new clean response shape behind a workspace feature flag (`IS_REST_METADATA_API_NEW_FORMAT_DIRECT`) — see grace period below. - Update the OpenAPI spec so the REST playground reflects the (default) legacy shape during the grace period. ## Why The legacy metadata controller was over-complex: it routed every method through a path parser, a set of GraphQL query-builder factories, an internal GraphQL call, and a `cleanGraphQLResponse` post-processor. Operation names from GraphQL (`createOneObject`, `updateOneField`, …) leaked straight into REST responses. The internal-GraphQL hop also gave us nothing on metadata reads — pagination, filtering, and serialization all happen against the same Postgres tables either way. ## Feature flag & grace period `IS_REST_METADATA_API_NEW_FORMAT_DIRECT` (workspace-scoped): - **Existing workspaces:** flag absent → resolves to `false` → **legacy response shape** (no behavior change). - **Newly created workspaces:** flag seeded to `true` via `DEFAULT_FEATURE_FLAGS` → **new response shape** from day one. - **Toggle:** support-assisted (no frontend); customers contact us to opt into the new shape early. - **Removal:** the flag, the legacy adapter utils (`to-legacy-{object,field}-metadata-response.util.ts`), and the parametrized test wrapper get deleted after the grace window. New shape becomes the only shape; OpenAPI flips to new shape; POST loses the conditional and reverts to a declarative response. ## Response shapes | Operation | Legacy (flag OFF, default for existing) | New (flag ON) | |-----------|-----------------------------------------|---------------| | `GET /rest/metadata/objects` | `{ data: { objects: [...] }, pageInfo, totalCount }` | `{ data: [...], pageInfo, totalCount }` | | `GET /rest/metadata/objects/:id` | `{ data: { object: {...} } }` | `{ ... }` | | `POST /rest/metadata/objects` | `201 { data: { createOneObject: {...} } }` | `201 { ... }` | | `PATCH/PUT /rest/metadata/objects/:id` | `{ data: { updateOneObject: {...} } }` | `{ ... }` | | `DELETE /rest/metadata/objects/:id` | `{ data: { deleteOneObject: { ... } } }` | `{ ... }` | Same matrix for `/rest/metadata/fields`. Cursor params (`starting_after`, `ending_before`, `limit`) and `totalCount` are preserved across both shapes. POST returns `201` in both (old controller already did — the doc on main saying `200` was wrong). ## Implementation notes - Reads go straight to Postgres with TypeORM cursor pagination (`paginateByIdCursor` util, mutually-exclusive `starting_after` / `ending_before`). No cache on this path — caching + filterable pagination didn't combine cleanly. - Object endpoints inline `fields[]` via a single follow-up `WHERE objectMetadataId IN (...)` query. - Controllers read the flag via `FeatureFlagService.isFeatureEnabled` and conditionally pass the result through a legacy-shape adapter util before returning. - Per-domain REST exception filters (`{Object,Field}MetadataRestApiExceptionFilter`); the `exceptionCode → httpStatus` switch is extracted to a util so it can be merged with the existing GraphQL handler later. - New controllers live inside the metadata domain modules (`metadata-modules/{object,field}-metadata/controllers/`) to match existing precedent (view-field, view, page-layout, …). - Removes: `RestApiMetadataController`, `RestApiMetadataService`, `metadata/query-builder/`, `clean-graphql-response.utils.ts`. - Integration tests are parametrized over both flag values via `describe.each` — both shapes are asserted in CI. - OpenAPI fixes inherited from the migration (kept as-is): documents flat `fields: [...]` rather than the obsolete `{edges:{node:[...]}}` wrapping; always emits `totalCount`; POST status `201`. These match what customers actually receive on both shapes. Note: Next goal is to implement something similar for graphql and remove nestjs-query dependency for those 2 entities, then generalise it. Note2: We have the same issue with Core Rest API such as ```json { "data": { "createCompany": { "id": "123e4567-e89b-12d3-a456-426614174000", "createdAt": "2026-05-07T12:14:52.769Z", "updatedAt": "2026-05-07T12:14:52.769Z", "deletedAt": "2026-05-07T12:14:52.769Z", ... ``` with "createCompany" here which is odd compared to REST standards (FYI @etiennejouan @charlesBochet) ## Before (Without feature flag) Screenshot 2026-05-12 at 20 50 38 Screenshot 2026-05-12 at 20 52 24 ## After (With feature flag) Screenshot 2026-05-12 at 20 50 46 Screenshot 2026-05-12 at 20 51 47 --------- Co-authored-by: prastoin --- .../src/metadata/generated/schema.graphql | 1 + .../src/metadata/generated/schema.ts | 5 +- .../src/generated-admin/graphql.ts | 1 + .../src/generated-metadata/graphql.ts | 1 + .../create-metadata-query.factory.ts | 34 -- .../delete-metadata-query.factory.ts | 27 - .../find-many-metadata-query.factory.ts | 36 -- .../find-one-metadata-query.factory.ts | 32 -- .../get-metadata-variables.factory.ts | 38 -- .../factories/metadata-factories.ts | 15 - .../update-metadata-query.factory.ts | 34 -- .../metadata-query-builder.factory.ts | 116 ---- .../metadata-query-builder.module.ts | 12 - .../parse-metadata-path.utils.spec.ts | 39 -- .../utils/fetch-metadata-fields.utils.ts | 90 ---- .../has-select-all-fields-selector.util.ts | 9 - .../utils/parse-metadata-path.utils.ts | 48 -- .../metadata/rest-api-metadata.controller.ts | 70 --- .../metadata/rest-api-metadata.service.ts | 91 ---- .../metadata/types/metadata-entity.type.ts | 11 - .../types/metadata-query-variables.type.ts | 10 - .../metadata/types/metadata-query.type.ts | 10 - .../utils/paginate-by-id-cursor.util.ts | 80 +++ .../src/engine/api/rest/rest-api.module.ts | 20 +- .../clean-graphql-response.utils.spec.ts | 165 ------ .../utils/clean-graphql-response.utils.ts | 75 --- .../core-modules/open-api/open-api.service.ts | 4 +- .../open-api/utils/components.utils.ts | 16 +- .../open-api/utils/responses.utils.ts | 9 +- .../controllers/field-metadata.controller.ts | 203 +++++++ .../field-metadata/field-metadata.module.ts | 6 + ...ield-metadata-rest-api-exception.filter.ts | 88 ++++ ...data-exception-code-to-http-status.util.ts | 31 ++ ...adata-entity-to-field-metadata-dto.util.ts | 31 ++ .../to-legacy-field-metadata-response.util.ts | 32 ++ ...maps-exception-code-to-http-status.util.ts | 20 + .../controllers/object-metadata.controller.ts | 267 ++++++++++ .../dtos/object-metadata-with-fields.dto.ts | 6 + ...ject-metadata-rest-api-exception.filter.ts | 88 ++++ .../object-metadata/object-metadata.module.ts | 6 + ...data-entity-to-object-metadata-dto.util.ts | 34 ++ ...data-exception-code-to-http-status.util.ts | 28 + ...to-legacy-object-metadata-response.util.ts | 33 ++ .../workspace-entity-manager.spec.ts | 1 + .../constant/default-feature-flags.ts | 1 + .../suites/field-metadata.integration-spec.ts | 456 ++++++++++++++++ .../object-metadata.integration-spec.ts | 498 ++++++++++++++++++ .../rest/utils/metadata-rest-api.util.ts | 149 ++++++ .../twenty-shared/src/types/FeatureFlagKey.ts | 1 + 49 files changed, 2074 insertions(+), 1004 deletions(-) delete mode 100644 packages/twenty-server/src/engine/api/rest/metadata/query-builder/factories/create-metadata-query.factory.ts delete mode 100644 packages/twenty-server/src/engine/api/rest/metadata/query-builder/factories/delete-metadata-query.factory.ts delete mode 100644 packages/twenty-server/src/engine/api/rest/metadata/query-builder/factories/find-many-metadata-query.factory.ts delete mode 100644 packages/twenty-server/src/engine/api/rest/metadata/query-builder/factories/find-one-metadata-query.factory.ts delete mode 100644 packages/twenty-server/src/engine/api/rest/metadata/query-builder/factories/get-metadata-variables.factory.ts delete mode 100644 packages/twenty-server/src/engine/api/rest/metadata/query-builder/factories/metadata-factories.ts delete mode 100644 packages/twenty-server/src/engine/api/rest/metadata/query-builder/factories/update-metadata-query.factory.ts delete mode 100644 packages/twenty-server/src/engine/api/rest/metadata/query-builder/metadata-query-builder.factory.ts delete mode 100644 packages/twenty-server/src/engine/api/rest/metadata/query-builder/metadata-query-builder.module.ts delete mode 100644 packages/twenty-server/src/engine/api/rest/metadata/query-builder/utils/__tests__/parse-metadata-path.utils.spec.ts delete mode 100644 packages/twenty-server/src/engine/api/rest/metadata/query-builder/utils/fetch-metadata-fields.utils.ts delete mode 100644 packages/twenty-server/src/engine/api/rest/metadata/query-builder/utils/has-select-all-fields-selector.util.ts delete mode 100644 packages/twenty-server/src/engine/api/rest/metadata/query-builder/utils/parse-metadata-path.utils.ts delete mode 100644 packages/twenty-server/src/engine/api/rest/metadata/rest-api-metadata.controller.ts delete mode 100644 packages/twenty-server/src/engine/api/rest/metadata/rest-api-metadata.service.ts delete mode 100644 packages/twenty-server/src/engine/api/rest/metadata/types/metadata-entity.type.ts delete mode 100644 packages/twenty-server/src/engine/api/rest/metadata/types/metadata-query-variables.type.ts delete mode 100644 packages/twenty-server/src/engine/api/rest/metadata/types/metadata-query.type.ts create mode 100644 packages/twenty-server/src/engine/api/rest/metadata/utils/paginate-by-id-cursor.util.ts delete mode 100644 packages/twenty-server/src/engine/api/rest/utils/__tests__/clean-graphql-response.utils.spec.ts delete mode 100644 packages/twenty-server/src/engine/api/rest/utils/clean-graphql-response.utils.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/field-metadata/controllers/field-metadata.controller.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/field-metadata/filters/field-metadata-rest-api-exception.filter.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/field-metadata-exception-code-to-http-status.util.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/from-field-metadata-entity-to-field-metadata-dto.util.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/to-legacy-field-metadata-response.util.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/flat-entity-maps-exception-code-to-http-status.util.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/object-metadata/controllers/object-metadata.controller.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/object-metadata/dtos/object-metadata-with-fields.dto.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/object-metadata/filters/object-metadata-rest-api-exception.filter.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/from-object-metadata-entity-to-object-metadata-dto.util.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/object-metadata-exception-code-to-http-status.util.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/to-legacy-object-metadata-response.util.ts create mode 100644 packages/twenty-server/test/integration/rest/suites/field-metadata.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/rest/suites/object-metadata.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/rest/utils/metadata-rest-api.util.ts diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql index 5d3e8f6b08..d09443516c 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql @@ -1756,6 +1756,7 @@ enum FeatureFlagKey { IS_JUNCTION_RELATIONS_ENABLED IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED IS_BILLING_V2_ENABLED + IS_REST_METADATA_API_NEW_FORMAT_DIRECT } type WorkspaceUrls { diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.ts b/packages/twenty-client-sdk/src/metadata/generated/schema.ts index 57485fa2a6..f8f4fbc623 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.ts @@ -1389,7 +1389,7 @@ export interface FeatureFlag { __typename: 'FeatureFlag' } -export type FeatureFlagKey = 'IS_UNIQUE_INDEXES_ENABLED' | 'IS_JSON_FILTER_ENABLED' | 'IS_MARKETPLACE_SETTING_TAB_VISIBLE' | 'IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED' | 'IS_PUBLIC_DOMAIN_ENABLED' | 'IS_EMAILING_DOMAIN_ENABLED' | 'IS_EMAIL_GROUP_ENABLED' | 'IS_JUNCTION_RELATIONS_ENABLED' | 'IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED' | 'IS_BILLING_V2_ENABLED' +export type FeatureFlagKey = 'IS_UNIQUE_INDEXES_ENABLED' | 'IS_JSON_FILTER_ENABLED' | 'IS_MARKETPLACE_SETTING_TAB_VISIBLE' | 'IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED' | 'IS_PUBLIC_DOMAIN_ENABLED' | 'IS_EMAILING_DOMAIN_ENABLED' | 'IS_EMAIL_GROUP_ENABLED' | 'IS_JUNCTION_RELATIONS_ENABLED' | 'IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED' | 'IS_BILLING_V2_ENABLED' | 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT' export interface WorkspaceUrls { customUrl?: Scalars['String'] @@ -8765,7 +8765,8 @@ export const enumFeatureFlagKey = { IS_EMAIL_GROUP_ENABLED: 'IS_EMAIL_GROUP_ENABLED' as const, IS_JUNCTION_RELATIONS_ENABLED: 'IS_JUNCTION_RELATIONS_ENABLED' as const, IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED: 'IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED' as const, - IS_BILLING_V2_ENABLED: 'IS_BILLING_V2_ENABLED' as const + IS_BILLING_V2_ENABLED: 'IS_BILLING_V2_ENABLED' as const, + IS_REST_METADATA_API_NEW_FORMAT_DIRECT: 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT' as const } export const enumIdentityProviderType = { diff --git a/packages/twenty-front/src/generated-admin/graphql.ts b/packages/twenty-front/src/generated-admin/graphql.ts index d7ebd6162a..5938d474e9 100644 --- a/packages/twenty-front/src/generated-admin/graphql.ts +++ b/packages/twenty-front/src/generated-admin/graphql.ts @@ -279,6 +279,7 @@ export enum FeatureFlagKey { IS_PUBLIC_DOMAIN_ENABLED = 'IS_PUBLIC_DOMAIN_ENABLED', IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED = 'IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED', IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED = 'IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED', + IS_REST_METADATA_API_NEW_FORMAT_DIRECT = 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT', IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED' } diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 96153e5d2f..d9a141efd8 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -1650,6 +1650,7 @@ export enum FeatureFlagKey { IS_PUBLIC_DOMAIN_ENABLED = 'IS_PUBLIC_DOMAIN_ENABLED', IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED = 'IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED', IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED = 'IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED', + IS_REST_METADATA_API_NEW_FORMAT_DIRECT = 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT', IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED' } diff --git a/packages/twenty-server/src/engine/api/rest/metadata/query-builder/factories/create-metadata-query.factory.ts b/packages/twenty-server/src/engine/api/rest/metadata/query-builder/factories/create-metadata-query.factory.ts deleted file mode 100644 index bc4785d432..0000000000 --- a/packages/twenty-server/src/engine/api/rest/metadata/query-builder/factories/create-metadata-query.factory.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { capitalize } from 'twenty-shared/utils'; - -import { fetchMetadataFields } from 'src/engine/api/rest/metadata/query-builder/utils/fetch-metadata-fields.utils'; -import { - type ObjectName, - type Singular, -} from 'src/engine/api/rest/metadata/types/metadata-entity.type'; -import { type Selectors } from 'src/engine/api/rest/metadata/types/metadata-query.type'; - -@Injectable() -export class CreateMetadataQueryFactory { - create( - objectNameSingular: Singular, - objectNamePlural: ObjectName, - selectors: Selectors, - ): string { - const objectNameCapitalized = capitalize(objectNameSingular); - - const fields = fetchMetadataFields(objectNamePlural, selectors); - - return ` - mutation CreateOne${objectNameCapitalized}($input: CreateOne${objectNameCapitalized}${ - objectNameSingular === 'field' ? 'Metadata' : '' - }Input!) { - createOne${objectNameCapitalized}(input: $input) { - id - ${fields} - } - } - `; - } -} diff --git a/packages/twenty-server/src/engine/api/rest/metadata/query-builder/factories/delete-metadata-query.factory.ts b/packages/twenty-server/src/engine/api/rest/metadata/query-builder/factories/delete-metadata-query.factory.ts deleted file mode 100644 index 64bfc64923..0000000000 --- a/packages/twenty-server/src/engine/api/rest/metadata/query-builder/factories/delete-metadata-query.factory.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { capitalize } from 'twenty-shared/utils'; - -import { - type ObjectName, - type Singular, -} from 'src/engine/api/rest/metadata/types/metadata-entity.type'; - -@Injectable() -export class DeleteMetadataQueryFactory { - create(objectNameSingular: Singular): string { - const objectNameCapitalized = capitalize(objectNameSingular); - const formattedObjectName = - objectNameCapitalized === 'RelationMetadata' - ? 'Relation' - : objectNameCapitalized; - - return ` - mutation Delete${objectNameCapitalized}($input: DeleteOne${formattedObjectName}Input!) { - deleteOne${formattedObjectName}(input: $input) { - id - } - } - `; - } -} diff --git a/packages/twenty-server/src/engine/api/rest/metadata/query-builder/factories/find-many-metadata-query.factory.ts b/packages/twenty-server/src/engine/api/rest/metadata/query-builder/factories/find-many-metadata-query.factory.ts deleted file mode 100644 index c7401aa587..0000000000 --- a/packages/twenty-server/src/engine/api/rest/metadata/query-builder/factories/find-many-metadata-query.factory.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { capitalize } from 'twenty-shared/utils'; - -import { fetchMetadataFields } from 'src/engine/api/rest/metadata/query-builder/utils/fetch-metadata-fields.utils'; -import { type ObjectName } from 'src/engine/api/rest/metadata/types/metadata-entity.type'; -import { type Selectors } from 'src/engine/api/rest/metadata/types/metadata-query.type'; - -@Injectable() -export class FindManyMetadataQueryFactory { - create(objectNamePlural: ObjectName, selectors: Selectors): string { - const fields = fetchMetadataFields(objectNamePlural, selectors); - - return ` - query FindMany${capitalize(objectNamePlural)}( - $paging: CursorPaging! - ) { - ${objectNamePlural}( - paging: $paging - ) { - edges { - node { - id - ${fields} - } - } - pageInfo { - hasNextPage - startCursor - endCursor - } - } - } - `; - } -} diff --git a/packages/twenty-server/src/engine/api/rest/metadata/query-builder/factories/find-one-metadata-query.factory.ts b/packages/twenty-server/src/engine/api/rest/metadata/query-builder/factories/find-one-metadata-query.factory.ts deleted file mode 100644 index b794d530b1..0000000000 --- a/packages/twenty-server/src/engine/api/rest/metadata/query-builder/factories/find-one-metadata-query.factory.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { capitalize } from 'twenty-shared/utils'; - -import { fetchMetadataFields } from 'src/engine/api/rest/metadata/query-builder/utils/fetch-metadata-fields.utils'; -import { - type ObjectName, - type Singular, -} from 'src/engine/api/rest/metadata/types/metadata-entity.type'; -import { type Selectors } from 'src/engine/api/rest/metadata/types/metadata-query.type'; - -@Injectable() -export class FindOneMetadataQueryFactory { - create( - objectNameSingular: Singular, - objectNamePlural: ObjectName, - selectors: Selectors, - ): string { - const fields = fetchMetadataFields(objectNamePlural, selectors); - - return ` - query FindOne${capitalize(objectNameSingular)}( - $id: UUID!, - ) { - ${objectNameSingular}(id: $id) { - id - ${fields} - } - } - `; - } -} diff --git a/packages/twenty-server/src/engine/api/rest/metadata/query-builder/factories/get-metadata-variables.factory.ts b/packages/twenty-server/src/engine/api/rest/metadata/query-builder/factories/get-metadata-variables.factory.ts deleted file mode 100644 index a1524e8cff..0000000000 --- a/packages/twenty-server/src/engine/api/rest/metadata/query-builder/factories/get-metadata-variables.factory.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { BadRequestException, Injectable } from '@nestjs/common'; - -import { parseEndingBeforeRestRequest } from 'src/engine/api/rest/input-request-parsers/ending-before-parser-utils/parse-ending-before-rest-request.util'; -import { parseLimitRestRequest } from 'src/engine/api/rest/input-request-parsers/limit-parser-utils/parse-limit-rest-request.util'; -import { parseStartingAfterRestRequest } from 'src/engine/api/rest/input-request-parsers/starting-after-parser-utils/parse-starting-after-rest-request.util'; -import { MetadataQueryVariables } from 'src/engine/api/rest/metadata/types/metadata-query-variables.type'; -import { RequestContext } from 'src/engine/api/rest/types/RequestContext'; - -@Injectable() -export class GetMetadataVariablesFactory { - create( - id: string | undefined, - requestContext: RequestContext, - ): MetadataQueryVariables { - if (id) { - return { id }; - } - - const limit = parseLimitRestRequest(requestContext, 1000); - const before = parseEndingBeforeRestRequest(requestContext); - const after = parseStartingAfterRestRequest(requestContext); - - if (before && after) { - throw new BadRequestException( - `Only one of 'endingBefore' and 'startingAfter' may be provided`, - ); - } - - return { - paging: { - first: !before ? limit : undefined, - last: before ? limit : undefined, - after, - before, - }, - }; - } -} diff --git a/packages/twenty-server/src/engine/api/rest/metadata/query-builder/factories/metadata-factories.ts b/packages/twenty-server/src/engine/api/rest/metadata/query-builder/factories/metadata-factories.ts deleted file mode 100644 index f17be6afd6..0000000000 --- a/packages/twenty-server/src/engine/api/rest/metadata/query-builder/factories/metadata-factories.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { CreateMetadataQueryFactory } from 'src/engine/api/rest/metadata/query-builder/factories/create-metadata-query.factory'; -import { DeleteMetadataQueryFactory } from 'src/engine/api/rest/metadata/query-builder/factories/delete-metadata-query.factory'; -import { FindManyMetadataQueryFactory } from 'src/engine/api/rest/metadata/query-builder/factories/find-many-metadata-query.factory'; -import { FindOneMetadataQueryFactory } from 'src/engine/api/rest/metadata/query-builder/factories/find-one-metadata-query.factory'; -import { GetMetadataVariablesFactory } from 'src/engine/api/rest/metadata/query-builder/factories/get-metadata-variables.factory'; -import { UpdateMetadataQueryFactory } from 'src/engine/api/rest/metadata/query-builder/factories/update-metadata-query.factory'; - -export const metadataQueryBuilderFactories = [ - FindOneMetadataQueryFactory, - FindManyMetadataQueryFactory, - CreateMetadataQueryFactory, - DeleteMetadataQueryFactory, - UpdateMetadataQueryFactory, - GetMetadataVariablesFactory, -]; diff --git a/packages/twenty-server/src/engine/api/rest/metadata/query-builder/factories/update-metadata-query.factory.ts b/packages/twenty-server/src/engine/api/rest/metadata/query-builder/factories/update-metadata-query.factory.ts deleted file mode 100644 index a49851fad2..0000000000 --- a/packages/twenty-server/src/engine/api/rest/metadata/query-builder/factories/update-metadata-query.factory.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { capitalize } from 'twenty-shared/utils'; - -import { fetchMetadataFields } from 'src/engine/api/rest/metadata/query-builder/utils/fetch-metadata-fields.utils'; -import { - type ObjectName, - type Singular, -} from 'src/engine/api/rest/metadata/types/metadata-entity.type'; -import { type Selectors } from 'src/engine/api/rest/metadata/types/metadata-query.type'; - -@Injectable() -export class UpdateMetadataQueryFactory { - create( - objectNameSingular: Singular, - objectNamePlural: ObjectName, - selectors: Selectors, - ): string { - const objectNameCapitalized = capitalize(objectNameSingular); - - const fields = fetchMetadataFields(objectNamePlural, selectors); - - return ` - mutation Update${objectNameCapitalized}($input: UpdateOne${objectNameCapitalized}${ - objectNameSingular === 'field' ? 'Metadata' : '' - }Input!) { - updateOne${objectNameCapitalized}(input: $input) { - id - ${fields} - } - } - `; - } -} diff --git a/packages/twenty-server/src/engine/api/rest/metadata/query-builder/metadata-query-builder.factory.ts b/packages/twenty-server/src/engine/api/rest/metadata/query-builder/metadata-query-builder.factory.ts deleted file mode 100644 index c33d934245..0000000000 --- a/packages/twenty-server/src/engine/api/rest/metadata/query-builder/metadata-query-builder.factory.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { BadRequestException, Injectable } from '@nestjs/common'; - -import { GetMetadataVariablesFactory } from 'src/engine/api/rest/metadata/query-builder/factories/get-metadata-variables.factory'; -import { FindOneMetadataQueryFactory } from 'src/engine/api/rest/metadata/query-builder/factories/find-one-metadata-query.factory'; -import { FindManyMetadataQueryFactory } from 'src/engine/api/rest/metadata/query-builder/factories/find-many-metadata-query.factory'; -import { parseMetadataPath } from 'src/engine/api/rest/metadata/query-builder/utils/parse-metadata-path.utils'; -import { CreateMetadataQueryFactory } from 'src/engine/api/rest/metadata/query-builder/factories/create-metadata-query.factory'; -import { UpdateMetadataQueryFactory } from 'src/engine/api/rest/metadata/query-builder/factories/update-metadata-query.factory'; -import { DeleteMetadataQueryFactory } from 'src/engine/api/rest/metadata/query-builder/factories/delete-metadata-query.factory'; -import { - type MetadataQuery, - type Selectors, -} from 'src/engine/api/rest/metadata/types/metadata-query.type'; -import { type RequestContext } from 'src/engine/api/rest/types/RequestContext'; - -@Injectable() -export class MetadataQueryBuilderFactory { - constructor( - private readonly findOneQueryFactory: FindOneMetadataQueryFactory, - private readonly findManyQueryFactory: FindManyMetadataQueryFactory, - private readonly createQueryFactory: CreateMetadataQueryFactory, - private readonly updateQueryFactory: UpdateMetadataQueryFactory, - private readonly deleteQueryFactory: DeleteMetadataQueryFactory, - private readonly getMetadataVariablesFactory: GetMetadataVariablesFactory, - ) {} - - async get( - request: RequestContext, - selectors?: Selectors, - ): Promise { - const { id, objectNameSingular, objectNamePlural } = parseMetadataPath( - request.path, - ); - - return { - query: id - ? this.findOneQueryFactory.create( - objectNameSingular, - objectNamePlural, - selectors, - ) - : this.findManyQueryFactory.create(objectNamePlural, selectors), - variables: this.getMetadataVariablesFactory.create(id, request), - }; - } - - async create( - { path, body }: Pick, - selectors?: Selectors, - ): Promise { - const { objectNameSingular, objectNamePlural } = parseMetadataPath(path); - - return { - query: this.createQueryFactory.create( - objectNameSingular, - objectNamePlural, - selectors, - ), - variables: { - input: { - [objectNameSingular]: body, - }, - }, - }; - } - - async update( - request: Pick, - selectors?: Selectors, - ): Promise { - const { objectNameSingular, objectNamePlural, id } = parseMetadataPath( - request.path, - ); - - if (!id) { - throw new BadRequestException( - `update ${objectNameSingular} query invalid. Id missing. eg: /rest/metadata/${objectNameSingular}/0d4389ef-ea9c-4ae8-ada1-1cddc440fb56`, - ); - } - - return { - query: this.updateQueryFactory.create( - objectNameSingular, - objectNamePlural, - selectors, - ), - variables: { - input: { - update: request.body, - id, - }, - }, - }; - } - - async delete( - request: Pick, - ): Promise { - const { objectNameSingular, id } = parseMetadataPath(request.path); - - if (!id) { - throw new BadRequestException( - `delete ${objectNameSingular} query invalid. Id missing. eg: /rest/metadata/${objectNameSingular}/0d4389ef-ea9c-4ae8-ada1-1cddc440fb56`, - ); - } - - return { - query: this.deleteQueryFactory.create(objectNameSingular), - variables: { - input: { - id, - }, - }, - }; - } -} diff --git a/packages/twenty-server/src/engine/api/rest/metadata/query-builder/metadata-query-builder.module.ts b/packages/twenty-server/src/engine/api/rest/metadata/query-builder/metadata-query-builder.module.ts deleted file mode 100644 index 82bf0fb567..0000000000 --- a/packages/twenty-server/src/engine/api/rest/metadata/query-builder/metadata-query-builder.module.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Module } from '@nestjs/common'; - -import { AuthModule } from 'src/engine/core-modules/auth/auth.module'; -import { MetadataQueryBuilderFactory } from 'src/engine/api/rest/metadata/query-builder/metadata-query-builder.factory'; -import { metadataQueryBuilderFactories } from 'src/engine/api/rest/metadata/query-builder/factories/metadata-factories'; - -@Module({ - imports: [AuthModule], - providers: [...metadataQueryBuilderFactories, MetadataQueryBuilderFactory], - exports: [MetadataQueryBuilderFactory], -}) -export class MetadataQueryBuilderModule {} diff --git a/packages/twenty-server/src/engine/api/rest/metadata/query-builder/utils/__tests__/parse-metadata-path.utils.spec.ts b/packages/twenty-server/src/engine/api/rest/metadata/query-builder/utils/__tests__/parse-metadata-path.utils.spec.ts deleted file mode 100644 index 383baee68b..0000000000 --- a/packages/twenty-server/src/engine/api/rest/metadata/query-builder/utils/__tests__/parse-metadata-path.utils.spec.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { parseMetadataPath } from 'src/engine/api/rest/metadata/query-builder/utils/parse-metadata-path.utils'; - -describe('parseMetadataPath', () => { - it('should parse object from request path with uuid', () => { - const request: any = { path: '/rest/metadata/fields/uuid' }; - - expect(parseMetadataPath(request.path)).toEqual({ - objectNameSingular: 'field', - objectNamePlural: 'fields', - id: 'uuid', - }); - }); - - it('should parse object from request path', () => { - const request: any = { path: '/rest/metadata/fields' }; - - expect(parseMetadataPath(request.path)).toEqual({ - objectNameSingular: 'field', - objectNamePlural: 'fields', - id: undefined, - }); - }); - - it('should throw for wrong request path', () => { - const request: any = { path: '/rest/metadata/INVALID' }; - - expect(() => parseMetadataPath(request.path)).toThrow( - 'Query path \'/rest/metadata/INVALID\' invalid. Metadata path "INVALID" does not exist. Valid examples: /rest/metadata/fields or /rest/metadata/objects', - ); - }); - - it('should throw for wrong request path', () => { - const request: any = { path: '/rest/metadata/fields/uuid/toto' }; - - expect(() => parseMetadataPath(request.path)).toThrow( - "Query path '/rest/metadata/fields/uuid/toto' invalid. Valid examples: /rest/metadata/fields or /rest/metadata/objects/id", - ); - }); -}); diff --git a/packages/twenty-server/src/engine/api/rest/metadata/query-builder/utils/fetch-metadata-fields.utils.ts b/packages/twenty-server/src/engine/api/rest/metadata/query-builder/utils/fetch-metadata-fields.utils.ts deleted file mode 100644 index 3a93894e3a..0000000000 --- a/packages/twenty-server/src/engine/api/rest/metadata/query-builder/utils/fetch-metadata-fields.utils.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { hasSelectAllFieldsSelector } from 'src/engine/api/rest/metadata/query-builder/utils/has-select-all-fields-selector.util'; -import { type Selectors } from 'src/engine/api/rest/metadata/types/metadata-query.type'; - -export const fetchMetadataFields = ( - objectNamePlural: string, - selector: Selectors, -) => { - const defaultFields = ` - id - type - name - label - description - icon - isCustom - isActive - isSystem - isNullable - createdAt - updatedAt - defaultValue - options - relation { - type - targetObjectMetadata { - id - nameSingular - namePlural - } - targetFieldMetadata { - id - name - } - sourceObjectMetadata { - id - nameSingular - namePlural - } - sourceFieldMetadata { - id - name - } - } - `; - - const fieldsSelection = hasSelectAllFieldsSelector(selector) - ? defaultFields - : (selector?.fields?.join('\n') ?? defaultFields); - - switch (objectNamePlural) { - case 'objects': { - const objectsSelection = - selector?.objects?.join('\n') ?? - ` - nameSingular - namePlural - labelSingular - labelPlural - description - icon - isCustom - isActive - isSystem - createdAt - updatedAt - labelIdentifierFieldMetadataId - imageIdentifierFieldMetadataId - `; - - const fieldsPart = selector?.fields - ? ` - fields(paging: { first: 1000 }) { - edges { - node { - ${fieldsSelection} - } - } - } - ` - : ''; - - return ` - ${objectsSelection} - ${fieldsPart} - `; - } - case 'fields': - return fieldsSelection; - } -}; diff --git a/packages/twenty-server/src/engine/api/rest/metadata/query-builder/utils/has-select-all-fields-selector.util.ts b/packages/twenty-server/src/engine/api/rest/metadata/query-builder/utils/has-select-all-fields-selector.util.ts deleted file mode 100644 index 7bcdac8b3a..0000000000 --- a/packages/twenty-server/src/engine/api/rest/metadata/query-builder/utils/has-select-all-fields-selector.util.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { type Selectors } from 'src/engine/api/rest/metadata/types/metadata-query.type'; - -export const hasSelectAllFieldsSelector = (selector: Selectors) => { - return ( - selector?.fields?.length && - selector?.fields.length === 1 && - selector?.fields.includes('*') - ); -}; diff --git a/packages/twenty-server/src/engine/api/rest/metadata/query-builder/utils/parse-metadata-path.utils.ts b/packages/twenty-server/src/engine/api/rest/metadata/query-builder/utils/parse-metadata-path.utils.ts deleted file mode 100644 index 5c8f7ffc69..0000000000 --- a/packages/twenty-server/src/engine/api/rest/metadata/query-builder/utils/parse-metadata-path.utils.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { BadRequestException } from '@nestjs/common'; - -import { - type ObjectName, - type ObjectNameSingularAndPlural, -} from 'src/engine/api/rest/metadata/types/metadata-entity.type'; - -const getObjectNames = ( - objectName: ObjectName, -): ObjectNameSingularAndPlural => { - return { - objectNameSingular: objectName.substring( - 0, - objectName.length - 1, - ) as ObjectNameSingularAndPlural['objectNameSingular'], - objectNamePlural: objectName, - }; -}; - -export const parseMetadataPath = ( - path: string, -): ObjectNameSingularAndPlural => { - const queryAction = path.replace('/rest/metadata/', '').split('/'); - - if (queryAction.length >= 3 || queryAction.length === 0) { - throw new BadRequestException( - `Query path '${path}' invalid. Valid examples: /rest/metadata/fields or /rest/metadata/objects/id`, - ); - } - - if (!['fields', 'objects'].includes(queryAction[0])) { - throw new BadRequestException( - `Query path '${path}' invalid. Metadata path "${queryAction[0]}" does not exist. Valid examples: /rest/metadata/fields or /rest/metadata/objects`, - ); - } - - const hasId = queryAction.length === 2; - - const { objectNameSingular, objectNamePlural } = getObjectNames( - queryAction[0] as ObjectName, - ); - - return { - objectNameSingular, - objectNamePlural, - ...(hasId ? { id: queryAction[1] } : {}), - }; -}; diff --git a/packages/twenty-server/src/engine/api/rest/metadata/rest-api-metadata.controller.ts b/packages/twenty-server/src/engine/api/rest/metadata/rest-api-metadata.controller.ts deleted file mode 100644 index fc57b2f530..0000000000 --- a/packages/twenty-server/src/engine/api/rest/metadata/rest-api-metadata.controller.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { - Controller, - Delete, - Get, - Patch, - Post, - Put, - Req, - Res, - UseGuards, -} from '@nestjs/common'; - -import { Request, Response } from 'express'; -import { PermissionFlagType } from 'twenty-shared/constants'; - -import { RestApiMetadataService } from 'src/engine/api/rest/metadata/rest-api-metadata.service'; -import { cleanGraphQLResponse } from 'src/engine/api/rest/utils/clean-graphql-response.utils'; -import { JwtAuthGuard } from 'src/engine/guards/jwt-auth.guard'; -import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard'; -import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; - -@Controller('rest/metadata/*path') -@UseGuards( - JwtAuthGuard, - WorkspaceAuthGuard, - SettingsPermissionGuard(PermissionFlagType.DATA_MODEL), -) -export class RestApiMetadataController { - constructor( - private readonly restApiMetadataService: RestApiMetadataService, - ) {} - - @Get() - async handleApiGet(@Req() request: Request, @Res() res: Response) { - const result = await this.restApiMetadataService.get(request); - - res.status(200).send(cleanGraphQLResponse(result.data.data)); - } - - @Delete() - async handleApiDelete(@Req() request: Request, @Res() res: Response) { - const result = await this.restApiMetadataService.delete(request); - - res.status(200).send(cleanGraphQLResponse(result.data.data)); - } - - @Post() - async handleApiPost(@Req() request: Request, @Res() res: Response) { - const result = await this.restApiMetadataService.create(request); - - res.status(201).send(cleanGraphQLResponse(result.data.data)); - } - - @Patch() - async handleApiPatch(@Req() request: Request, @Res() res: Response) { - const result = await this.restApiMetadataService.update(request); - - res.status(200).send(cleanGraphQLResponse(result.data.data)); - } - - // This endpoint is not documented in the OpenAPI schema. - // We keep it to avoid a breaking change since it initially used PUT instead of PATCH, - // and because the PUT verb is often used as a PATCH. - @Put() - async handleApiPut(@Req() request: Request, @Res() res: Response) { - const result = await this.restApiMetadataService.update(request); - - res.status(200).send(cleanGraphQLResponse(result.data.data)); - } -} diff --git a/packages/twenty-server/src/engine/api/rest/metadata/rest-api-metadata.service.ts b/packages/twenty-server/src/engine/api/rest/metadata/rest-api-metadata.service.ts deleted file mode 100644 index 7973861697..0000000000 --- a/packages/twenty-server/src/engine/api/rest/metadata/rest-api-metadata.service.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { type Request } from 'express'; - -import { MetadataQueryBuilderFactory } from 'src/engine/api/rest/metadata/query-builder/metadata-query-builder.factory'; -import { - GraphqlApiType, - RestApiService, -} from 'src/engine/api/rest/rest-api.service'; -import { type RequestContext } from 'src/engine/api/rest/types/RequestContext'; -import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; -import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; -import { getServerUrl } from 'src/utils/get-server-url'; - -@Injectable() -export class RestApiMetadataService { - constructor( - private readonly accessTokenService: AccessTokenService, - private readonly metadataQueryBuilderFactory: MetadataQueryBuilderFactory, - private readonly restApiService: RestApiService, - private readonly twentyConfigService: TwentyConfigService, - ) {} - - async get(request: Request) { - await this.accessTokenService.validateTokenByRequest(request); - const requestContext = this.getRequestContext(request); - const data = await this.metadataQueryBuilderFactory.get(requestContext, { - fields: ['*'], - }); - - return await this.restApiService.call( - GraphqlApiType.METADATA, - requestContext, - data, - ); - } - - async create(request: Request) { - await this.accessTokenService.validateTokenByRequest(request); - const requestContext = this.getRequestContext(request); - const data = await this.metadataQueryBuilderFactory.create(requestContext, { - fields: ['*'], - }); - - return await this.restApiService.call( - GraphqlApiType.METADATA, - requestContext, - data, - ); - } - - async update(request: Request) { - await this.accessTokenService.validateTokenByRequest(request); - const requestContext = this.getRequestContext(request); - const data = await this.metadataQueryBuilderFactory.update(requestContext, { - fields: ['*'], - }); - - return await this.restApiService.call( - GraphqlApiType.METADATA, - requestContext, - data, - ); - } - - async delete(request: Request) { - await this.accessTokenService.validateTokenByRequest(request); - const requestContext = this.getRequestContext(request); - const data = await this.metadataQueryBuilderFactory.delete(requestContext); - - return await this.restApiService.call( - GraphqlApiType.METADATA, - requestContext, - data, - ); - } - - private getRequestContext(request: Request): RequestContext { - const baseUrl = getServerUrl({ - serverUrlEnv: this.twentyConfigService.get('SERVER_URL'), - serverUrlFallback: `${request.protocol}://${request.get('host')}`, - }); - - return { - body: request.body, - baseUrl: baseUrl, - path: request.url, - headers: request.headers, - }; - } -} diff --git a/packages/twenty-server/src/engine/api/rest/metadata/types/metadata-entity.type.ts b/packages/twenty-server/src/engine/api/rest/metadata/types/metadata-entity.type.ts deleted file mode 100644 index f0af73bb09..0000000000 --- a/packages/twenty-server/src/engine/api/rest/metadata/types/metadata-entity.type.ts +++ /dev/null @@ -1,11 +0,0 @@ -export type Singular = S extends `${infer Stem}s` ? Stem : S; - -export type ObjectName = 'fields' | 'objects'; - -export type ObjectNameSingularAndPlural< - ObjectNameGeneric extends ObjectName = ObjectName, -> = { - objectNameSingular: Singular; - objectNamePlural: ObjectNameGeneric; - id?: string; -}; diff --git a/packages/twenty-server/src/engine/api/rest/metadata/types/metadata-query-variables.type.ts b/packages/twenty-server/src/engine/api/rest/metadata/types/metadata-query-variables.type.ts deleted file mode 100644 index edc86f56fb..0000000000 --- a/packages/twenty-server/src/engine/api/rest/metadata/types/metadata-query-variables.type.ts +++ /dev/null @@ -1,10 +0,0 @@ -export type MetadataQueryVariables = { - id?: string; - input?: object; - paging?: { - first?: number; - last?: number; - after?: string; - before?: string; - }; -}; diff --git a/packages/twenty-server/src/engine/api/rest/metadata/types/metadata-query.type.ts b/packages/twenty-server/src/engine/api/rest/metadata/types/metadata-query.type.ts deleted file mode 100644 index 258752fb68..0000000000 --- a/packages/twenty-server/src/engine/api/rest/metadata/types/metadata-query.type.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { type MetadataQueryVariables } from 'src/engine/api/rest/metadata/types/metadata-query-variables.type'; - -export type MetadataQuery = { - query: string; - variables: MetadataQueryVariables; -}; - -export type Selectors = - | { fields?: Array; objects?: Array } - | undefined; diff --git a/packages/twenty-server/src/engine/api/rest/metadata/utils/paginate-by-id-cursor.util.ts b/packages/twenty-server/src/engine/api/rest/metadata/utils/paginate-by-id-cursor.util.ts new file mode 100644 index 0000000000..e32a372d4a --- /dev/null +++ b/packages/twenty-server/src/engine/api/rest/metadata/utils/paginate-by-id-cursor.util.ts @@ -0,0 +1,80 @@ +import { BadRequestException } from '@nestjs/common'; + +import { isDefined } from 'twenty-shared/utils'; +import { + type FindManyOptions, + type FindOptionsWhere, + LessThan, + MoreThan, + type Repository, +} from 'typeorm'; + +export type RestCursorPageInfo = { + hasNextPage: boolean; + startCursor: string | null; + endCursor: string | null; +}; + +export const paginateByIdCursor = async < + T extends { id: string; workspaceId: string }, +>({ + repository, + workspaceId, + where, + limit, + startingAfter, + endingBefore, +}: { + repository: Repository; + workspaceId: string; + where?: FindOptionsWhere; + limit: number; + startingAfter?: string; + endingBefore?: string; +}): Promise<{ + items: T[]; + pageInfo: RestCursorPageInfo; + totalCount: number; +}> => { + if (isDefined(startingAfter) && isDefined(endingBefore)) { + throw new BadRequestException( + `'starting_after' and 'ending_before' cannot be used together.`, + ); + } + + const isBackward = isDefined(endingBefore); + + const idCondition = isBackward + ? { id: MoreThan(endingBefore) } + : isDefined(startingAfter) + ? { id: LessThan(startingAfter) } + : {}; + + const baseWhere = { ...where, workspaceId } as FindOptionsWhere; + + const [rows, totalCount] = await Promise.all([ + repository.find({ + where: { ...baseWhere, ...idCondition }, + order: { id: isBackward ? 'ASC' : 'DESC' }, + take: limit + 1, + } as FindManyOptions), + repository.count({ where: baseWhere }), + ]); + + const hasMore = rows.length > limit; + const items = hasMore ? rows.slice(0, limit) : rows; + + if (isBackward) { + items.reverse(); + } + + return { + items, + pageInfo: { + hasNextPage: hasMore, + startCursor: items[0]?.id ?? null, + endCursor: items[items.length - 1]?.id ?? null, + }, + totalCount, + }; +}; diff --git a/packages/twenty-server/src/engine/api/rest/rest-api.module.ts b/packages/twenty-server/src/engine/api/rest/rest-api.module.ts index 5deb9c9c58..22a91743ce 100644 --- a/packages/twenty-server/src/engine/api/rest/rest-api.module.ts +++ b/packages/twenty-server/src/engine/api/rest/rest-api.module.ts @@ -1,26 +1,8 @@ import { Module } from '@nestjs/common'; -import { MetadataQueryBuilderModule } from 'src/engine/api/rest/metadata/query-builder/metadata-query-builder.module'; -import { RestApiMetadataService } from 'src/engine/api/rest/metadata/rest-api-metadata.service'; -import { AuthModule } from 'src/engine/core-modules/auth/auth.module'; -import { SecureHttpClientModule } from 'src/engine/core-modules/secure-http-client/secure-http-client.module'; -import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module'; import { RestApiCoreModule } from 'src/engine/api/rest/core/rest-api-core.module'; -import { RestApiService } from 'src/engine/api/rest/rest-api.service'; -import { RestApiMetadataController } from 'src/engine/api/rest/metadata/rest-api-metadata.controller'; -import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; @Module({ - imports: [ - MetadataQueryBuilderModule, - WorkspaceCacheStorageModule, - AuthModule, - RestApiCoreModule, - PermissionsModule, - SecureHttpClientModule, - ], - controllers: [RestApiMetadataController], - providers: [RestApiService, RestApiMetadataService], - exports: [RestApiMetadataService, RestApiService], + imports: [RestApiCoreModule], }) export class RestApiModule {} diff --git a/packages/twenty-server/src/engine/api/rest/utils/__tests__/clean-graphql-response.utils.spec.ts b/packages/twenty-server/src/engine/api/rest/utils/__tests__/clean-graphql-response.utils.spec.ts deleted file mode 100644 index 1c2f580794..0000000000 --- a/packages/twenty-server/src/engine/api/rest/utils/__tests__/clean-graphql-response.utils.spec.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { cleanGraphQLResponse } from 'src/engine/api/rest/utils/clean-graphql-response.utils'; - -describe('cleanGraphQLResponse', () => { - it('should remove edges/node from results', () => { - const data = { - companies: { - edges: [ - { - node: { id: 'id', createdAt: '2023-01-01' }, - }, - ], - }, - }; - const expectedResult = { - data: { - companies: [{ id: 'id', createdAt: '2023-01-01' }], - }, - }; - - expect(cleanGraphQLResponse(data)).toEqual(expectedResult); - }); - it('should remove nested edges/node from results', () => { - const data = { - companies: { - totalCount: 14, - pageInfo: { - hasNextPage: true, - startCursor: - 'WyIwMDliYjNkYy1hNGEyLTRiNWUtYTZmYi1iMTFiMmFlMGI1MmIiXQ==', - endCursor: 'WyIyMDIwMjAyMC0wNzEzLTQwYTUtODIxNi04MjgwMjQwMWQzM2UiXQ==', - }, - edges: [ - { - node: { - id: 'id', - createdAt: '2023-01-01', - people: { - edges: [{ node: { id: 'id1' } }, { node: { id: 'id2' } }], - }, - }, - }, - ], - }, - }; - const expectedResult = { - data: { - companies: [ - { - id: 'id', - createdAt: '2023-01-01', - people: [{ id: 'id1' }, { id: 'id2' }], - }, - ], - }, - totalCount: 14, - pageInfo: { - hasNextPage: true, - startCursor: 'WyIwMDliYjNkYy1hNGEyLTRiNWUtYTZmYi1iMTFiMmFlMGI1MmIiXQ==', - endCursor: 'WyIyMDIwMjAyMC0wNzEzLTQwYTUtODIxNi04MjgwMjQwMWQzM2UiXQ==', - }, - }; - - expect(cleanGraphQLResponse(data)).toEqual(expectedResult); - }); - it('should not format when no list returned', () => { - const data = { company: { id: 'id' } }; - const expectedResult = { - data: { - company: { id: 'id' }, - }, - }; - - expect(cleanGraphQLResponse(data)).toEqual(expectedResult); - }); - - it('should remove nested edges/node from results if data key is an array', () => { - const data = { - companyDuplicates: [ - { - totalCount: 14, - pageInfo: { - hasNextPage: true, - startCursor: - 'WyIwMDliYjNkYy1hNGEyLTRiNWUtYTZmYi1iMTFiMmFlMGI1MmIiXQ==', - endCursor: - 'WyIyMDIwMjAyMC0wNzEzLTQwYTUtODIxNi04MjgwMjQwMWQzM2UiXQ==', - }, - edges: [ - { - node: { - id: 'id', - createdAt: '2023-01-01', - people: { - edges: [{ node: { id: 'id1' } }, { node: { id: 'id2' } }], - }, - }, - }, - ], - }, - { - totalCount: 14, - pageInfo: { - hasNextPage: true, - startCursor: - 'WyIwMDliYjNkYy1hNGEyLTRiNWUtYTZmYi1iMTFiMmFlMGI1MmIiXQ==', - endCursor: - 'WyIyMDIwMjAyMC0wNzEzLTQwYTUtODIxNi04MjgwMjQwMWQzM2UiXQ==', - }, - edges: [ - { - node: { - id: 'id', - createdAt: '2023-01-01', - people: { - edges: [{ node: { id: 'id1' } }, { node: { id: 'id2' } }], - }, - }, - }, - ], - }, - ], - }; - - const expectedResult = { - data: [ - { - totalCount: 14, - pageInfo: { - hasNextPage: true, - startCursor: - 'WyIwMDliYjNkYy1hNGEyLTRiNWUtYTZmYi1iMTFiMmFlMGI1MmIiXQ==', - endCursor: - 'WyIyMDIwMjAyMC0wNzEzLTQwYTUtODIxNi04MjgwMjQwMWQzM2UiXQ==', - }, - companyDuplicates: [ - { - id: 'id', - createdAt: '2023-01-01', - people: [{ id: 'id1' }, { id: 'id2' }], - }, - ], - }, - { - totalCount: 14, - pageInfo: { - hasNextPage: true, - startCursor: - 'WyIwMDliYjNkYy1hNGEyLTRiNWUtYTZmYi1iMTFiMmFlMGI1MmIiXQ==', - endCursor: - 'WyIyMDIwMjAyMC0wNzEzLTQwYTUtODIxNi04MjgwMjQwMWQzM2UiXQ==', - }, - companyDuplicates: [ - { - id: 'id', - createdAt: '2023-01-01', - people: [{ id: 'id1' }, { id: 'id2' }], - }, - ], - }, - ], - }; - - expect(cleanGraphQLResponse(data)).toEqual(expectedResult); - }); -}); diff --git a/packages/twenty-server/src/engine/api/rest/utils/clean-graphql-response.utils.ts b/packages/twenty-server/src/engine/api/rest/utils/clean-graphql-response.utils.ts deleted file mode 100644 index 0a2c94f7ab..0000000000 --- a/packages/twenty-server/src/engine/api/rest/utils/clean-graphql-response.utils.ts +++ /dev/null @@ -1,75 +0,0 @@ -// oxlint-disable-next-line @typescripttypescript/no-explicit-any -export const cleanGraphQLResponse = (input: any) => { - if (!input) return null; - const output = { data: {} }; // Initialize the output with a data key at the top level - - // oxlint-disable-next-line @typescripttypescript/no-explicit-any - const isObject = (obj: any) => { - return obj !== null && typeof obj === 'object' && !Array.isArray(obj); - }; - - // oxlint-disable-next-line @typescripttypescript/no-explicit-any - const cleanObject = (obj: any) => { - const cleanedObj = {}; - - Object.keys(obj).forEach((key) => { - if (isObject(obj[key])) { - if (obj[key].edges) { - // Handle edges by mapping over them and applying cleanObject to each node - // @ts-expect-error legacy noImplicitAny - cleanedObj[key] = obj[key].edges.map((edge) => - cleanObject(edge.node), - ); - } else { - // Recursively clean nested objects - // @ts-expect-error legacy noImplicitAny - cleanedObj[key] = cleanObject(obj[key]); - } - } else { - // Directly assign non-object properties - // @ts-expect-error legacy noImplicitAny - cleanedObj[key] = obj[key]; - } - }); - - return cleanedObj; - }; - - Object.keys(input).forEach((key) => { - if (isObject(input[key]) && input[key].edges) { - // Handle collections with edges, ensuring data is placed under the data key - // @ts-expect-error legacy noImplicitAny - output.data[key] = input[key].edges.map((edge) => cleanObject(edge.node)); - // Move pageInfo and totalCount to the top level - if (input[key].pageInfo) { - // @ts-expect-error legacy noImplicitAny - output['pageInfo'] = input[key].pageInfo; - } - if (input[key].totalCount) { - // @ts-expect-error legacy noImplicitAny - output['totalCount'] = input[key].totalCount; - } - } else if (isObject(input[key])) { - // Recursively clean and assign nested objects under the data key - // @ts-expect-error legacy noImplicitAny - output.data[key] = cleanObject(input[key]); - } else if (Array.isArray(input[key])) { - const itemsWithEdges = input[key].filter((item) => item.edges); - const cleanedObjArray = itemsWithEdges.map(({ edges, ...item }) => { - return { - ...item, - // @ts-expect-error legacy noImplicitAny - [key]: edges.map((edge) => cleanObject(edge.node)), - }; - }); - - output.data = cleanedObjArray; - } else { - // Assign all other properties directly under the data key - // @ts-expect-error legacy noImplicitAny - output.data[key] = input[key]; - } - }); - - return output; -}; diff --git a/packages/twenty-server/src/engine/core-modules/open-api/open-api.service.ts b/packages/twenty-server/src/engine/core-modules/open-api/open-api.service.ts index d5ffc6bb6f..f95bd5ecf3 100644 --- a/packages/twenty-server/src/engine/core-modules/open-api/open-api.service.ts +++ b/packages/twenty-server/src/engine/core-modules/open-api/open-api.service.ts @@ -332,7 +332,7 @@ export class OpenApiService { { $ref: '#/components/parameters/endingBefore' }, ], responses: { - '200': getFindManyResponse200(item, true), + '200': getFindManyResponse200(item), '400': { $ref: '#/components/responses/400' }, '401': { $ref: '#/components/responses/401' }, }, @@ -343,7 +343,7 @@ export class OpenApiService { operationId: `createOne${capitalize(item.nameSingular)}`, requestBody: getRequestBody(capitalize(item.nameSingular)), responses: { - '200': getCreateOneResponse201(item, true), + '201': getCreateOneResponse201(item, true), '400': { $ref: '#/components/responses/400' }, '401': { $ref: '#/components/responses/401' }, }, diff --git a/packages/twenty-server/src/engine/core-modules/open-api/utils/components.utils.ts b/packages/twenty-server/src/engine/core-modules/open-api/utils/components.utils.ts index af9cea1502..91a41275a2 100644 --- a/packages/twenty-server/src/engine/core-modules/open-api/utils/components.utils.ts +++ b/packages/twenty-server/src/engine/core-modules/open-api/utils/components.utils.ts @@ -366,19 +366,9 @@ export const computeMetadataSchemaComponents = ( createdAt: { type: 'string', format: 'date-time' }, updatedAt: { type: 'string', format: 'date-time' }, fields: { - type: 'object', - properties: { - edges: { - type: 'object', - properties: { - node: { - type: 'array', - items: { - $ref: '#/components/schemas/FieldForResponse', - }, - }, - }, - }, + type: 'array', + items: { + $ref: '#/components/schemas/FieldForResponse', }, }, }, diff --git a/packages/twenty-server/src/engine/core-modules/open-api/utils/responses.utils.ts b/packages/twenty-server/src/engine/core-modules/open-api/utils/responses.utils.ts index d55bf77610..f494e955f3 100644 --- a/packages/twenty-server/src/engine/core-modules/open-api/utils/responses.utils.ts +++ b/packages/twenty-server/src/engine/core-modules/open-api/utils/responses.utils.ts @@ -4,7 +4,6 @@ import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object export const getFindManyResponse200 = ( item: Pick, - fromMetadata = false, ) => { const schemaRef = `#/components/schemas/${capitalize( item.nameSingular, @@ -42,11 +41,9 @@ export const getFindManyResponse200 = ( }, }, }, - ...(!fromMetadata && { - totalCount: { - type: 'integer', - }, - }), + totalCount: { + type: 'integer', + }, }, }, }, diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/controllers/field-metadata.controller.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/controllers/field-metadata.controller.ts new file mode 100644 index 0000000000..50489a542c --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/controllers/field-metadata.controller.ts @@ -0,0 +1,203 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + Put, + Req, + UseFilters, + UseGuards, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { Repository } from 'typeorm'; +import { PermissionFlagType } from 'twenty-shared/constants'; +import { FeatureFlagKey } from 'twenty-shared/types'; + +import { parseEndingBeforeRestRequest } from 'src/engine/api/rest/input-request-parsers/ending-before-parser-utils/parse-ending-before-rest-request.util'; +import { parseLimitRestRequest } from 'src/engine/api/rest/input-request-parsers/limit-parser-utils/parse-limit-rest-request.util'; +import { parseStartingAfterRestRequest } from 'src/engine/api/rest/input-request-parsers/starting-after-parser-utils/parse-starting-after-rest-request.util'; +import { + paginateByIdCursor, + type RestCursorPageInfo, +} from 'src/engine/api/rest/metadata/utils/paginate-by-id-cursor.util'; +import { type AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request'; +import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; +import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; +import { JwtAuthGuard } from 'src/engine/guards/jwt-auth.guard'; +import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard'; +import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; +import { CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input'; +import { type FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto'; +import { UpdateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/update-field.input'; +import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { + FieldMetadataException, + FieldMetadataExceptionCode, +} from 'src/engine/metadata-modules/field-metadata/field-metadata.exception'; +import { FieldMetadataRestApiExceptionFilter } from 'src/engine/metadata-modules/field-metadata/filters/field-metadata-rest-api-exception.filter'; +import { FieldMetadataService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata.service'; +import { fromFieldMetadataEntityToFieldMetadataDto } from 'src/engine/metadata-modules/field-metadata/utils/from-field-metadata-entity-to-field-metadata-dto.util'; +import { + toLegacyFieldMetadataCreateResponse, + toLegacyFieldMetadataDeleteResponse, + toLegacyFieldMetadataFindOneResponse, + toLegacyFieldMetadataListResponse, + toLegacyFieldMetadataUpdateResponse, +} from 'src/engine/metadata-modules/field-metadata/utils/to-legacy-field-metadata-response.util'; +import { fromFlatFieldMetadataToFieldMetadataDto } from 'src/engine/metadata-modules/flat-field-metadata/utils/from-flat-field-metadata-to-field-metadata-dto.util'; + +@Controller('rest/metadata/fields') +@UseGuards( + JwtAuthGuard, + WorkspaceAuthGuard, + SettingsPermissionGuard(PermissionFlagType.DATA_MODEL), +) +@UseFilters(FieldMetadataRestApiExceptionFilter) +@UsePipes(new ValidationPipe()) +export class FieldMetadataController { + constructor( + @InjectRepository(FieldMetadataEntity) + private readonly fieldMetadataRepository: Repository, + private readonly fieldMetadataService: FieldMetadataService, + private readonly featureFlagService: FeatureFlagService, + ) {} + + @Get() + async findMany( + @Req() request: AuthenticatedRequest, + @AuthWorkspace() { id: workspaceId }: WorkspaceEntity, + ) { + const { items, pageInfo, totalCount } = await paginateByIdCursor({ + repository: this.fieldMetadataRepository, + workspaceId, + limit: parseLimitRestRequest(request), + startingAfter: parseStartingAfterRestRequest(request), + endingBefore: parseEndingBeforeRestRequest(request), + }); + + const result: { + data: FieldMetadataDTO[]; + pageInfo: RestCursorPageInfo; + totalCount: number; + } = { + data: items.map(fromFieldMetadataEntityToFieldMetadataDto), + pageInfo, + totalCount, + }; + + return (await this.isNewMetadataFormat(workspaceId)) + ? result + : toLegacyFieldMetadataListResponse(result); + } + + @Get(':id') + async findOne( + @Param('id', new ParseUUIDPipe()) id: string, + @AuthWorkspace() { id: workspaceId }: WorkspaceEntity, + ) { + const field = await this.fieldMetadataRepository.findOne({ + where: { id, workspaceId }, + }); + + if (!field) { + throw new FieldMetadataException( + 'Field metadata not found', + FieldMetadataExceptionCode.FIELD_METADATA_NOT_FOUND, + ); + } + + const result = fromFieldMetadataEntityToFieldMetadataDto(field); + + return (await this.isNewMetadataFormat(workspaceId)) + ? result + : toLegacyFieldMetadataFindOneResponse(result); + } + + @Post() + async createOne( + @Body() input: CreateFieldInput, + @AuthWorkspace() { id: workspaceId }: WorkspaceEntity, + ) { + const flatField = await this.fieldMetadataService.createOneField({ + createFieldInput: input, + workspaceId, + }); + + const result = fromFlatFieldMetadataToFieldMetadataDto(flatField); + + return (await this.isNewMetadataFormat(workspaceId)) + ? result + : toLegacyFieldMetadataCreateResponse(result); + } + + @Patch(':id') + async updateOnePatch( + @Param('id', new ParseUUIDPipe()) id: string, + @Body() update: UpdateFieldInput, + @AuthWorkspace() { id: workspaceId }: WorkspaceEntity, + ) { + return this.handleUpdate({ id, update, workspaceId }); + } + + @Put(':id') + async updateOnePut( + @Param('id', new ParseUUIDPipe()) id: string, + @Body() update: UpdateFieldInput, + @AuthWorkspace() { id: workspaceId }: WorkspaceEntity, + ) { + return this.handleUpdate({ id, update, workspaceId }); + } + + @Delete(':id') + async deleteOne( + @Param('id', new ParseUUIDPipe()) id: string, + @AuthWorkspace() { id: workspaceId }: WorkspaceEntity, + ) { + const flatField = await this.fieldMetadataService.deleteOneField({ + deleteOneFieldInput: { id }, + workspaceId, + }); + + const result = fromFlatFieldMetadataToFieldMetadataDto(flatField); + + return (await this.isNewMetadataFormat(workspaceId)) + ? result + : toLegacyFieldMetadataDeleteResponse(result); + } + + private async handleUpdate({ + id, + update, + workspaceId, + }: { + id: string; + update: UpdateFieldInput; + workspaceId: string; + }) { + const flatField = await this.fieldMetadataService.updateOneField({ + updateFieldInput: { ...update, id }, + workspaceId, + }); + + const result = fromFlatFieldMetadataToFieldMetadataDto(flatField); + + return (await this.isNewMetadataFormat(workspaceId)) + ? result + : toLegacyFieldMetadataUpdateResponse(result); + } + + private async isNewMetadataFormat(workspaceId: string): Promise { + return this.featureFlagService.isFeatureEnabled( + FeatureFlagKey.IS_REST_METADATA_API_NEW_FORMAT_DIRECT, + workspaceId, + ); + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.module.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.module.ts index 50fba41ca0..3d92e094a9 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.module.ts @@ -10,8 +10,10 @@ import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm'; import { TypeORMModule } from 'src/database/typeorm/typeorm.module'; import { ActorModule } from 'src/engine/core-modules/actor/actor.module'; import { ApplicationModule } from 'src/engine/core-modules/application/application.module'; +import { TokenModule } from 'src/engine/core-modules/auth/token/token.module'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; +import { FieldMetadataController } from 'src/engine/metadata-modules/field-metadata/controllers/field-metadata.controller'; import { FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto'; import { FieldMetadataResolver } from 'src/engine/metadata-modules/field-metadata/field-metadata.resolver'; import { FieldMetadataGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/field-metadata/interceptors/field-metadata-graphql-api-exception.interceptor'; @@ -39,6 +41,9 @@ import { UpdateFieldInput } from './dtos/update-field.input'; @Module({ imports: [ + TokenModule, + WorkspaceCacheStorageModule, + FeatureFlagModule, NestjsQueryGraphQLModule.forFeature({ imports: [ NestjsQueryTypeOrmModule.forFeature([ @@ -88,6 +93,7 @@ import { UpdateFieldInput } from './dtos/update-field.input'; ], }), ], + controllers: [FieldMetadataController], providers: [ FieldMetadataService, FieldMetadataResolver, diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/filters/field-metadata-rest-api-exception.filter.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/filters/field-metadata-rest-api-exception.filter.ts new file mode 100644 index 0000000000..4128134c23 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/filters/field-metadata-rest-api-exception.filter.ts @@ -0,0 +1,88 @@ +import { + type ArgumentsHost, + Catch, + type ExceptionFilter, + Injectable, +} from '@nestjs/common'; + +import { type Response } from 'express'; +import { SOURCE_LOCALE } from 'twenty-shared/translations'; + +import { RestInputRequestParserException } from 'src/engine/api/rest/input-request-parsers/rest-input-request-parser.exception'; +import { HttpExceptionHandlerService } from 'src/engine/core-modules/exception-handler/http-exception-handler.service'; +import { I18nService } from 'src/engine/core-modules/i18n/i18n.service'; +import { FieldMetadataException } from 'src/engine/metadata-modules/field-metadata/field-metadata.exception'; +import { fieldMetadataExceptionCodeToHttpStatus } from 'src/engine/metadata-modules/field-metadata/utils/field-metadata-exception-code-to-http-status.util'; +import { FlatEntityMapsException } from 'src/engine/metadata-modules/flat-entity/exceptions/flat-entity-maps.exception'; +import { flatEntityMapsExceptionCodeToHttpStatus } from 'src/engine/metadata-modules/flat-entity/utils/flat-entity-maps-exception-code-to-http-status.util'; +import { InvalidMetadataException } from 'src/engine/metadata-modules/utils/exceptions/invalid-metadata.exception'; +import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception'; +import { workspaceMigrationBuilderRestApiExceptionHandler } from 'src/engine/workspace-manager/workspace-migration/interceptors/utils/workspace-migration-builder-rest-api-exception-handler.util'; +import { type CustomException } from 'src/utils/custom-exception'; + +type CaughtException = + | FieldMetadataException + | InvalidMetadataException + | WorkspaceMigrationBuilderException + | RestInputRequestParserException + | FlatEntityMapsException; + +@Injectable() +@Catch( + FieldMetadataException, + InvalidMetadataException, + WorkspaceMigrationBuilderException, + RestInputRequestParserException, + FlatEntityMapsException, +) +export class FieldMetadataRestApiExceptionFilter implements ExceptionFilter { + constructor( + private readonly httpExceptionHandlerService: HttpExceptionHandlerService, + private readonly i18nService: I18nService, + ) {} + + catch(exception: CaughtException, host: ArgumentsHost) { + const response = host.switchToHttp().getResponse(); + + if (exception instanceof WorkspaceMigrationBuilderException) { + return workspaceMigrationBuilderRestApiExceptionHandler({ + exception, + response, + i18n: this.i18nService.getI18nInstance(SOURCE_LOCALE), + }); + } + + if ( + exception instanceof InvalidMetadataException || + exception instanceof RestInputRequestParserException + ) { + return this.httpExceptionHandlerService.handleError( + exception as CustomException, + response, + 400, + ); + } + + if (exception instanceof FlatEntityMapsException) { + return this.httpExceptionHandlerService.handleError( + exception as CustomException, + response, + flatEntityMapsExceptionCodeToHttpStatus(exception.code), + ); + } + + if (exception instanceof FieldMetadataException) { + return this.httpExceptionHandlerService.handleError( + exception as CustomException, + response, + fieldMetadataExceptionCodeToHttpStatus(exception.code), + ); + } + + return this.httpExceptionHandlerService.handleError( + exception, + response, + 500, + ); + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/field-metadata-exception-code-to-http-status.util.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/field-metadata-exception-code-to-http-status.util.ts new file mode 100644 index 0000000000..558123d9ab --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/field-metadata-exception-code-to-http-status.util.ts @@ -0,0 +1,31 @@ +import { assertUnreachable } from 'twenty-shared/utils'; + +import { FieldMetadataExceptionCode } from 'src/engine/metadata-modules/field-metadata/field-metadata.exception'; + +export const fieldMetadataExceptionCodeToHttpStatus = ( + code: keyof typeof FieldMetadataExceptionCode, +): number => { + switch (code) { + case FieldMetadataExceptionCode.FIELD_METADATA_NOT_FOUND: + return 404; + case FieldMetadataExceptionCode.FIELD_ALREADY_EXISTS: + return 409; + case FieldMetadataExceptionCode.FIELD_MUTATION_NOT_ALLOWED: + return 403; + case FieldMetadataExceptionCode.INVALID_FIELD_INPUT: + case FieldMetadataExceptionCode.OBJECT_METADATA_NOT_FOUND: + case FieldMetadataExceptionCode.APPLICATION_NOT_FOUND: + case FieldMetadataExceptionCode.FIELD_METADATA_RELATION_NOT_ENABLED: + case FieldMetadataExceptionCode.FIELD_METADATA_RELATION_MALFORMED: + case FieldMetadataExceptionCode.UNCOVERED_FIELD_METADATA_TYPE_VALIDATION: + case FieldMetadataExceptionCode.LABEL_IDENTIFIER_FIELD_METADATA_ID_NOT_FOUND: + case FieldMetadataExceptionCode.RESERVED_KEYWORD: + case FieldMetadataExceptionCode.NOT_AVAILABLE: + case FieldMetadataExceptionCode.NAME_NOT_SYNCED_WITH_LABEL: + return 400; + case FieldMetadataExceptionCode.INTERNAL_SERVER_ERROR: + return 500; + default: + return assertUnreachable(code); + } +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/from-field-metadata-entity-to-field-metadata-dto.util.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/from-field-metadata-entity-to-field-metadata-dto.util.ts new file mode 100644 index 0000000000..960ef0469f --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/from-field-metadata-entity-to-field-metadata-dto.util.ts @@ -0,0 +1,31 @@ +import { type FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto'; +import { type FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; + +export const fromFieldMetadataEntityToFieldMetadataDto = ( + entity: FieldMetadataEntity, +): FieldMetadataDTO => ({ + id: entity.id, + universalIdentifier: entity.universalIdentifier, + applicationId: entity.applicationId, + type: entity.type, + name: entity.name, + label: entity.label, + description: entity.description ?? undefined, + icon: entity.icon ?? undefined, + standardOverrides: entity.standardOverrides ?? undefined, + isCustom: entity.isCustom, + isActive: entity.isActive, + isSystem: entity.isSystem, + isUIReadOnly: entity.isUIReadOnly, + isNullable: entity.isNullable ?? false, + isUnique: entity.isUnique ?? false, + defaultValue: entity.defaultValue ?? undefined, + options: entity.options ?? undefined, + settings: entity.settings ?? undefined, + workspaceId: entity.workspaceId, + objectMetadataId: entity.objectMetadataId, + isLabelSyncedWithName: entity.isLabelSyncedWithName, + morphId: entity.morphId ?? undefined, + createdAt: entity.createdAt, + updatedAt: entity.updatedAt, +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/to-legacy-field-metadata-response.util.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/to-legacy-field-metadata-response.util.ts new file mode 100644 index 0000000000..c0a2c3751c --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/to-legacy-field-metadata-response.util.ts @@ -0,0 +1,32 @@ +import { type RestCursorPageInfo } from 'src/engine/api/rest/metadata/utils/paginate-by-id-cursor.util'; +import { type FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto'; + +export const toLegacyFieldMetadataListResponse = ({ + data, + pageInfo, + totalCount, +}: { + data: FieldMetadataDTO[]; + pageInfo: RestCursorPageInfo; + totalCount: number; +}) => ({ + data: { fields: data }, + pageInfo, + totalCount, +}); + +export const toLegacyFieldMetadataFindOneResponse = ( + field: FieldMetadataDTO, +) => ({ data: { field } }); + +export const toLegacyFieldMetadataCreateResponse = ( + field: FieldMetadataDTO, +) => ({ data: { createOneField: field } }); + +export const toLegacyFieldMetadataUpdateResponse = ( + field: FieldMetadataDTO, +) => ({ data: { updateOneField: field } }); + +export const toLegacyFieldMetadataDeleteResponse = ( + field: FieldMetadataDTO, +) => ({ data: { deleteOneField: field } }); diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/flat-entity-maps-exception-code-to-http-status.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/flat-entity-maps-exception-code-to-http-status.util.ts new file mode 100644 index 0000000000..8e799f8989 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/flat-entity-maps-exception-code-to-http-status.util.ts @@ -0,0 +1,20 @@ +import { assertUnreachable } from 'twenty-shared/utils'; + +import { FlatEntityMapsExceptionCode } from 'src/engine/metadata-modules/flat-entity/exceptions/flat-entity-maps.exception'; + +export const flatEntityMapsExceptionCodeToHttpStatus = ( + code: keyof typeof FlatEntityMapsExceptionCode, +): number => { + switch (code) { + case FlatEntityMapsExceptionCode.ENTITY_NOT_FOUND: + return 404; + case FlatEntityMapsExceptionCode.ENTITY_ALREADY_EXISTS: + return 409; + case FlatEntityMapsExceptionCode.RELATION_UNIVERSAL_IDENTIFIER_NOT_FOUND: + case FlatEntityMapsExceptionCode.ENTITY_MALFORMED: + case FlatEntityMapsExceptionCode.INTERNAL_SERVER_ERROR: + return 500; + default: + return assertUnreachable(code); + } +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/controllers/object-metadata.controller.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/controllers/object-metadata.controller.ts new file mode 100644 index 0000000000..2bb13106aa --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/controllers/object-metadata.controller.ts @@ -0,0 +1,267 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + Put, + Req, + UseFilters, + UseGuards, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { In, Repository } from 'typeorm'; +import { PermissionFlagType } from 'twenty-shared/constants'; +import { FeatureFlagKey } from 'twenty-shared/types'; + +import { parseEndingBeforeRestRequest } from 'src/engine/api/rest/input-request-parsers/ending-before-parser-utils/parse-ending-before-rest-request.util'; +import { parseLimitRestRequest } from 'src/engine/api/rest/input-request-parsers/limit-parser-utils/parse-limit-rest-request.util'; +import { parseStartingAfterRestRequest } from 'src/engine/api/rest/input-request-parsers/starting-after-parser-utils/parse-starting-after-rest-request.util'; +import { + paginateByIdCursor, + type RestCursorPageInfo, +} from 'src/engine/api/rest/metadata/utils/paginate-by-id-cursor.util'; +import { type AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request'; +import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; +import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; +import { JwtAuthGuard } from 'src/engine/guards/jwt-auth.guard'; +import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard'; +import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; +import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { fromFieldMetadataEntityToFieldMetadataDto } from 'src/engine/metadata-modules/field-metadata/utils/from-field-metadata-entity-to-field-metadata-dto.util'; +import { fromFlatObjectMetadataToObjectMetadataDto } from 'src/engine/metadata-modules/flat-object-metadata/utils/from-flat-object-metadata-to-object-metadata-dto.util'; +import { CreateObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/create-object.input'; +import { type ObjectMetadataWithFieldsDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata-with-fields.dto'; +import { UpdateObjectPayload } from 'src/engine/metadata-modules/object-metadata/dtos/update-object.input'; +import { ObjectMetadataRestApiExceptionFilter } from 'src/engine/metadata-modules/object-metadata/filters/object-metadata-rest-api-exception.filter'; +import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; +import { + ObjectMetadataException, + ObjectMetadataExceptionCode, +} from 'src/engine/metadata-modules/object-metadata/object-metadata.exception'; +import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service'; +import { fromObjectMetadataEntityToObjectMetadataDto } from 'src/engine/metadata-modules/object-metadata/utils/from-object-metadata-entity-to-object-metadata-dto.util'; +import { + toLegacyObjectMetadataCreateResponse, + toLegacyObjectMetadataDeleteResponse, + toLegacyObjectMetadataFindOneResponse, + toLegacyObjectMetadataListResponse, + toLegacyObjectMetadataUpdateResponse, +} from 'src/engine/metadata-modules/object-metadata/utils/to-legacy-object-metadata-response.util'; + +@Controller('rest/metadata/objects') +@UseGuards( + JwtAuthGuard, + WorkspaceAuthGuard, + SettingsPermissionGuard(PermissionFlagType.DATA_MODEL), +) +@UseFilters(ObjectMetadataRestApiExceptionFilter) +@UsePipes(new ValidationPipe()) +export class ObjectMetadataController { + constructor( + @InjectRepository(ObjectMetadataEntity) + private readonly objectMetadataRepository: Repository, + @InjectRepository(FieldMetadataEntity) + private readonly fieldMetadataRepository: Repository, + private readonly objectMetadataService: ObjectMetadataService, + private readonly featureFlagService: FeatureFlagService, + ) {} + + @Get() + async findMany( + @Req() request: AuthenticatedRequest, + @AuthWorkspace() { id: workspaceId }: WorkspaceEntity, + ) { + const { items, pageInfo, totalCount } = await paginateByIdCursor({ + repository: this.objectMetadataRepository, + workspaceId, + limit: parseLimitRestRequest(request), + startingAfter: parseStartingAfterRestRequest(request), + endingBefore: parseEndingBeforeRestRequest(request), + }); + + const fields = await this.findFieldsForObjectIds( + workspaceId, + items.map((object) => object.id), + ); + + const data = items.map((object) => + this.toObjectWithFieldsDto(object, fields.get(object.id) ?? []), + ); + + const result: { + data: ObjectMetadataWithFieldsDTO[]; + pageInfo: RestCursorPageInfo; + totalCount: number; + } = { data, pageInfo, totalCount }; + + return (await this.isNewMetadataFormat(workspaceId)) + ? result + : toLegacyObjectMetadataListResponse(result); + } + + @Get(':id') + async findOne( + @Param('id', new ParseUUIDPipe()) id: string, + @AuthWorkspace() { id: workspaceId }: WorkspaceEntity, + ) { + const object = await this.objectMetadataRepository.findOne({ + where: { id, workspaceId }, + }); + + if (!object) { + throw new ObjectMetadataException( + 'Object metadata not found', + ObjectMetadataExceptionCode.OBJECT_METADATA_NOT_FOUND, + ); + } + + const fields = await this.fieldMetadataRepository.find({ + where: { objectMetadataId: object.id, workspaceId }, + }); + + const result = this.toObjectWithFieldsDto(object, fields); + + return (await this.isNewMetadataFormat(workspaceId)) + ? result + : toLegacyObjectMetadataFindOneResponse(result); + } + + @Post() + async createOne( + @Body() input: CreateObjectInput, + @AuthWorkspace() { id: workspaceId }: WorkspaceEntity, + ) { + const flatObject = await this.objectMetadataService.createOneObject({ + createObjectInput: input, + workspaceId, + }); + + const fields = await this.fieldMetadataRepository.find({ + where: { objectMetadataId: flatObject.id, workspaceId }, + }); + + const result: ObjectMetadataWithFieldsDTO = { + ...fromFlatObjectMetadataToObjectMetadataDto(flatObject), + fields: fields.map(fromFieldMetadataEntityToFieldMetadataDto), + }; + + return (await this.isNewMetadataFormat(workspaceId)) + ? result + : toLegacyObjectMetadataCreateResponse(result); + } + + @Patch(':id') + async updateOnePatch( + @Param('id', new ParseUUIDPipe()) id: string, + @Body() update: UpdateObjectPayload, + @AuthWorkspace() { id: workspaceId }: WorkspaceEntity, + ) { + return this.handleUpdate({ id, update, workspaceId }); + } + + @Put(':id') + async updateOnePut( + @Param('id', new ParseUUIDPipe()) id: string, + @Body() update: UpdateObjectPayload, + @AuthWorkspace() { id: workspaceId }: WorkspaceEntity, + ) { + return this.handleUpdate({ id, update, workspaceId }); + } + + @Delete(':id') + async deleteOne( + @Param('id', new ParseUUIDPipe()) id: string, + @AuthWorkspace() { id: workspaceId }: WorkspaceEntity, + ) { + const flatObject = await this.objectMetadataService.deleteOneObject({ + deleteObjectInput: { id }, + workspaceId, + }); + + const result = fromFlatObjectMetadataToObjectMetadataDto(flatObject); + + return (await this.isNewMetadataFormat(workspaceId)) + ? result + : toLegacyObjectMetadataDeleteResponse(result); + } + + private async handleUpdate({ + id, + update, + workspaceId, + }: { + id: string; + update: UpdateObjectPayload; + workspaceId: string; + }) { + const flatObject = await this.objectMetadataService.updateOneObject({ + updateObjectInput: { id, update }, + workspaceId, + }); + + const fields = await this.fieldMetadataRepository.find({ + where: { objectMetadataId: flatObject.id, workspaceId }, + }); + + const result: ObjectMetadataWithFieldsDTO = { + ...fromFlatObjectMetadataToObjectMetadataDto(flatObject), + fields: fields.map(fromFieldMetadataEntityToFieldMetadataDto), + }; + + return (await this.isNewMetadataFormat(workspaceId)) + ? result + : toLegacyObjectMetadataUpdateResponse(result); + } + + private async isNewMetadataFormat(workspaceId: string): Promise { + return this.featureFlagService.isFeatureEnabled( + FeatureFlagKey.IS_REST_METADATA_API_NEW_FORMAT_DIRECT, + workspaceId, + ); + } + + private async findFieldsForObjectIds( + workspaceId: string, + objectIds: string[], + ): Promise> { + const grouped = new Map(); + + if (objectIds.length === 0) { + return grouped; + } + + const fields = await this.fieldMetadataRepository.find({ + where: { workspaceId, objectMetadataId: In(objectIds) }, + }); + + for (const field of fields) { + const list = grouped.get(field.objectMetadataId); + + if (list) { + list.push(field); + } else { + grouped.set(field.objectMetadataId, [field]); + } + } + + return grouped; + } + + private toObjectWithFieldsDto( + object: ObjectMetadataEntity, + fields: FieldMetadataEntity[], + ): ObjectMetadataWithFieldsDTO { + return { + ...fromObjectMetadataEntityToObjectMetadataDto(object), + fields: fields.map(fromFieldMetadataEntityToFieldMetadataDto), + }; + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/dtos/object-metadata-with-fields.dto.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/dtos/object-metadata-with-fields.dto.ts new file mode 100644 index 0000000000..893c88b2ab --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/dtos/object-metadata-with-fields.dto.ts @@ -0,0 +1,6 @@ +import { type FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto'; +import { type ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto'; + +export type ObjectMetadataWithFieldsDTO = ObjectMetadataDTO & { + fields: FieldMetadataDTO[]; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/filters/object-metadata-rest-api-exception.filter.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/filters/object-metadata-rest-api-exception.filter.ts new file mode 100644 index 0000000000..c0a759a59b --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/filters/object-metadata-rest-api-exception.filter.ts @@ -0,0 +1,88 @@ +import { + type ArgumentsHost, + Catch, + type ExceptionFilter, + Injectable, +} from '@nestjs/common'; + +import { type Response } from 'express'; +import { SOURCE_LOCALE } from 'twenty-shared/translations'; + +import { RestInputRequestParserException } from 'src/engine/api/rest/input-request-parsers/rest-input-request-parser.exception'; +import { HttpExceptionHandlerService } from 'src/engine/core-modules/exception-handler/http-exception-handler.service'; +import { I18nService } from 'src/engine/core-modules/i18n/i18n.service'; +import { FlatEntityMapsException } from 'src/engine/metadata-modules/flat-entity/exceptions/flat-entity-maps.exception'; +import { flatEntityMapsExceptionCodeToHttpStatus } from 'src/engine/metadata-modules/flat-entity/utils/flat-entity-maps-exception-code-to-http-status.util'; +import { ObjectMetadataException } from 'src/engine/metadata-modules/object-metadata/object-metadata.exception'; +import { objectMetadataExceptionCodeToHttpStatus } from 'src/engine/metadata-modules/object-metadata/utils/object-metadata-exception-code-to-http-status.util'; +import { InvalidMetadataException } from 'src/engine/metadata-modules/utils/exceptions/invalid-metadata.exception'; +import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception'; +import { workspaceMigrationBuilderRestApiExceptionHandler } from 'src/engine/workspace-manager/workspace-migration/interceptors/utils/workspace-migration-builder-rest-api-exception-handler.util'; +import { type CustomException } from 'src/utils/custom-exception'; + +type CaughtException = + | ObjectMetadataException + | InvalidMetadataException + | WorkspaceMigrationBuilderException + | RestInputRequestParserException + | FlatEntityMapsException; + +@Injectable() +@Catch( + ObjectMetadataException, + InvalidMetadataException, + WorkspaceMigrationBuilderException, + RestInputRequestParserException, + FlatEntityMapsException, +) +export class ObjectMetadataRestApiExceptionFilter implements ExceptionFilter { + constructor( + private readonly httpExceptionHandlerService: HttpExceptionHandlerService, + private readonly i18nService: I18nService, + ) {} + + catch(exception: CaughtException, host: ArgumentsHost) { + const response = host.switchToHttp().getResponse(); + + if (exception instanceof WorkspaceMigrationBuilderException) { + return workspaceMigrationBuilderRestApiExceptionHandler({ + exception, + response, + i18n: this.i18nService.getI18nInstance(SOURCE_LOCALE), + }); + } + + if ( + exception instanceof InvalidMetadataException || + exception instanceof RestInputRequestParserException + ) { + return this.httpExceptionHandlerService.handleError( + exception as CustomException, + response, + 400, + ); + } + + if (exception instanceof FlatEntityMapsException) { + return this.httpExceptionHandlerService.handleError( + exception as CustomException, + response, + flatEntityMapsExceptionCodeToHttpStatus(exception.code), + ); + } + + if (exception instanceof ObjectMetadataException) { + return this.httpExceptionHandlerService.handleError( + exception as CustomException, + response, + objectMetadataExceptionCodeToHttpStatus(exception.code), + ); + } + + return this.httpExceptionHandlerService.handleError( + exception, + response, + 500, + ); + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.module.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.module.ts index fc92b225a8..5bed45928a 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.module.ts @@ -10,6 +10,7 @@ import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm'; import { TypeORMModule } from 'src/database/typeorm/typeorm.module'; import { ApplicationModule } from 'src/engine/core-modules/application/application.module'; +import { TokenModule } from 'src/engine/core-modules/auth/token/token.module'; import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; @@ -17,6 +18,7 @@ import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/ import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module'; import { FlatFieldMetadataTypeValidatorService } from 'src/engine/metadata-modules/flat-field-metadata/services/flat-field-metadata-type-validator.service'; import { IndexMetadataModule } from 'src/engine/metadata-modules/index-metadata/index-metadata.module'; +import { ObjectMetadataController } from 'src/engine/metadata-modules/object-metadata/controllers/object-metadata.controller'; import { CreateObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/create-object.input'; import { ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto'; import { UpdateObjectPayload } from 'src/engine/metadata-modules/object-metadata/dtos/update-object.input'; @@ -40,6 +42,9 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace @Module({ imports: [ + TokenModule, + WorkspaceCacheStorageModule, + FeatureFlagModule, NestjsQueryGraphQLModule.forFeature({ imports: [ TypeORMModule, @@ -89,6 +94,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace ], }), ], + controllers: [ObjectMetadataController], providers: [ ObjectMetadataService, ObjectMetadataResolver, diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/from-object-metadata-entity-to-object-metadata-dto.util.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/from-object-metadata-entity-to-object-metadata-dto.util.ts new file mode 100644 index 0000000000..6ef0228bb2 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/from-object-metadata-entity-to-object-metadata-dto.util.ts @@ -0,0 +1,34 @@ +import { type ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto'; +import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; + +export const fromObjectMetadataEntityToObjectMetadataDto = ( + entity: ObjectMetadataEntity, +): ObjectMetadataDTO => ({ + id: entity.id, + universalIdentifier: entity.universalIdentifier, + applicationId: entity.applicationId, + nameSingular: entity.nameSingular, + namePlural: entity.namePlural, + labelSingular: entity.labelSingular, + labelPlural: entity.labelPlural, + description: entity.description ?? undefined, + icon: entity.icon ?? undefined, + color: entity.color ?? undefined, + shortcut: entity.shortcut ?? undefined, + standardOverrides: entity.standardOverrides ?? undefined, + isCustom: entity.isCustom, + isRemote: entity.isRemote, + isActive: entity.isActive, + isSystem: entity.isSystem, + isUIReadOnly: entity.isUIReadOnly, + isSearchable: entity.isSearchable, + isLabelSyncedWithName: entity.isLabelSyncedWithName, + workspaceId: entity.workspaceId, + labelIdentifierFieldMetadataId: + entity.labelIdentifierFieldMetadataId ?? undefined, + imageIdentifierFieldMetadataId: + entity.imageIdentifierFieldMetadataId ?? undefined, + duplicateCriteria: entity.duplicateCriteria ?? undefined, + createdAt: entity.createdAt, + updatedAt: entity.updatedAt, +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/object-metadata-exception-code-to-http-status.util.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/object-metadata-exception-code-to-http-status.util.ts new file mode 100644 index 0000000000..30a58f6504 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/object-metadata-exception-code-to-http-status.util.ts @@ -0,0 +1,28 @@ +import { assertUnreachable } from 'twenty-shared/utils'; + +import { ObjectMetadataExceptionCode } from 'src/engine/metadata-modules/object-metadata/object-metadata.exception'; + +export const objectMetadataExceptionCodeToHttpStatus = ( + code: ObjectMetadataExceptionCode, +): number => { + switch (code) { + case ObjectMetadataExceptionCode.OBJECT_METADATA_NOT_FOUND: + return 404; + case ObjectMetadataExceptionCode.OBJECT_ALREADY_EXISTS: + return 409; + case ObjectMetadataExceptionCode.OBJECT_MUTATION_NOT_ALLOWED: + case ObjectMetadataExceptionCode.NAME_CONFLICT: + return 403; + case ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT: + case ObjectMetadataExceptionCode.MISSING_SYSTEM_FIELD: + case ObjectMetadataExceptionCode.INVALID_SYSTEM_FIELD: + case ObjectMetadataExceptionCode.MISSING_CUSTOM_OBJECT_DEFAULT_LABEL_IDENTIFIER_FIELD: + case ObjectMetadataExceptionCode.APPLICATION_NOT_FOUND: + return 400; + case ObjectMetadataExceptionCode.INTERNAL_SERVER_ERROR: + case ObjectMetadataExceptionCode.INVALID_ORM_OUTPUT: + return 500; + default: + return assertUnreachable(code); + } +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/to-legacy-object-metadata-response.util.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/to-legacy-object-metadata-response.util.ts new file mode 100644 index 0000000000..ac63d1e9aa --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/to-legacy-object-metadata-response.util.ts @@ -0,0 +1,33 @@ +import { type RestCursorPageInfo } from 'src/engine/api/rest/metadata/utils/paginate-by-id-cursor.util'; +import { type ObjectMetadataWithFieldsDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata-with-fields.dto'; +import { type ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto'; + +export const toLegacyObjectMetadataListResponse = ({ + data, + pageInfo, + totalCount, +}: { + data: ObjectMetadataWithFieldsDTO[]; + pageInfo: RestCursorPageInfo; + totalCount: number; +}) => ({ + data: { objects: data }, + pageInfo, + totalCount, +}); + +export const toLegacyObjectMetadataFindOneResponse = ( + object: ObjectMetadataWithFieldsDTO, +) => ({ data: { object } }); + +export const toLegacyObjectMetadataCreateResponse = ( + object: ObjectMetadataWithFieldsDTO, +) => ({ data: { createOneObject: object } }); + +export const toLegacyObjectMetadataUpdateResponse = ( + object: ObjectMetadataWithFieldsDTO, +) => ({ data: { updateOneObject: object } }); + +export const toLegacyObjectMetadataDeleteResponse = ( + object: ObjectMetadataDTO, +) => ({ data: { deleteOneObject: object } }); diff --git a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts index fffa3b6f28..e7c32b99a3 100644 --- a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts +++ b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts @@ -240,6 +240,7 @@ describe('WorkspaceEntityManager', () => { IS_EMAIL_GROUP_ENABLED: false, IS_JUNCTION_RELATIONS_ENABLED: false, IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED: false, + IS_REST_METADATA_API_NEW_FORMAT_DIRECT: false, [FeatureFlagKey.IS_BILLING_V2_ENABLED]: false, }, userWorkspaceRoleMap: {}, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/constant/default-feature-flags.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/constant/default-feature-flags.ts index 1decc68a1f..5b90fa8cd2 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/constant/default-feature-flags.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/constant/default-feature-flags.ts @@ -3,5 +3,6 @@ import { FeatureFlagKey } from 'twenty-shared/types'; export const DEFAULT_FEATURE_FLAGS = [ FeatureFlagKey.IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED, FeatureFlagKey.IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED, + FeatureFlagKey.IS_REST_METADATA_API_NEW_FORMAT_DIRECT, FeatureFlagKey.IS_BILLING_V2_ENABLED, ] as const satisfies FeatureFlagKey[]; diff --git a/packages/twenty-server/test/integration/rest/suites/field-metadata.integration-spec.ts b/packages/twenty-server/test/integration/rest/suites/field-metadata.integration-spec.ts new file mode 100644 index 0000000000..040b8641d3 --- /dev/null +++ b/packages/twenty-server/test/integration/rest/suites/field-metadata.integration-spec.ts @@ -0,0 +1,456 @@ +import { updateFeatureFlag } from 'test/integration/metadata/suites/utils/update-feature-flag.util'; +import { makeRestAPIRequest } from 'test/integration/rest/utils/make-rest-api-request.util'; +import { + cleanupTestField, + cleanupTestObject, + createTestFieldViaGraphql, + createTestObjectViaGraphql, + extractMetadataItemPayload, + extractMetadataListPayload, + NON_EXISTENT_UUID, + uniqueSuffix, +} from 'test/integration/rest/utils/metadata-rest-api.util'; +import { + assertRestApiErrorNotFoundResponse, + assertRestApiErrorResponse, + assertRestApiSuccessfulResponse, +} from 'test/integration/rest/utils/rest-test-assertions.util'; +import { FeatureFlagKey, FieldMetadataType } from 'twenty-shared/types'; + +type FieldShape = { + id: string; + name?: string; + label?: string; + objectMetadataId?: string; +}; + +describe.each([ + ['new format', true], + ['legacy format', false], +] as const)('Field Metadata REST API (%s)', (_shapeLabel, isNewFormat) => { + let parentObjectId: string; + + beforeAll(async () => { + await updateFeatureFlag({ + featureFlag: FeatureFlagKey.IS_REST_METADATA_API_NEW_FORMAT_DIRECT, + value: isNewFormat, + expectToFail: false, + }); + + const { id } = await createTestObjectViaGraphql(); + + parentObjectId = id; + }); + + afterAll(async () => { + await cleanupTestObject(parentObjectId); + await updateFeatureFlag({ + featureFlag: FeatureFlagKey.IS_REST_METADATA_API_NEW_FORMAT_DIRECT, + value: false, + expectToFail: false, + }); + }); + + describe('GET /metadata/fields', () => { + const seededIds: string[] = []; + + beforeAll(async () => { + for (let i = 0; i < 3; i++) { + const { id } = await createTestFieldViaGraphql(parentObjectId); + + seededIds.push(id); + } + }); + + afterAll(async () => { + await Promise.all(seededIds.map(cleanupTestField)); + seededIds.length = 0; + }); + + it('returns the expected envelope shape', async () => { + const response = await makeRestAPIRequest({ + method: 'get', + path: '/metadata/fields', + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(response); + if (isNewFormat) { + expect(response.body).not.toHaveProperty('data.fields'); + expect(Array.isArray(response.body.data)).toBe(true); + } else { + expect(Array.isArray(response.body.data?.fields)).toBe(true); + } + expect(response.body).toHaveProperty('pageInfo.hasNextPage'); + expect(response.body).toHaveProperty('pageInfo.startCursor'); + expect(response.body).toHaveProperty('pageInfo.endCursor'); + expect(typeof response.body.totalCount).toBe('number'); + expect(response.body.totalCount).toBeGreaterThanOrEqual(seededIds.length); + }); + + it('respects limit and surfaces hasNextPage', async () => { + const response = await makeRestAPIRequest({ + method: 'get', + path: '/metadata/fields?limit=1', + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(response); + const { items, pageInfo } = extractMetadataListPayload( + response.body, + 'fields', + ); + + expect(items.length).toBe(1); + expect(pageInfo.hasNextPage).toBe(true); + }); + + it('paginates forward with starting_after without overlap', async () => { + const firstPage = await makeRestAPIRequest({ + method: 'get', + path: '/metadata/fields?limit=2', + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + const firstPayload = extractMetadataListPayload( + firstPage.body, + 'fields', + ); + + const secondPage = await makeRestAPIRequest({ + method: 'get', + path: `/metadata/fields?limit=2&starting_after=${firstPayload.pageInfo.endCursor}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(secondPage); + const secondPayload = extractMetadataListPayload( + secondPage.body, + 'fields', + ); + + const firstIds = firstPayload.items.map((f) => f.id); + const secondIds = secondPayload.items.map((f) => f.id); + + expect(firstIds.some((id) => secondIds.includes(id))).toBe(false); + }); + + it('paginates backward with ending_before', async () => { + const firstPage = await makeRestAPIRequest({ + method: 'get', + path: '/metadata/fields?limit=2', + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + const firstPayload = extractMetadataListPayload( + firstPage.body, + 'fields', + ); + + const secondPage = await makeRestAPIRequest({ + method: 'get', + path: `/metadata/fields?limit=2&starting_after=${firstPayload.pageInfo.endCursor}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + const secondPayload = extractMetadataListPayload( + secondPage.body, + 'fields', + ); + + const backPage = await makeRestAPIRequest({ + method: 'get', + path: `/metadata/fields?limit=2&ending_before=${secondPayload.pageInfo.startCursor}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(backPage); + const backPayload = extractMetadataListPayload( + backPage.body, + 'fields', + ); + + const firstIds = firstPayload.items.map((f) => f.id); + const backIds = backPayload.items.map((f) => f.id); + + expect(backIds.every((id) => firstIds.includes(id))).toBe(true); + }); + + it('rejects combining starting_after and ending_before with 400', async () => { + const response = await makeRestAPIRequest({ + method: 'get', + path: `/metadata/fields?starting_after=${NON_EXISTENT_UUID}&ending_before=${NON_EXISTENT_UUID}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiErrorResponse(response, 400); + }); + + it('keeps totalCount stable across pages', async () => { + const firstPage = await makeRestAPIRequest({ + method: 'get', + path: '/metadata/fields?limit=2', + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + const firstPayload = extractMetadataListPayload( + firstPage.body, + 'fields', + ); + + const secondPage = await makeRestAPIRequest({ + method: 'get', + path: `/metadata/fields?limit=2&starting_after=${firstPayload.pageInfo.endCursor}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + expect(firstPage.body.totalCount).toBe(secondPage.body.totalCount); + }); + }); + + describe('GET /metadata/fields/:id', () => { + let testFieldId: string; + + beforeAll(async () => { + const { id } = await createTestFieldViaGraphql(parentObjectId); + + testFieldId = id; + }); + + afterAll(async () => { + await cleanupTestField(testFieldId); + }); + + it('returns the field with expected envelope', async () => { + const response = await makeRestAPIRequest({ + method: 'get', + path: `/metadata/fields/${testFieldId}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(response); + const field = extractMetadataItemPayload( + response.body, + 'field', + ); + + expect(field.id).toBe(testFieldId); + expect(field.objectMetadataId).toBe(parentObjectId); + if (isNewFormat) { + expect(response.body).not.toHaveProperty('data.field'); + } else { + expect(response.body).toHaveProperty('data.field'); + } + }); + + it('returns 400 on a malformed UUID', async () => { + const response = await makeRestAPIRequest({ + method: 'get', + path: `/metadata/fields/not-a-uuid`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiErrorResponse(response, 400); + }); + + it('returns 404 for an unknown id', async () => { + const response = await makeRestAPIRequest({ + method: 'get', + path: `/metadata/fields/${NON_EXISTENT_UUID}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiErrorNotFoundResponse(response); + }); + }); + + describe('POST /metadata/fields', () => { + it('creates a field and returns 201', async () => { + const suffix = uniqueSuffix(); + const input = { + objectMetadataId: parentObjectId, + type: FieldMetadataType.TEXT, + name: `postField${suffix}`, + label: `Post Field ${suffix}`, + isLabelSyncedWithName: false, + }; + + const response = await makeRestAPIRequest({ + method: 'post', + path: '/metadata/fields', + body: input, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + const field = extractMetadataItemPayload( + response.body, + 'createOneField', + ); + + try { + assertRestApiSuccessfulResponse(response, 201); + expect(field.id).toBeDefined(); + expect(field.name).toBe(input.name); + expect(field.objectMetadataId).toBe(parentObjectId); + if (isNewFormat) { + expect(response.body).not.toHaveProperty('data.createOneField'); + } else { + expect(response.body).toHaveProperty('data.createOneField'); + } + } finally { + if (field.id) { + await cleanupTestField(field.id); + } + } + }); + + it('returns 400 on duplicate name within an object', async () => { + const { id, input } = await createTestFieldViaGraphql(parentObjectId); + + try { + const response = await makeRestAPIRequest({ + method: 'post', + path: '/metadata/fields', + body: input, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiErrorResponse(response, 400); + } finally { + await cleanupTestField(id); + } + }); + + it('returns 400 on invalid input', async () => { + const response = await makeRestAPIRequest({ + method: 'post', + path: '/metadata/fields', + body: { name: '' }, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiErrorResponse(response, 400); + }); + }); + + describe('PATCH /metadata/fields/:id', () => { + let testFieldId: string; + + beforeEach(async () => { + const { id } = await createTestFieldViaGraphql(parentObjectId); + + testFieldId = id; + }); + + afterEach(async () => { + await cleanupTestField(testFieldId); + }); + + it('updates and returns the field', async () => { + const newLabel = `Updated Field ${uniqueSuffix()}`; + + const response = await makeRestAPIRequest({ + method: 'patch', + path: `/metadata/fields/${testFieldId}`, + body: { label: newLabel }, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(response); + const field = extractMetadataItemPayload( + response.body, + 'updateOneField', + ); + + expect(field.id).toBe(testFieldId); + expect(field.label).toBe(newLabel); + if (isNewFormat) { + expect(response.body).not.toHaveProperty('data.updateOneField'); + } else { + expect(response.body).toHaveProperty('data.updateOneField'); + } + }); + + it('returns 400 for an unknown id', async () => { + const response = await makeRestAPIRequest({ + method: 'patch', + path: `/metadata/fields/${NON_EXISTENT_UUID}`, + body: { label: 'Whatever' }, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiErrorResponse(response, 400); + }); + }); + + describe('PUT /metadata/fields/:id', () => { + it('behaves equivalently to PATCH', async () => { + const { id } = await createTestFieldViaGraphql(parentObjectId); + + try { + const newLabel = `PutField ${uniqueSuffix()}`; + + const response = await makeRestAPIRequest({ + method: 'put', + path: `/metadata/fields/${id}`, + body: { label: newLabel }, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(response); + const field = extractMetadataItemPayload( + response.body, + 'updateOneField', + ); + + expect(field.id).toBe(id); + expect(field.label).toBe(newLabel); + } finally { + await cleanupTestField(id); + } + }); + }); + + describe('DELETE /metadata/fields/:id', () => { + it('deletes the field and returns the deleted resource', async () => { + const { id } = await createTestFieldViaGraphql(parentObjectId); + + const response = await makeRestAPIRequest({ + method: 'delete', + path: `/metadata/fields/${id}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(response); + const deleted = extractMetadataItemPayload( + response.body, + 'deleteOneField', + ); + + expect(deleted.id).toBe(id); + if (isNewFormat) { + expect(response.body).not.toHaveProperty('data.deleteOneField'); + } else { + expect(response.body).toHaveProperty('data.deleteOneField'); + } + + const getResponse = await makeRestAPIRequest({ + method: 'get', + path: `/metadata/fields/${id}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiErrorNotFoundResponse(getResponse); + }); + + it('returns 404 for an unknown id', async () => { + const response = await makeRestAPIRequest({ + method: 'delete', + path: `/metadata/fields/${NON_EXISTENT_UUID}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiErrorNotFoundResponse(response); + }); + }); +}); diff --git a/packages/twenty-server/test/integration/rest/suites/object-metadata.integration-spec.ts b/packages/twenty-server/test/integration/rest/suites/object-metadata.integration-spec.ts new file mode 100644 index 0000000000..eb65786b77 --- /dev/null +++ b/packages/twenty-server/test/integration/rest/suites/object-metadata.integration-spec.ts @@ -0,0 +1,498 @@ +import { updateFeatureFlag } from 'test/integration/metadata/suites/utils/update-feature-flag.util'; +import { makeRestAPIRequest } from 'test/integration/rest/utils/make-rest-api-request.util'; +import { + cleanupTestObject, + createTestObjectViaGraphql, + extractMetadataItemPayload, + extractMetadataListPayload, + NON_EXISTENT_UUID, + uniqueSuffix, +} from 'test/integration/rest/utils/metadata-rest-api.util'; +import { + assertRestApiErrorNotFoundResponse, + assertRestApiErrorResponse, + assertRestApiSuccessfulResponse, +} from 'test/integration/rest/utils/rest-test-assertions.util'; +import { FeatureFlagKey } from 'twenty-shared/types'; + +type ObjectShape = { id: string; fields: unknown[]; labelSingular?: string }; + +describe.each([ + ['new format', true], + ['legacy format', false], +] as const)('Object Metadata REST API (%s)', (_shapeLabel, isNewFormat) => { + beforeAll(async () => { + await updateFeatureFlag({ + featureFlag: FeatureFlagKey.IS_REST_METADATA_API_NEW_FORMAT_DIRECT, + value: isNewFormat, + expectToFail: false, + }); + }); + + afterAll(async () => { + await updateFeatureFlag({ + featureFlag: FeatureFlagKey.IS_REST_METADATA_API_NEW_FORMAT_DIRECT, + value: false, + expectToFail: false, + }); + }); + + describe('GET /metadata/objects', () => { + const seededIds: string[] = []; + + beforeAll(async () => { + for (let i = 0; i < 3; i++) { + const { id } = await createTestObjectViaGraphql(); + + seededIds.push(id); + } + }); + + afterAll(async () => { + await Promise.all(seededIds.map(cleanupTestObject)); + seededIds.length = 0; + }); + + it('returns the expected envelope shape', async () => { + const response = await makeRestAPIRequest({ + method: 'get', + path: '/metadata/objects', + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(response); + if (isNewFormat) { + expect(response.body).not.toHaveProperty('data.objects'); + expect(Array.isArray(response.body.data)).toBe(true); + } else { + expect(Array.isArray(response.body.data?.objects)).toBe(true); + } + expect(response.body).toHaveProperty('pageInfo.hasNextPage'); + expect(response.body).toHaveProperty('pageInfo.startCursor'); + expect(response.body).toHaveProperty('pageInfo.endCursor'); + expect(typeof response.body.totalCount).toBe('number'); + expect(response.body.totalCount).toBeGreaterThanOrEqual(seededIds.length); + }); + + it('inlines fields[] on each object', async () => { + const response = await makeRestAPIRequest({ + method: 'get', + path: '/metadata/objects?limit=5', + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(response); + const { items } = extractMetadataListPayload( + response.body, + 'objects', + ); + + for (const object of items) { + expect(Array.isArray(object.fields)).toBe(true); + } + }); + + it('respects limit and surfaces hasNextPage', async () => { + const response = await makeRestAPIRequest({ + method: 'get', + path: '/metadata/objects?limit=1', + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(response); + const { items, pageInfo } = extractMetadataListPayload( + response.body, + 'objects', + ); + + expect(items.length).toBe(1); + expect(pageInfo.hasNextPage).toBe(true); + expect(pageInfo.startCursor).toBe(items[0].id); + expect(pageInfo.endCursor).toBe(items[0].id); + }); + + it('paginates forward with starting_after without overlap', async () => { + const firstPage = await makeRestAPIRequest({ + method: 'get', + path: '/metadata/objects?limit=2', + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(firstPage); + const firstPayload = extractMetadataListPayload( + firstPage.body, + 'objects', + ); + + const secondPage = await makeRestAPIRequest({ + method: 'get', + path: `/metadata/objects?limit=2&starting_after=${firstPayload.pageInfo.endCursor}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(secondPage); + const secondPayload = extractMetadataListPayload( + secondPage.body, + 'objects', + ); + + const firstIds = firstPayload.items.map((o) => o.id); + const secondIds = secondPayload.items.map((o) => o.id); + + expect(firstIds.some((id) => secondIds.includes(id))).toBe(false); + }); + + it('paginates backward with ending_before', async () => { + const firstPage = await makeRestAPIRequest({ + method: 'get', + path: '/metadata/objects?limit=2', + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + const firstPayload = extractMetadataListPayload( + firstPage.body, + 'objects', + ); + + const secondPage = await makeRestAPIRequest({ + method: 'get', + path: `/metadata/objects?limit=2&starting_after=${firstPayload.pageInfo.endCursor}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + const secondPayload = extractMetadataListPayload( + secondPage.body, + 'objects', + ); + + const backPage = await makeRestAPIRequest({ + method: 'get', + path: `/metadata/objects?limit=2&ending_before=${secondPayload.pageInfo.startCursor}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(backPage); + const backPayload = extractMetadataListPayload( + backPage.body, + 'objects', + ); + + const firstIds = firstPayload.items.map((o) => o.id); + const backIds = backPayload.items.map((o) => o.id); + + expect(backIds.every((id) => firstIds.includes(id))).toBe(true); + }); + + it('rejects combining starting_after and ending_before with 400', async () => { + const response = await makeRestAPIRequest({ + method: 'get', + path: `/metadata/objects?starting_after=${NON_EXISTENT_UUID}&ending_before=${NON_EXISTENT_UUID}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiErrorResponse(response, 400); + }); + + it('keeps totalCount stable across pages', async () => { + const firstPage = await makeRestAPIRequest({ + method: 'get', + path: '/metadata/objects?limit=2', + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + const firstPayload = extractMetadataListPayload( + firstPage.body, + 'objects', + ); + + const secondPage = await makeRestAPIRequest({ + method: 'get', + path: `/metadata/objects?limit=2&starting_after=${firstPayload.pageInfo.endCursor}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(firstPage); + assertRestApiSuccessfulResponse(secondPage); + expect(firstPage.body.totalCount).toBe(secondPage.body.totalCount); + }); + + it('reports hasNextPage=false when the page covers all results', async () => { + const response = await makeRestAPIRequest({ + method: 'get', + path: '/metadata/objects?limit=200', + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(response); + const { items, pageInfo } = extractMetadataListPayload( + response.body, + 'objects', + ); + + expect(items.length).toBe(response.body.totalCount); + expect(pageInfo.hasNextPage).toBe(false); + }); + }); + + describe('GET /metadata/objects/:id', () => { + let testObjectId: string; + + beforeAll(async () => { + const { id } = await createTestObjectViaGraphql(); + + testObjectId = id; + }); + + afterAll(async () => { + await cleanupTestObject(testObjectId); + }); + + it('returns the object with fields[] populated', async () => { + const response = await makeRestAPIRequest({ + method: 'get', + path: `/metadata/objects/${testObjectId}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(response); + const object = extractMetadataItemPayload( + response.body, + 'object', + ); + + expect(object.id).toBe(testObjectId); + expect(Array.isArray(object.fields)).toBe(true); + expect(object.fields.length).toBeGreaterThan(0); + if (isNewFormat) { + expect(response.body).not.toHaveProperty('data.object'); + } else { + expect(response.body).toHaveProperty('data.object'); + } + }); + + it('returns 400 on a malformed UUID', async () => { + const response = await makeRestAPIRequest({ + method: 'get', + path: `/metadata/objects/not-a-uuid`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiErrorResponse(response, 400); + }); + + it('returns 404 for an unknown id', async () => { + const response = await makeRestAPIRequest({ + method: 'get', + path: `/metadata/objects/${NON_EXISTENT_UUID}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiErrorNotFoundResponse(response); + }); + }); + + describe('POST /metadata/objects', () => { + it('creates an object and returns 201 with default fields[]', async () => { + const suffix = uniqueSuffix(); + const input = { + nameSingular: `postObj${suffix}`, + namePlural: `postObj${suffix}s`, + labelSingular: `Post Obj ${suffix}`, + labelPlural: `Post Objs ${suffix}`, + icon: 'IconTestPipe', + isLabelSyncedWithName: false, + }; + + const response = await makeRestAPIRequest({ + method: 'post', + path: '/metadata/objects', + body: input, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + const object = extractMetadataItemPayload( + response.body, + 'createOneObject', + ); + + try { + assertRestApiSuccessfulResponse(response, 201); + expect(object.id).toBeDefined(); + expect( + (object as ObjectShape & { nameSingular: string }).nameSingular, + ).toBe(input.nameSingular); + expect(Array.isArray(object.fields)).toBe(true); + expect(object.fields.length).toBeGreaterThan(0); + if (isNewFormat) { + expect(response.body).not.toHaveProperty('data.createOneObject'); + } else { + expect(response.body).toHaveProperty('data.createOneObject'); + } + } finally { + if (object.id) { + await cleanupTestObject(object.id); + } + } + }); + + it('returns 400 on duplicate nameSingular', async () => { + const { id, input } = await createTestObjectViaGraphql(); + + try { + const response = await makeRestAPIRequest({ + method: 'post', + path: '/metadata/objects', + body: input, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiErrorResponse(response, 400); + } finally { + await cleanupTestObject(id); + } + }); + + it('returns 400 on invalid input', async () => { + const response = await makeRestAPIRequest({ + method: 'post', + path: '/metadata/objects', + body: { nameSingular: '' }, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiErrorResponse(response, 400); + }); + }); + + describe('PATCH /metadata/objects/:id', () => { + let testObjectId: string; + + beforeEach(async () => { + const { id } = await createTestObjectViaGraphql(); + + testObjectId = id; + }); + + afterEach(async () => { + await cleanupTestObject(testObjectId); + }); + + it('updates and returns the object with fields[]', async () => { + const newLabel = `Updated ${uniqueSuffix()}`; + + const response = await makeRestAPIRequest({ + method: 'patch', + path: `/metadata/objects/${testObjectId}`, + body: { labelSingular: newLabel }, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(response); + const object = extractMetadataItemPayload( + response.body, + 'updateOneObject', + ); + + expect(object.id).toBe(testObjectId); + expect(object.labelSingular).toBe(newLabel); + expect(Array.isArray(object.fields)).toBe(true); + if (isNewFormat) { + expect(response.body).not.toHaveProperty('data.updateOneObject'); + } else { + expect(response.body).toHaveProperty('data.updateOneObject'); + } + }); + + it('returns 404 for an unknown id', async () => { + const response = await makeRestAPIRequest({ + method: 'patch', + path: `/metadata/objects/${NON_EXISTENT_UUID}`, + body: { labelSingular: 'Whatever' }, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiErrorNotFoundResponse(response); + }); + }); + + describe('PUT /metadata/objects/:id', () => { + it('behaves equivalently to PATCH', async () => { + const { id } = await createTestObjectViaGraphql(); + + try { + const newLabel = `PutUpdate ${uniqueSuffix()}`; + + const response = await makeRestAPIRequest({ + method: 'put', + path: `/metadata/objects/${id}`, + body: { labelSingular: newLabel }, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(response); + const object = extractMetadataItemPayload( + response.body, + 'updateOneObject', + ); + + expect(object.id).toBe(id); + expect(object.labelSingular).toBe(newLabel); + expect(Array.isArray(object.fields)).toBe(true); + } finally { + await cleanupTestObject(id); + } + }); + }); + + describe('DELETE /metadata/objects/:id', () => { + it('deletes the object and returns the deleted resource', async () => { + const { id } = await createTestObjectViaGraphql(); + + const patchResponse = await makeRestAPIRequest({ + method: 'patch', + path: `/metadata/objects/${id}`, + body: { isActive: false }, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(patchResponse); + + const deleteResponse = await makeRestAPIRequest({ + method: 'delete', + path: `/metadata/objects/${id}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(deleteResponse); + const deleted = extractMetadataItemPayload<{ id: string }>( + deleteResponse.body, + 'deleteOneObject', + ); + + expect(deleted.id).toBe(id); + if (isNewFormat) { + expect(deleteResponse.body).not.toHaveProperty('data.deleteOneObject'); + } else { + expect(deleteResponse.body).toHaveProperty('data.deleteOneObject'); + } + + const getResponse = await makeRestAPIRequest({ + method: 'get', + path: `/metadata/objects/${id}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiErrorNotFoundResponse(getResponse); + }); + + it('returns 404 for an unknown id', async () => { + const response = await makeRestAPIRequest({ + method: 'delete', + path: `/metadata/objects/${NON_EXISTENT_UUID}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiErrorNotFoundResponse(response); + }); + }); +}); diff --git a/packages/twenty-server/test/integration/rest/utils/metadata-rest-api.util.ts b/packages/twenty-server/test/integration/rest/utils/metadata-rest-api.util.ts new file mode 100644 index 0000000000..4ab1eb61d1 --- /dev/null +++ b/packages/twenty-server/test/integration/rest/utils/metadata-rest-api.util.ts @@ -0,0 +1,149 @@ +import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util'; +import { deleteOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/delete-one-field-metadata.util'; +import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util'; +import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util'; +import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util'; +import { FieldMetadataType } from 'twenty-shared/types'; + +export const NON_EXISTENT_UUID = '00000000-0000-4000-8000-000000000000'; + +export const uniqueSuffix = (): string => + Math.random().toString(36).slice(2, 10); + +export type CreateTestObjectInput = { + nameSingular: string; + namePlural: string; + labelSingular: string; + labelPlural: string; + icon: string; + isLabelSyncedWithName: boolean; +}; + +export const buildUniqueObjectInput = (): CreateTestObjectInput => { + const suffix = uniqueSuffix(); + + return { + nameSingular: `restTest${suffix}`, + namePlural: `restTest${suffix}s`, + labelSingular: `Rest Test ${suffix}`, + labelPlural: `Rest Tests ${suffix}`, + icon: 'IconTestPipe', + isLabelSyncedWithName: false, + }; +}; + +export const createTestObjectViaGraphql = async ( + overrides?: Partial, +): Promise<{ id: string; input: CreateTestObjectInput }> => { + const input = { ...buildUniqueObjectInput(), ...overrides }; + const { data, errors } = await createOneObjectMetadata({ input }); + + if (!data?.createOneObject?.id) { + throw new Error( + `Failed to create test object: ${JSON.stringify(errors ?? data)}`, + ); + } + + return { id: data.createOneObject.id, input }; +}; + +export const cleanupTestObject = async (id: string): Promise => { + try { + await updateOneObjectMetadata({ + expectToFail: false, + input: { idToUpdate: id, updatePayload: { isActive: false } }, + }); + await deleteOneObjectMetadata({ + input: { idToDelete: id }, + expectToFail: false, + }); + } catch {} +}; + +export type CreateTestFieldInput = { + objectMetadataId: string; + type: FieldMetadataType; + name: string; + label: string; + isLabelSyncedWithName: boolean; +}; + +export const buildUniqueFieldInput = ( + objectMetadataId: string, +): CreateTestFieldInput => { + const suffix = uniqueSuffix(); + + return { + objectMetadataId, + type: FieldMetadataType.TEXT, + name: `restTestField${suffix}`, + label: `Rest Test Field ${suffix}`, + isLabelSyncedWithName: false, + }; +}; + +export const createTestFieldViaGraphql = async ( + objectMetadataId: string, + overrides?: Partial, +): Promise<{ id: string; input: CreateTestFieldInput }> => { + const input = { ...buildUniqueFieldInput(objectMetadataId), ...overrides }; + const { data, errors } = await createOneFieldMetadata({ input }); + + if (!data?.createOneField?.id) { + throw new Error( + `Failed to create test field: ${JSON.stringify(errors ?? data)}`, + ); + } + + return { id: data.createOneField.id, input }; +}; + +export const cleanupTestField = async (id: string): Promise => { + try { + await deleteOneFieldMetadata({ + input: { idToDelete: id }, + expectToFail: false, + }); + } catch { + // best-effort cleanup + } +}; + +export type MetadataListPageInfo = { + hasNextPage: boolean; + startCursor: string | null; + endCursor: string | null; +}; + +export const extractMetadataListPayload = ( + body: Record, + pluralKey: 'objects' | 'fields', +): { items: T[]; pageInfo: MetadataListPageInfo; totalCount: number } => { + if (Array.isArray(body.data)) { + return { + items: body.data as T[], + pageInfo: body.pageInfo as MetadataListPageInfo, + totalCount: body.totalCount as number, + }; + } + const data = body.data as Record; + + return { + items: (data[pluralKey] ?? []) as T[], + pageInfo: body.pageInfo as MetadataListPageInfo, + totalCount: body.totalCount as number, + }; +}; + +export const extractMetadataItemPayload = ( + body: Record, + legacyKey: string, +): T => { + const data = body.data as Record | undefined; + + if (data && legacyKey in data) { + return data[legacyKey] as T; + } + + return body as T; +}; diff --git a/packages/twenty-shared/src/types/FeatureFlagKey.ts b/packages/twenty-shared/src/types/FeatureFlagKey.ts index ee487bb1db..69251f0747 100644 --- a/packages/twenty-shared/src/types/FeatureFlagKey.ts +++ b/packages/twenty-shared/src/types/FeatureFlagKey.ts @@ -9,4 +9,5 @@ export enum FeatureFlagKey { IS_JUNCTION_RELATIONS_ENABLED = 'IS_JUNCTION_RELATIONS_ENABLED', IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED = 'IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED', IS_BILLING_V2_ENABLED = 'IS_BILLING_V2_ENABLED', + IS_REST_METADATA_API_NEW_FORMAT_DIRECT = 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT', }