apps/web/src/lib/agenda.ts

1import { canonicalAgendaUrl } from '@local/ai/components/agendaUrl'
2import {
3	DECISION_PILLARS,
4	decisionPillar,
5} from '@local/config/decision-pillars'
6import { getPublishedSanityClient } from '@local/sanity/client'
7import { sanityFetch } from '@local/sanity/utils/fetch'
8import {
9	getInwinkEvent,
10	getInwinkEventSessions,
11	getInwinkToken,
12} from '@local/utils'
13
14const LOG_PREFIX = '[Agenda]'
15
16function log(msg: string, data?: Record<string, unknown>) {
17	if (process.env.SYNC_DEBUG !== '1' && process.env.SYNC_DEBUG !== 'true') return
18	const payload = data ? ` ${JSON.stringify(data)}` : ''
19	console.log(`${LOG_PREFIX} ${msg}${payload}`)
20}
21
22function logError(msg: string, error: unknown) {
23	const err = error instanceof Error ? error : new Error(String(error))
24	console.error(`${LOG_PREFIX} ${msg}`, err.message, err.stack ?? '')
25}
26
27export const EVENT_TYPES = ['Onsite', 'Online'] as const
28export type EventType = (typeof EVENT_TYPES)[number]
29
30export type SanityEvent = {
31	title?: string
32	fullSlug?: string
33	/** The event's dedicated agenda page (`<event>/agenda`), when published. */
34	agendaPage?: string | null
35	eventId: string
36}
37
38/** Minimal Inwink session row shape returned by `getInwinkEventSessions`. */
39type InwinkSessionRow = {
40	id?: string
41	title?: { en?: string }
42	description?: { en?: string }
43	timeslotView?: {
44		startDate?: string
45		endDate?: string
46		roomName?: string
47	}
48	sessiontypewp?: string
49	sessionaccess?: string
50	sessiontrack?: string
51	decisiontheme?: string
52	eventThemes?: Array<{ title?: { en?: string } } | null | undefined>
53	speakers?: Array<
54		| {
55				firstname?: string
56				lastname?: string
57				jobTitle?: string
58				company?: string
59		  }
60		| null
61		| undefined
62	>
63	moderators?: Array<
64		| {
65				firstname?: string
66				lastname?: string
67				jobTitle?: string
68				company?: string
69		  }
70		| null
71		| undefined
72	>
73}
74
75export type AgendaSession = {
76	id?: string
77	title?: string
78	description?: string
79	startDate?: string
80	endDate?: string
81	roomName?: string
82	format?: string
83	/** Inwink sessionaccess: empty = public, 'inviteonly' = restricted. */
84	access?: string
85	/** Inwink sessiontrack: stage or sub-event identity (e.g. workhumanforum, chrosummit). */
86	track?: string
87	decisionPillarId?: string
88	decisionPillar?: string
89	topics?: string[]
90	speakers?: string[]
91	moderators?: string[]
92}
93
94export type AgendaEventPayload = {
95	title: string
96	slug: string
97	/** Where the agenda is browsed on the site — the agenda page, else the event root. */
98	agendaPage: string
99	eventId: string
100	eventType: EventType[]
101	sessions: AgendaSession[]
102}
103
104export function normalizeEventType(value: string | null): EventType | null {
105	if (!value) return null
106	const normalized = value.trim().toLowerCase()
107	if (normalized === 'onsite') return 'Onsite'
108	if (normalized === 'online') return 'Online'
109	return null
110}
111
112/** Inwink event ids are GUIDs; skip bogus Sanity values (e.g. `" nb"`) so we don't call the API with garbage. */
113function isLikelyInwinkEventId(id: string): boolean {
114	const t = id.trim()
115	return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
116		t,
117	)
118}
119
120function formatPersonName(person: { firstname?: string; lastname?: string }) {
121	return [person.firstname, person.lastname].filter(Boolean).join(' ').trim()
122}
123
124/** "Jane Doe (CHRO, Acme Corp)" — role/company enable grounded speaker credentials in agendas. */
125function formatPersonLine(person: {
126	firstname?: string
127	lastname?: string
128	jobTitle?: string
129	company?: string
130}) {
131	const name = formatPersonName(person)
132	if (!name) return ''
133	const credentials = [person.jobTitle?.trim(), person.company?.trim()]
134		.filter(Boolean)
135		.join(', ')
136	return credentials ? `${name} (${credentials})` : name
137}
138
139/** "2026-10-21T09:00:00" → "Wednesday 21 October 2026" (date part only, no TZ shift). */
140export function formatDayLabel(startDate: string | undefined): string {
141	const match = startDate?.match(/^(\d{4})-(\d{2})-(\d{2})/)
142	if (!match) return ''
143	const date = new Date(Date.UTC(+match[1], +match[2] - 1, +match[3]))
144	return new Intl.DateTimeFormat('en-GB', {
145		weekday: 'long',
146		day: 'numeric',
147		month: 'long',
148		year: 'numeric',
149		timeZone: 'UTC',
150	})
151		.format(date)
152		.replace(',', '')
153}
154
155export function toMarkdown(
156	label: string,
157	events: AgendaEventPayload[],
158): string {
159	const lines: string[] = [
160		`# Agenda Content (${label})`,
161		'',
162		`Generated: ${new Date().toISOString()}`,
163		'',
164	]
165
166	if (!events.length) {
167		lines.push('No agenda events found.')
168		return lines.join('\n')
169	}
170
171	for (const event of events) {
172		lines.push(`## ${event.title}`)
173		lines.push(`- Slug: ${event.slug}`)
174		lines.push(`- Agenda page: ${event.agendaPage}`)
175		lines.push(`- Event ID: ${event.eventId}`)
176		lines.push(`- Session count: ${event.sessions.length}`)
177		lines.push(
178			`- Decision pillars: ${DECISION_PILLARS.map((pillar) => pillar.label).join('; ')}`,
179		)
180		lines.push('')
181
182		for (const session of event.sessions) {
183			lines.push(`### ${session.title || 'Untitled session'}`)
184			// Deep link: GlobalLayout mounts AgendaSessionQueryModal on every page,
185			// so ?agendaEvent=&agendaSession= opens the session on the agenda page
186			// (where the visitor lands among the other sessions), not the event root.
187			if (session.id && event.agendaPage && event.eventId) {
188				lines.push(
189					`- Session URL: ${event.agendaPage}?agendaEvent=${encodeURIComponent(event.eventId)}&agendaSession=${encodeURIComponent(session.id)}`,
190				)
191			}
192			if (session.startDate || session.endDate) {
193				lines.push(
194					`- Time: ${session.startDate || '?'} -> ${session.endDate || '?'}`,
195				)
196			}
197			// Weekday spelled out so the model never has to compute it (and get
198			// it wrong): "Wednesday 21 October 2026".
199			const dayLabel = formatDayLabel(session.startDate)
200			if (dayLabel) lines.push(`- Day: ${dayLabel}`)
201			if (session.roomName) lines.push(`- Room: ${session.roomName}`)
202			if (session.format) lines.push(`- Format: ${session.format}`)
203			// 'keynote' arrives via sessiontypewp/format; spell it out so
204			// retrieval for "keynote speakers" matches these chunks reliably.
205			if (session.format?.trim().toLowerCase() === 'keynote')
206				lines.push('- Keynote session: yes')
207			if (session.track) lines.push(`- Track / sub-event: ${session.track}`)
208			lines.push(
209				// Plain label only: handling rules live in the prompt, and any
210				// instruction written here gets echoed to visitors verbatim.
211				session.access === 'inviteonly'
212					? '- Access: INVITE ONLY'
213					: `- Access: ${session.access?.trim() || 'public'}`,
214			)
215			if (session.decisionPillarId)
216				lines.push(`- Decision pillar ID: ${session.decisionPillarId}`)
217			if (session.decisionPillar)
218				lines.push(`- Decision pillar: ${session.decisionPillar}`)
219			if (session.topics?.length)
220				lines.push(`- Topics: ${session.topics.join(', ')}`)
221			if (session.speakers?.length)
222				lines.push(`- Speakers: ${session.speakers.join(', ')}`)
223			if (session.moderators?.length) {
224				lines.push(`- Moderators: ${session.moderators.join(', ')}`)
225			}
226			if (session.description) lines.push(`- Description: ${session.description}`)
227			lines.push('')
228		}
229	}
230
231	return lines.join('\n')
232}
233
234/**
235 * Slug fragments identifying each flagship event's Sanity doc. City slugs are
236 * canonical (unleash-miami / unleash-paris); the legacy event-name slugs are
237 * kept for older docs and future renames — mirrors eventScopeFromSlug
238 * (~/lib/eventDates.ts).
239 */
240const SCOPE_EVENT_SLUGS = {
241	'event-world': ['unleash-paris', 'unleash-world'],
242	'event-america': ['unleash-miami', 'unleash-america'],
243} as const
244export type AgendaScopeKey = keyof typeof SCOPE_EVENT_SLUGS
245
246type AgendaFetchResult = {
247	events: AgendaEventPayload[]
248	markdown: string
249}
250
251/**
252 * Legacy Onsite/Online view (kept for /api/ai/agenda diagnostics). NOT an
253 * event-identity filter: a hybrid event carries both types.
254 */
255export async function fetchAgendaByEventType(
256	eventType: EventType,
257): Promise<AgendaFetchResult> {
258	return fetchAgendaEvents({ label: eventType, requireEventType: eventType })
259}
260
261/**
262 * Agenda for one flagship event, selected by its Sanity slug. The Inwink
263 * eventType axis (Onsite/Online) does not identify the event — the Paris
264 * event is hybrid (both types), so filtering by type fills both stores with
265 * the same agenda. Slug selection is the hard guardrail against that.
266 */
267export async function fetchAgendaForScope(
268	scopeKey: AgendaScopeKey,
269): Promise<AgendaFetchResult> {
270	const slugs = SCOPE_EVENT_SLUGS[scopeKey]
271	const label =
272		scopeKey === 'event-world' ? 'UNLEASH in Paris' : 'UNLEASH in Miami'
273	return fetchAgendaEvents({
274		label,
275		selectEvent: (event) =>
276			slugs.some((slug) => (event.fullSlug ?? '').includes(slug)),
277	})
278}
279
280async function fetchAgendaEvents(options: {
281	label: string
282	selectEvent?: (event: SanityEvent) => boolean
283	requireEventType?: EventType
284}): Promise<AgendaFetchResult> {
285	const { label, selectEvent, requireEventType } = options
286	log('Fetching agenda', { label })
287
288	const accessToken = await getInwinkToken()
289	if (!accessToken) {
290		logError('Inwink token failed', new Error('No token returned'))
291		throw new Error('Failed to get Inwink access token')
292	}
293
294	const sanityEvents = await sanityFetch<SanityEvent[]>(
295		getPublishedSanityClient(),
296		`*[_type == "event" && defined(inwink.eventId)]{
297      title,
298      fullSlug,
299      "agendaPage": *[_type == "page" && fullSlug == ^.fullSlug + "/agenda"][0].fullSlug,
300      "eventId": inwink.eventId
301    }`,
302		undefined,
303		{ tag: 'agenda' },
304	)
305
306	const normalized = sanityEvents.map((e) => ({
307		...e,
308		eventId: typeof e.eventId === 'string' ? e.eventId.trim() : '',
309	}))
310
311	const skippedIds = normalized
312		.filter((e) => e.eventId.length > 0 && !isLikelyInwinkEventId(e.eventId))
313		.map((e) => e.eventId)
314	if (skippedIds.length > 0) {
315		log('Skipping events with invalid Inwink eventId (fix in Sanity)', {
316			skippedIds,
317		})
318	}
319
320	log('Sanity events with Inwink ID', {
321		label,
322		count: sanityEvents.length,
323		eventIds: normalized.map((e) => e.eventId).filter(Boolean),
324	})
325
326	const uniqueEvents = Array.from(
327		new Map(
328			normalized
329				.filter((event) => isLikelyInwinkEventId(event.eventId))
330				.filter((event) => !selectEvent || selectEvent(event))
331				.map((event) => [event.eventId, event]),
332		).values(),
333	)
334
335	const results = await Promise.allSettled(
336		uniqueEvents.map(async (event): Promise<AgendaEventPayload | null> => {
337			const inwinkEvent = await getInwinkEvent({
338				accessToken,
339				eventId: event.eventId,
340				customerId: process.env.INWINK_CUSTOMER_ID ?? '',
341				audienceId: process.env.INWINK_AUDIENCE_ID ?? '',
342			})
343
344			const types = Array.isArray(inwinkEvent?.eventType)
345				? inwinkEvent.eventType.filter(
346						(value): value is EventType =>
347							typeof value === 'string' && EVENT_TYPES.includes(value as EventType),
348					)
349				: []
350
351			if (requireEventType && !types.includes(requireEventType)) return null
352
353			const sessions = await getInwinkEventSessions({
354				accessToken,
355				eventId: event.eventId,
356			})
357
358			const sessionRows = (sessions ?? []) as InwinkSessionRow[]
359
360			const mappedSessions: AgendaSession[] = sessionRows.map((session) => ({
361				id: session.id,
362				title: session.title?.en,
363				description: session.description?.en,
364				startDate: session.timeslotView?.startDate,
365				endDate: session.timeslotView?.endDate,
366				roomName: session.timeslotView?.roomName,
367				format: session.sessiontypewp,
368				access: session.sessionaccess?.trim() || undefined,
369				track: session.sessiontrack?.trim() || undefined,
370				decisionPillarId: session.decisiontheme?.trim() || undefined,
371				decisionPillar: decisionPillar(session.decisiontheme || '')?.label,
372				topics: session.eventThemes
373					?.map(
374						(theme: { title?: { en?: string } } | null | undefined) =>
375							theme?.title?.en,
376					)
377					.filter((topic): topic is string => Boolean(topic)),
378				speakers: session.speakers
379					?.filter(
380						(speaker): speaker is NonNullable<typeof speaker> => speaker != null,
381					)
382					.map((speaker) => formatPersonLine(speaker))
383					.filter((name): name is string => Boolean(name)),
384				moderators: session.moderators
385					?.filter(
386						(moderator): moderator is NonNullable<typeof moderator> =>
387							moderator != null,
388					)
389					.map((moderator) => formatPersonLine(moderator))
390					.filter((name): name is string => Boolean(name)),
391			}))
392
393			const slug = event.fullSlug?.trim() || ''
394			return {
395				title: event.title?.trim() || 'Untitled event',
396				slug,
397				agendaPage: canonicalAgendaUrl(event.agendaPage?.trim() || slug),
398				eventId: event.eventId,
399				eventType: types,
400				sessions: mappedSessions,
401			}
402		}),
403	)
404
405	const rejected = results.filter((r) => r.status === 'rejected')
406	if (rejected.length > 0) {
407		rejected.forEach((r, i) => {
408			const reason = (r as PromiseRejectedResult).reason
409			logError(`Event fetch rejected (${i + 1}/${rejected.length})`, reason)
410		})
411	}
412
413	const agendaEvents = results
414		.filter(
415			(result): result is PromiseFulfilledResult<AgendaEventPayload | null> =>
416				result.status === 'fulfilled',
417		)
418		.map((result) => result.value)
419		.filter((event): event is AgendaEventPayload => event !== null)
420
421	log('Agenda fetch done', {
422		label,
423		eventCount: agendaEvents.length,
424		rejectedCount: rejected.length,
425	})
426
427	const markdown = toMarkdown(label, agendaEvents)
428	return { events: agendaEvents, markdown }
429}
430