Files
logos-web/apps/cms/scripts/sync-from-content.ts

535 lines
13 KiB
TypeScript

/**
* Imports content fixtures from `content/**` into the Payload database so
* editors can find and modify them in Admin.
*
* The CMS is the editor's source of truth at edit time; the Git repo is
* source of truth at runtime. This script bridges the two on demand: run it
* after a fresh DB boot or after a content PR merges, so the Admin view
* reflects the latest committed state.
*
* Plan §Phase 3 envisions an automatic `beforeRead` hook to do the same
* job inside Payload itself — this script is the manual interim until that
* lands.
*
* Usage:
* cd apps/cms && set -a && source .env && set +a && \
* pnpm sync-from-content
*
* Skipped (out of scope for this script):
* - Press articles: fetched directly from Logos Press Engine
*/
import { getPayload } from 'payload'
import {
getAllIdeas,
getAllPageCopy,
getAllRfps,
getBuilderHubListingSettings,
getBuilderHubSettings,
getBuilderResources,
getCircleEvents,
getCircleInitiatives,
getCircleResources,
getCircles,
getFooter,
getNavigationContent,
getSiteSettings,
type Circle,
type CircleEvent,
type CircleInitiative,
type CircleResource,
type BuilderResource,
type PageCopyRecord,
type Idea,
type Rfp,
} from '@repo/content/loaders'
import type {
BuilderHubListingPageSettings,
BuilderHubSettings,
Footer,
Navigation,
SiteSettings,
} from '@repo/content/schemas'
import config from '@payload-config'
import { SKIP_CONTENT_PR_CONTEXT } from '../src/collections/content-pr-hooks'
type SyncResult = { created: number; updated: number; skipped: number }
const flattenImage = (image?: {
src?: string
alt?: string
width?: number
height?: number
}): {
imageSrc: string | null
imageAlt: string | null
imageWidth: number | null
imageHeight: number | null
} => {
if (!image) {
return {
imageSrc: null,
imageAlt: null,
imageWidth: null,
imageHeight: null,
}
}
return {
imageSrc: image.src ?? null,
imageAlt: image.alt ?? '',
imageWidth: image.width ?? null,
imageHeight: image.height ?? null,
}
}
const mapCircle = (c: Circle): Record<string, unknown> & { slug: string } => ({
slug: c.slug,
status: c.status,
name: c.name,
description: c.description,
city: c.city,
country: c.country,
region: c.region ?? null,
lat: c.coordinates.lat,
lng: c.coordinates.lng,
timezone: c.timezone,
memberCount: c.memberCount ?? null,
discordChannel: c.discordChannel ?? null,
discordUrl: c.discordUrl ?? null,
forumUrl: c.forumUrl ?? null,
joinUrl: c.joinUrl,
...flattenImage(c.image),
// Payload array fields reject `null` (would trigger a `$push` on null);
// use `[]` for absent arrays.
organizers:
c.organizers && c.organizers.length > 0
? c.organizers.map((o) => ({ name: o.name, handle: o.handle ?? null }))
: [],
order: c.order ?? null,
})
const mapCircleEvent = (
e: CircleEvent
): Record<string, unknown> & { slug: string } => ({
slug: e.slug,
status: e.status,
circleSlug: e.circleSlug,
title: e.title,
locationLabel: e.locationLabel,
startsAt: e.startsAt,
endsAt: e.endsAt ?? null,
timezone: e.timezone,
venueName: e.venueName ?? null,
address: e.address ?? null,
eventUrl: e.eventUrl ?? null,
...flattenImage(e.image),
hostedBy:
e.hostedBy && e.hostedBy.length > 0
? e.hostedBy.map((h) => ({ name: h.name }))
: [],
featured: e.featured,
// The loader fills sequenceNumber when absent in file; we round-trip the
// computed value so Admin reflects what runtime sees.
sequenceNumber: e.sequenceNumber ?? null,
})
const mapCircleInitiative = (
i: CircleInitiative
): Record<string, unknown> & { slug: string } => ({
slug: i.slug,
status: i.status,
circleSlug: i.circleSlug,
href: i.href,
title: i.title,
locationLabel: i.locationLabel,
description: i.description,
ctaLabel: i.ctaLabel,
...flattenImage(i.image),
featured: i.featured,
order: i.order ?? null,
})
const mapCircleResource = (
r: CircleResource
): Record<string, unknown> & { slug: string } => ({
slug: r.slug,
status: r.status,
title: r.title,
description: r.description,
ctaLabel: r.ctaLabel,
href: r.href,
})
const mapBuilderResource = (
r: BuilderResource
): Record<string, unknown> & { slug: string } => ({
slug: r.slug,
status: r.status,
title: r.title,
description: r.description,
ctaLabel: r.ctaLabel,
href: r.href,
})
const mapBuilderListingSettings = (
settings: BuilderHubListingPageSettings
): Record<string, unknown> & { page: 'ideas' | 'rfps' } => ({
page: settings.page,
title: settings.title,
description: settings.description,
breadcrumbLabel: settings.breadcrumbLabel,
submitCtaLabel: settings.submitCta.label,
submitCtaHref: settings.submitCta.href,
submitCtaExternal: settings.submitCta.external ?? false,
defaultView: settings.defaultView,
pageSize: settings.pageSize,
previousLabel: settings.pagination.previousLabel,
nextLabel: settings.pagination.nextLabel,
bottomCtaTitle: settings.bottomCta.title,
bottomCtaLabel: settings.bottomCta.cta.label,
bottomCtaHref: settings.bottomCta.cta.href,
bottomCtaExternal: settings.bottomCta.cta.external ?? false,
})
const mapBuilderHubSettings = (
settings: BuilderHubSettings
): Record<string, unknown> & { slug: string } => ({
slug: 'builders-hub',
settings,
})
const mapPage = (
record: PageCopyRecord
): Record<string, unknown> & { slug: string } => ({
slug: record.slug,
route: record.page.route,
title: record.page.title,
page: record.page,
})
const mapSiteSettings = (
settings: SiteSettings
): Record<string, unknown> & { slug: string } => ({
slug: 'settings',
settings,
})
const mapSiteNavigation = (
navigation: Navigation
): Record<string, unknown> & { slug: string } => ({
slug: 'navigation',
navigation,
})
const mapSiteFooter = (
footer: Footer
): Record<string, unknown> & { slug: string } => ({
slug: 'footer',
footer,
})
const mapRfp = (r: Rfp): Record<string, unknown> & { slug: string } => ({
slug: r.slug,
status: r.status,
title: r.title,
tagline: r.tagline ?? null,
summary: r.summary,
description: r.description,
ctaLabel: r.ctaLabel ?? null,
rewardAmount: r.reward.amount,
rewardCurrency: r.reward.currency,
rewardXp: r.reward.xp ?? null,
applyUrl: r.applyUrl,
tags: r.tags ?? [],
featured: r.featured,
order: r.order ?? null,
publishedAt: r.publishedAt ?? null,
closesAt: r.closesAt ?? null,
ownerName: r.owner?.name ?? null,
ownerHandle: r.owner?.handle ?? null,
relatedIdeas: r.relatedIdeas ?? null,
})
const mapIdea = (i: Idea): Record<string, unknown> & { slug: string } => ({
slug: i.slug,
status: i.status,
title: i.title,
tagline: i.tagline ?? null,
summary: i.summary,
description: i.description,
ctaLabel: i.ctaLabel ?? null,
submitterName: i.submitter.name ?? null,
submitterHandle: i.submitter.handle,
rewardAmount: i.reward?.amount ?? null,
rewardCurrency: i.reward?.currency ?? null,
rewardXp: i.reward?.xp ?? null,
discussionUrl: i.discussionUrl ?? null,
tags: i.tags ?? [],
featured: i.featured,
order: i.order ?? null,
submittedAt: i.submittedAt ?? null,
})
type PayloadInstance = Awaited<ReturnType<typeof getPayload>>
const upsertBySlug = async (
payload: PayloadInstance,
collection: string,
data: Record<string, unknown> & { slug: string }
): Promise<'created' | 'updated'> => {
return upsertByField(payload, collection, 'slug', data.slug, data)
}
const upsertByField = async (
payload: PayloadInstance,
collection: string,
field: string,
value: string,
data: Record<string, unknown>
): Promise<'created' | 'updated'> => {
const found = await payload.find({
collection: collection as Parameters<
PayloadInstance['find']
>[0]['collection'],
where: { [field]: { equals: value } },
limit: 1,
depth: 0,
})
if (found.docs.length > 0) {
await payload.update({
collection: collection as Parameters<
PayloadInstance['update']
>[0]['collection'],
id: found.docs[0]!.id,
data: data as never,
context: { [SKIP_CONTENT_PR_CONTEXT]: true },
})
return 'updated'
}
await payload.create({
collection: collection as Parameters<
PayloadInstance['create']
>[0]['collection'],
data: data as never,
context: { [SKIP_CONTENT_PR_CONTEXT]: true },
})
return 'created'
}
const syncSingleton = async <TData extends Record<string, unknown>>(
payload: PayloadInstance,
collectionSlug: string,
field: string,
value: string,
data: TData
): Promise<SyncResult> => {
try {
const op = await upsertByField(payload, collectionSlug, field, value, data)
return {
created: op === 'created' ? 1 : 0,
updated: op === 'updated' ? 1 : 0,
skipped: 0,
}
} catch (err) {
console.error(
` ✗ ${collectionSlug}/${value}: ${
err instanceof Error ? err.message : String(err)
}`
)
return { created: 0, updated: 0, skipped: 1 }
}
}
const syncCollection = async <T extends { slug: string }>(
payload: PayloadInstance,
collectionSlug: string,
list: () => Promise<T[]>,
map: (item: T) => Record<string, unknown> & { slug: string }
): Promise<SyncResult> => {
const items = await list()
const result: SyncResult = { created: 0, updated: 0, skipped: 0 }
for (const item of items) {
try {
const op = await upsertBySlug(payload, collectionSlug, map(item))
result[op]++
} catch (err) {
console.error(
` ✗ ${collectionSlug}/${item.slug}: ${
err instanceof Error ? err.message : String(err)
}`
)
result.skipped++
}
}
return result
}
const main = async (): Promise<void> => {
const payload = await getPayload({ config })
const targets = [
{
name: 'pages',
run: () =>
syncCollection(payload, 'pages', () => getAllPageCopy('en'), mapPage),
},
{
name: 'site-settings',
run: async () =>
syncSingleton(
payload,
'site-settings-content',
'slug',
'settings',
mapSiteSettings(await getSiteSettings('en'))
),
},
{
name: 'site-navigation',
run: async () =>
syncSingleton(
payload,
'site-navigation-content',
'slug',
'navigation',
mapSiteNavigation(await getNavigationContent('en'))
),
},
{
name: 'site-footer',
run: async () =>
syncSingleton(
payload,
'site-footer-content',
'slug',
'footer',
mapSiteFooter(await getFooter('en'))
),
},
{
name: 'circles',
run: () =>
syncCollection(
payload,
'circles',
() => getCircles({ locale: 'en' }),
mapCircle
),
},
{
name: 'circle-events',
run: () =>
syncCollection(
payload,
'circle-events',
() => getCircleEvents({ locale: 'en' }),
mapCircleEvent
),
},
{
name: 'circle-initiatives',
run: () =>
syncCollection(
payload,
'circle-initiatives',
() => getCircleInitiatives({ locale: 'en' }),
mapCircleInitiative
),
},
{
name: 'circle-resources',
run: () =>
syncCollection(
payload,
'circle-resources',
() => getCircleResources({ locale: 'en' }),
mapCircleResource
),
},
{
name: 'builder-resources',
run: () =>
syncCollection(
payload,
'builder-resources',
() => getBuilderResources({ locale: 'en' }),
mapBuilderResource
),
},
{
name: 'builder-listing-rfps',
run: async () =>
syncSingleton(
payload,
'builder-listing-settings',
'page',
'rfps',
mapBuilderListingSettings(
await getBuilderHubListingSettings({ page: 'rfps', locale: 'en' })
)
),
},
{
name: 'builder-listing-ideas',
run: async () =>
syncSingleton(
payload,
'builder-listing-settings',
'page',
'ideas',
mapBuilderListingSettings(
await getBuilderHubListingSettings({ page: 'ideas', locale: 'en' })
)
),
},
{
name: 'builder-hub-settings',
run: async () =>
syncSingleton(
payload,
'builder-hub-settings',
'slug',
'builders-hub',
mapBuilderHubSettings(await getBuilderHubSettings('en'))
),
},
{
name: 'rfps',
run: () =>
syncCollection(
payload,
'rfps',
() => getAllRfps({ locale: 'en' }),
mapRfp
),
},
{
name: 'ideas',
run: () =>
syncCollection(
payload,
'ideas',
() => getAllIdeas({ locale: 'en' }),
mapIdea
),
},
]
for (const target of targets) {
process.stdout.write(`${target.name.padEnd(20)} `)
const result = await target.run()
console.log(
`created=${result.created}, updated=${result.updated}, skipped=${result.skipped}`
)
}
// Payload keeps DB pool open; force-exit so the script doesn't hang.
process.exit(0)
}
main().catch((err) => {
console.error(err)
process.exit(1)
})