refactor(S11): quebra o ciclo config→feed — getInstagramFeed muda para instagramFeedView

O  em instagramFeed.ts fechava um ciclo rejeitado pelo
check:cycles quando o grafo do config alcançava o feed via o hook da global
(config → global → instagramSync → instagramFeed → config; o madge segue
imports dinâmicos também). Move o loader cached (único importador de
@payload-config entre eles) para instagramFeedView.ts; instagramFeed vira puro
(fetch/parse/persist/describe/builders/gate), sem next/cache nem config. Com a
aresta config recurvada eliminada, o hook volta a ser um import estático — e o
config deixa de puxar unstable_cache, então as int suites que mockam next/cache
passam sem mudança. Gates: check:cycles 0, unit 2463, int 791, lint/tsc/build,
e2e frontend serial 19/19 + admin 5/5.
This commit is contained in:
Francisco Solla 2026-08-20 00:23:25 -03:00
parent 3ce5a69555
commit aeb33eb409
4 changed files with 108 additions and 80 deletions

View file

@ -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,

View file

@ -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

View file

@ -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<InstagramFeedResult | null> => {
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 },
)

View file

@ -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<InstagramFeedResult | null> => {
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 },
)