mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
## 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 -->
322 lines
12 KiB
TypeScript
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)
|
|
}
|