213 lines
7.0 KiB
TypeScript
213 lines
7.0 KiB
TypeScript
/* ============================================================================
|
|||
|
|
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),
|
||
|
|
};
|
||
|
|
}
|