Add marketplace catalog synchronization to admin panel (#22260)

## After

<img width="1476" height="658" alt="image"
src="https://github.com/user-attachments/assets/25a042dd-eabf-4b71-9872-d3b627104634"
/>


## Summary
This PR adds the ability for admins to manually synchronize the
marketplace application catalog from the admin panel. It introduces a
new mutation endpoint and UI controls to trigger catalog synchronization
with user feedback via snackbar notifications.

## Key Changes
- **Frontend (SettingsAdminApps component)**:
  - Added `useSnackBar` hook for user feedback on sync success/failure
- Imported `useMutation` from Apollo Client to handle the sync operation
  - Added `IconRefresh` and `Button` imports for the sync UI control
- Created `handleSyncCatalog` function that triggers the mutation,
refetches app registrations, and displays appropriate snackbar messages
- Added a new "General" section with a "Synchronize catalog" button
above the existing app registrations table
  - Button shows loading state and is disabled while sync is in progress

- **Backend (AdminPanelResolver)**:
- Added `syncMarketplaceCatalog` mutation that queues a
`MarketplaceCatalogSyncCronJob` via the message queue
- Uses `@InjectMessageQueue` decorator to inject the cron queue service
- Includes job deduplication via `id: 'marketplace-catalog-sync'` to
prevent multiple pending sync jobs
  - Protected with `@UseGuards(AdminPanelGuard)` for admin-only access

- **GraphQL Schema**:
  - Added `SyncMarketplaceCatalog` mutation type definition
  - Generated corresponding TypeScript types and mutation document

- **New Files**:
  - Created `syncMarketplaceCatalog.ts` GraphQL mutation document

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22260?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
martmull
2026-06-29 14:58:34 +02:00
committed by GitHub
parent 66edd96213
commit d1854bc7e9
5 changed files with 129 additions and 63 deletions
@@ -415,6 +415,7 @@ export type Mutation = {
setAdminAiModelsRecommended: Scalars['Boolean']['output'];
setAdminDefaultAiModel: Scalars['Boolean']['output'];
setMaintenanceMode: Scalars['Boolean']['output'];
syncMarketplaceCatalog: Scalars['Boolean']['output'];
updateAdminApplicationRegistration: ApplicationRegistration;
updateAdminApplicationRegistrationVariable: ApplicationRegistrationVariableDto;
updateDatabaseConfigVariable: Scalars['Boolean']['output'];
@@ -1007,6 +1008,11 @@ export type BackfillApplicationInstallationMutationVariables = Exact<{
export type BackfillApplicationInstallationMutation = { __typename?: 'Mutation', backfillApplicationInstallation: boolean };
export type SyncMarketplaceCatalogMutationVariables = Exact<{ [key: string]: never; }>;
export type SyncMarketplaceCatalogMutation = { __typename?: 'Mutation', syncMarketplaceCatalog: boolean };
export type UpdateAdminApplicationRegistrationMutationVariables = Exact<{
input: UpdateApplicationRegistrationInput;
}>;
@@ -1268,6 +1274,7 @@ export const GetAiProvidersDocument = {"kind":"Document","definitions":[{"kind":
export const GetModelsDevProvidersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetModelsDevProviders"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getModelsDevProviders"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"modelCount"}},{"kind":"Field","name":{"kind":"Name","value":"npm"}}]}}]}}]} as unknown as DocumentNode<GetModelsDevProvidersQuery, GetModelsDevProvidersQueryVariables>;
export const GetModelsDevSuggestionsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetModelsDevSuggestions"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"providerType"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getModelsDevSuggestions"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"providerType"},"value":{"kind":"Variable","name":{"kind":"Name","value":"providerType"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"modelId"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"inputCostPerMillionTokens"}},{"kind":"Field","name":{"kind":"Name","value":"outputCostPerMillionTokens"}},{"kind":"Field","name":{"kind":"Name","value":"cachedInputCostPerMillionTokens"}},{"kind":"Field","name":{"kind":"Name","value":"cacheCreationCostPerMillionTokens"}},{"kind":"Field","name":{"kind":"Name","value":"contextWindowTokens"}},{"kind":"Field","name":{"kind":"Name","value":"maxOutputTokens"}},{"kind":"Field","name":{"kind":"Name","value":"modalities"}},{"kind":"Field","name":{"kind":"Name","value":"supportsReasoning"}}]}}]}}]} as unknown as DocumentNode<GetModelsDevSuggestionsQuery, GetModelsDevSuggestionsQueryVariables>;
export const BackfillApplicationInstallationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"BackfillApplicationInstallation"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"applicationRegistrationId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"backfillApplicationInstallation"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"applicationRegistrationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"applicationRegistrationId"}}}]}]}}]} as unknown as DocumentNode<BackfillApplicationInstallationMutation, BackfillApplicationInstallationMutationVariables>;
export const SyncMarketplaceCatalogDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SyncMarketplaceCatalog"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"syncMarketplaceCatalog"}}]}}]} as unknown as DocumentNode<SyncMarketplaceCatalogMutation, SyncMarketplaceCatalogMutationVariables>;
export const UpdateAdminApplicationRegistrationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateAdminApplicationRegistration"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateApplicationRegistrationInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateAdminApplicationRegistration"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ApplicationRegistrationFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ApplicationRegistrationFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ApplicationRegistration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"universalIdentifier"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"logoUrl"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthClientId"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthRedirectUris"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthScopes"}},{"kind":"Field","name":{"kind":"Name","value":"sourceType"}},{"kind":"Field","name":{"kind":"Name","value":"sourcePackage"}},{"kind":"Field","name":{"kind":"Name","value":"latestAvailableVersion"}},{"kind":"Field","name":{"kind":"Name","value":"isListed"}},{"kind":"Field","name":{"kind":"Name","value":"isFeatured"}},{"kind":"Field","name":{"kind":"Name","value":"isPreInstalled"}},{"kind":"Field","name":{"kind":"Name","value":"isConfigured"}},{"kind":"Field","name":{"kind":"Name","value":"ownerWorkspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode<UpdateAdminApplicationRegistrationMutation, UpdateAdminApplicationRegistrationMutationVariables>;
export const UpdateAdminApplicationRegistrationVariableDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateAdminApplicationRegistrationVariable"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateApplicationRegistrationVariableInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateAdminApplicationRegistrationVariable"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"isSecret"}},{"kind":"Field","name":{"kind":"Name","value":"isRequired"}},{"kind":"Field","name":{"kind":"Name","value":"isFilled"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode<UpdateAdminApplicationRegistrationVariableMutation, UpdateAdminApplicationRegistrationVariableMutationVariables>;
export const FindAdminApplicationRegistrationVariablesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FindAdminApplicationRegistrationVariables"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"applicationRegistrationId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"findAdminApplicationRegistrationVariables"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"applicationRegistrationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"applicationRegistrationId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"value"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"isSecret"}},{"kind":"Field","name":{"kind":"Name","value":"isRequired"}},{"kind":"Field","name":{"kind":"Name","value":"isFilled"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode<FindAdminApplicationRegistrationVariablesQuery, FindAdminApplicationRegistrationVariablesQueryVariables>;
@@ -1,5 +1,6 @@
import { AppChip } from '@/applications/components/AppChip';
import { useApolloAdminClient } from '@/settings/admin-panel/apollo/hooks/useApolloAdminClient';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
@@ -8,15 +9,15 @@ import { TableBody } from '@/ui/layout/table/components/TableBody';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { useQuery } from '@apollo/client/react';
import { useMutation, useQuery } from '@apollo/client/react';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { type ReactNode, useContext, useState } from 'react';
import { assertUnreachable, getSettingsPath } from 'twenty-shared/utils';
import { SettingsPath } from 'twenty-shared/types';
import { IconChevronRight, IconPinned } from 'twenty-ui/icon';
import { IconChevronRight, IconPinned, IconRefresh } from 'twenty-ui/icon';
import { H2Title } from 'twenty-ui/typography';
import { SearchInput } from 'twenty-ui/input';
import { Button, SearchInput } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { MenuItemToggle } from 'twenty-ui/navigation';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
@@ -25,6 +26,7 @@ import {
type ApplicationRegistrationFragmentFragment,
ApplicationRegistrationSourceType,
FindAllApplicationRegistrationsDocument,
SyncMarketplaceCatalogDocument,
} from '~/generated-admin/graphql';
const StyledTableContainer = styled.div`
@@ -37,6 +39,7 @@ const TABLE_GRID_MOBILE = '3fr 3fr 1fr 1fr 40px';
export const SettingsAdminApps = () => {
const apolloAdminClient = useApolloAdminClient();
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const [searchQuery, setSearchQuery] = useState('');
const [showPreInstalledOnly, setShowPreInstalledOnly] = useState(false);
@@ -44,6 +47,24 @@ export const SettingsAdminApps = () => {
client: apolloAdminClient,
});
const [syncMarketplaceCatalog, { loading: isSyncing }] = useMutation(
SyncMarketplaceCatalogDocument,
{ client: apolloAdminClient },
);
const handleSyncCatalog = async () => {
try {
await syncMarketplaceCatalog();
enqueueSuccessSnackBar({
message: t`Marketplace catalog synchronization started.`,
});
} catch {
enqueueErrorSnackBar({
message: t`Failed to synchronize the marketplace catalog.`,
});
}
};
const registrations = data?.findAllApplicationRegistrations ?? [];
const query = searchQuery.trim().toLowerCase();
@@ -88,63 +109,80 @@ export const SettingsAdminApps = () => {
};
return (
<Section>
<H2Title
title={t`All App Registrations`}
description={t`All application registrations across the platform, including orphaned marketplace apps`}
/>
<SearchInput
placeholder={t`Search registrations...`}
value={searchQuery}
onChange={setSearchQuery}
filterDropdown={(filterButton: ReactNode) => (
<Dropdown
dropdownId="settings-admin-apps-filter-dropdown"
dropdownPlacement="bottom-end"
dropdownOffset={{ x: 0, y: 8 }}
clickableComponent={filterButton}
dropdownComponents={
<DropdownContent>
<DropdownMenuItemsContainer>
<MenuItemToggle
LeftIcon={IconPinned}
onToggleChange={() =>
setShowPreInstalledOnly(!showPreInstalledOnly)
}
toggled={showPreInstalledOnly}
text={t`Pre-installed only`}
toggleSize="small"
/>
</DropdownMenuItemsContainer>
</DropdownContent>
}
/>
)}
/>
<StyledTableContainer>
<Table>
<TableRow
gridAutoColumns={TABLE_GRID}
mobileGridAutoColumns={TABLE_GRID_MOBILE}
>
<TableHeader>{t`Name`}</TableHeader>
<TableHeader align="right">{t`Source`}</TableHeader>
<TableHeader align="right">{t`Listed`}</TableHeader>
<TableHeader align="right">{t`Configured`}</TableHeader>
<TableHeader></TableHeader>
</TableRow>
<TableBody>
{filtered.map((registration) => (
<SettingsAdminAppsTableRow
key={registration.id}
registration={registration}
getFormattedSource={getFormattedSource}
/>
))}
</TableBody>
</Table>
</StyledTableContainer>
</Section>
<>
<Section>
<H2Title
title={t`General`}
description={t`Manage the marketplace application catalog`}
/>
<Button
Icon={IconRefresh}
title={t`Synchronize catalog`}
size="small"
variant="secondary"
onClick={handleSyncCatalog}
isLoading={isSyncing}
disabled={isSyncing}
/>
</Section>
<Section>
<H2Title
title={t`All App Registrations`}
description={t`All application registrations across the platform, including orphaned marketplace apps`}
/>
<SearchInput
placeholder={t`Search registrations...`}
value={searchQuery}
onChange={setSearchQuery}
filterDropdown={(filterButton: ReactNode) => (
<Dropdown
dropdownId="settings-admin-apps-filter-dropdown"
dropdownPlacement="bottom-end"
dropdownOffset={{ x: 0, y: 8 }}
clickableComponent={filterButton}
dropdownComponents={
<DropdownContent>
<DropdownMenuItemsContainer>
<MenuItemToggle
LeftIcon={IconPinned}
onToggleChange={() =>
setShowPreInstalledOnly(!showPreInstalledOnly)
}
toggled={showPreInstalledOnly}
text={t`Pre-installed only`}
toggleSize="small"
/>
</DropdownMenuItemsContainer>
</DropdownContent>
}
/>
)}
/>
<StyledTableContainer>
<Table>
<TableRow
gridAutoColumns={TABLE_GRID}
mobileGridAutoColumns={TABLE_GRID_MOBILE}
>
<TableHeader>{t`Name`}</TableHeader>
<TableHeader align="right">{t`Source`}</TableHeader>
<TableHeader align="right">{t`Listed`}</TableHeader>
<TableHeader align="right">{t`Configured`}</TableHeader>
<TableHeader></TableHeader>
</TableRow>
<TableBody>
{filtered.map((registration) => (
<SettingsAdminAppsTableRow
key={registration.id}
registration={registration}
getFormattedSource={getFormattedSource}
/>
))}
</TableBody>
</Table>
</StyledTableContainer>
</Section>
</>
);
};
@@ -0,0 +1,7 @@
import { gql } from '@apollo/client';
export const SYNC_MARKETPLACE_CATALOG = gql`
mutation SyncMarketplaceCatalog {
syncMarketplaceCatalog
}
`;
@@ -3,7 +3,6 @@ import {
FieldActorSource,
type FullNameMetadata,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
type BuildCreatedByFromFullNameMetadataArgs = {
workspaceMemberId: string;
@@ -65,6 +65,7 @@ import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/service
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { MarketplaceCatalogSyncCronJob } from 'src/engine/core-modules/application/application-marketplace/crons/marketplace-catalog-sync.cron.job';
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';
@@ -139,8 +140,10 @@ export class AdminPanelResolver {
private readonly upgradeStatusService: UpgradeStatusService,
@InjectRepository(WorkspaceEntity)
private readonly workspaceRepository: Repository<WorkspaceEntity>,
@InjectMessageQueue(MessageQueue.cronQueue)
private readonly cronQueueService: MessageQueueService,
@InjectMessageQueue(MessageQueue.workspaceQueue)
private readonly messageQueueService: MessageQueueService,
private readonly workspaceQueueService: MessageQueueService,
) {}
@UseGuards(AdminPanelOrImpersonateGuard)
@@ -479,6 +482,18 @@ export class AdminPanelResolver {
return this.applicationRegistrationService.findAll();
}
@UseGuards(AdminPanelGuard)
@Mutation(() => Boolean)
async syncMarketplaceCatalog(): Promise<boolean> {
await this.cronQueueService.add(
MarketplaceCatalogSyncCronJob.name,
{},
{ id: 'marketplace-catalog-sync' }, // Avoids triggering multiple pending jobs
);
return true;
}
@UseGuards(AdminPanelGuard)
@Mutation(() => ApplicationRegistrationEntity)
async updateAdminApplicationRegistration(
@@ -503,7 +518,7 @@ export class AdminPanelResolver {
);
}
await this.messageQueueService.add<BackfillApplicationInstallationJobData>(
await this.workspaceQueueService.add<BackfillApplicationInstallationJobData>(
BACKFILL_APPLICATION_INSTALLATION_JOB_NAME,
{ applicationRegistrationId },
{