/* ============================================================================ OPPORTUNITIES - Strapi access. SERVER ONLY. ---------------------------------------------------------------------------- CONTENT TYPE api::opportunity.opportunity (collection, i18n enabled, draft & publish) Field names copied from apps/cms/src/api/opportunity/content-types/opportunity/schema.json. slug uid, required, targets `title` title string, required summary text (max 500) - the card excerpt body richtext eligibility richtext - draft ยง8 marks the network criteria "TBD"; the page shows this block only when it is filled howToApply richtext opportunityType enum call-eoi | network-membership | training | grant | other opportunityStatus enum upcoming | open | closed (required, default "upcoming" - see deriveOpportunityStatus) openDate date - before it, the call is "Upcoming" deadline datetime - inclusive; after it, "Closed" externalApplicationUrl string - an EXTERNAL portal; when set, the on-site form is not rendered attachments media (multiple) - ToR, guidelines, annexes coverImage media (single image) There is NO `applicationEmail` and NO `responseWeeks` on this type: the draft marks the response time TBD and the CMS never grew a field for it, so the acknowledgement never names a number. Where the applications collection is written: ./applications.server.ts. ========================================================================= */ import type { Locale } from "~/i18n"; import type { StrapiEntity, StrapiMedia } from "~/lib/strapi"; import { collectLocalizedSlugs, LOCALIZATION_POPULATE, type CmsLocalizable, type LocalizedSlugs, } from "../_shared/alternates"; import { fetchBySlug, fetchCollection, toFile, toImage, type CmsFile, type CmsRichText, } from "../_shared/cms"; import { deriveOpportunityStatus, OPPORTUNITY_TYPES, type OpportunityCardData, type OpportunityDetailData, type OpportunityStatus, type OpportunityType, } from "./opportunities"; export const OPPORTUNITIES_RESOURCE = "opportunities"; /** Hard ceiling on one listing request. */ export const OPPORTUNITIES_FETCH_LIMIT = 100; interface StrapiOpportunity extends StrapiEntity, CmsLocalizable { readonly slug: string; readonly title: string; readonly summary?: string | null; readonly body?: CmsRichText; readonly eligibility?: CmsRichText; readonly howToApply?: CmsRichText; readonly opportunityType?: OpportunityType | null; readonly opportunityStatus?: OpportunityStatus | null; readonly openDate?: string | null; readonly deadline?: string | null; readonly externalApplicationUrl?: string | null; readonly attachments?: readonly StrapiMedia[] | null; readonly coverImage?: StrapiMedia | null; } const STATUSES: readonly OpportunityStatus[] = ["open", "closed", "upcoming"]; const LIST_POPULATE = { coverImage: { fields: ["url", "alternativeText", "width", "height", "formats"], }, } as const; /* --------------------------------------------------------------------------- MAPPING ------------------------------------------------------------------------ */ function text(value: string | null | undefined): string | undefined { const trimmed = typeof value === "string" ? value.trim() : ""; return trimmed.length > 0 ? trimmed : undefined; } function oneOf( value: unknown, allowed: readonly T[], ): T | undefined { return typeof value === "string" && (allowed as readonly string[]).includes(value) ? (value as T) : undefined; } function toCard(entry: StrapiOpportunity, now: Date): OpportunityCardData { return { slug: entry.slug, title: entry.title, summary: text(entry.summary), status: deriveOpportunityStatus( { openDate: text(entry.openDate), deadline: text(entry.deadline), status: oneOf(entry.opportunityStatus, STATUSES), }, now, ), openDate: text(entry.openDate), deadline: text(entry.deadline), opportunityType: oneOf(entry.opportunityType, OPPORTUNITY_TYPES), image: toImage(entry.coverImage, "medium"), }; } function toDetail(entry: StrapiOpportunity, now: Date): OpportunityDetailData { const attachments = (entry.attachments ?? []) .map((media) => toFile(media)) .filter((file): file is CmsFile => file !== undefined); return { ...toCard(entry, now), documentId: entry.documentId, description: entry.body ?? undefined, eligibility: entry.eligibility ?? undefined, howToApply: entry.howToApply ?? undefined, externalApplicationUrl: text(entry.externalApplicationUrl), attachments, }; } /* --------------------------------------------------------------------------- QUERIES ------------------------------------------------------------------------ */ export interface OpportunityListResult { readonly opportunities: OpportunityCardData[]; /** False when the collection could not be read at all. */ readonly available: boolean; } /** * Every published opportunity for a locale. Open / Upcoming / Closed is * derived from the dates (see ./opportunities.ts), so - as with the events * calendar - the set is fetched once and filtered in memory rather than * duplicating that rule in a Strapi query. */ export async function fetchOpportunities( locale: Locale, now: Date = new Date(), ): Promise { const result = await fetchCollection( OPPORTUNITIES_RESOURCE, { locale, sort: ["deadline:asc", "title:asc"], populate: LIST_POPULATE, pagination: { page: 1, pageSize: OPPORTUNITIES_FETCH_LIMIT }, }, ); return { opportunities: result.entries .filter((entry) => Boolean(entry?.slug && entry.title)) .map((entry) => toCard(entry, now)), available: result.available, }; } export interface OpportunityDetailResult { readonly opportunity: OpportunityDetailData; readonly alternates: LocalizedSlugs; } /** One opportunity by slug, or null when the CMS has no such entry. */ export async function fetchOpportunity( slug: string, locale: Locale, now: Date = new Date(), ): Promise { const entry = await fetchBySlug( OPPORTUNITIES_RESOURCE, slug, { locale, populate: { ...LIST_POPULATE, attachments: { fields: ["url", "name", "ext", "mime", "size"] }, localizations: LOCALIZATION_POPULATE, }, }, ); if (!entry) return null; return { opportunity: toDetail(entry, now), alternates: collectLocalizedSlugs(locale, entry.slug, entry.localizations), }; }