From a8bdad4f4a14e75356322724c1bb55703b094c23 Mon Sep 17 00:00:00 2001 From: Elvis020 Date: Tue, 8 Sep 2026 13:56:51 +0000 Subject: [PATCH] feat(events): honor explicit Project Night contact actions - accept and validate the EMS primary action contract, including strict Slack deep links - prioritize explicit contact actions over recordings, livestreams, and registration links - apply the same action selection to static pages and live event refreshes - avoid forcing custom-protocol links into a new browser tab Verification: - pnpm build - direct Node assertions for action precedence and Slack URL validation - git diff --check --- src/lib/event-card-actions.ts | 25 +++++++++++++++++++----- src/lib/events.ts | 8 ++++++++ src/lib/public-event-contract.ts | 27 +++++++++++++++++++++++++- src/lib/public-url.ts | 33 ++++++++++++++++++++++++++++++++ src/pages/events/[slug].astro | 32 ++++--------------------------- src/pages/events/index.astro | 25 ++++++++++-------------- 6 files changed, 101 insertions(+), 49 deletions(-) diff --git a/src/lib/event-card-actions.ts b/src/lib/event-card-actions.ts index 9be86b4..d27c120 100644 --- a/src/lib/event-card-actions.ts +++ b/src/lib/event-card-actions.ts @@ -7,23 +7,38 @@ export interface EventCardAction { label: string; } -export function getEventCardAction( +export function getEventPrimaryAction( event: WebsiteEvent, status: EventStatus, ): EventCardAction | null { + if (event.primaryAction) { + return { + href: event.primaryAction.url, + label: event.primaryAction.label, + }; + } + if (status === 'past') { return event.streamUrl && !event.embedStream - ? { href: event.streamUrl, label: 'Watch recording →' } + ? { href: event.streamUrl, label: 'Watch recording' } : null; } if (status === 'live') { - if (event.streamUrl) return { href: event.streamUrl, label: 'Watch live →' }; - if (event.onlineUrl) return { href: event.onlineUrl, label: 'Join online →' }; + if (event.streamUrl) return { href: event.streamUrl, label: 'Watch live' }; + if (event.onlineUrl) return { href: event.onlineUrl, label: 'Join online' }; return null; } return event.registrationUrl - ? { href: event.registrationUrl, label: 'Register →' } + ? { href: event.registrationUrl, label: 'Register' } : null; } + +export function getEventCardAction( + event: WebsiteEvent, + status: EventStatus, +): EventCardAction | null { + const action = getEventPrimaryAction(event, status); + return action ? { ...action, label: `${action.label} →` } : null; +} diff --git a/src/lib/events.ts b/src/lib/events.ts index 168de39..6762309 100644 --- a/src/lib/events.ts +++ b/src/lib/events.ts @@ -29,6 +29,12 @@ const EVENTS_API_TIMEOUT_MS = 8_000; export type EventLocationType = "in_person" | "online" | "hybrid"; export type EventClassification = "official" | "community"; +export interface WebsiteEventPrimaryAction { + kind: "slack_profile"; + label: string; + url: string; +} + export interface WebsiteEvent { id: string; slug: string; @@ -47,6 +53,7 @@ export interface WebsiteEvent { streamUrl: string | null; embedStream: boolean; registrationUrl: string | null; + primaryAction: WebsiteEventPrimaryAction | null; organizerName: string; organizerWebsite: string | null; coverUrl: string | null; @@ -115,6 +122,7 @@ async function fetchMeetupFallback(): Promise { streamUrl: meetup.data.stream_url ?? null, embedStream: meetup.data.embed_stream, registrationUrl: meetup.data.registration_url ?? null, + primaryAction: null, organizerName: "DevCongress", organizerWebsite: "https://devcongress.org", coverUrl: meetup.data.cover, diff --git a/src/lib/public-event-contract.ts b/src/lib/public-event-contract.ts index 625804e..c6e5639 100644 --- a/src/lib/public-event-contract.ts +++ b/src/lib/public-event-contract.ts @@ -1,6 +1,10 @@ import { z } from 'zod'; import type { WebsiteEvent } from './events'; -import { normalizePublicHttpUrl, normalizePublicWebsiteUrl } from './public-url'; +import { + normalizePublicHttpUrl, + normalizePublicSlackProfileUrl, + normalizePublicWebsiteUrl, +} from './public-url'; const EVENTS_MANAGEMENT_ORIGIN = new URL('https://em.devcongress.org'); const MAX_EVENTS_RESPONSE_BYTES = 2 * 1024 * 1024; @@ -21,6 +25,15 @@ const publicWebsiteUrlSchema = z 'Expected a public website URL', ); +const publicPrimaryActionSchema = z.object({ + kind: z.literal('slack_profile'), + label: z.string().trim().min(1).max(80), + url: z.string().trim().max(2_048).refine( + (value) => normalizePublicSlackProfileUrl(value) !== null, + 'Expected a Slack member profile URL', + ), +}).nullable().optional().default(null); + export const eventFormatSchema = z.enum([ 'meetup', 'conference', @@ -53,6 +66,7 @@ const publicEventSchema = z stream_url: publicHttpUrlSchema.nullable().optional(), embed_stream: z.boolean().optional().default(false), registration_url: publicWebsiteUrlSchema.nullable(), + primary_action: publicPrimaryActionSchema, organizer_name: z.string().trim().min(1).max(300), organizer_website: publicHttpUrlSchema.nullable(), cover_url: publicWebsiteUrlSchema.nullable(), @@ -176,6 +190,10 @@ export async function readPublicEventJson(response: Response): Promise } function mapPublicEvent(event: PublicEvent): WebsiteEvent { + const primaryActionUrl = event.primary_action + ? normalizePublicSlackProfileUrl(event.primary_action.url) + : null; + return { id: event.id, slug: event.slug, @@ -194,6 +212,13 @@ function mapPublicEvent(event: PublicEvent): WebsiteEvent { streamUrl: normalizePublicHttpUrl(event.stream_url), embedStream: event.embed_stream, registrationUrl: normalizePublicWebsiteUrl(event.registration_url, EVENTS_MANAGEMENT_ORIGIN), + primaryAction: event.primary_action && primaryActionUrl + ? { + kind: event.primary_action.kind, + label: event.primary_action.label, + url: primaryActionUrl, + } + : null, organizerName: event.organizer_name, organizerWebsite: normalizePublicHttpUrl(event.organizer_website), coverUrl: normalizePublicWebsiteUrl(event.cover_url, EVENTS_MANAGEMENT_ORIGIN), diff --git a/src/lib/public-url.ts b/src/lib/public-url.ts index 5d334da..3822874 100644 --- a/src/lib/public-url.ts +++ b/src/lib/public-url.ts @@ -27,6 +27,39 @@ export function normalizePublicWebsiteUrl(value: unknown, relativeOrigin: URL): return normalizePublicHttpUrl(candidate); } +export function normalizePublicSlackProfileUrl(value: unknown): string | null { + if (typeof value !== 'string') return null; + const candidate = value.trim(); + if (!candidate || candidate.length > MAX_PUBLIC_URL_LENGTH) return null; + + try { + const url = new URL(candidate); + const entries = [...url.searchParams.entries()]; + const team = url.searchParams.get('team'); + const member = url.searchParams.get('id'); + const hasOnlyExpectedParameters = entries.length === 2 + && entries.every(([key]) => key === 'team' || key === 'id'); + + if ( + url.protocol !== 'slack:' + || url.hostname !== 'user' + || (url.pathname !== '' && url.pathname !== '/') + || url.username + || url.password + || url.hash + || !hasOnlyExpectedParameters + || !team + || !/^T[A-Z0-9]{8,}$/.test(team) + || !member + || !/^[UW][A-Z0-9]{8,}$/.test(member) + ) return null; + + return `slack://user?team=${encodeURIComponent(team)}&id=${encodeURIComponent(member)}`; + } catch { + return null; + } +} + function isPrivateOrLocalHost(hostname: string): boolean { const host = hostname.toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, ''); if (host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local')) return true; diff --git a/src/pages/events/[slug].astro b/src/pages/events/[slug].astro index 966e4ec..0ffbf43 100644 --- a/src/pages/events/[slug].astro +++ b/src/pages/events/[slug].astro @@ -1,6 +1,7 @@ --- import Base from '../../layouts/Base.astro'; import { getEvents, type WebsiteEvent } from '../../lib/events'; +import { getEventPrimaryAction } from '../../lib/event-card-actions'; export async function getStaticPaths() { const events = await getEvents(); @@ -40,22 +41,8 @@ function getStatus(value: WebsiteEvent): EventStatus { return now < start ? 'upcoming' : now <= end ? 'live' : 'past'; } -function getPrimaryAction(value: WebsiteEvent, valueStatus: EventStatus): { href: string; label: string } | null { - if (valueStatus === 'past') { - return value.streamUrl && !value.embedStream ? { href: value.streamUrl, label: 'Watch recording' } : null; - } - - if (valueStatus === 'live') { - if (value.streamUrl) return { href: value.streamUrl, label: 'Watch live' }; - if (value.onlineUrl) return { href: value.onlineUrl, label: 'Join online' }; - return null; - } - - return value.registrationUrl ? { href: value.registrationUrl, label: 'Register' } : null; -} - const status = getStatus(event); -const action = getPrimaryAction(event, status); +const action = getEventPrimaryAction(event, status); const statusLabel = status === 'live' ? 'Live' : status === 'upcoming' ? 'Upcoming' : 'Past'; const externalAction = action?.href.startsWith('http') ?? false; --- @@ -111,6 +98,7 @@ const externalAction = action?.href.startsWith('http') ?? false;