Files
supabase/apps/www/lib/events.ts
Prashant Sridharan 344dc26a5e Fixed the event classifier for the events page (#49095)
## 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?

Modified the events page so that it reads the correct category from the
Notion database and displays if it's a hackathon, meetup, etc.

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

## Summary by CodeRabbit

* **New Features**
* Events now display categories based on their Notion type and category
information.
  * Hackathon events can be identified through category data.
  * Duplicate categories are automatically removed.

* **Bug Fixes**
* Events with unrecognized types now default to the conference category
for consistent display.


<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-14 17:43:17 +08:00

322 lines
12 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
* Type -> multi_select (drives the site's category filter)
* Category -> multi_select (audience taxonomy)
* 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'
// Maps Notion "Type" options to the app's category vocabulary (the values in
// CATEGORIES_FILTERS). Keys are lowercased for case-insensitive matching.
// "Supabase Event", "Party" and "Sales Event" have no filter bucket and fall
// through to DEFAULT_NOTION_CATEGORY.
const NOTION_TYPE_CATEGORY_MAP: Record<string, string> = {
conference: 'conference',
hackathon: 'hackathon',
meetup: 'meetup',
'meetup - third party': 'meetup',
}
const DEFAULT_NOTION_CATEGORY = 'conference'
// ─── 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)
}
/**
* Derive the site's category values for a Notion event.
*
* "Type" is the primary signal. "Category" is an audience taxonomy (AI / ML,
* Postgres / Databases, …) that also carries a "Hackathon" option, so it's read
* as a secondary signal for events typed as something else — e.g. a third-party
* meetup that is really a hackathon.
*/
function getNotionCategories(page: any): string[] {
const mapped = getMultiSelect(page, 'Type')
.map((type) => NOTION_TYPE_CATEGORY_MAP[type.trim().toLowerCase()])
.filter(Boolean)
const isHackathonCategory = getMultiSelect(page, 'Category').some(
(category) => category.trim().toLowerCase() === 'hackathon'
)
if (isHackathonCategory) mapped.push('hackathon')
return mapped.length > 0 ? Array.from(new Set(mapped)) : [DEFAULT_NOTION_CATEGORY]
}
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 = getNotionCategories(page)
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).
* Events already flipped to `onDemand: true` are excluded too — they've
* already happened and belong in the on-demand bucket (getOnDemandMdxEvents),
* not the upcoming one, regardless of how their UTC-converted date compares to today.
*/
export const getMdxEvents = (): SupabaseEvent[] => {
const today = startOfTodayUtc()
return getAllParsedMdxEvents().filter(
(event) => !event.onDemand && 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)
}