initial commit

This commit is contained in:
2026-09-11 02:46:56 +02:00
commit 20b384039c
288 changed files with 46557 additions and 0 deletions
@@ -0,0 +1,212 @@
/* ============================================================================
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<T extends string>(
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<OpportunityListResult> {
const result = await fetchCollection<StrapiOpportunity>(
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<OpportunityDetailResult | null> {
const entry = await fetchBySlug<StrapiOpportunity>(
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),
};
}