diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql index 4d96fd83e3..ea2f12d7a5 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql @@ -3154,6 +3154,8 @@ type Query { findOneApplication(id: UUID, universalIdentifier: UUID): Application! findManyMarketplaceApps: [MarketplaceApp!]! findMarketplaceAppDetail(universalIdentifier: String!): MarketplaceAppDetail! + publicMarketplaceApps(isFeatured: Boolean! = true): [MarketplaceApp!]! + publicMarketplaceAppDetail(universalIdentifier: String!): MarketplaceAppDetail! findApplicationRegistrationByClientId(clientId: String!): PublicApplicationRegistration findApplicationRegistrationByUniversalIdentifier(universalIdentifier: String!): ApplicationRegistration findManyApplicationRegistrations: [ApplicationRegistration!]! diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.ts b/packages/twenty-client-sdk/src/metadata/generated/schema.ts index 6c41965cd7..179caf7f40 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.ts @@ -2767,6 +2767,8 @@ export interface Query { findOneApplication: Application findManyMarketplaceApps: MarketplaceApp[] findMarketplaceAppDetail: MarketplaceAppDetail + publicMarketplaceApps: MarketplaceApp[] + publicMarketplaceAppDetail: MarketplaceAppDetail findApplicationRegistrationByClientId?: PublicApplicationRegistration findApplicationRegistrationByUniversalIdentifier?: ApplicationRegistration findManyApplicationRegistrations: ApplicationRegistration[] @@ -5990,6 +5992,8 @@ export interface QueryGenqlSelection{ findOneApplication?: (ApplicationGenqlSelection & { __args?: {id?: (Scalars['UUID'] | null), universalIdentifier?: (Scalars['UUID'] | null)} }) findManyMarketplaceApps?: MarketplaceAppGenqlSelection findMarketplaceAppDetail?: (MarketplaceAppDetailGenqlSelection & { __args: {universalIdentifier: Scalars['String']} }) + publicMarketplaceApps?: (MarketplaceAppGenqlSelection & { __args: {isFeatured: Scalars['Boolean']} }) + publicMarketplaceAppDetail?: (MarketplaceAppDetailGenqlSelection & { __args: {universalIdentifier: Scalars['String']} }) findApplicationRegistrationByClientId?: (PublicApplicationRegistrationGenqlSelection & { __args: {clientId: Scalars['String']} }) findApplicationRegistrationByUniversalIdentifier?: (ApplicationRegistrationGenqlSelection & { __args: {universalIdentifier: Scalars['String']} }) findManyApplicationRegistrations?: ApplicationRegistrationGenqlSelection diff --git a/packages/twenty-client-sdk/src/metadata/generated/types.ts b/packages/twenty-client-sdk/src/metadata/generated/types.ts index 2892301514..7e625cbe47 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/types.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/types.ts @@ -6540,6 +6540,24 @@ export default { ] } ], + "publicMarketplaceApps": [ + 228, + { + "isFeatured": [ + 6, + "Boolean!" + ] + } + ], + "publicMarketplaceAppDetail": [ + 232, + { + "universalIdentifier": [ + 1, + "String!" + ] + } + ], "findApplicationRegistrationByClientId": [ 199, { diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 817a7320be..4eee9256bd 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -4459,6 +4459,8 @@ export type Query = { objects: ObjectConnection; pieChartData: PieChartData; previewMessageCampaignAudience: CampaignAudiencePreviewDto; + publicMarketplaceAppDetail: MarketplaceAppDetail; + publicMarketplaceApps: Array; skill?: Maybe; skills: Array; unsubscribePagePreviewUrl: Scalars['String']['output']; @@ -4836,6 +4838,16 @@ export type QueryPreviewMessageCampaignAudienceArgs = { }; +export type QueryPublicMarketplaceAppDetailArgs = { + universalIdentifier: Scalars['String']['input']; +}; + + +export type QueryPublicMarketplaceAppsArgs = { + isFeatured?: Scalars['Boolean']['input']; +}; + + export type QuerySkillArgs = { id: Scalars['UUID']['input']; }; diff --git a/packages/twenty-sdk/src/sdk/define/application/__tests__/define-app.spec.ts b/packages/twenty-sdk/src/sdk/define/application/__tests__/define-app.spec.ts index 1b99e860d1..99e4c9a2b3 100644 --- a/packages/twenty-sdk/src/sdk/define/application/__tests__/define-app.spec.ts +++ b/packages/twenty-sdk/src/sdk/define/application/__tests__/define-app.spec.ts @@ -73,6 +73,33 @@ describe('defineApplication', () => { expect(warnings[0]).toMatch(/defineApplicationRole/); }); + it('should warn when category is not a known ApplicationCategory', () => { + const result = defineApplication({ + universalIdentifier: 'a9faf5f8-cf7e-4f24-9d37-fd523c30febe', + displayName: 'My App', + description: 'My app description', + category: 'NotARealCategory', + }); + + const warnings = result.warnings ?? []; + + expect(result.success).toBe(true); + expect( + warnings.some((warning) => warning.includes('NotARealCategory')), + ).toBe(true); + }); + + it('should not warn when category is a known ApplicationCategory', () => { + const result = defineApplication({ + universalIdentifier: 'a9faf5f8-cf7e-4f24-9d37-fd523c30febe', + displayName: 'My App', + description: 'My app description', + category: 'Data', + }); + + expect(result.warnings ?? []).toEqual([]); + }); + it('should return error when universalIdentifier is missing', () => { const config = { displayName: 'My App', diff --git a/packages/twenty-sdk/src/sdk/define/application/define-application.ts b/packages/twenty-sdk/src/sdk/define/application/define-application.ts index eabd33f78d..bdb2941505 100644 --- a/packages/twenty-sdk/src/sdk/define/application/define-application.ts +++ b/packages/twenty-sdk/src/sdk/define/application/define-application.ts @@ -1,3 +1,8 @@ +import { isNonEmptyString } from '@sniptt/guards'; +import { + APPLICATION_CATEGORIES, + isKnownApplicationCategory, +} from 'twenty-shared/application'; import { FieldMetadataType } from 'twenty-shared/types'; import { isNonEmptyArray } from 'twenty-shared/utils'; @@ -37,6 +42,16 @@ export const defineApplication: DefineEntity = (config) => { ); } + const { category } = config; + + if (isNonEmptyString(category) && !isKnownApplicationCategory(category)) { + warnings.push( + `Application category "${category}" is not a known ApplicationCategory (${APPLICATION_CATEGORIES.join( + ', ', + )}). Arbitrary category strings are kept for backward compatibility and may be removed. Ask for a new category at https://github.com/twentyhq/twenty.`, + ); + } + return createValidationResult({ config, errors, diff --git a/packages/twenty-server/src/engine/core-entity-cache/types/core-entity-cache-key.type.ts b/packages/twenty-server/src/engine/core-entity-cache/types/core-entity-cache-key.type.ts index 5356763cd2..fd04d78cf6 100644 --- a/packages/twenty-server/src/engine/core-entity-cache/types/core-entity-cache-key.type.ts +++ b/packages/twenty-server/src/engine/core-entity-cache/types/core-entity-cache-key.type.ts @@ -1,3 +1,4 @@ +import { type MarketplaceAppDTO } from 'src/engine/core-modules/application/application-marketplace/dtos/marketplace-app.dto'; import { type FlatUserWorkspace } from 'src/engine/core-modules/user-workspace/types/flat-user-workspace.type'; import { type FlatUser } from 'src/engine/core-modules/user/types/flat-user.type'; import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type'; @@ -7,6 +8,7 @@ export type CoreEntityCacheDataMap = { user: FlatUser; userWorkspaceEntity: FlatUserWorkspace; signingKeyPublicKey: string; + marketplaceCatalog: Record; }; export type CoreEntityCacheKeyName = keyof CoreEntityCacheDataMap; @@ -16,4 +18,5 @@ export const CORE_ENTITY_CACHE_KEYS: Record = { user: 'user', userWorkspaceEntity: 'user-workspace', signingKeyPublicKey: 'signing-key-public-key', + marketplaceCatalog: 'marketplace-catalog', }; diff --git a/packages/twenty-server/src/engine/core-modules/application/application-marketplace/constants/marketplace-apps-cache.constant.ts b/packages/twenty-server/src/engine/core-modules/application/application-marketplace/constants/marketplace-apps-cache.constant.ts new file mode 100644 index 0000000000..d5f6af7e1b --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/application-marketplace/constants/marketplace-apps-cache.constant.ts @@ -0,0 +1,2 @@ +export const MARKETPLACE_CATALOG_CACHE_ENTITY_ID = + '00000000-0000-4000-8000-000000000001'; diff --git a/packages/twenty-server/src/engine/core-modules/application/application-marketplace/marketplace-catalog-cache-provider.service.ts b/packages/twenty-server/src/engine/core-modules/application/application-marketplace/marketplace-catalog-cache-provider.service.ts new file mode 100644 index 0000000000..bb69eba838 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/application-marketplace/marketplace-catalog-cache-provider.service.ts @@ -0,0 +1,64 @@ +import { Injectable } from '@nestjs/common'; + +import { CoreEntityCache } from 'src/engine/core-entity-cache/decorators/core-entity-cache.decorator'; +import { CoreEntityCacheProvider } from 'src/engine/core-entity-cache/interfaces/core-entity-cache-provider.service'; +import { MarketplaceAppDTO } from 'src/engine/core-modules/application/application-marketplace/dtos/marketplace-app.dto'; +import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.service'; +import { + type ApplicationRegistrationCatalogCard, + ApplicationRegistrationService, +} from 'src/engine/core-modules/application/application-registration/application-registration.service'; + +@Injectable() +@CoreEntityCache('marketplaceCatalog') +export class MarketplaceCatalogCacheProviderService extends CoreEntityCacheProvider< + Record +> { + constructor( + private readonly applicationRegistrationService: ApplicationRegistrationService, + private readonly applicationRegistrationVariableService: ApplicationRegistrationVariableService, + ) { + super(); + } + + async computeForCache(): Promise> { + const registrations = + await this.applicationRegistrationService.findManyListedCatalogCards(); + + if (registrations.length === 0) { + return {}; + } + + const configuredStatuses = + await this.applicationRegistrationVariableService.isConfiguredBatch( + registrations.map((registration) => registration.id), + ); + + return registrations + .filter((registration) => configuredStatuses.get(registration.id) ?? true) + .reduce>( + (accumulator, registration) => { + accumulator[registration.universalIdentifier] = + this.toMarketplaceAppDTO(registration); + + return accumulator; + }, + {}, + ); + } + + private toMarketplaceAppDTO( + catalogCard: ApplicationRegistrationCatalogCard, + ): MarketplaceAppDTO { + return { + id: catalogCard.universalIdentifier, + name: catalogCard.name, + description: catalogCard.description ?? '', + author: catalogCard.author ?? 'Unknown', + category: catalogCard.category ?? '', + logo: catalogCard.logoUrl ?? undefined, + sourcePackage: catalogCard.sourcePackage ?? undefined, + isFeatured: catalogCard.isFeatured, + }; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/application/application-marketplace/marketplace-public.resolver.ts b/packages/twenty-server/src/engine/core-modules/application/application-marketplace/marketplace-public.resolver.ts new file mode 100644 index 0000000000..315c0c0167 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/application-marketplace/marketplace-public.resolver.ts @@ -0,0 +1,51 @@ +import { UseFilters, UseGuards } from '@nestjs/common'; +import { Args, Query } from '@nestjs/graphql'; + +import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator'; +import { ApplicationRegistrationExceptionFilter } from 'src/engine/core-modules/application/application-registration/application-registration-exception-filter'; +import { + ApplicationRegistrationException, + ApplicationRegistrationExceptionCode, +} from 'src/engine/core-modules/application/application-registration/application-registration.exception'; +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 { MarketplaceQueryService } from 'src/engine/core-modules/application/application-marketplace/marketplace-query.service'; +import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard'; +import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard'; + +@MetadataResolver() +@UseFilters(ApplicationRegistrationExceptionFilter) +export class MarketplacePublicResolver { + constructor( + private readonly marketplaceQueryService: MarketplaceQueryService, + ) {} + + @Query(() => [MarketplaceAppDTO], { name: 'publicMarketplaceApps' }) + @UseGuards(PublicEndpointGuard, NoPermissionGuard) + async findManyPublicMarketplaceApps( + @Args('isFeatured', { type: () => Boolean, defaultValue: true }) + isFeatured: boolean, + ): Promise { + return this.marketplaceQueryService.findManyMarketplaceApps(isFeatured); + } + + @Query(() => MarketplaceAppDetailDTO, { name: 'publicMarketplaceAppDetail' }) + @UseGuards(PublicEndpointGuard, NoPermissionGuard) + async findPublicMarketplaceAppDetail( + @Args('universalIdentifier') universalIdentifier: string, + ): Promise { + const detail = + await this.marketplaceQueryService.findMarketplaceAppDetail( + universalIdentifier, + ); + + if (!detail.isListed) { + throw new ApplicationRegistrationException( + `No listed marketplace application found for identifier "${universalIdentifier}"`, + ApplicationRegistrationExceptionCode.APPLICATION_REGISTRATION_NOT_FOUND, + ); + } + + return detail; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/application/application-marketplace/marketplace-query.service.ts b/packages/twenty-server/src/engine/core-modules/application/application-marketplace/marketplace-query.service.ts index fbe4aec013..6bf6518bab 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-marketplace/marketplace-query.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-marketplace/marketplace-query.service.ts @@ -1,66 +1,43 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { type RoleManifest } from 'twenty-shared/application'; import { isDefined, isNonEmptyArray } from 'twenty-shared/utils'; +import { CoreEntityCacheService } from 'src/engine/core-entity-cache/services/core-entity-cache.service'; +import { MARKETPLACE_CATALOG_CACHE_ENTITY_ID } from 'src/engine/core-modules/application/application-marketplace/constants/marketplace-apps-cache.constant'; +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 { type ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity'; import { ApplicationRegistrationException, ApplicationRegistrationExceptionCode, } from 'src/engine/core-modules/application/application-registration/application-registration.exception'; -import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.service'; -import { - type ApplicationRegistrationCatalogCard, - ApplicationRegistrationService, -} from 'src/engine/core-modules/application/application-registration/application-registration.service'; -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'; +import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service'; @Injectable() export class MarketplaceQueryService { - private readonly logger = new Logger(MarketplaceQueryService.name); - private hasSyncBeenEnqueued = false; - constructor( private readonly applicationRegistrationService: ApplicationRegistrationService, - private readonly applicationRegistrationVariableService: ApplicationRegistrationVariableService, - @InjectMessageQueue(MessageQueue.cronQueue) - private readonly messageQueueService: MessageQueueService, + private readonly coreEntityCacheService: CoreEntityCacheService, ) {} - async findManyMarketplaceApps(): Promise { - const registrations = - await this.applicationRegistrationService.findManyListedCatalogCards(); + async findManyMarketplaceApps( + isFeatured?: boolean, + ): Promise { + const appsByUniversalIdentifier = + (await this.coreEntityCacheService.get( + 'marketplaceCatalog', + MARKETPLACE_CATALOG_CACHE_ENTITY_ID, + )) ?? {}; - if (registrations.length === 0) { - if (!this.hasSyncBeenEnqueued) { - this.hasSyncBeenEnqueued = true; - this.logger.log( - 'No marketplace registrations found, enqueuing one-time sync job', - ); - await this.messageQueueService.add( - MarketplaceCatalogSyncCronJob.name, - {}, - { id: 'marketplace-catalog-sync' }, // Avoids triggering multiple pending jobs - ); - } + const apps = Object.values(appsByUniversalIdentifier); - return []; + if (!isDefined(isFeatured)) { + return apps; } - const configuredStatuses = - await this.applicationRegistrationVariableService.isConfiguredBatch( - registrations.map((registration) => registration.id), - ); - - return registrations - .filter((registration) => configuredStatuses.get(registration.id) ?? true) - .map((registration) => this.toMarketplaceAppDTO(registration)); + return apps.filter((app) => app.isFeatured === isFeatured); } async findMarketplaceAppDetail( @@ -90,21 +67,6 @@ export class MarketplaceQueryService { return registration; } - private toMarketplaceAppDTO( - catalogCard: ApplicationRegistrationCatalogCard, - ): MarketplaceAppDTO { - return { - id: catalogCard.universalIdentifier, - name: catalogCard.name, - description: catalogCard.description ?? '', - author: catalogCard.author ?? 'Unknown', - category: catalogCard.category ?? '', - logo: catalogCard.logoUrl ?? undefined, - sourcePackage: catalogCard.sourcePackage ?? undefined, - isFeatured: catalogCard.isFeatured, - }; - } - private toMarketplaceAppDetailDTO( registration: ApplicationRegistrationEntity, ): MarketplaceAppDetailDTO { diff --git a/packages/twenty-server/src/engine/core-modules/application/application-marketplace/marketplace.module.ts b/packages/twenty-server/src/engine/core-modules/application/application-marketplace/marketplace.module.ts index e47cde26c5..f21004f398 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-marketplace/marketplace.module.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-marketplace/marketplace.module.ts @@ -1,10 +1,13 @@ import { Module } from '@nestjs/common'; +import { CoreEntityCacheModule } from 'src/engine/core-entity-cache/core-entity-cache.module'; import { ApplicationModule } from 'src/engine/core-modules/application/application.module'; import { ApplicationRegistrationModule } from 'src/engine/core-modules/application/application-registration/application-registration.module'; import { MarketplaceCatalogSyncCronCommand } from 'src/engine/core-modules/application/application-marketplace/crons/commands/marketplace-catalog-sync.cron.command'; import { MarketplaceCatalogSyncCronJob } from 'src/engine/core-modules/application/application-marketplace/crons/marketplace-catalog-sync.cron.job'; +import { MarketplaceCatalogCacheProviderService } from 'src/engine/core-modules/application/application-marketplace/marketplace-catalog-cache-provider.service'; import { MarketplaceCatalogSyncService } from 'src/engine/core-modules/application/application-marketplace/marketplace-catalog-sync.service'; +import { MarketplacePublicResolver } from 'src/engine/core-modules/application/application-marketplace/marketplace-public.resolver'; import { MarketplaceQueryService } from 'src/engine/core-modules/application/application-marketplace/marketplace-query.service'; import { MarketplaceResolver } from 'src/engine/core-modules/application/application-marketplace/marketplace.resolver'; import { MarketplaceService } from 'src/engine/core-modules/application/application-marketplace/marketplace.service'; @@ -17,6 +20,7 @@ import { MarketplaceCatalogSyncCommand } from 'src/engine/core-modules/applicati imports: [ ApplicationModule, ApplicationRegistrationModule, + CoreEntityCacheModule, FeatureFlagModule, PermissionsModule, TwentyConfigModule, @@ -25,10 +29,12 @@ import { MarketplaceCatalogSyncCommand } from 'src/engine/core-modules/applicati MarketplaceService, MarketplaceCatalogSyncService, MarketplaceQueryService, + MarketplaceCatalogCacheProviderService, MarketplaceCatalogSyncCronJob, MarketplaceCatalogSyncCronCommand, MarketplaceCatalogSyncCommand, MarketplaceResolver, + MarketplacePublicResolver, ], exports: [ MarketplaceCatalogSyncService, diff --git a/packages/twenty-server/src/engine/core-modules/application/application-registration/application-registration.module.ts b/packages/twenty-server/src/engine/core-modules/application/application-registration/application-registration.module.ts index 201e4ba0f6..cf3c78dc1c 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-registration/application-registration.module.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-registration/application-registration.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { CoreEntityCacheModule } from 'src/engine/core-entity-cache/core-entity-cache.module'; import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity'; import { ApplicationRegistrationResolver } from 'src/engine/core-modules/application/application-registration/application-registration.resolver'; import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service'; @@ -30,6 +31,7 @@ import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/ ApplicationModule, ApplicationPackageModule, CacheLockModule, + CoreEntityCacheModule, DomainServerConfigModule, FeatureFlagModule, PermissionsModule, diff --git a/packages/twenty-server/src/engine/core-modules/application/application-registration/application-registration.service.ts b/packages/twenty-server/src/engine/core-modules/application/application-registration/application-registration.service.ts index 3a3f63db43..ea8fe00f6b 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-registration/application-registration.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-registration/application-registration.service.ts @@ -32,6 +32,8 @@ import { ApplicationEntity } from 'src/engine/core-modules/application/applicati import { validateRedirectUri } from 'src/engine/core-modules/auth/utils/validate-redirect-uri.util'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.service'; +import { CoreEntityCacheService } from 'src/engine/core-entity-cache/services/core-entity-cache.service'; +import { MARKETPLACE_CATALOG_CACHE_ENTITY_ID } from 'src/engine/core-modules/application/application-marketplace/constants/marketplace-apps-cache.constant'; import { MARKETPLACE_FEATURED_APPLICATIONS } from 'src/engine/core-modules/application/application-marketplace/constants/marketplace-featured-applications.constant'; const BCRYPT_SALT_ROUNDS = 10; @@ -92,8 +94,20 @@ export class ApplicationRegistrationService { private readonly workspaceRepository: Repository, private readonly applicationRegistrationVariableService: ApplicationRegistrationVariableService, private readonly cacheLockService: CacheLockService, + private readonly coreEntityCacheService: CoreEntityCacheService, ) {} + private async invalidateMarketplaceAppsCache(): Promise { + try { + await this.coreEntityCacheService.invalidate( + 'marketplaceCatalog', + MARKETPLACE_CATALOG_CACHE_ENTITY_ID, + ); + } catch (error) { + this.logger.error('Failed to invalidate marketplace apps cache', error); + } + } + async findMany( ownerWorkspaceId: string, ): Promise { @@ -233,6 +247,8 @@ export class ApplicationRegistrationService { applicationRegistration, ); + await this.invalidateMarketplaceAppsCache(); + return { applicationRegistration: saved, clientSecret }; } @@ -285,6 +301,7 @@ export class ApplicationRegistrationService { if (Object.keys(updateData).length > 0) { await this.applicationRegistrationRepository.update(id, updateData); + await this.invalidateMarketplaceAppsCache(); } } @@ -332,6 +349,8 @@ export class ApplicationRegistrationService { latestAvailableVersion, }), }); + + await this.invalidateMarketplaceAppsCache(); }, `application-registration-update:${applicationRegistrationId}`); } @@ -339,6 +358,8 @@ export class ApplicationRegistrationService { await this.findOneById(id, ownerWorkspaceId); await this.applicationRegistrationRepository.softDelete(id); + await this.invalidateMarketplaceAppsCache(); + return true; } @@ -355,6 +376,8 @@ export class ApplicationRegistrationService { oAuthClientSecretHash: clientSecretHash, }); + await this.invalidateMarketplaceAppsCache(); + return clientSecret; } @@ -399,6 +422,7 @@ export class ApplicationRegistrationService { sourceType: params.sourceType, sourcePackage: params.sourcePackage, latestAvailableVersion: params.latestAvailableVersion, + isFeatured, manifest: params.manifest, ...fromManifestApplicationToDisplayFields(params.manifest?.application), }); @@ -422,6 +446,8 @@ export class ApplicationRegistrationService { await this.applicationRegistrationRepository.save(registration); } + await this.invalidateMarketplaceAppsCache(); + if (!isDefined(params.manifest?.application?.serverVariables)) { return; } @@ -462,7 +488,12 @@ export class ApplicationRegistrationService { createdByUserId: null, }); - return this.applicationRegistrationRepository.save(registration); + const saved = + await this.applicationRegistrationRepository.save(registration); + + await this.invalidateMarketplaceAppsCache(); + + return saved; } async findManyListedCatalogCards(): Promise< @@ -645,6 +676,8 @@ export class ApplicationRegistrationService { ); } + await this.invalidateMarketplaceAppsCache(); + return this.applicationRegistrationRepository.findOneOrFail({ where: { id: registration.id }, }); @@ -682,6 +715,8 @@ export class ApplicationRegistrationService { ownerWorkspaceId: targetWorkspace.id, }); + await this.invalidateMarketplaceAppsCache(); + return this.applicationRegistrationRepository.findOneOrFail({ where: { id: registration.id }, }); diff --git a/packages/twenty-shared/src/application/applicationCategoryType.ts b/packages/twenty-shared/src/application/applicationCategoryType.ts new file mode 100644 index 0000000000..e2403a3d95 --- /dev/null +++ b/packages/twenty-shared/src/application/applicationCategoryType.ts @@ -0,0 +1,20 @@ +export const APPLICATION_CATEGORIES = [ + 'Communication', + 'Productivity', + 'Product management', + 'Sales', + 'Marketing', + 'Enrichment', + 'Data', + 'Search', + 'Other', +] as const; + +export type KnownApplicationCategory = (typeof APPLICATION_CATEGORIES)[number]; + +export type ApplicationCategory = KnownApplicationCategory | (string & {}); + +export const isKnownApplicationCategory = ( + category: string, +): category is KnownApplicationCategory => + (APPLICATION_CATEGORIES as readonly string[]).includes(category); diff --git a/packages/twenty-shared/src/application/applicationType.ts b/packages/twenty-shared/src/application/applicationType.ts index 75e9c132b7..7817a58b8a 100644 --- a/packages/twenty-shared/src/application/applicationType.ts +++ b/packages/twenty-shared/src/application/applicationType.ts @@ -1,3 +1,4 @@ +import { type ApplicationCategory } from './applicationCategoryType'; import { type ApplicationVariables } from './applicationVariablesType'; import { type ServerVariables } from './server-variables.type'; import { type SyncableEntityOptions } from './syncableEntityOptionsType'; @@ -11,7 +12,7 @@ export type ApplicationManifest = SyncableEntityOptions & { applicationVariables?: ApplicationVariables; serverVariables?: ServerVariables; author?: string; - category?: string; + category?: ApplicationCategory; logoUrl?: string; screenshots?: string[]; aboutDescription?: string; diff --git a/packages/twenty-shared/src/application/index.ts b/packages/twenty-shared/src/application/index.ts index e0d0bb6b31..80c63afa8b 100644 --- a/packages/twenty-shared/src/application/index.ts +++ b/packages/twenty-shared/src/application/index.ts @@ -9,6 +9,14 @@ export type { AgentManifest } from './agentManifestType'; export type { AppConnection } from './appConnectionType'; +export type { + KnownApplicationCategory, + ApplicationCategory, +} from './applicationCategoryType'; +export { + APPLICATION_CATEGORIES, + isKnownApplicationCategory, +} from './applicationCategoryType'; export type { ApplicationManifest } from './applicationType'; export type { ApplicationVariableType,