UNPKG

@stackbit/cms-contentful

Version:

Stackbit Contentful CMS Interface

1,219 lines (1,116 loc) 63.7 kB
import * as path from 'path'; import _ from 'lodash'; import { PlainClientAPI, EntryProps, CreateEntryProps, AssetProps, Link, VersionedLink, LocaleProps, UserProps, AppInstallationProps, ContentTypeProps, EditorInterfaceProps, BulkActionProps, BulkActionPayload, WebhookProps, SnapshotProps } from 'contentful-management'; import type { DocumentVersion, DocumentVersionWithDocument, FieldList, FieldSpecificProps, Model, Schema } from '@stackbit/types'; import type * as ContentSourceTypes from '@stackbit/types'; import * as stackbitUtils from '@stackbit/types'; import { TaskQueue } from '@stackbit/utils'; import { createPlainApiClient, fetchAllContentTypes, fetchAllEditorInterfaces, fetchAllEntries, fetchEntriesUpdatedAfter, fetchAllAssets, fetchAssetsUpdatedAfter, fetchAllLocales, fetchAllUsers, fetchAllAppInstallations, createEntry, fetchEntryById, fetchAssetById, updateEntry, unpublishEntry, deleteEntry, createAsset, createAssetFromFile, processAssetForAllLocales, publishAsset, createValidateBulkAction, createPublishBulkAction, getBulkAction, fetchEntriesByIds, fetchAssetsByIds, fetchAllWebhooks, createWebhook, fetchAllReleases, fetchAllSchedules, createScheduledAction, updateScheduledAction, cancelScheduledAction, getScheduledAction, fetchScheduleById, fetchEntityVersions, fetchEntityVersion, createUnpublishBulkAction, archiveEntry, unarchiveEntry, updateAsset } from './contentful-api-client'; import { convertSchema } from './contentful-schema-converter'; import { DocumentContext, AssetContext, ContextualDocument, ContextualAsset, convertAssets, convertEntities, EntityProps, convertDocumentVersions } from './contentful-entries-converter'; import { convertAndFilterScheduledActions } from './contentful-scheduled-actions-converter'; import { ContentPoller, ContentPollerSyncResult, ContentPollerSyncContext } from './content-poller'; import { Readable } from 'stream'; import { CONTENTFUL_BUILT_IN_IMAGE_SOURCES, CONTENTFUL_BYNDER_APP, CONTENTFUL_CLOUDINARY_APP } from './contentful-consts'; import { getLastUpdatedEntityDate } from './utils'; export interface ContentSourceOptions { /** Contentful Space ID */ spaceId: string; /** Contentful Space Environment. Default: 'master' */ environment?: string; /** Contentful Preview Token */ previewToken: string; /** Contentful Personal Access Token */ accessToken: string; /** * Use webhook for content updates. */ useWebhookForContentUpdates?: boolean; /** * Use accessToken instead of userContext.accessToken for write operations. */ useAccessTokenForUpdates?: boolean; /** * Switches the ContentfulContentSource to work with EU data regions. * When set to true, the following updated accordingly: * previewHost: 'preview.eu.contentful.com' * manageHost: 'api.eu.contentful.com' * uploadHost: 'upload.eu.contentful.com' */ useEURegion?: boolean; /** * The host of the Contentful's preview API. * By default, this value is set to 'preview.contentful.com'. * If `useEURegion` is set, uses 'preview.eu.contentful.com'. */ previewHost?: string; /** * The host of the Contentful's management API. * By default, this value is `undefined` which makes the contentful-management * use the 'api.contentful.com'. * If `useEURegion` is set, this value is set to 'api.eu.contentful.com'. */ managementHost?: string; /** * The host used to upload assets to Contentful. * By default, this value is `undefined` which makes the contentful-management * use the 'upload.contentful.com'. * If `useEURegion` is set, this value is set to 'upload.eu.contentful.com'. */ uploadHost?: string; } type UserContext = { // The user's accessToken is provided by the "Netlify Create" Contentful // OAuth Application when users connect their Netlify Create and Contentful // accounts via OAuth flow. accessToken: string; }; export type SchemaContext = { lastUpdatedContentTypeDate?: string; }; type EntityError = { name: string; path: Array<string | number>; details: string; customMessage?: string; }; export class ContentfulContentSource implements ContentSourceTypes.ContentSourceInterface<UserContext, SchemaContext, DocumentContext, AssetContext> { private readonly spaceId: string; private readonly environment: string; private readonly accessToken: string; private readonly previewToken: string; private logger!: ContentSourceTypes.Logger; private userLogger!: ContentSourceTypes.Logger; private localDev!: boolean; private contentPoller: ContentPoller | null = null; private useWebhookForContentUpdates: boolean; private useAccessTokenForUpdates: boolean; private useEURegion: boolean; private previewHost?: string; private managementHost?: string; private uploadHost?: string; private locales: LocaleProps[] = []; private defaultLocale?: LocaleProps; private userMap: Record<string, UserProps> = {}; private cloudinaryImagesAsList!: boolean; private bynderImagesAsList!: boolean; private plainClient!: PlainClientAPI; private webhookUrl?: string; private devAppRestartNeeded?: () => void; private cache!: ContentSourceTypes.Cache<SchemaContext, DocumentContext, AssetContext>; private taskQueue: TaskQueue; constructor(options: ContentSourceOptions) { this.spaceId = options.spaceId ?? process.env.CONTENTFUL_SPACE_ID; this.environment = options.environment ?? process.env.CONTENTFUL_ENVIRONMENT ?? 'master'; this.accessToken = options.accessToken ?? process.env.CONTENTFUL_ACCESS_TOKEN; this.previewToken = options.previewToken ?? process.env.CONTENTFUL_PREVIEW_TOKEN; this.useWebhookForContentUpdates = options.useWebhookForContentUpdates ?? false; this.useAccessTokenForUpdates = options.useAccessTokenForUpdates ?? false; this.useEURegion = options.useEURegion ?? false; this.previewHost = options.previewHost ?? (this.useEURegion ? 'preview.eu.contentful.com' : undefined); this.managementHost = options.managementHost ?? (this.useEURegion ? 'api.eu.contentful.com' : undefined); this.uploadHost = options.uploadHost ?? (this.useEURegion ? 'upload.eu.contentful.com' : undefined); // Max active bulk actions per space is limited to 5. // Max of 200 items per bulk action. this.taskQueue = new TaskQueue({ limit: 5 }); } async getVersion(): Promise<ContentSourceTypes.Version> { return stackbitUtils.getVersion({ packageJsonPath: path.join(__dirname, '../package.json') }); } getContentSourceType(): string { return this.useEURegion ? 'contentfuleu' : 'contentful'; } getProjectId(): string { return this.spaceId; } getProjectEnvironment(): string { return this.environment; } getProjectManageUrl(): string { return `${this.getProjectUrl()}/home`; } private getProjectUrl(): string { return `https://app.${this.useEURegion ? 'eu.' : ''}contentful.com/spaces/${this.spaceId}`; } async init({ logger, userLogger, localDev, webhookUrl, devAppRestartNeeded, cache }: ContentSourceTypes.InitOptions<SchemaContext, DocumentContext, AssetContext>): Promise<void> { this.logger = logger.createLogger({ label: 'cms-contentful' }); this.userLogger = userLogger.createLogger({ label: 'cms-contentful' }); this.localDev = localDev; this.webhookUrl = webhookUrl; this.devAppRestartNeeded = devAppRestartNeeded; this.cache = cache; // If running locally, use ContentPoller instead of webhook unless explicitly debugging webhooks if (localDev && !webhookUrl) { this.useWebhookForContentUpdates = false; } if (this.useWebhookForContentUpdates) { this.userLogger.info('Using webhook for content updates'); } if (this.useEURegion) { this.userLogger.info('Using EU data region'); } this.plainClient = createPlainApiClient({ spaceId: this.spaceId, accessToken: this.accessToken, environment: this.environment, managementHost: this.managementHost, uploadHost: this.uploadHost }); await this.validateConfig(); await this.createWebhookIfNeeded(); await this.reset(); } async reset() { const locales: LocaleProps[] = await fetchAllLocales(this.plainClient); const appInstallations: AppInstallationProps[] = await fetchAllAppInstallations(this.plainClient); await this.fetchUsers(); // replace all data at once in atomic action this.locales = locales.filter((locale) => { // filter out disabled locales return locale.contentManagementApi; }); this.defaultLocale = this.locales.find((locale) => locale.default)!; // Fetch up installations, and find the cloudinary app installation. // If cloudinary app is installed, use its 'maxFiles' parameter to decide // if cloudinary images should be presented as a single 'image' field or // as an array of 'images'. const cloudinaryAppInstallation = appInstallations.find((appInstallation) => appInstallation.sys.appDefinition.sys.id === CONTENTFUL_CLOUDINARY_APP); const maxFiles = _.get(cloudinaryAppInstallation, 'parameters.maxFiles', 1); this.cloudinaryImagesAsList = maxFiles > 1; if (cloudinaryAppInstallation) { this.logger.debug( `found cloudinary app installation, maxFiles=${maxFiles}, treating cloudinary images as ${ this.cloudinaryImagesAsList ? 'list of images' : 'a single image field' }` ); } // same story with bynder as with cloudinary images - either array or one const bynderAppInstallation = appInstallations.find((appInstallation) => appInstallation.sys.appDefinition.sys.id === CONTENTFUL_BYNDER_APP); const viewMode = _.get(bynderAppInstallation, 'parameters.compactViewMode', 'MultiSelect'); this.bynderImagesAsList = viewMode === 'MultiSelect'; if (bynderAppInstallation) { this.logger.debug( `found bynder app installation, viewMode=${viewMode}, treating bynder images as ${ this.bynderImagesAsList ? 'list of images' : 'a single image field' }` ); } } async destroy(): Promise<void> {} async validateConfig() { this.logger.debug('Validating config...'); let previewTokens; let environments; try { previewTokens = await this.plainClient.previewApiKey.getMany({}); environments = await this.plainClient.environment.getMany({}); } catch (err: any) { if (err?.message?.includes('The resource could not be found')) { throw new Error(`Can't find Contentful space '${this.spaceId}'. Verify that the space exists and accessible with the provided access token.`); } else { throw new Error(`Can't access Contentful. Verify that the access token you provided is valid.`); } } const foundPreviewToken = previewTokens?.items.find((item) => item.accessToken === this.previewToken); if (!foundPreviewToken) { throw new Error( `Can't find Contentful preview token. Verify that the preview token is valid and associated with the provided Contentful space-id.` ); } const foundEnvironment = environments?.items.find( (item) => item.name === this.environment || item.sys?.aliases?.map((alias) => alias?.sys?.id).includes(this.environment) ); if (!foundEnvironment) { throw new Error(`Can't find Contentful environment '${this.environment}'. Verify that the environment exists in the provided Contentful space-id.`); } } async createWebhookIfNeeded() { if (!this.webhookUrl) { return; } try { this.logger.debug('Creating webhook...'); const webhooks: WebhookProps[] = await fetchAllWebhooks(this.plainClient); if (!webhooks.find((webhook) => webhook.url === this.webhookUrl)) { await this.createWebhook(this.webhookUrl); } } catch (err) { this.userLogger.error('Error fetching or creating a contentful webhook', { srcProjectId: this.getProjectId(), error: err }); if (this.useWebhookForContentUpdates) { this.userLogger.error('Falling back to using content poller', { srcProjectId: this.getProjectId() }); this.useWebhookForContentUpdates = false; } } } async createWebhook(webhookURL: string) { return createWebhook(this.plainClient, { url: webhookURL, name: 'stackbit-content-source-webhook', topics: ['*.*'], transformation: { contentType: 'application/json' } }); } startWatchingContentUpdates() { this.logger.debug('startWatchingContentUpdates'); if (this.useWebhookForContentUpdates) { return; } if (this.contentPoller) { this.stopWatchingContentUpdates(); } this.contentPoller = new ContentPoller({ spaceId: this.spaceId, environment: this.environment, previewToken: this.previewToken, managementToken: this.accessToken, previewHost: this.previewHost, managementHost: this.managementHost, uploadHost: this.uploadHost, pollType: 'date', syncContext: this.getSyncContextForContentPollerFromCache(), notificationCallback: async (syncResult) => { if (syncResult.contentTypes.length) { this.logger.debug('content type was changed, invalidate schema'); await this.cache.invalidateSchema(); } else { const result = await this.convertSyncResult(syncResult); await this.cache.updateContent(result); } }, logger: this.logger }); this.contentPoller.start(); } stopWatchingContentUpdates() { this.logger.debug('stopWatchingContentUpdates'); if (this.contentPoller) { this.contentPoller.stop(); this.contentPoller = null; } } private async fetchUsers() { const users: UserProps[] = await fetchAllUsers(this.plainClient); this.userMap = _.keyBy(users, 'sys.id'); } private async fetchUsersIfNeeded(entities: (EntryProps | AssetProps)[]) { const entityHasNoCachedAuthor = entities.some((entity) => !this.userMap[entity.sys.updatedBy?.sys.id ?? '']); if (entityHasNoCachedAuthor) { await this.fetchUsers(); } } private updateContentPollerSyncContext(syncContext: ContentPollerSyncContext) { if (this.contentPoller && this.contentPoller.pollType === 'date') { this.contentPoller.setSyncContext({ ...this.getSyncContextForContentPollerFromCache(), ...syncContext }); } } private getSyncContextForContentPollerFromCache() { const cacheSyncContext = this.cache.getSyncContext(); return { lastUpdatedEntryDate: cacheSyncContext.documentsSyncContext as string | undefined, lastUpdatedAssetDate: cacheSyncContext.assetsSyncContext as string | undefined, lastUpdatedContentTypeDate: this.cache.getSchema().context?.lastUpdatedContentTypeDate }; } private async convertSyncResult(syncResult: ContentPollerSyncResult) { // remove deleted entries and assets from fieldData // generally, the "sync" method of the preview API never notifies of deleted objects, therefore we rely on // the deleteObject method to notify the user that restart is needed. Then, once user restarts the SSG, it // will re-fetch the data effectively removing deleted objects // https://www.notion.so/stackbit/Contentful-Sync-API-preview-issue-6b4816ebceef4ab181cdf1603058d324 this.logger.debug('received sync data', { entries: syncResult.entries.length, assets: syncResult.assets.length, deletedEntries: syncResult.deletedEntries.length, deletedAssets: syncResult.deletedAssets.length }); await this.fetchUsersIfNeeded([...syncResult.entries, ...syncResult.assets]); // convert updated/created entries and assets const documents = this.convertEntries(syncResult.entries, this.cache.getModelByName); const assets = this.convertAssets(syncResult.assets); return { documents, assets, deletedDocumentIds: syncResult.deletedEntries.map((entry) => entry.sys.id), deletedAssetIds: syncResult.deletedAssets.map((asset) => asset.sys.id) }; } async getSchema(): Promise<Schema<SchemaContext>> { this.logger.debug('getSchema'); const contentTypes: ContentTypeProps[] = await fetchAllContentTypes(this.plainClient); const editorInterfaces: EditorInterfaceProps[] = await fetchAllEditorInterfaces(this.plainClient); const defaultLocaleCode = this.localeOrDefaultOrThrow(); const { models } = convertSchema({ contentTypes: contentTypes, editorInterfaces: editorInterfaces, defaultLocaleCode: defaultLocaleCode, cloudinaryImagesAsList: this.cloudinaryImagesAsList, bynderImagesAsList: this.bynderImagesAsList }); const prevLastUpdatedContentTypeDate = this.cache.getSchema().context?.lastUpdatedContentTypeDate; // Check if one of the content types was changed from the last time the // content types were fetched, in which case remove all cached content. const lastUpdatedContentTypeDate = getLastUpdatedEntityDate(contentTypes); if (prevLastUpdatedContentTypeDate !== lastUpdatedContentTypeDate) { this.logger.debug( `last updated content type date '${lastUpdatedContentTypeDate}' is different ` + `from the cached date '${prevLastUpdatedContentTypeDate}', clearing cache` ); await this.cache.clearSyncContext({ clearDocumentsSyncContext: true, clearAssetsSyncContext: false }); } this.updateContentPollerSyncContext({ lastUpdatedContentTypeDate }); return { models, locales: this.locales.map((locale) => ({ code: locale.code, default: locale.default })), context: { lastUpdatedContentTypeDate: lastUpdatedContentTypeDate } }; } async getDocuments(options?: { syncContext?: string }): Promise<ContextualDocument[] | { documents: ContextualDocument[]; syncContext?: string }> { this.logger.debug('getDocuments'); let lastUpdatedEntryDate = options?.syncContext; let entries: EntryProps[]; try { if (lastUpdatedEntryDate) { this.logger.debug(`fetching entries updated after ${lastUpdatedEntryDate}`); entries = await fetchEntriesUpdatedAfter(this.plainClient, lastUpdatedEntryDate, this.userLogger); this.logger.debug(`got ${entries.length} updated/created entries after ${lastUpdatedEntryDate}`); if (entries.length) { lastUpdatedEntryDate = getLastUpdatedEntityDate(entries); } } else { entries = await fetchAllEntries(this.plainClient, this.userLogger); lastUpdatedEntryDate = getLastUpdatedEntityDate(entries); } } catch (error: any) { // Stackbit won't be able to work properly even if one of the entries was not fetched. // All fetch methods use Contentful's API client that handles errors and retries automatically. this.logger.error(`Failed fetching documents from Contentful, error: ${error.message}`); // By returning the original syncContext we are ensuring that next // time the getDocuments is called, it will try to get the documents // using the same syncContext return { documents: [], syncContext: lastUpdatedEntryDate }; } this.updateContentPollerSyncContext({ lastUpdatedEntryDate }); this.logger.debug( `got ${entries.length} entries from space ${this.spaceId}, environment ${this.environment}, lastUpdatedEntryDate: ${lastUpdatedEntryDate}` ); await this.fetchUsersIfNeeded(entries); const documents = this.convertEntries(entries, this.cache.getModelByName); return { documents, syncContext: lastUpdatedEntryDate }; } async getAssets(options?: { syncContext?: string }): Promise<ContextualAsset[] | { assets: ContextualAsset[]; syncContext?: string }> { this.logger.debug('getAssets'); let lastUpdatedAssetDate = options?.syncContext; let ctflAssets: AssetProps[]; try { if (lastUpdatedAssetDate) { this.logger.debug(`fetching assets updated after ${lastUpdatedAssetDate}`); ctflAssets = await fetchAssetsUpdatedAfter(this.plainClient, lastUpdatedAssetDate, this.userLogger); this.logger.debug(`got ${ctflAssets.length} updated/created assets after ${lastUpdatedAssetDate}`); if (ctflAssets.length) { lastUpdatedAssetDate = getLastUpdatedEntityDate(ctflAssets); } } else { ctflAssets = await fetchAllAssets(this.plainClient, this.userLogger); lastUpdatedAssetDate = getLastUpdatedEntityDate(ctflAssets); } } catch (error: any) { // Stackbit won't be able to work properly even if one of the entries or the assets was not fetched. // All fetch methods use Contentful's API client that handles errors and retries automatically. this.logger.error(`Failed fetching assets from Contentful, error: ${error.message}`); return { assets: [], syncContext: lastUpdatedAssetDate }; } this.updateContentPollerSyncContext({ lastUpdatedAssetDate }); this.logger.debug( `got ${ctflAssets.length} assets from space ${this.spaceId}, environment ${this.environment}, lastUpdatedAssetDate: ${lastUpdatedAssetDate}` ); await this.fetchUsersIfNeeded(ctflAssets); const assets = this.convertAssets(ctflAssets); return { assets, syncContext: lastUpdatedAssetDate }; } async hasAccess({ userContext }: { userContext?: UserContext }): Promise<{ hasConnection: boolean; hasPermissions: boolean }> { if (!this.localDev && !this.useAccessTokenForUpdates && !userContext?.accessToken) { return { hasConnection: false, hasPermissions: false }; } try { const apiClient = this.getPlainApiClientForUser({ userContext }); await apiClient.entry.getMany({ query: { limit: 1 } }); return { hasConnection: true, hasPermissions: true }; } catch (error) { this.logger.debug('Contentful: failed to access space', { error }); return { hasConnection: true, hasPermissions: false }; } } async createDocument({ updateOperationFields, model, locale, userContext }: { updateOperationFields: Record<string, ContentSourceTypes.UpdateOperationField>; model: Model; locale?: string; userContext?: UserContext; }): Promise<{ documentId: string }> { this.logger.debug('createDocument'); const entry: CreateEntryProps = { fields: {} }; _.forEach(updateOperationFields, (operationField, fieldName) => { const modelField = model.fields?.find((field) => field.name === fieldName); if (!modelField) { return; } const value = mapOperationFieldToContentfulValue(operationField, modelField, false); const localeOrDefault = this.localeOrDefaultOrThrow(locale); setEntryField(entry, value, [fieldName], modelField.localized ? localeOrDefault : this.defaultLocale!.code); }); const apiClient = this.getPlainApiClientForUser({ userContext }); const entryResult = await createEntry(apiClient, model.name, entry); return { documentId: entryResult.sys.id }; } async updateDocument({ document, operations, userContext }: { document: ContextualDocument; operations: ContentSourceTypes.UpdateOperation[]; userContext?: UserContext; }): Promise<void> { this.logger.debug('updateDocument'); const documentId = document.id; const entry = await fetchEntryById(this.plainClient, documentId); const modelName = entry.sys.contentType.sys.id; const model = this.cache.getModelByName(modelName); if (!model) { throw new Error(`Error updating document: could not find document model '${modelName}'.`); } const defaultLocale = this.defaultLocale?.code ?? 'en-US'; for (const operation of operations) { const locale = 'localized' in operation.modelField && operation.modelField.localized ? operation.locale ?? defaultLocale : defaultLocale; const operationWithLocale = { ...operation, locale }; const opFunc = Operations[operationWithLocale.opType] as OperationFunction<typeof operationWithLocale.opType>; await opFunc({ entry, operation: operationWithLocale }); } const apiClient = this.getPlainApiClientForUser({ userContext }); await updateEntry(apiClient, documentId, entry); } async deleteDocument({ document, userContext }: { document: ContextualDocument; userContext?: UserContext }): Promise<void> { this.logger.debug('deleteDocument'); const documentId = document.id; try { const entry = await fetchEntryById(this.plainClient, documentId); const apiClient = this.getPlainApiClientForUser({ userContext }); // Contentful won't let us delete a published entry, so if the entry is // published we must unpublish it first. if (_.get(entry, 'sys.publishedVersion')) { await unpublishEntry(apiClient, documentId); } await deleteEntry(apiClient, documentId); } catch (err) { this.logger.error('Contentful: Failed to delete object', { documentId: documentId, error: err }); throw err; } } async uploadAsset({ url, base64, fileName, mimeType, locale, userContext }: { url?: string; base64?: string; fileName: string; mimeType: string; locale?: string; userContext?: UserContext; }): Promise<ContextualAsset> { this.logger.debug('uploadAsset'); locale = this.localeOrDefaultOrThrow(locale); const apiClient = this.getPlainApiClientForUser({ userContext }); let asset: AssetProps; if (url) { asset = await createAsset(apiClient, { fields: { title: { [locale]: fileName }, file: { [locale]: { fileName: fileName, contentType: mimeType, upload: url } } } }); } else { const imgBuffer = Buffer.from(base64!, 'base64'); const readable = new Readable(); readable.push(imgBuffer); readable.push(null); asset = await createAssetFromFile(apiClient, { fields: { title: { [locale]: fileName }, // the description is marked as required in Contentful's Typescript description: { [locale]: '' }, file: { [locale]: { fileName: fileName, contentType: mimeType, file: readable } } } }); } const processedAsset = await processAssetForAllLocales(apiClient, asset); const publishedAsset = await publishAsset(apiClient, processedAsset); const assetDocument = this.convertAssets([publishedAsset]); return assetDocument[0]!; } async updateAsset({ asset, operations, userContext }: { asset: ContextualAsset; operations: ContentSourceTypes.UpdateOperation[]; userContext?: UserContext; }): Promise<void> { this.logger.debug('updateAsset'); const assetId = asset.id; const assetEntry = await fetchAssetById(this.plainClient, assetId); const defaultLocale = this.defaultLocale?.code ?? 'en-US'; for (const operation of operations) { const locale = 'localized' in operation.modelField && operation.modelField.localized ? operation.locale ?? defaultLocale : defaultLocale; const operationWithLocale = { ...operation, locale }; const opFunc = Operations[operationWithLocale.opType] as OperationFunction<typeof operationWithLocale.opType>; await opFunc({ entry: assetEntry, operation: operationWithLocale }); } const apiClient = this.getPlainApiClientForUser({ userContext }); await updateAsset(apiClient, assetId, assetEntry); } async validateDocuments({ documents, assets, locale, userContext }: { documents: ContextualDocument[]; assets: ContextualAsset[]; locale?: string; userContext?: UserContext; }): Promise<{ errors: ContentSourceTypes.ValidationError[] }> { const linkChunks: Link<'Entry' | 'Asset'>[][] = _.chunk( [ ...documents.map((document) => ({ sys: { type: 'Link' as const, id: document.id, linkType: 'Entry' as const } })), ...assets.map((asset) => ({ sys: { type: 'Link' as const, id: asset.id, linkType: 'Asset' as const } })) ], 200 ); const apiClient = this.getPlainApiClientForUser({ userContext }); locale = this.localeOrDefaultOrThrow(locale); const result = await Promise.all( linkChunks.map((link) => { return this.taskQueue.addTask(async (): Promise<ContentSourceTypes.ValidationError[]> => { const bulkActionOptions = { spaceId: this.spaceId, environmentId: this.environment }; const bulkActionCreateResult = await createValidateBulkAction(apiClient, bulkActionOptions, { entities: { sys: { type: 'Array' }, items: link } }); const bulkAction = await waitBulkActionToComplete(bulkActionCreateResult, apiClient, bulkActionOptions); if (bulkAction.sys.status === 'failed') { const errors = bulkAction.error?.details?.errors ?? []; return errors.reduce((result: ContentSourceTypes.ValidationError[], { error, entity }) => { const entityErrors: EntityError[] = error?.details?.errors ?? []; return entityErrors.reduce((result: ContentSourceTypes.ValidationError[], entityError) => { // error.path is: // ['fields', 'title'] => ['title'] // ['fields', 'title', 'en-US'] => ['title'] // ['fields', 'action', 'en-US', 1] => ['title'] const fieldPath = entityError.path.slice(1); const fieldLocale = fieldPath[1]; if (typeof fieldLocale === 'string' && ['en-US', locale].includes(fieldLocale)) { // remove locale from fieldPath fieldPath.splice(1, 1); } result.push({ message: entityError?.customMessage ?? entityError.details, objectType: entity.sys.linkType === 'Entry' ? 'document' : 'asset', objectId: entity.sys.id, fieldPath: fieldPath, isUniqueValidation: entityError.name === 'unique' }); return result; }, result); }, []); } return []; }); }) ); return { errors: _.flatten(result) }; } async publishDocuments({ documents, assets, userContext }: { documents: ContextualDocument[]; assets: ContextualAsset[]; userContext?: UserContext; }): Promise<void> { const apiClient = this.getPlainApiClientForUser({ userContext }); // to publish, we need the most recent entity version, fetch the entities // by id to get their latest version and also ensure they were not publish // by someone else. const entries = await fetchEntriesByIds( apiClient, documents.map((document) => document.id), { 'sys.archivedVersion[exists]': false, select: 'sys.id,sys.type,sys.version,sys.publishedVersion,sys.archivedVersion' } ); const contentfulAssets = await fetchAssetsByIds( apiClient, assets.map((asset) => asset.id), { 'sys.archivedVersion[exists]': false, select: 'sys.id,sys.type,sys.version,sys.publishedVersion,sys.archivedVersion' } ); const entities = [...entries, ...contentfulAssets]; const entitiesToPublish = entities.filter((entity) => { const isDraft = !entity.sys.publishedVersion && !entity.sys.archivedVersion; const isChanged = entity.sys.publishedVersion && entity.sys.version >= entity.sys.publishedVersion + 2; return isDraft || isChanged; }); const versionedLinkChunks: VersionedLink<'Entry' | 'Asset'>[][] = _.chunk( entitiesToPublish.map((entity) => ({ sys: { type: 'Link', id: entity.sys.id, linkType: entity.sys.type as 'Entry' | 'Asset', version: entity.sys.version } })), 200 ); await Promise.all( versionedLinkChunks.map((versionedLinks) => { return this.taskQueue.addTask(async (): Promise<void> => { const bulkActionOptions = { spaceId: this.spaceId, environmentId: this.environment }; const bulkActionCreateResult = await createPublishBulkAction(apiClient, bulkActionOptions, { entities: { sys: { type: 'Array' }, items: versionedLinks } }); await waitBulkActionToComplete(bulkActionCreateResult, apiClient, bulkActionOptions); }); }) ); } async unpublishDocuments({ documents, assets, userContext }: { documents: ContextualDocument[]; assets: ContextualAsset[]; userContext?: UserContext; }): Promise<void> { const apiClient = this.getPlainApiClientForUser({ userContext }); const entities = [...documents, ...assets]; const linkChunks: Link<'Entry' | 'Asset'>[][] = _.chunk( entities.map((entity) => ({ sys: { type: 'Link', id: entity.id, linkType: entity.type === 'document' ? 'Entry' : 'Asset' } })), 200 ); await Promise.all( linkChunks.map((links) => { return this.taskQueue.addTask(async (): Promise<void> => { const bulkActionOptions = { spaceId: this.spaceId, environmentId: this.environment }; const bulkActionCreateResult = await createUnpublishBulkAction(apiClient, bulkActionOptions, { entities: { sys: { type: 'Array' }, items: links } }); await waitBulkActionToComplete(bulkActionCreateResult, apiClient, bulkActionOptions); }); }) ); } async archiveDocument({ document, userContext }: { document: ContextualDocument; userContext?: UserContext }): Promise<void> { this.logger.debug('archiveDocument'); const documentId = document.id; try { const entry = await fetchEntryById(this.plainClient, documentId); const apiClient = this.getPlainApiClientForUser({ userContext }); // Contentful won't let us archive a published entry, so if the entry is // published we must unpublish it first. if (_.get(entry, 'sys.publishedVersion')) { await unpublishEntry(apiClient, documentId); } await archiveEntry(apiClient, documentId); } catch (err) { this.logger.error('Contentful: Failed to archive object', { documentId: documentId, error: err }); throw err; } } async unarchiveDocument({ document, userContext }: { document: ContextualDocument; userContext?: UserContext }): Promise<void> { this.logger.debug('unarchiveDocument'); const documentId = document.id; try { const apiClient = this.getPlainApiClientForUser({ userContext }); await unarchiveEntry(apiClient, documentId); } catch (err) { this.logger.error('Contentful: Failed to archive object', { documentId: documentId, error: err }); throw err; } } async onWebhook({ data, headers }: { data: unknown; headers: Record<string, string> }) { const topic = headers['x-contentful-topic'] || ''; const didContentTypeChange = topic.startsWith('ContentManagement.ContentType.'); const didDocumentDelete = topic === 'ContentManagement.Entry.delete'; const didAssetDelete = topic === 'ContentManagement.Asset.delete'; const didScheduledActionDelete = topic === 'ContentManagement.ScheduledAction.delete' || topic === 'ContentManagement.Release.delete'; const environment = _.get(data, 'sys.environment.sys.id'); // skip updates on other environments if (environment !== this.environment) { return; } // skip updates on BulkAction event type if (topic.includes('BulkAction')) { return; } // if contentPoller.pollType === 'date', then the invalidateSchema will // be called by contentPoller when it identifies change in content types if (didContentTypeChange && this.contentPoller?.pollType !== 'date') { this.cache.invalidateSchema(); } if (didDocumentDelete) { const documentId = _.get(data, 'sys.id'); if (documentId) { this.cache.updateContent({ documents: [], assets: [], deletedDocumentIds: [documentId], deletedAssetIds: [] }); } } if (didAssetDelete) { const assetId = _.get(data, 'sys.id'); if (assetId) { this.cache.updateContent({ documents: [], assets: [], deletedDocumentIds: [], deletedAssetIds: [assetId] }); } } if (didScheduledActionDelete) { let scheduledActionId; if (_.get(data, 'sys.type') === 'ScheduledAction') { // refetch schedule because we're not getting the entity.sys.id in the webhook data const schedule = await fetchScheduleById(this.plainClient, { environment: this.environment, scheduleId: _.get(data, 'sys.id') }); scheduledActionId = schedule.entity.sys.id; } else { scheduledActionId = _.get(data, 'sys.id'); } if (scheduledActionId) { this.cache.updateContent({ deletedScheduledActionIds: [scheduledActionId] }); } } // ScheduledActions are handled outside of this.useWebhookForContentUpdates block below because Releases and Schedules are not handled by sync. if ((topic.startsWith('ContentManagement.ScheduledAction') || topic.startsWith('ContentManagement.Release')) && !didScheduledActionDelete) { const scheduledActionId = _.get(data, 'sys.type') === 'ScheduledAction' && _.get(data, 'entity.sys.linkType') === 'Release' ? _.get(data, 'entity.sys.id') : _.get(data, 'sys.type') === 'Release' ? _.get(data, 'sys.id') : null; this.logger.debug(`onWebhook ScheduledAction/Release - ${scheduledActionId}`); if (scheduledActionId) { const scheduledAction = await getScheduledAction(this.plainClient, scheduledActionId, { environment: this.environment }); if (scheduledAction) { // If a schedule is updated from the studio, both the ScheduledAction and the Release will post a webhook update, // So this diff change makes sure we send only one update. const cachedScheduledAction = this.cache.getScheduledActions().find((schedule) => schedule.id === scheduledActionId); const updatedActions = _.differenceWith([scheduledAction], [cachedScheduledAction], _.isEqual); if (updatedActions.length) { this.cache.updateContent({ scheduledActions: updatedActions }); } } } } // if sync poller is used, content updates are handled in the poller and we don't need to do anything here if (this.useWebhookForContentUpdates) { const userId = _.get(data, 'sys.updatedBy.sys.id'); if (userId && !this.userMap[userId]) { await this.fetchUsers(); } if (topic.startsWith('ContentManagement.Entry') && !didDocumentDelete) { const documentId = _.get(data, 'sys.id'); this.logger.debug(`onWebhook ${topic} ${documentId}`); if (documentId) { const entry = await fetchEntryById(this.plainClient, documentId); this.cache.updateContent({ documents: this.convertEntries([entry], this.cache.getModelByName), assets: [], deletedDocumentIds: [], deletedAssetIds: [] }); } } else if (topic.startsWith('ContentManagement.Asset') && !didAssetDelete) { const assetId = _.get(data, 'sys.id'); this.logger.debug(`onWebhook ${topic} ${assetId}`); if (assetId) { const asset = await fetchAssetById(this.plainClient, assetId); this.cache.updateContent({ documents: [], assets: this.convertAssets([asset]), deletedDocumentIds: [], deletedAssetIds: [] }); } } } } private getPlainApiClientForUser({ userContext }: { userContext?: UserContext }): PlainClientAPI { if (this.localDev || this.useAccessTokenForUpdates) { return this.plainClient; } const userAccessToken = userContext?.accessToken; if (!userAccessToken) { throw new Error(`User does not have an access token for space '${this.spaceId}'.`); } return createPlainApiClient({ spaceId: this.spaceId, accessToken: userAccessToken, environment: this.environment, managementHost: this.managementHost, uploadHost: this.uploadHost }); } private convertEntries(entries: EntityProps[], getModelByName: ContentSourceTypes.Cache['getModelByName']) { return convertEntities({ entries: entries, getModelByName: getModelByName, userMap: this.userMap, defaultLocale: this.localeOrDefaultOrThrow(), projectUrl: this.getProjectUrl() }); } private convertAssets(assets: AssetProps[]) { return convertAssets({ assets: assets, userMap: this.userMap, defaultLocale: this.localeOrDefaultOrThrow(), projectUrl: this.getProjectUrl() }); } private convertVersions(entries: SnapshotProps<EntryProps>[]) { return convertDocumentVersions({ entries, getModelByName: this.cache.getModelByName, userMap: this.userMap, defaultLocale: this.localeOrDefaultOrThrow(), projectUrl: this.getProjectUrl() }); } private localeOrDefaultOrThrow(locale?: string): string { const result = locale ?? this.defaultLocale?.code; if (!result) { throw new Error('Localization error: default locale is not set.'); } return result; } async cancelScheduledAction({ scheduledActionId, userContext }: { scheduledActionId: string; userContext?: UserContext; }): Promise<{ cancelledScheduledActionId: string }> { try { this.logger.debug('cancelScheduledAction', { scheduledActionId }); const apiClient = this.getPlainApiClientForUser({ userContext }); const schedule = await cancelScheduledAction(apiClient, scheduledActionId, { environment: this.environment }); this.logger.debug('cancelScheduledAction - success', { schedule }); return { cancelledScheduledActionId: schedule.id }; } catch (err) { this.logger.debug('Failed to update scheduled actions'); throw err; } } async createScheduledAction({ documentIds, name, action, executeAt, userContext }: { documentIds: string[]; name: string; action: ContentSourceTypes.ScheduledActionActionType; executeAt: string; userContext?: UserContext; }): Promise<{ newScheduledActionId: string }> { this.logger.debug('createScheduledAction', { action, executeAt }); const apiClient = this.getPlainApiClientForUser({ userContext }); const { schedule, contentfulSchedule, contentfulRelease } = await createScheduledAction(apiClient, { name, documentIds, executeAt, action, environment: this.environment }); this.logger.debug('createScheduledAction - Create success', { schedule, contentfulSchedule, contentfulRelease }); return { newScheduledActionId: schedule.id }; } async getScheduledActions(): Promise<ContentSourceTypes.ScheduledAction[]> { this.logger.debug('getScheduledActions'); c