Files
sharper.ly/apps/web/app/routes/_events-opportunities/events.server.ts
T

237 lines
8.3 KiB
TypeScript
Raw Normal View History

2026-09-11 02:46:56 +02:00
/* ============================================================================
EVENTS - Strapi access. SERVER ONLY.
----------------------------------------------------------------------------
Maps the CMS entity onto the plain view model in ./events.ts, so the route
component never sees a Strapi shape and the loader payload stays small and
serialisable. The fault-tolerant fetch helpers come from ../_shared/cms, so
this section degrades exactly like Resources, Knowledge Hub and News do.
CONTENT TYPE api::event.event (collection, i18n enabled, draft & publish)
The field names below are copied from
apps/cms/src/api/event/content-types/event/schema.json - they are the
contract, and getting one wrong is a 400 from Strapi, not a blank field.
slug uid, required, targets `title`
title string, required
description text (max 500) - the card excerpt
body richtext - the detail page body
eventType enum suq-dialogue | training | law-session |
network-meeting | launch | other
startDateTime datetime, required
endDateTime datetime
timezone string, default "Africa/Tripoli"
format enum in-person | online | hybrid
venue string - "Bab Al-Bahr Hotel". There is NO
`city` field.
onlineUrl string - joining link for online / hybrid
region RELATION -> api::region.region (populate it; a
region is an entry, not a string)
registrationStatus enum open | closed | full | waitlist | not-required
capacity integer
waitlistEnabled boolean
registrationDeadline datetime
recapUrl string - "Read recap" for a past event
coverImage media (single image)
There is no external "registrationUrl" on this type: registration is either
handled by the on-site form or it is not open.
WHY THE WHOLE SET IS FETCHED
Upcoming / Ongoing / Completed are derived from the dates (see ./events.ts)
and cannot be expressed as a Strapi filter without duplicating the rule in
two places, where it would drift. The calendar of a two-year project is
small, so one request brings it back and the filtering, sorting and paging
happen in memory. EVENTS_FETCH_LIMIT is the ceiling; raise it or move to
date-range queries if the project ever outgrows it.
========================================================================= */
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,
toImage,
type CmsRichText,
type CmsTaxonomy,
} from "../_shared/cms";
import {
deriveTimeStatus,
EVENT_REGISTRATION_STATUSES,
isDateOnly,
type EventCardData,
type EventDetailData,
type EventFormat,
type EventRegistrationStatus,
EVENT_TYPES,
type EventType,
2026-09-11 02:46:56 +02:00
} from "./events";
export const EVENTS_RESOURCE = "events";
/** Hard ceiling on one listing request. */
export const EVENTS_FETCH_LIMIT = 200;
interface StrapiEvent extends StrapiEntity, CmsLocalizable {
readonly slug: string;
readonly title: string;
readonly description?: string | null;
readonly body?: CmsRichText;
readonly startDateTime: string;
readonly endDateTime?: string | null;
readonly eventType?: EventType | null;
2026-09-11 02:46:56 +02:00
readonly format?: EventFormat | null;
readonly venue?: string | null;
readonly region?: CmsTaxonomy | null;
readonly onlineUrl?: string | null;
readonly registrationStatus?: EventRegistrationStatus | null;
readonly registrationDeadline?: string | null;
readonly waitlistEnabled?: boolean | null;
readonly recapUrl?: string | null;
readonly coverImage?: StrapiMedia | null;
}
const FORMATS: readonly EventFormat[] = ["in-person", "online", "hybrid"];
/** What one listing request populates. Named so the two queries cannot drift. */
const LIST_POPULATE = {
coverImage: {
fields: ["url", "alternativeText", "width", "height", "formats"],
},
region: { fields: ["name", "slug"] },
} as const;
/* ---------------------------------------------------------------------------
MAPPING
------------------------------------------------------------------------ */
/** Trimmed, or undefined - so an empty CMS string never renders as a gap. */
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: StrapiEvent, now: Date): EventCardData {
const start = entry.startDateTime;
const end = text(entry.endDateTime);
const allDay = isDateOnly(start);
return {
slug: entry.slug,
title: entry.title,
summary: text(entry.description),
start,
end,
allDay,
eventType: oneOf(entry.eventType, EVENT_TYPES),
2026-09-11 02:46:56 +02:00
format: oneOf(entry.format, FORMATS),
place: text(entry.venue),
region: text(entry.region?.name),
/* The CMS default is "not-required", not "open": an event nobody has
opened registration for must not invite one. */
registrationStatus:
oneOf(entry.registrationStatus, EVENT_REGISTRATION_STATUSES) ??
"not-required",
registrationDeadline: text(entry.registrationDeadline),
2026-09-11 02:46:56 +02:00
timeStatus: deriveTimeStatus({ start, end, allDay }, now),
recapUrl: text(entry.recapUrl),
image: toImage(entry.coverImage, "medium"),
};
}
function toDetail(entry: StrapiEvent, now: Date): EventDetailData {
return {
...toCard(entry, now),
documentId: entry.documentId,
description: entry.body ?? undefined,
onlineUrl: text(entry.onlineUrl),
registrationDeadline: text(entry.registrationDeadline),
venue: text(entry.venue),
waitlistEnabled: entry.waitlistEnabled === true,
};
}
/* ---------------------------------------------------------------------------
QUERIES
------------------------------------------------------------------------ */
export interface EventListResult {
readonly events: EventCardData[];
/**
* False when the collection could not be read at all. The listing then
* renders its empty state with a notice rather than a 500 - an unreachable
* calendar is not a broken page, and the rest of the site still works.
*/
readonly available: boolean;
}
/** Every published event for a locale, mapped against a single "now". */
export async function fetchEvents(
locale: Locale,
now: Date = new Date(),
): Promise<EventListResult> {
const result = await fetchCollection<StrapiEvent>(EVENTS_RESOURCE, {
locale,
sort: ["startDateTime:asc"],
populate: LIST_POPULATE,
pagination: { page: 1, pageSize: EVENTS_FETCH_LIMIT },
});
return {
events: result.entries
.filter((entry) => Boolean(entry?.slug && entry.startDateTime))
.map((entry) => toCard(entry, now)),
available: result.available,
};
}
/**
* One event by slug, or null when the CMS has no such entry.
*
* A detail page does not degrade the way the listing does: a missing entry is
* a genuine 404, and a CMS outage propagates as a 500 rather than telling a
* crawler that a real event does not exist.
*/
export interface EventDetailResult {
readonly event: EventDetailData;
/** The slug of this event in every language the CMS holds it in. */
readonly alternates: LocalizedSlugs;
}
export async function fetchEvent(
slug: string,
locale: Locale,
now: Date = new Date(),
): Promise<EventDetailResult | null> {
const entry = await fetchBySlug<StrapiEvent>(EVENTS_RESOURCE, slug, {
locale,
populate: {
...LIST_POPULATE,
// The Arabic translation has its own slug - see ../_shared/alternates.ts.
localizations: LOCALIZATION_POPULATE,
},
});
if (!entry) return null;
return {
event: toDetail(entry, now),
alternates: collectLocalizedSlugs(locale, entry.slug, entry.localizations),
};
}