// Server-side access to the Frappe backend. Only import this from server components and // route handlers: it reads secrets from the environment. import { cache } from 'react' import { EMPTY_EVENT, distanceLabel, type PublicCategory, type PublicEvent, type PublicPrizes, } from './event-types' const APP = 'lukenya_marathon.lukenya_marathon' export const REVALIDATE_SECONDS = 60 export const METHODS = { publicEvent: `${APP}.api.get_public_event`, publicPrizes: `${APP}.api.get_public_prizes`, eventCategories: `${APP}.api.get_event_categories`, // older endpoint, used as a fallback createRegistration: `${APP}.api.create_registration`, startPayment: `${APP}.payments.start_payment`, paymentStatus: `${APP}.payments.check_payment_status`, myRace: `${APP}.my_race.get_my_race`, myRaceLink: `${APP}.my_race.request_my_race_link`, legalDocuments: 'lukenya_marathon.website_content.api.get_legal_documents', } as const export class FrappeError extends Error { status: number constructor(message: string, status: number) { super(message) this.status = status } } function baseUrl(): string { const url = process.env.FRAPPE_URL if (!url) throw new FrappeError('FRAPPE_URL is not set', 500) return url.replace(/\/+$/, '') } function headers(): Record { const h: Record = { Accept: 'application/json' } const key = process.env.FRAPPE_API_KEY const secret = process.env.FRAPPE_API_SECRET if (key && secret) h.Authorization = `token ${key}:${secret}` return h } // Frappe puts user-facing errors in _server_messages: a JSON list of JSON strings. function readFrappeError(body: unknown, status: number): string { const b = (body || {}) as Record try { if (typeof b._server_messages === 'string') { const list = JSON.parse(b._server_messages) as string[] const messages = list .map((m) => { try { return (JSON.parse(m) as { message?: string }).message || m } catch { return m } }) .filter(Boolean) if (messages.length) return messages.join(' ').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim() } } catch { /* fall through */ } if (typeof b.message === 'string') return b.message if (typeof b.exc_type === 'string') return `Request failed (${b.exc_type})` return `Request failed (${status})` } interface CallOptions { args?: Record post?: boolean revalidate?: number | false } export async function frappeCall(method: string, opts: CallOptions = {}): Promise { const { args = {}, post = false, revalidate = false } = opts let url = `${baseUrl()}/api/method/${method}` const init: RequestInit & { next?: { revalidate: number | false; tags?: string[] } } = { method: post ? 'POST' : 'GET', headers: headers(), } if (post) { init.headers = { ...headers(), 'Content-Type': 'application/json' } init.body = JSON.stringify(args) init.cache = 'no-store' } else { const qs = new URLSearchParams() for (const [k, v] of Object.entries(args)) { if (v !== undefined && v !== null && v !== '') qs.set(k, typeof v === 'string' ? v : JSON.stringify(v)) } if ([...qs.keys()].length) url += `?${qs.toString()}` if (revalidate === false) init.cache = 'no-store' else init.next = { revalidate, tags: ['lukenya'] } } const res = await fetch(url, { ...init, signal: AbortSignal.timeout(15000) }) let body: unknown = null try { body = await res.json() } catch { /* non-JSON error page */ } if (!res.ok) { const detail = readFrappeError(body, res.status) if (res.status >= 500) console.error(`[lukenya] ${method} failed (${res.status}): ${detail}`) throw new FrappeError(res.status >= 500 ? 'Our booking system had a problem. Please try again in a moment.' : detail, res.status) } return (body as { message: T }).message } // ---- public event ---------------------------------------------------------------------- function edition(): string | undefined { return process.env.FRAPPE_EDITION || undefined } interface LegacyCategory { name: string category_code: string category_name: string discipline: string distance_km: number | null start_time: string | null cutoff_hours: number | null eligibility: string | null kenyan_price: number | null non_resident_price: number | null early_bird_price: number | null prize_eligible: number inclusions: string | null } function fromLegacy(rows: LegacyCategory[]): PublicCategory[] { return rows.map((c) => ({ name: c.name, category_code: c.category_code, category_name: c.category_name, discipline: c.discipline, distance_km: c.distance_km, distance_label: distanceLabel(c.distance_km, c.discipline), short_description: '', start_time: c.start_time, cutoff_hours: c.cutoff_hours, min_age: null, elevation_gain_m: null, eligibility: c.eligibility || '', inclusions: (c.inclusions || '').split('\n').map((s) => s.trim()).filter(Boolean), prize_eligible: Boolean(c.prize_eligible), is_featured: false, sold_out: false, prices: { kenyan: c.kenyan_price, non_resident: c.non_resident_price, early_bird: c.early_bird_price, // The old endpoint doesn't say whether early bird is running, so don't promise it. early_bird_active: false, }, })) } function normalise(event: PublicEvent): PublicEvent { return { ...event, currency: event.currency || 'KES', categories: (event.categories || []).map((c) => ({ ...c, distance_label: c.distance_label || distanceLabel(c.distance_km, c.discipline), inclusions: Array.isArray(c.inclusions) ? c.inclusions : [], short_description: c.short_description || '', eligibility: c.eligibility || '', prize_eligible: Boolean(c.prize_eligible), is_featured: Boolean(c.is_featured), sold_out: Boolean(c.sold_out), })), } } export const getPublicEvent = cache(async (): Promise => { try { const event = await frappeCall(METHODS.publicEvent, { args: { edition: edition() }, revalidate: REVALIDATE_SECONDS, }) return normalise({ ...event, source: 'live' }) } catch (err) { console.warn('[lukenya] get_public_event failed, trying get_event_categories:', (err as Error).message) } try { const rows = await frappeCall(METHODS.eventCategories, { args: { event_edition: edition() }, revalidate: REVALIDATE_SECONDS, }) return { edition: null, categories: fromLegacy(rows || []), currency: 'KES', source: 'fallback' } } catch (err) { console.error('[lukenya] backend unavailable:', (err as Error).message) return EMPTY_EVENT } }) export const getPublicPrizes = cache(async (): Promise => { try { return await frappeCall(METHODS.publicPrizes, { args: { edition: edition() }, revalidate: REVALIDATE_SECONDS, }) } catch (err) { console.warn('[lukenya] get_public_prizes failed:', (err as Error).message) return null } }) export function registrationEnabled(): boolean { return process.env.REGISTRATION_ENABLED === 'true' }