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,
|
getPostCanonicalPath,
|
||||||
getVisiblePosts,
|
getVisiblePosts,
|
||||||
} from '@/utilities/posts'
|
} 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 {
|
import {
|
||||||
formatYouTubeViews,
|
formatYouTubeViews,
|
||||||
getYouTubeFeed,
|
getYouTubeFeed,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
import { payloadAdminOnly } from '@/utilities/campaignAccess'
|
import { payloadAdminOnly } from '@/utilities/campaignAccess'
|
||||||
import { REVALIDATE_SOCIAL_FEED_TAG } from '@/utilities/revalidateRequest'
|
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 { revalidateTag } from 'next/cache'
|
||||||
import type { GlobalAfterChangeHook, GlobalConfig } from 'payload'
|
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
|
* failure is swallowed — the sync's own status IS the result, and a broken
|
||||||
* sync must never break the save.
|
* sync must never break the save.
|
||||||
*
|
*
|
||||||
* The sync modules are imported lazily: `payload.config.ts` must not pull
|
* The sync modules must not import `@payload-config` (they do not), so the
|
||||||
* the feed module graph (`unstable_cache` at load time) — the config is the
|
* static import here closes no cycle: `payload.config → this global →
|
||||||
* one module every int spec imports.
|
* instagramSync → instagramFeed` ends at `instagramFeed`, which is pure.
|
||||||
*/
|
*/
|
||||||
const syncInstagramAfterChange: GlobalAfterChangeHook = async ({ doc, previousDoc, req }) => {
|
const syncInstagramAfterChange: GlobalAfterChangeHook = async ({ doc, previousDoc, req }) => {
|
||||||
const { instagramCredentialsChanged, syncInstagramFeed, INSTAGRAM_SYNC_TIMEOUT_MS } =
|
|
||||||
await import('@/utilities/socialFeed/instagramSync')
|
|
||||||
if (!instagramCredentialsChanged(doc, previousDoc)) return
|
if (!instagramCredentialsChanged(doc, previousDoc)) return
|
||||||
try {
|
try {
|
||||||
// `req` lets the sync write inside this very transaction (same row lock
|
// `req` lets the sync write inside this very transaction (same row lock
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,8 @@
|
||||||
import 'server-only'
|
import 'server-only'
|
||||||
|
|
||||||
import type { SocialFeedSetting } from '@/payload-types'
|
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 { sql } from '@payloadcms/db-postgres'
|
||||||
import { unstable_cache } from 'next/cache'
|
import type { Payload } from 'payload'
|
||||||
import { getPayload, type Payload } from 'payload'
|
|
||||||
|
|
||||||
const INSTAGRAM_API_BASE_URL = process.env.INSTAGRAM_API_BASE_URL ?? 'https://graph.instagram.com'
|
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
|
* Operational state of the Instagram sync, persisted in the global's
|
||||||
* `instagram_sync_status` column and shown by the admin status panel:
|
* `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)
|
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