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