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:
parent
3ce5a69555
commit
aeb33eb409
4 changed files with 108 additions and 80 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
)
|
||||
|
|
|
|||
97
src/utilities/socialFeed/instagramFeedView.ts
Normal file
97
src/utilities/socialFeed/instagramFeedView.ts
Normal 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 },
|
||||
)
|
||||
Loading…
Reference in a new issue