/* ============================================================================ SEED - idempotent document helpers ---------------------------------------------------------------------------- Every write in the seed goes through here, so the whole seed can be run again over a database it has already written to and change nothing but the fields it owns. Nothing is ever deleted except demo entries, and only on an explicit `--demo --clean`. THE SHAPE OF A WRITE A seed entry is always a pair - the Arabic document and its English translation - because every localised content type on this site ships in both languages (CLAUDE.md rule 7) and Arabic is the CMS's default locale. `upsert` writes the Arabic document first, because that is the one carrying the identity (the slug), then writes the English locale onto the SAME document, which is how Strapi 5 models a translation: one `documentId`, one row per locale. DRAFT AND PUBLISH Content is written as a draft and then published explicitly. Publishing is a separate call rather than `status: 'published'` on the write, so a half-built entry is never live for the moment between the two locales. Two content types - `topic` and `region` - are pure taxonomy and have draft & publish switched off, and a document service for such a type has no `publish` method and no `status` to filter on at all. `usesDraft` below reads that off the schema rather than keeping a list, so a type that gains or loses the setting needs no change here. TYPES `strapi.documents(uid)` is generated per content type, so a helper that takes the uid as a parameter cannot be typed against one schema. The single cast below is the seam: past it, the seed's own data is checked by the content files' own types, and Strapi validates the payload at runtime against the real schema - a wrong field name fails the seed loudly. ========================================================================= */ import type { Core } from '@strapi/strapi'; /** The two locales this site ships. */ export const LOCALES = ['en', 'ar'] as const; export type SeedLocale = (typeof LOCALES)[number]; /** One piece of content, written once per locale. */ export type Localised = Readonly>; /** A field payload - checked by Strapi against the real schema at runtime. */ export type SeedData = Record; /** Any content type uid `strapi.documents()` accepts. */ export type SeedUid = Parameters[0]; interface SeedDocument { documentId: string; [field: string]: unknown; } /** * The document service, minus the per-uid generics. * * See the TYPES note above: this is the one place the seed steps outside * Strapi's generated types, and it is deliberate. */ interface LooseDocumentService { findFirst(params: SeedData): Promise; findMany(params: SeedData): Promise; create(params: SeedData): Promise; update(params: SeedData): Promise; delete(params: SeedData): Promise; publish(params: SeedData): Promise; } const documents = (strapi: Core.Strapi, uid: SeedUid): LooseDocumentService => strapi.documents(uid) as unknown as LooseDocumentService; /** Whether this content type has draft & publish, and so a `publish` call. */ const usesDraft = (strapi: Core.Strapi, uid: SeedUid): boolean => Boolean(strapi.contentType(uid).options?.draftAndPublish); /** `status: 'draft'`, but only where a status exists to ask about. */ const draftFilter = (strapi: Core.Strapi, uid: SeedUid): SeedData => usesDraft(strapi, uid) ? { status: 'draft' } : {}; /** What a seed step did, for the run summary. */ export interface SeedResult { documentId: string; created: boolean; } /** * Write both locales of a document and publish it. * * `find` identifies the entry across runs - a slug for a collection type, * nothing at all for a single type, which has only ever one document. When it * matches nothing the document is created; when it matches, the same fields * are written over the existing one, so an editor's OTHER changes survive a * re-run and the fields the seed owns go back to the seeded value. */ export async function upsert( strapi: Core.Strapi, uid: SeedUid, data: Localised, find?: SeedData ): Promise { const service = documents(strapi, uid); /* Looked up as a draft: a document that exists but has never been published is still the same document, and creating a second one beside it is how a seed ends up with duplicates. */ const existing = await service.findFirst({ ...(find ? { filters: find } : {}), locale: 'ar', ...draftFilter(strapi, uid), }); let documentId: string; let created = false; if (existing) { documentId = existing.documentId; await service.update({ documentId, locale: 'ar', data: data.ar }); } else { const document = await service.create({ locale: 'ar', data: data.ar }); documentId = document.documentId; created = true; } /* An update against a locale the document does not have yet creates that translation - there is no separate "add a locale" call. */ await service.update({ documentId, locale: 'en', data: data.en }); if (usesDraft(strapi, uid)) { await service.publish({ documentId, locale: '*' }); } return { documentId, created }; } /** * Delete the documents a filter matches, in every locale. * * TWO THINGS THE DOCUMENT SERVICE DOES NOT DO BY ITSELF, both of which leave * Arabic rows behind that the next run then collides with on the unique slug: * * 1. `delete({ documentId })` with no locale removes the DEFAULT locale * only, not the document. It takes `locale: '*'` to remove the lot. * 2. `findMany({ locale })` answers for one locale, so a document whose * base half has already gone is invisible to a search in that locale. * Both locales are searched and the documentIds pooled, which also * sweeps up orphans an earlier, less careful run left. */ async function deleteMatching( strapi: Core.Strapi, uid: SeedUid, filters: SeedData ): Promise { const service = documents(strapi, uid); const documentIds = new Set(); for (const locale of LOCALES) { const matches = await service.findMany({ filters, locale, ...draftFilter(strapi, uid), pagination: { limit: -1 }, }); for (const match of matches) documentIds.add(match.documentId); } for (const documentId of documentIds) { await service.delete({ documentId, locale: '*' }); } return documentIds.size; } /** * Delete every document of a type whose slug starts with the given prefix. * * This is how `--demo --clean` finds what it planted: the prefix is on the * slug itself, so nothing has to be remembered between runs and an entry an * editor has since renamed out of the prefix is deliberately left alone. */ export async function deleteBySlugPrefix( strapi: Core.Strapi, uid: SeedUid, prefix: string ): Promise { return deleteMatching(strapi, uid, { slug: { $startsWith: prefix } }); } /** * Delete documents matched by an arbitrary filter. * * The FAQ content type has no slug to prefix, so its demo entries are found by * their exact questions instead - in both languages, because the filter is * matched per locale. Anything the seed did not write is left untouched, * because the filter is the seeded list itself. */ export async function deleteWhere( strapi: Core.Strapi, uid: SeedUid, filters: SeedData ): Promise { return deleteMatching(strapi, uid, filters); } /** * Read one field off a single type before overwriting it. * * Used for the media an editor has already uploaded: the seed owns the words * on a page, not the pictures, so it reads the current hero image back and * writes it out again with the new copy rather than dropping it. A component * is replaced wholesale on write, so anything inside it that is not in the * payload is gone. */ export async function readExisting( strapi: Core.Strapi, uid: SeedUid, populate: string[], find?: SeedData, locale: SeedLocale = 'ar' ): Promise { return documents(strapi, uid).findFirst({ ...(find ? { filters: find } : {}), locale, ...draftFilter(strapi, uid), populate, }); } /** The id of a media entry nested in a populated component, if there is one. */ export function mediaId(component: unknown, field: string): number | undefined { if (!component || typeof component !== 'object') return undefined; const media = (component as Record)[field]; if (!media || typeof media !== 'object') return undefined; const id = (media as Record).id; return typeof id === 'number' ? id : undefined; } /** The ids of populated media entries nested in a component. */ export function mediaIds(component: unknown, field: string): number[] { if (!component || typeof component !== 'object') return []; const media = (component as Record)[field]; if (!Array.isArray(media)) return []; return media.flatMap((item: unknown) => { if (!item || typeof item !== 'object') return []; const id = (item as Record).id; return typeof id === 'number' ? [id] : []; }); }