diff --git a/src/components/CampaignContentSection.tsx b/src/components/CampaignContentSection.tsx index 4fe90bf6..2ffb55ab 100644 --- a/src/components/CampaignContentSection.tsx +++ b/src/components/CampaignContentSection.tsx @@ -7,7 +7,8 @@ import { getPostCanonicalPath, getVisiblePosts, } from '@/utilities/posts' -import { getInstagramFeed, type InstagramPost } from '@/utilities/socialFeed/instagramFeed' +import type { InstagramPost } from '@/utilities/socialFeed/instagramFeed' +import { getInstagramFeed } from '@/utilities/socialFeed/instagramFeedView' import { formatYouTubeViews, getYouTubeFeed, diff --git a/src/globals/SocialFeedSettings.ts b/src/globals/SocialFeedSettings.ts index 57208971..663c52fa 100644 --- a/src/globals/SocialFeedSettings.ts +++ b/src/globals/SocialFeedSettings.ts @@ -1,5 +1,10 @@ import { payloadAdminOnly } from '@/utilities/campaignAccess' import { REVALIDATE_SOCIAL_FEED_TAG } from '@/utilities/revalidateRequest' +import { + INSTAGRAM_SYNC_TIMEOUT_MS, + instagramCredentialsChanged, + syncInstagramFeed, +} from '@/utilities/socialFeed/instagramSync' import { revalidateTag } from 'next/cache' import type { GlobalAfterChangeHook, GlobalConfig } from 'payload' @@ -18,13 +23,11 @@ const revalidateFeed = async () => revalidateTag(REVALIDATE_SOCIAL_FEED_TAG) * failure is swallowed — the sync's own status IS the result, and a broken * sync must never break the save. * - * The sync modules are imported lazily: `payload.config.ts` must not pull - * the feed module graph (`unstable_cache` at load time) — the config is the - * one module every int spec imports. + * The sync modules must not import `@payload-config` (they do not), so the + * static import here closes no cycle: `payload.config → this global → + * instagramSync → instagramFeed` ends at `instagramFeed`, which is pure. */ const syncInstagramAfterChange: GlobalAfterChangeHook = async ({ doc, previousDoc, req }) => { - const { instagramCredentialsChanged, syncInstagramFeed, INSTAGRAM_SYNC_TIMEOUT_MS } = - await import('@/utilities/socialFeed/instagramSync') if (!instagramCredentialsChanged(doc, previousDoc)) return try { // `req` lets the sync write inside this very transaction (same row lock diff --git a/src/utilities/socialFeed/instagramFeed.ts b/src/utilities/socialFeed/instagramFeed.ts index 97d4f431..13af5afe 100644 --- a/src/utilities/socialFeed/instagramFeed.ts +++ b/src/utilities/socialFeed/instagramFeed.ts @@ -1,11 +1,8 @@ import 'server-only' import type { SocialFeedSetting } from '@/payload-types' -import { REVALIDATE_SOCIAL_FEED_TAG } from '@/utilities/revalidateRequest' -import configPromise from '@payload-config' import { sql } from '@payloadcms/db-postgres' -import { unstable_cache } from 'next/cache' -import { getPayload, type Payload } from 'payload' +import type { Payload } from 'payload' const INSTAGRAM_API_BASE_URL = process.env.INSTAGRAM_API_BASE_URL ?? 'https://graph.instagram.com' @@ -257,15 +254,6 @@ export const loadInstagramFeed = async ({ } } -const excludedInstagramIds = (settings: SocialFeedSetting): string[] => - (settings.excludedItems ?? []) - .filter((item) => item.platform === 'instagram') - .map((item) => item.itemId) - .filter(Boolean) - -const isInstagramSnapshot = (value: unknown): value is { username?: unknown; posts?: unknown } => - typeof value === 'object' && value !== null - /** * Operational state of the Instagram sync, persisted in the global's * `instagram_sync_status` column and shown by the admin status panel: @@ -432,64 +420,3 @@ export const persistInstagramAccessToken = async ( WHERE "id" = (SELECT "id" FROM "social_feed_settings" ORDER BY "id" LIMIT 1) `) } - -/** - * Cached Instagram feed for the campaign home content board: `null` when the - * feed is off (kill switch, platform toggle or missing credentials), otherwise - * the latest eligible posts plus the profile username. A successful fetch - * persists the RAW list (pre-exclusion) as the global snapshot AND the sync - * status (so the admin panel shows "Sincronizado · há X min · N posts"); a - * failed fetch persists the failure status with the product-language reason - * and falls back to the snapshot with the current exclusions applied — so a - * new exclusion works even while the API is down, and the page never breaks. - * Busted by the global's `afterChange` (tag `social-feed`); the per-entry - * `revalidate` keeps new uploads appearing without an admin edit. - */ -export const getInstagramFeed = unstable_cache( - async (): Promise => { - const payload = await getPayload({ config: configPromise }) - const settings = await payload.findGlobal({ - slug: 'social-feed-settings', - depth: 0, - }) - - if (!isInstagramFeedConfigured(settings)) { - return null - } - - const maxItems = Math.min(Math.max(settings.instagramMaxItems ?? 3, 1), 5) - const excludedIds = excludedInstagramIds(settings) - - try { - const result = await loadInstagramFeed({ - accessToken: settings.instagramAccessToken as string, - userId: settings.instagramUserId as string, - maxResults: Math.min(maxItems + 10, INSTAGRAM_MAX_RESULTS_CAP), - }) - await persistInstagramSnapshot(payload, { username: result.username, posts: result.posts }) - if (result.refreshedAccessToken) { - await persistInstagramAccessToken(payload, result.refreshedAccessToken) - } - await persistInstagramSyncStatus(payload, successInstagramSyncStatus(result.posts.length)) - return { - username: result.username, - posts: eligibleInstagramPosts(result.posts, excludedIds, maxItems), - } - } catch (cause) { - await persistInstagramSyncStatus( - payload, - failedInstagramSyncStatus(describeInstagramError(cause)), - ) - const snapshot = settings.instagramFeedSnapshot as unknown - if (!isInstagramSnapshot(snapshot) || !Array.isArray(snapshot.posts)) { - return { username: null, posts: [] } - } - return { - username: typeof snapshot.username === 'string' ? snapshot.username : null, - posts: eligibleInstagramPosts(snapshot.posts, excludedIds, maxItems), - } - } - }, - ['instagram-feed'], - { tags: [REVALIDATE_SOCIAL_FEED_TAG], revalidate: 300 }, -) diff --git a/src/utilities/socialFeed/instagramFeedView.ts b/src/utilities/socialFeed/instagramFeedView.ts new file mode 100644 index 00000000..f950fc56 --- /dev/null +++ b/src/utilities/socialFeed/instagramFeedView.ts @@ -0,0 +1,97 @@ +import 'server-only' + +import type { SocialFeedSetting } from '@/payload-types' +import { REVALIDATE_SOCIAL_FEED_TAG } from '@/utilities/revalidateRequest' +import configPromise from '@payload-config' +import { unstable_cache } from 'next/cache' +import { getPayload } from 'payload' + +import { + describeInstagramError, + eligibleInstagramPosts, + failedInstagramSyncStatus, + INSTAGRAM_MAX_RESULTS_CAP, + type InstagramFeedResult, + isInstagramFeedConfigured, + loadInstagramFeed, + persistInstagramAccessToken, + persistInstagramSnapshot, + persistInstagramSyncStatus, + successInstagramSyncStatus, +} from '@/utilities/socialFeed/instagramFeed' + +const excludedInstagramIds = (settings: SocialFeedSetting): string[] => + (settings.excludedItems ?? []) + .filter((item) => item.platform === 'instagram') + .map((item) => item.itemId) + .filter(Boolean) + +const isInstagramSnapshot = (value: unknown): value is { username?: unknown; posts?: unknown } => + typeof value === 'object' && value !== null + +/** + * Cached Instagram feed for the campaign home content board: `null` when the + * feed is off (kill switch, platform toggle or missing credentials), otherwise + * the latest eligible posts plus the profile username. A successful fetch + * persists the RAW list (pre-exclusion) as the global snapshot AND the sync + * status (so the admin panel shows "Sincronizado · há X min · N posts"); a + * failed fetch persists the failure status with the product-language reason + * and falls back to the snapshot with the current exclusions applied — so a + * new exclusion works even while the API is down, and the page never breaks. + * Busted by the global's `afterChange` (tag `social-feed`); the per-entry + * `revalidate` keeps new uploads appearing without an admin edit. + * + * Lives in `instagramFeedView.ts` (NOT `instagramFeed.ts`) on purpose: this + * module is the only one that imports `@payload-config`, while + * `instagramFeed.ts` is reachable from `payload.config.ts` via the global's + * sync hook — importing the config back from the feed module would close a + * cycle the `check:cycles` gate rejects. + */ +export const getInstagramFeed = unstable_cache( + async (): Promise => { + const payload = await getPayload({ config: configPromise }) + const settings = await payload.findGlobal({ + slug: 'social-feed-settings', + depth: 0, + }) + + if (!isInstagramFeedConfigured(settings)) { + return null + } + + const maxItems = Math.min(Math.max(settings.instagramMaxItems ?? 3, 1), 5) + const excludedIds = excludedInstagramIds(settings) + + try { + const result = await loadInstagramFeed({ + accessToken: settings.instagramAccessToken as string, + userId: settings.instagramUserId as string, + maxResults: Math.min(maxItems + 10, INSTAGRAM_MAX_RESULTS_CAP), + }) + await persistInstagramSnapshot(payload, { username: result.username, posts: result.posts }) + if (result.refreshedAccessToken) { + await persistInstagramAccessToken(payload, result.refreshedAccessToken) + } + await persistInstagramSyncStatus(payload, successInstagramSyncStatus(result.posts.length)) + return { + username: result.username, + posts: eligibleInstagramPosts(result.posts, excludedIds, maxItems), + } + } catch (cause) { + await persistInstagramSyncStatus( + payload, + failedInstagramSyncStatus(describeInstagramError(cause)), + ) + const snapshot = settings.instagramFeedSnapshot as unknown + if (!isInstagramSnapshot(snapshot) || !Array.isArray(snapshot.posts)) { + return { username: null, posts: [] } + } + return { + username: typeof snapshot.username === 'string' ? snapshot.username : null, + posts: eligibleInstagramPosts(snapshot.posts, excludedIds, maxItems), + } + } + }, + ['instagram-feed'], + { tags: [REVALIDATE_SOCIAL_FEED_TAG], revalidate: 300 }, +)