apps/web/src/lib/livePricing.server.ts

1import { getPublishedSanityClient } from '@local/sanity/client'
2import { sanityFetch } from '@local/sanity/utils/fetch'
3import { getInwinkToken } from '@local/utils'
4import { z } from 'zod'
5import {
6	type PriceProduct,
7	productSchema,
8	type ResolvedPrice,
9	resolvePublicPrice,
10} from './livePricing'
11
12const groupSchema = z.object({
13	eventId: z.string(),
14	passes: z.array(
15		z.object({
16			title: z.string(),
17			inwinkCategoryId: z.string().nullish(),
18			tax: z.enum(['none', 'vat', 'sales-tax']).nullish(),
19			pricingOverride: z
20				.object({ price: z.number().finite().nonnegative().nullish() })
21				.nullish(),
22		}),
23	),
24})
25const rowsSchema = z.array(
26	z.object({ slug: z.string(), groups: z.array(groupSchema).nullable() }),
27)
28const configs = new Map<
29	string,
30	{ expires: number; rows: z.infer<typeof rowsSchema> }
31>()
32const products = new Map<string, { expires: number; data: PriceProduct[] }>()
33export function clearLivePricingCache() {
34	configs.clear()
35	products.clear()
36}
37export async function loadPriceConfiguration(eventId = '', slug = '') {
38	const key = eventId || slug
39	const cached = configs.get(key)
40	if (cached && cached.expires > Date.now()) return cached.rows
41	const raw = await sanityFetch(
42		getPublishedSanityClient().withConfig({ useCdn: false }),
43		`*[_type == "page" && (fullSlug == $slug || ($eventId != "" && $eventId in slices[].subsections[].eventId))]{"slug":fullSlug,"groups":array::compact(slices[].subsections[][_type == "eventPasses"]{eventId,passes[]{title,inwinkCategoryId,tax,pricingOverride{price}}})}`,
44		{ eventId, slug },
45		{ tag: 'badge-pages' },
46	)
47	const rows = rowsSchema.parse(raw)
48	configs.set(key, { expires: Date.now() + 60_000, rows })
49	return rows
50}
51export async function loadPublicProducts(
52	eventId: string,
53): Promise<PriceProduct[]> {
54	const cached = products.get(eventId)
55	if (cached && cached.expires > Date.now()) return cached.data
56	const token = await getInwinkToken()
57	const all: PriceProduct[] = []
58	for (let index = 0; index < 20; index++) {
59		const response = await fetch(
60			`https://my.inwink.com/api/event/${encodeURIComponent(eventId)}/Product/query`,
61			{
62				method: 'POST',
63				signal: AbortSignal.timeout(8000),
64				headers: {
65					Authorization: `Bearer ${token}`,
66					'Content-Type': 'application/json',
67				},
68				body: JSON.stringify({
69					selects: { $all: true },
70					filters: { showcms: true },
71					page: { index, size: 100 },
72				}),
73			},
74		)
75		if (!response.ok) throw new Error('Registration prices unavailable')
76		const page = z
77			.object({ data: z.array(productSchema) })
78			.parse(await response.json()).data
79		all.push(...page)
80		if (page.length < 100) {
81			products.set(eventId, { expires: Date.now() + 60_000, data: all })
82			return all
83		}
84	}
85	throw new Error('Registration product pagination limit exceeded')
86}
87
88/** Re-evaluate date bands on every read, even when upstream data is cached. */
89export async function loadEventPublicPrices(eventId: string) {
90	const rows = await loadPriceConfiguration(eventId)
91	const canonical = rows.filter((r) => r.slug.endsWith('/badges'))
92	const groups = (canonical.length ? canonical : rows)
93		.flatMap((r) => r.groups || [])
94		.filter((g) => g.eventId === eventId)
95	const result: Record<string, ResolvedPrice> = {}
96	if (!groups.length) return result
97	const data = await loadPublicProducts(eventId)
98	for (const group of groups)
99		for (const pass of group.passes) {
100			const category = pass.inwinkCategoryId
101			if (!category) continue
102			const price = resolvePublicPrice(data, eventId, {
103				category,
104				tax: pass.tax ?? undefined,
105				override: pass.pricingOverride?.price ?? undefined,
106				applicationOnly: /^explorer(?: badge)?$/i.test(pass.title),
107			})
108			const previous = result[category]
109			if (
110				previous &&
111				[
112					previous.status,
113					previous.amount,
114					previous.currency,
115					previous.taxLabel,
116					previous.productId,
117				].join('|') !==
118					[
119						price.status,
120						price.amount,
121						price.currency,
122						price.taxLabel,
123						price.productId,
124					].join('|')
125			)
126				result[category] = {
127					status: 'conflict',
128					amount: null,
129					currency: null,
130					taxLabel: '',
131					displayPrice: 'Price unavailable',
132					reason: 'Conflicting CMS display policies',
133				}
134			else result[category] = price
135		}
136	return result
137}
138
139const details = [
140	{
141		id: 'general',
142		badge: 'General Attendee',
143		path: 'general-attendee',
144		title: /general (?:admission|attendee)/i,
145		summary:
146			'Days 2–3 conference access, 7 stages, exhibition and official networking. Day 1 premium programmes are not included.',
147	},
148	{
149		id: 'explorer',
150		badge: 'Explorer',
151		path: 'explorer',
152		title: /^explorer(?: badge)?$/i,
153		summary:
154			'Day 1 Expedition, Executive Dinner and full three-day access. Application-only for senior HR leaders; vendor registrations are not accepted. Approval is not automatic.',
155	},
156	{
157		id: 'diamond',
158		badge: 'Diamond',
159		path: 'diamond',
160		title: /^diamond(?: badge)?$/i,
161		summary:
162			'General Admission access plus Day 1 Summits & Salons and premium benefits. Separate invitation-only programmes are not guaranteed.',
163	},
164]
165export async function loadBadgePricing(eventSlug: string) {
166	// These curated badge inclusions apply only to the Paris edition.
167	if (eventSlug !== '/events/unleash-paris') return []
168	const rows = await loadPriceConfiguration('', `${eventSlug}/badges`)
169	const groups = rows.flatMap((r) => r.groups || [])
170	if (groups.length !== 1) return []
171	const group = groups[0]
172	let live: Record<string, ResolvedPrice> = {}
173	try {
174		live = await loadEventPublicPrices(group.eventId)
175	} catch {
176		/* Unknown prices stay unavailable; application policy still applies. */
177	}
178	return details.flatMap((detail) => {
179		const pass = group.passes.find((p) => detail.title.test(p.title))
180		if (!pass) return []
181		const price =
182			detail.id === 'explorer'
183				? resolvePublicPrice([], group.eventId, { applicationOnly: true })
184				: (live[pass.inwinkCategoryId || ''] ?? {
185						status: 'unavailable' as const,
186						amount: null,
187						currency: null,
188						taxLabel: '',
189						displayPrice: 'Price unavailable',
190					})
191		return [
192			{
193				id: detail.id,
194				badge: detail.badge,
195				summary: detail.summary,
196				url: `${eventSlug}/attend/${detail.path}`,
197				sourceUrl: `${eventSlug}/badges`,
198				eventId: group.eventId,
199				verifiedAt: new Date().toISOString(),
200				...price,
201			},
202		]
203	})
204}
205export type BadgePrice = Awaited<ReturnType<typeof loadBadgePricing>>[number]
206