feat: expose marketplace app detail fields explicitly and deprecate manifest blob (#22526)

Part of the application settings architecture work:
https://github.com/twentyhq/core-team-issues/issues/2456 — follow-up to
#22513.

`MarketplaceAppDetail` returned the entire `manifest` jsonb (100KB+)
over GraphQL and the front dug display fields and roles out of it. This
PR:

- Adds explicit fields to `MarketplaceAppDetail`: `description, author,
category, logo, websiteUrl, aboutDescription, termsUrl, emailSupport,
issueReportUrl, screenshots, defaultRoleUniversalIdentifier`, sourced
from the registration columns introduced in #22513, and `roles:
[MarketplaceAppRole!]` (full permission shape — the permissions tab and
install modal render object/field permissions), sourced from the
manifest at detail time.
- Marks the `manifest` field `@deprecated` (kept functional — removal
would be a breaking change).
- Front: the shared `marketplaceAppDetailFragment` no longer selects
`manifest`; display and role reads are flattened across
`SettingsAvailableApplicationDetails`, `SettingsApplicationDetails`, and
the share-link buttons. The three consumers that genuinely need deep
manifest structure (content-tab counts/`manifestContent`, permissions
objects, `useApplicationManifest` page-layout/view reads) use a scoped
`FindMarketplaceAppManifest` query until the manifest demotion PR
removes that need.
- Codegen regenerated where the documents live: front metadata config +
twenty-client-sdk metadata client (data/admin configs verified
untouched).

Verified: server+front typecheck, lint (0 warnings), server marketplace
suite 10/10, front marketplace/applications suites 41/41, live schema
introspection confirms the new fields and the deprecation.

<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22526?utm_source=github"
rel="nofollow noreferrer noopener" target="_blank">``&lt;img alt="Review
in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"&gt;``</a>
This commit is contained in:
martmull
2026-07-04 23:26:53 +02:00
committed by GitHub
parent 3db09e4423
commit ea851f7d9c
16 changed files with 1098 additions and 371 deletions
@@ -4,6 +4,7 @@ import { IsBoolean, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { GraphQLJSON } from 'graphql-type-json';
import { type Manifest } from 'twenty-shared/application';
import { MarketplaceAppRoleDTO } from 'src/engine/core-modules/application/application-marketplace/dtos/marketplace-app-role.dto';
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
@ObjectType('MarketplaceAppDetail')
@@ -44,6 +45,67 @@ export class MarketplaceAppDetailDTO {
@Field(() => Boolean)
isFeatured: boolean;
@Field(() => GraphQLJSON, { nullable: true })
@IsOptional()
@IsString()
@Field({ nullable: true })
description?: string;
@IsOptional()
@IsString()
@Field({ nullable: true })
author?: string;
@IsOptional()
@IsString()
@Field({ nullable: true })
category?: string;
@IsOptional()
@IsString()
@Field({ nullable: true })
logo?: string;
@IsOptional()
@IsString()
@Field({ nullable: true })
websiteUrl?: string;
@IsOptional()
@IsString()
@Field({ nullable: true })
aboutDescription?: string;
@IsOptional()
@IsString()
@Field({ nullable: true })
termsUrl?: string;
@IsOptional()
@IsString()
@Field({ nullable: true })
emailSupport?: string;
@IsOptional()
@IsString()
@Field({ nullable: true })
issueReportUrl?: string;
@Field(() => [String])
screenshots: string[];
@IsOptional()
@IsString()
@Field({ nullable: true })
defaultRoleUniversalIdentifier?: string;
@IsOptional()
@Field(() => [MarketplaceAppRoleDTO], { nullable: true })
roles?: MarketplaceAppRoleDTO[];
@Field(() => GraphQLJSON, {
nullable: true,
deprecationReason:
'Use the explicit MarketplaceAppDetail fields (description, author, roles, ...) instead',
})
manifest?: Manifest;
}
@@ -0,0 +1,129 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { IsBoolean, IsNotEmpty, IsOptional, IsString } from 'class-validator';
@ObjectType('MarketplaceAppRoleObjectPermission')
export class MarketplaceAppRoleObjectPermissionDTO {
@IsString()
@IsNotEmpty()
@Field()
universalIdentifier: string;
@IsString()
@IsNotEmpty()
@Field()
objectUniversalIdentifier: string;
@IsOptional()
@IsBoolean()
@Field(() => Boolean, { nullable: true })
canReadObjectRecords?: boolean;
@IsOptional()
@IsBoolean()
@Field(() => Boolean, { nullable: true })
canUpdateObjectRecords?: boolean;
@IsOptional()
@IsBoolean()
@Field(() => Boolean, { nullable: true })
canSoftDeleteObjectRecords?: boolean;
@IsOptional()
@IsBoolean()
@Field(() => Boolean, { nullable: true })
canDestroyObjectRecords?: boolean;
}
@ObjectType('MarketplaceAppRoleFieldPermission')
export class MarketplaceAppRoleFieldPermissionDTO {
@IsString()
@IsNotEmpty()
@Field()
universalIdentifier: string;
@IsString()
@IsNotEmpty()
@Field()
objectUniversalIdentifier: string;
@IsString()
@IsNotEmpty()
@Field()
fieldUniversalIdentifier: string;
@IsOptional()
@IsBoolean()
@Field(() => Boolean, { nullable: true })
canReadFieldValue?: boolean;
@IsOptional()
@IsBoolean()
@Field(() => Boolean, { nullable: true })
canUpdateFieldValue?: boolean;
}
@ObjectType('MarketplaceAppRole')
export class MarketplaceAppRoleDTO {
@IsString()
@IsNotEmpty()
@Field()
universalIdentifier: string;
@IsString()
@IsNotEmpty()
@Field()
label: string;
@IsOptional()
@IsString()
@Field({ nullable: true })
description?: string;
@IsOptional()
@IsString()
@Field({ nullable: true })
icon?: string;
@IsOptional()
@IsBoolean()
@Field(() => Boolean, { nullable: true })
canUpdateAllSettings?: boolean;
@IsOptional()
@IsBoolean()
@Field(() => Boolean, { nullable: true })
canAccessAllTools?: boolean;
@IsOptional()
@IsBoolean()
@Field(() => Boolean, { nullable: true })
canReadAllObjectRecords?: boolean;
@IsOptional()
@IsBoolean()
@Field(() => Boolean, { nullable: true })
canUpdateAllObjectRecords?: boolean;
@IsOptional()
@IsBoolean()
@Field(() => Boolean, { nullable: true })
canSoftDeleteAllObjectRecords?: boolean;
@IsOptional()
@IsBoolean()
@Field(() => Boolean, { nullable: true })
canDestroyAllObjectRecords?: boolean;
@IsOptional()
@Field(() => [String], { nullable: true })
permissionFlagUniversalIdentifiers?: string[];
@IsOptional()
@Field(() => [MarketplaceAppRoleObjectPermissionDTO], { nullable: true })
objectPermissions?: MarketplaceAppRoleObjectPermissionDTO[];
@IsOptional()
@Field(() => [MarketplaceAppRoleFieldPermissionDTO], { nullable: true })
fieldPermissions?: MarketplaceAppRoleFieldPermissionDTO[];
}
@@ -1,6 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { type RoleManifest } from 'twenty-shared/application';
import { isDefined, isNonEmptyArray } from 'twenty-shared/utils';
import { type ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import {
@@ -15,6 +16,7 @@ import {
import { MarketplaceCatalogSyncCronJob } from 'src/engine/core-modules/application/application-marketplace/crons/marketplace-catalog-sync.cron.job';
import { MarketplaceAppDTO } from 'src/engine/core-modules/application/application-marketplace/dtos/marketplace-app.dto';
import { MarketplaceAppDetailDTO } from 'src/engine/core-modules/application/application-marketplace/dtos/marketplace-app-detail.dto';
import { MarketplaceAppRoleDTO } from 'src/engine/core-modules/application/application-marketplace/dtos/marketplace-app-role.dto';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
@@ -115,7 +117,80 @@ export class MarketplaceQueryService {
latestAvailableVersion: registration.latestAvailableVersion ?? undefined,
isListed: registration.isListed,
isFeatured: registration.isFeatured,
description:
registration.description ??
registration.manifest?.application?.description ??
undefined,
author:
registration.author ??
registration.manifest?.application?.author ??
undefined,
category:
registration.category ??
registration.manifest?.application?.category ??
undefined,
logo: registration.logoUrl ?? undefined,
websiteUrl:
registration.websiteUrl ??
registration.manifest?.application?.websiteUrl ??
undefined,
aboutDescription:
registration.aboutDescription ??
registration.manifest?.application?.aboutDescription ??
undefined,
termsUrl:
registration.termsUrl ??
registration.manifest?.application?.termsUrl ??
undefined,
emailSupport:
registration.emailSupport ??
registration.manifest?.application?.emailSupport ??
undefined,
issueReportUrl:
registration.issueReportUrl ??
registration.manifest?.application?.issueReportUrl ??
undefined,
screenshots: isNonEmptyArray(registration.screenshots)
? registration.screenshots
: (registration.manifest?.application?.screenshots ?? []),
defaultRoleUniversalIdentifier:
registration.manifest?.application?.defaultRoleUniversalIdentifier,
roles: registration.manifest?.roles?.map((role) =>
this.toMarketplaceAppRoleDTO(role),
),
manifest: registration.manifest ?? undefined,
};
}
private toMarketplaceAppRoleDTO(role: RoleManifest): MarketplaceAppRoleDTO {
return {
universalIdentifier: role.universalIdentifier,
label: role.label,
description: role.description,
icon: role.icon,
canUpdateAllSettings: role.canUpdateAllSettings,
canAccessAllTools: role.canAccessAllTools,
canReadAllObjectRecords: role.canReadAllObjectRecords,
canUpdateAllObjectRecords: role.canUpdateAllObjectRecords,
canSoftDeleteAllObjectRecords: role.canSoftDeleteAllObjectRecords,
canDestroyAllObjectRecords: role.canDestroyAllObjectRecords,
permissionFlagUniversalIdentifiers:
role.permissionFlagUniversalIdentifiers,
objectPermissions: role.objectPermissions?.map((permission) => ({
universalIdentifier: permission.universalIdentifier,
objectUniversalIdentifier: permission.objectUniversalIdentifier,
canReadObjectRecords: permission.canReadObjectRecords,
canUpdateObjectRecords: permission.canUpdateObjectRecords,
canSoftDeleteObjectRecords: permission.canSoftDeleteObjectRecords,
canDestroyObjectRecords: permission.canDestroyObjectRecords,
})),
fieldPermissions: role.fieldPermissions?.map((permission) => ({
universalIdentifier: permission.universalIdentifier,
objectUniversalIdentifier: permission.objectUniversalIdentifier,
fieldUniversalIdentifier: permission.fieldUniversalIdentifier,
canReadFieldValue: permission.canReadFieldValue,
canUpdateFieldValue: permission.canUpdateFieldValue,
})),
};
}
}