initial commit
This commit is contained in:
@@ -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),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user