Files
supabase/apps/www/lib/events.ts
Prashant Sridharan 4dd3a0aacf Fix events page (#47089)
## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

As context, the events page pulls data from our webinar MDX files, Luma
(for Hackathons, Events, and Meetups), and Notion (for third-party
events we participate in). I recently unified our multiple Luma
calendars into one and started using tags for Luma events.

I fixed a few things on the /events page:

1. Webinars now show the proper location instead of "Unknown"
2. Event cards now show location properly depending on where the source
of the events are
3. Event data is pulled from Luma and categorized based on tags.
Fallback tag is "event" if a tag is absent

Note that this structure assume that Notion events are third-party, Luma
events are first-party (Hosted by Supabase), and Webinars are always
online. This is a safe assumption for the time being, but I will revisit
in the future if that changes.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
  * Events now display tags and categories from Luma event data.
* Webinar events are identified and labeled as "Supabase Live" with a
video icon for improved visibility.

* **Improvements**
* Enhanced event metadata display with improved location and category
information.
* Streamlined event categorization for more consistent handling across
event sources.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-06-18 18:58:27 +01:00

282 lines
10 KiB
TypeScript

/*
* @file events.ts
* @description Fetch events from the Notion "Developer Events" database.
* Only events with "Publish to Web" select set to "Yes" are returned.
*
* Notion DB schema (actual column names):
* Event Name -> title
* Start Date -> date
* End Date -> date
* Publish to Web -> select ("Yes" / other)
* Publish to Web Description -> rich_text
* URL -> rich_text (event URL)
* Book Meeting Link -> url
* Location -> rich_text
* Category -> multi_select
* Are you speaking at this event? -> multi_select
* Participation -> multi_select
*/
import fs from 'fs'
import path from 'path'
import matter from 'gray-matter'
import { queryDatabase } from 'lib/notion'
import { EventHost, SUPABASE_HOST, SupabaseEvent } from './eventsTypes'
// The actual DB ID (child database inside the page)
const NOTION_EVENTS_DB_ID_FALLBACK = '21b5004b775f8058872fe8fa81e2c7ac'
// ─── Helpers ────────────────────────────────────────────────────────────────
function isSafeHttpUrl(url: string): boolean {
try {
const parsed = new URL(url)
return parsed.protocol === 'http:' || parsed.protocol === 'https:'
} catch {
return false
}
}
function getTitle(page: any): string {
const prop = Object.values(page.properties).find((p: any) => p.type === 'title') as any
return prop?.title?.map((t: any) => t.plain_text).join('') ?? ''
}
function getRichText(page: any, name: string): string {
const prop = page.properties[name]
if (!prop || prop.type !== 'rich_text') return ''
return prop.rich_text.map((t: any) => t.plain_text).join('')
}
function getUrl(page: any, name: string): string {
const prop = page.properties[name]
if (!prop || prop.type !== 'url') return ''
return prop.url ?? ''
}
function getDate(page: any, name: string): string | null {
const prop = page.properties[name]
if (!prop || prop.type !== 'date' || !prop.date) return null
const raw = prop.date.start ?? null
if (!raw) return null
// Normalize date-only strings to noon UTC so the event day displays correctly
// in all timezones (T00:00:00Z rolls back to the previous day for UTC-negative zones)
if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) {
return `${raw}T12:00:00Z`
}
return raw
}
function getMultiSelect(page: any, name: string): string[] {
const prop = page.properties[name]
if (!prop || prop.type !== 'multi_select') return []
return prop.multi_select.map((s: any) => s.name)
}
function getFormulaString(page: any, name: string): string {
const prop = page.properties[name]
if (!prop || prop.type !== 'formula' || prop.formula?.type !== 'string') return ''
return prop.formula.string ?? ''
}
// ─── Main fetch ─────────────────────────────────────────────────────────────
/**
* Fetch events from Notion where "Publish to Web" select = "Yes".
* Returns SupabaseEvent objects sorted by start date ascending.
*/
export const getNotionEvents = async (): Promise<SupabaseEvent[]> => {
const apiKey = process.env.NOTION_EVENTS_API_KEY
const dbId = process.env.NOTION_EVENTS_DB_ID_ACTUAL ?? NOTION_EVENTS_DB_ID_FALLBACK
if (!apiKey) {
console.error('NOTION_EVENTS_API_KEY is not set')
return []
}
try {
const pages = await queryDatabase(
dbId,
apiKey,
{ property: 'Publish to Web', select: { equals: 'Yes' } },
[{ property: 'Start Date', direction: 'ascending' }]
)
return pages
.map((page): SupabaseEvent | null => {
const title = getFormulaString(page, 'Publish to Web Title') || getTitle(page)
const startDate = getDate(page, 'Start Date')
if (!title || !startDate) return null
const endDate = getDate(page, 'End Date')
const description =
getRichText(page, 'Publish to Web Description') || getRichText(page, 'Notes')
const rawEventUrl = getRichText(page, 'URL')
const eventUrl = isSafeHttpUrl(rawEventUrl) ? rawEventUrl : ''
const rawMeetingLink = getUrl(page, 'Book Meeting Link')
const meetingLink = isSafeHttpUrl(rawMeetingLink) ? rawMeetingLink : ''
const location = getRichText(page, 'Location')
const categories = ['conference']
const speakingAnswers = getMultiSelect(page, 'Are you speaking at this event?')
const isSpeaking = speakingAnswers.includes('Yes')
return {
slug: page.id,
type: 'event',
title,
date: startDate,
end_date: endDate ?? undefined,
description,
thumb: '',
cover_url: page.cover?.external?.url ?? page.cover?.file?.url ?? '',
path: '',
url: eventUrl,
tags: categories,
categories,
timezone: '',
location,
// Notion events are third-party — Supabase attends but does not host,
// so no "Hosted by" line. Only Luma events are Supabase-hosted.
hosts: [],
source: 'notion',
disable_page_build: true,
isSpeaking,
meetingLink: meetingLink || undefined,
link: eventUrl ? { href: eventUrl, target: '_blank' } : undefined,
}
})
.filter((e): e is SupabaseEvent => e !== null)
} catch (error) {
console.error('Error fetching Notion events:', error)
return []
}
}
// ─── MDX events ─────────────────────────────────────────────────────────────
const EVENTS_DIRECTORY = '_events'
const FILENAME_DATE_PREFIX_LENGTH = 11
type MdxHost = { name?: string; avatar_url?: string }
type MdxFrontmatter = {
title?: string
subtitle?: string
description?: string
meta_description?: string
type?: string
date?: string
end_date?: string
timezone?: string
categories?: string[]
tags?: string[]
onDemand?: boolean
disable_page_build?: boolean
hosts?: MdxHost[]
main_cta?: { url?: string; target?: '_blank' | '_self'; label?: string }
}
function mdxSlugFromFilename(filename: string): string {
// Matches the slug produced by getAllPostSlugs for `_events` (keeps any leading
// underscore from `YYYY-MM-DD__name.mdx`), so /events/{slug} lands on the built page.
return filename.replace(/\.mdx$/, '').substring(FILENAME_DATE_PREFIX_LENGTH)
}
function mdxHostsToEventHosts(hosts: MdxHost[] | undefined): EventHost[] {
if (!hosts || hosts.length === 0) return [SUPABASE_HOST]
return hosts.map((host, i) => ({
id: `mdx-host-${i}`,
email: '',
name: host.name ?? null,
first_name: host.name ?? null,
last_name: null,
avatar_url: host.avatar_url ?? '',
}))
}
function startOfTodayUtc(): Date {
const now = new Date()
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()))
}
function readMdxEventFilenames(): string[] {
const postDirectory = path.join(process.cwd(), EVENTS_DIRECTORY)
try {
return fs.readdirSync(postDirectory).filter((filename) => filename.endsWith('.mdx'))
} catch (error) {
console.error('Error reading _events directory:', error)
return []
}
}
function mdxFileToEvent(filename: string): SupabaseEvent | null {
try {
const fullPath = path.join(process.cwd(), EVENTS_DIRECTORY, filename)
const fileContents = fs.readFileSync(fullPath, 'utf8')
const { data } = matter(fileContents) as unknown as { data: MdxFrontmatter }
if (!data.title || !data.date) return null
const slug = mdxSlugFromFilename(filename)
const categories = data.categories ?? (data.type ? [data.type] : [])
// Webinars don't show a "Hosted by" line — drop hosts entirely.
const isWebinar = data.type === 'webinar' || categories.includes('webinar')
const rawCtaUrl = data.main_cta?.url ?? ''
const isExternalCta = /^https?:\/\//i.test(rawCtaUrl)
const safeExternalCta = isExternalCta && isSafeHttpUrl(rawCtaUrl) ? rawCtaUrl : ''
const internalPath = `/events/${slug}`
const href = data.disable_page_build ? safeExternalCta || internalPath : internalPath
const target = data.disable_page_build && safeExternalCta ? '_blank' : '_self'
return {
slug,
type: data.type ?? 'event',
title: data.title,
date: data.date,
end_date: data.end_date,
description: data.meta_description ?? data.description ?? data.subtitle ?? '',
thumb: '',
cover_url: '',
path: internalPath,
url: href,
tags: data.tags ?? categories,
categories,
timezone: data.timezone ?? '',
location: '',
hosts: isWebinar ? [] : mdxHostsToEventHosts(data.hosts),
source: 'mdx',
onDemand: data.onDemand,
disable_page_build: data.disable_page_build,
link: { href, target, label: data.main_cta?.label },
}
} catch (error) {
console.error(`Error parsing mdx event ${filename}:`, error)
return null
}
}
let parsedMdxEventsCache: SupabaseEvent[] | null = null
/** Parse every `_events/*.mdx` file once per process and reuse the result. */
function getAllParsedMdxEvents(): SupabaseEvent[] {
if (parsedMdxEventsCache) return parsedMdxEventsCache
parsedMdxEventsCache = readMdxEventFilenames()
.map(mdxFileToEvent)
.filter((e): e is SupabaseEvent => e !== null)
return parsedMdxEventsCache
}
/**
* Read all events under `_events/` and return today-and-future events.
* Past events are excluded (by end_date when present, otherwise start date).
*/
export const getMdxEvents = (): SupabaseEvent[] => {
const today = startOfTodayUtc()
return getAllParsedMdxEvents().filter((event) => new Date(event.end_date ?? event.date) >= today)
}
/** All MDX events with `onDemand: true`, including past recordings. */
export const getOnDemandMdxEvents = (): SupabaseEvent[] => {
return getAllParsedMdxEvents().filter((event) => event.onDemand === true)
}