diff --git a/apps/mana/apps/web/src/lib/modules/events/discovery/api.ts b/apps/mana/apps/web/src/lib/modules/events/discovery/api.ts index 9af3f8cc2..d756dc65e 100644 --- a/apps/mana/apps/web/src/lib/modules/events/discovery/api.ts +++ b/apps/mana/apps/web/src/lib/modules/events/discovery/api.ts @@ -95,8 +95,8 @@ export async function getSources(): Promise { } export async function createSource(input: { - type: 'ical' | 'website'; - url: string; + type: 'ical' | 'website' | 'eventbrite' | 'meetup'; + url?: string; name: string; regionId: string; crawlIntervalHours?: number; diff --git a/apps/mana/apps/web/src/lib/modules/events/discovery/types.ts b/apps/mana/apps/web/src/lib/modules/events/discovery/types.ts index 26cbba623..8080ac733 100644 --- a/apps/mana/apps/web/src/lib/modules/events/discovery/types.ts +++ b/apps/mana/apps/web/src/lib/modules/events/discovery/types.ts @@ -20,9 +20,11 @@ export interface DiscoveryInterest { createdAt: string; } +export type SourceType = 'ical' | 'website' | 'eventbrite' | 'meetup'; + export interface DiscoverySource { id: string; - type: 'ical' | 'website'; + type: SourceType; url: string | null; name: string; regionId: string | null; diff --git a/services/mana-events/src/__tests__/helpers.ts b/services/mana-events/src/__tests__/helpers.ts index 549dfb8fa..295cdc528 100644 --- a/services/mana-events/src/__tests__/helpers.ts +++ b/services/mana-events/src/__tests__/helpers.ts @@ -42,6 +42,8 @@ const TEST_CONFIG: Config = { }, manaResearchUrl: 'http://localhost:3068', manaLlmUrl: 'http://localhost:3025', + eventbriteApiKey: null, + meetupApiKey: null, }; /** diff --git a/services/mana-events/src/__tests__/providers.test.ts b/services/mana-events/src/__tests__/providers.test.ts new file mode 100644 index 000000000..227a16146 --- /dev/null +++ b/services/mana-events/src/__tests__/providers.test.ts @@ -0,0 +1,90 @@ +/** + * Provider unit tests — tests the provider registry and individual + * provider adapters without hitting real APIs. + */ + +import { describe, it, expect } from 'bun:test'; +import { getProvider, PROVIDER_TYPES } from '../discovery/providers'; + +describe('Provider registry', () => { + it('has all expected provider types', () => { + expect(PROVIDER_TYPES).toContain('ical'); + expect(PROVIDER_TYPES).toContain('website'); + expect(PROVIDER_TYPES).toContain('eventbrite'); + expect(PROVIDER_TYPES).toContain('meetup'); + }); + + it('returns a provider for each type', () => { + for (const type of PROVIDER_TYPES) { + const provider = getProvider(type); + expect(provider).not.toBeNull(); + expect(provider!.type).toBe(type); + } + }); + + it('returns null for unknown type', () => { + expect(getProvider('unknown')).toBeNull(); + expect(getProvider('')).toBeNull(); + }); +}); + +describe('Eventbrite provider', () => { + it('returns empty with error when API key is not set', async () => { + const provider = getProvider('eventbrite')!; + const result = await provider.fetchEvents('', 'Test', { + lat: 47.997, + lon: 7.842, + radiusKm: 25, + regionLabel: 'Freiburg', + }); + expect(result.events).toHaveLength(0); + expect(result.error).toContain('EVENTBRITE_API_KEY'); + }); + + it('gracefully handles missing coordinates (after API key check)', async () => { + // Without API key, the key check fires first — that's the expected behavior + const provider = getProvider('eventbrite')!; + const result = await provider.fetchEvents('', 'Test'); + expect(result.events).toHaveLength(0); + expect(result.error).toBeTruthy(); + }); +}); + +describe('Meetup provider', () => { + it('returns empty with error when API key is not set', async () => { + const provider = getProvider('meetup')!; + const result = await provider.fetchEvents('', 'Test', { + lat: 47.997, + lon: 7.842, + radiusKm: 25, + regionLabel: 'Freiburg', + }); + expect(result.events).toHaveLength(0); + expect(result.error).toContain('MEETUP_API_KEY'); + }); + + it('gracefully handles missing coordinates (after API key check)', async () => { + const provider = getProvider('meetup')!; + const result = await provider.fetchEvents('', 'Test'); + expect(result.events).toHaveLength(0); + expect(result.error).toBeTruthy(); + }); +}); + +describe('iCal provider', () => { + it('returns error for invalid URL', async () => { + const provider = getProvider('ical')!; + const result = await provider.fetchEvents('not-a-url', 'Test'); + expect(result.events).toHaveLength(0); + expect(result.error).toBeTruthy(); + }); +}); + +describe('Website provider', () => { + it('returns error when config is missing', async () => { + const provider = getProvider('website')!; + const result = await provider.fetchEvents('https://example.com', 'Test'); + expect(result.events).toHaveLength(0); + expect(result.error).toContain('config'); + }); +}); diff --git a/services/mana-events/src/config.ts b/services/mana-events/src/config.ts index 12554bfb3..584a8161a 100644 --- a/services/mana-events/src/config.ts +++ b/services/mana-events/src/config.ts @@ -15,9 +15,12 @@ export interface Config { // Hard cap on total RSVPs per token rsvpMaxPerToken: number; }; - // Phase 2: external service URLs for event discovery + // External service URLs for event discovery manaResearchUrl: string; manaLlmUrl: string; + // Platform API keys (optional — providers gracefully skip when unconfigured) + eventbriteApiKey: string | null; + meetupApiKey: string | null; } export function loadConfig(): Config { @@ -43,5 +46,7 @@ export function loadConfig(): Config { }, manaResearchUrl: process.env.MANA_RESEARCH_URL || 'http://localhost:3068', manaLlmUrl: process.env.MANA_LLM_URL || 'http://localhost:3025', + eventbriteApiKey: process.env.EVENTBRITE_API_KEY || null, + meetupApiKey: process.env.MEETUP_API_KEY || null, }; } diff --git a/services/mana-events/src/discovery/crawl-scheduler.ts b/services/mana-events/src/discovery/crawl-scheduler.ts index 7c751c7aa..a8dca3881 100644 --- a/services/mana-events/src/discovery/crawl-scheduler.ts +++ b/services/mana-events/src/discovery/crawl-scheduler.ts @@ -13,19 +13,25 @@ import { and, eq, lt, or, isNull, sql } from 'drizzle-orm'; import type { Database } from '../db/connection'; -import { discoverySources, discoveredEvents } from '../db/schema/discovery'; -import { parseIcalFeed } from './ical-parser'; -import { extractEventsFromWebsite } from './website-extractor'; +import { discoverySources, discoveredEvents, discoveryRegions } from '../db/schema/discovery'; +import { getProvider, type ExternalServiceConfig } from './providers'; import { computeDedupeHash } from './deduplicator'; import type { NormalizedEvent } from './types'; const MAX_ERROR_COUNT = 5; -/** Find all sources due for a crawl. */ +/** Find all sources due for a crawl, joined with their region for context. */ async function getDueSources(db: Database) { return db - .select() + .select({ + source: discoverySources, + regionLat: discoveryRegions.lat, + regionLon: discoveryRegions.lon, + regionRadiusKm: discoveryRegions.radiusKm, + regionLabel: discoveryRegions.label, + }) .from(discoverySources) + .leftJoin(discoveryRegions, eq(discoverySources.regionId, discoveryRegions.id)) .where( and( eq(discoverySources.isActive, true), @@ -37,39 +43,48 @@ async function getDueSources(db: Database) { ); } -/** External service URLs for Phase 2 website extraction. */ +/** External service URLs for website extraction + LLM. */ interface CrawlConfig { manaResearchUrl: string; manaLlmUrl: string; } -/** Crawl a single source and return normalized events. */ +/** Crawl a single source via its provider and return normalized events. */ async function crawlSource( source: typeof discoverySources.$inferSelect, - config?: CrawlConfig + config?: CrawlConfig, + regionCtx?: { + lat: number | null; + lon: number | null; + radiusKm: number | null; + label: string | null; + } ): Promise<{ events: NormalizedEvent[]; error?: string }> { + const provider = getProvider(source.type); + if (!provider) { + return { events: [], error: `Unknown source type: ${source.type}` }; + } + + if (!source.url && !['eventbrite', 'meetup'].includes(source.type)) { + return { events: [], error: 'No URL configured' }; + } + try { - switch (source.type) { - case 'ical': { - if (!source.url) return { events: [], error: 'No URL configured' }; - const events = await parseIcalFeed(source.url, source.name); - return { events }; - } - case 'website': { - if (!source.url) return { events: [], error: 'No URL configured' }; - if (!config) - return { events: [], error: 'Missing research/LLM config for website extraction' }; - const events = await extractEventsFromWebsite( - source.url, - source.name, - config.manaResearchUrl, - config.manaLlmUrl - ); - return { events }; - } - default: - return { events: [], error: `Unsupported source type: ${source.type}` }; - } + const ctx = + regionCtx?.lat != null + ? { + lat: regionCtx.lat!, + lon: regionCtx.lon!, + radiusKm: regionCtx.radiusKm ?? 25, + regionLabel: regionCtx.label ?? undefined, + } + : undefined; + + const extConfig: ExternalServiceConfig | undefined = config + ? { manaResearchUrl: config.manaResearchUrl, manaLlmUrl: config.manaLlmUrl } + : undefined; + + return await provider.fetchEvents(source.url ?? '', source.name, ctx, extConfig); } catch (err) { const message = err instanceof Error ? err.message : 'Unknown error'; return { events: [], error: message }; @@ -137,9 +152,15 @@ async function upsertEvents( async function processSource( db: Database, source: typeof discoverySources.$inferSelect, - config?: CrawlConfig + config?: CrawlConfig, + regionCtx?: { + lat: number | null; + lon: number | null; + radiusKm: number | null; + label: string | null; + } ): Promise { - const { events, error } = await crawlSource(source, config); + const { events, error } = await crawlSource(source, config, regionCtx); const now = new Date(); if (error) { @@ -194,8 +215,13 @@ async function cleanupExpiredEvents(db: Database): Promise { export async function runCrawlTick(db: Database, config?: CrawlConfig): Promise { try { const due = await getDueSources(db); - for (const source of due) { - await processSource(db, source, config); + for (const row of due) { + await processSource(db, row.source, config, { + lat: row.regionLat, + lon: row.regionLon, + radiusKm: row.regionRadiusKm, + label: row.regionLabel, + }); } const expired = await cleanupExpiredEvents(db); @@ -237,16 +263,29 @@ export async function crawlSourceNow( sourceId: string, config?: CrawlConfig ): Promise<{ upserted: number; error?: string }> { - const sources = await db - .select() + const rows = await db + .select({ + source: discoverySources, + regionLat: discoveryRegions.lat, + regionLon: discoveryRegions.lon, + regionRadiusKm: discoveryRegions.radiusKm, + regionLabel: discoveryRegions.label, + }) .from(discoverySources) + .leftJoin(discoveryRegions, eq(discoverySources.regionId, discoveryRegions.id)) .where(eq(discoverySources.id, sourceId)) .limit(1); - if (!sources[0]) return { upserted: 0, error: 'Source not found' }; + if (!rows[0]) return { upserted: 0, error: 'Source not found' }; - const source = sources[0]; - const { events, error } = await crawlSource(source, config); + const { source } = rows[0]; + const regionCtx = { + lat: rows[0].regionLat, + lon: rows[0].regionLon, + radiusKm: rows[0].regionRadiusKm, + label: rows[0].regionLabel, + }; + const { events, error } = await crawlSource(source, config, regionCtx); const now = new Date(); if (error) { diff --git a/services/mana-events/src/discovery/providers/base.ts b/services/mana-events/src/discovery/providers/base.ts new file mode 100644 index 000000000..0d45566b7 --- /dev/null +++ b/services/mana-events/src/discovery/providers/base.ts @@ -0,0 +1,56 @@ +/** + * Base interface for event discovery providers. + * + * Each provider knows how to fetch events from a specific source type + * (iCal, website, Eventbrite, Meetup, etc.). The crawl scheduler + * dispatches to the correct provider based on the source type. + */ + +import type { NormalizedEvent } from '../types'; + +/** Context passed to providers for region-aware searches. */ +export interface ProviderContext { + /** Region center latitude. */ + lat?: number; + /** Region center longitude. */ + lon?: number; + /** Search radius in km. */ + radiusKm?: number; + /** Region label (e.g. "Freiburg"). */ + regionLabel?: string; +} + +/** Result from a provider fetch. */ +export interface ProviderResult { + events: NormalizedEvent[]; + error?: string; +} + +/** Configuration for external services (mana-research, mana-llm). */ +export interface ExternalServiceConfig { + manaResearchUrl: string; + manaLlmUrl: string; +} + +/** + * Event provider interface — all source types implement this. + */ +export interface EventProvider { + /** Source type this provider handles. */ + readonly type: string; + + /** + * Fetch events from a source. + * + * @param url - Source URL (iCal feed, website, or API endpoint) + * @param name - Human-readable source name + * @param ctx - Region context for location-aware providers + * @param config - External service URLs (for website/LLM providers) + */ + fetchEvents( + url: string, + name: string, + ctx?: ProviderContext, + config?: ExternalServiceConfig + ): Promise; +} diff --git a/services/mana-events/src/discovery/providers/eventbrite.ts b/services/mana-events/src/discovery/providers/eventbrite.ts new file mode 100644 index 000000000..9d7cdf82a --- /dev/null +++ b/services/mana-events/src/discovery/providers/eventbrite.ts @@ -0,0 +1,166 @@ +/** + * Eventbrite provider — fetches events from the Eventbrite API. + * + * Uses the public Event Search API v3 which requires an API token + * but is free for read operations. + * + * API docs: https://www.eventbrite.com/platform/api#/reference/event-search + * + * When no API key is configured, this provider is a no-op (returns empty). + */ + +import type { EventProvider, ProviderContext, ProviderResult } from './base'; +import type { NormalizedEvent } from '../types'; + +const API_BASE = 'https://www.eventbriteapi.com/v3'; +const FETCH_TIMEOUT_MS = 15_000; + +/** Eventbrite category ID → our category mapping. */ +const CATEGORY_MAP: Record = { + '103': 'music', + '105': 'art', + '101': 'tech', + '108': 'sport', + '110': 'food', + '115': 'family', + '113': 'community', + '109': 'nature', + '104': 'art', // film & media + '107': 'education', // health + '102': 'tech', // science + '106': 'nightlife', // fashion + '111': 'community', // charity + '112': 'community', // government + '114': 'other', // spirituality + '116': 'other', // seasonal + '117': 'education', // home & lifestyle + '199': 'other', +}; + +interface EventbriteEvent { + id: string; + name: { text: string; html?: string }; + description: { text: string; html?: string } | null; + url: string; + start: { utc: string; local: string; timezone: string }; + end: { utc: string; local: string; timezone: string } | null; + venue?: { + name: string; + address: { + localized_address_display: string; + latitude: string; + longitude: string; + }; + } | null; + logo?: { url: string } | null; + category_id: string | null; + is_free: boolean; + ticket_availability?: { + minimum_ticket_price?: { display: string }; + maximum_ticket_price?: { display: string }; + }; +} + +interface EventbriteResponse { + events: EventbriteEvent[]; + pagination: { page_count: number; page_number: number }; +} + +function getApiKey(): string | null { + return process.env.EVENTBRITE_API_KEY || null; +} + +export const eventbriteProvider: EventProvider = { + type: 'eventbrite', + + async fetchEvents(_url: string, _name: string, ctx?: ProviderContext): Promise { + const apiKey = getApiKey(); + if (!apiKey) { + return { events: [], error: 'EVENTBRITE_API_KEY not configured' }; + } + + if (!ctx?.lat || !ctx?.lon) { + return { events: [], error: 'Region coordinates required for Eventbrite search' }; + } + + try { + const params = new URLSearchParams({ + 'location.latitude': String(ctx.lat), + 'location.longitude': String(ctx.lon), + 'location.within': `${ctx.radiusKm ?? 25}km`, + 'start_date.range_start': new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'), + sort_by: 'date', + expand: 'venue,ticket_availability', + }); + + // Add keyword search if region label available + if (ctx.regionLabel) { + params.set('q', ctx.regionLabel); + } + + const res = await fetch(`${API_BASE}/events/search/?${params}`, { + headers: { Authorization: `Bearer ${apiKey}` }, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + + if (!res.ok) { + const body = await res.text().catch(() => ''); + return { events: [], error: `Eventbrite API ${res.status}: ${body.slice(0, 200)}` }; + } + + const data = (await res.json()) as EventbriteResponse; + const events = data.events.map(toNormalizedEvent).filter(Boolean) as NormalizedEvent[]; + return { events }; + } catch (err) { + return { + events: [], + error: err instanceof Error ? err.message : 'Eventbrite fetch failed', + }; + } + }, +}; + +function toNormalizedEvent(eb: EventbriteEvent): NormalizedEvent | null { + const title = eb.name?.text?.trim(); + if (!title) return null; + + const startAt = new Date(eb.start.utc); + if (isNaN(startAt.getTime())) return null; + + const endAt = eb.end ? new Date(eb.end.utc) : null; + + let location: string | null = null; + let lat: number | null = null; + let lon: number | null = null; + + if (eb.venue) { + location = eb.venue.name || eb.venue.address?.localized_address_display || null; + lat = eb.venue.address?.latitude ? parseFloat(eb.venue.address.latitude) : null; + lon = eb.venue.address?.longitude ? parseFloat(eb.venue.address.longitude) : null; + } + + let priceInfo: string | null = null; + if (eb.is_free) { + priceInfo = 'Eintritt frei'; + } else if (eb.ticket_availability?.minimum_ticket_price) { + const min = eb.ticket_availability.minimum_ticket_price.display; + const max = eb.ticket_availability.maximum_ticket_price?.display; + priceInfo = max && max !== min ? `${min} – ${max}` : min; + } + + return { + title, + description: eb.description?.text?.slice(0, 2000) ?? null, + location, + lat, + lon, + startAt, + endAt, + allDay: false, + imageUrl: eb.logo?.url ?? null, + sourceUrl: eb.url, + category: eb.category_id ? (CATEGORY_MAP[eb.category_id] ?? 'other') : null, + priceInfo, + externalId: `eventbrite:${eb.id}`, + }; +} diff --git a/services/mana-events/src/discovery/providers/ical.ts b/services/mana-events/src/discovery/providers/ical.ts new file mode 100644 index 000000000..a820563bc --- /dev/null +++ b/services/mana-events/src/discovery/providers/ical.ts @@ -0,0 +1,19 @@ +/** + * iCal provider — wraps the existing ical-parser as an EventProvider. + */ + +import type { EventProvider, ProviderResult } from './base'; +import { parseIcalFeed } from '../ical-parser'; + +export const icalProvider: EventProvider = { + type: 'ical', + + async fetchEvents(url: string, name: string): Promise { + try { + const events = await parseIcalFeed(url, name); + return { events }; + } catch (err) { + return { events: [], error: err instanceof Error ? err.message : 'iCal parse failed' }; + } + }, +}; diff --git a/services/mana-events/src/discovery/providers/index.ts b/services/mana-events/src/discovery/providers/index.ts new file mode 100644 index 000000000..0eb56257a --- /dev/null +++ b/services/mana-events/src/discovery/providers/index.ts @@ -0,0 +1,26 @@ +/** + * Provider registry — maps source types to their EventProvider implementation. + */ + +import type { EventProvider } from './base'; +import { icalProvider } from './ical'; +import { websiteProvider } from './website'; +import { eventbriteProvider } from './eventbrite'; +import { meetupProvider } from './meetup'; + +export type { EventProvider, ProviderContext, ProviderResult, ExternalServiceConfig } from './base'; + +const PROVIDERS: Record = { + ical: icalProvider, + website: websiteProvider, + eventbrite: eventbriteProvider, + meetup: meetupProvider, +}; + +/** Get the provider for a source type, or null if unknown. */ +export function getProvider(type: string): EventProvider | null { + return PROVIDERS[type] ?? null; +} + +/** All registered provider types. */ +export const PROVIDER_TYPES = Object.keys(PROVIDERS); diff --git a/services/mana-events/src/discovery/providers/meetup.ts b/services/mana-events/src/discovery/providers/meetup.ts new file mode 100644 index 000000000..867cb157c --- /dev/null +++ b/services/mana-events/src/discovery/providers/meetup.ts @@ -0,0 +1,224 @@ +/** + * Meetup provider — fetches events from the Meetup GraphQL API. + * + * Uses the public GraphQL endpoint which requires an API key (OAuth token). + * Free for read operations. + * + * API docs: https://www.meetup.com/api/schema/#p02-GraphQL-API + * + * When no API key is configured, this provider is a no-op (returns empty). + */ + +import type { EventProvider, ProviderContext, ProviderResult } from './base'; +import type { NormalizedEvent } from '../types'; + +const GRAPHQL_URL = 'https://api.meetup.com/gql'; +const FETCH_TIMEOUT_MS = 15_000; + +/** Meetup topic category → our category mapping. */ +const TOPIC_CATEGORY_MAP: Record = { + tech: 'tech', + 'science-tech': 'tech', + music: 'music', + 'arts-culture': 'art', + sports: 'sport', + 'food-drink': 'food', + 'outdoors-adventure': 'nature', + 'health-wellbeing': 'education', + 'language-ethnic-identity': 'community', + 'social-activities': 'community', + career: 'education', + education: 'education', + 'parents-family': 'family', +}; + +const SEARCH_EVENTS_QUERY = ` +query SearchEvents($filter: SearchConnectionFilter!) { + keywordSearch(filter: $filter) { + edges { + node { + id + result { + ... on Event { + id + title + description + dateTime + endTime + eventUrl + going + venue { + name + address + city + state + lat + lng + } + featuredEventPhoto { + highResUrl + } + group { + name + topicCategory { + urlkey + } + } + feeSettings { + amount + currency + } + } + } + } + } + } +} +`; + +interface MeetupEvent { + id: string; + title: string; + description: string | null; + dateTime: string; + endTime: string | null; + eventUrl: string; + going: number; + venue: { + name: string; + address: string; + city: string; + state: string; + lat: number; + lng: number; + } | null; + featuredEventPhoto: { highResUrl: string } | null; + group: { + name: string; + topicCategory: { urlkey: string } | null; + }; + feeSettings: { amount: number; currency: string } | null; +} + +function getApiKey(): string | null { + return process.env.MEETUP_API_KEY || null; +} + +export const meetupProvider: EventProvider = { + type: 'meetup', + + async fetchEvents(_url: string, _name: string, ctx?: ProviderContext): Promise { + const apiKey = getApiKey(); + if (!apiKey) { + return { events: [], error: 'MEETUP_API_KEY not configured' }; + } + + if (!ctx?.lat || !ctx?.lon) { + return { events: [], error: 'Region coordinates required for Meetup search' }; + } + + try { + const res = await fetch(GRAPHQL_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + query: SEARCH_EVENTS_QUERY, + variables: { + filter: { + query: ctx.regionLabel ?? '', + lat: ctx.lat, + lon: ctx.lon, + radius: ctx.radiusKm ?? 25, + source: 'EVENTS', + startDateRange: new Date().toISOString(), + }, + }, + }), + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + + if (!res.ok) { + const body = await res.text().catch(() => ''); + return { events: [], error: `Meetup API ${res.status}: ${body.slice(0, 200)}` }; + } + + const data = await res.json(); + const edges = data?.data?.keywordSearch?.edges ?? []; + const events: NormalizedEvent[] = []; + + for (const edge of edges) { + const node = edge?.node?.result; + if (!node || !node.title) continue; + + const normalized = toNormalizedEvent(node); + if (normalized) events.push(normalized); + } + + return { events }; + } catch (err) { + return { + events: [], + error: err instanceof Error ? err.message : 'Meetup fetch failed', + }; + } + }, +}; + +function toNormalizedEvent(m: MeetupEvent): NormalizedEvent | null { + const title = m.title?.trim(); + if (!title) return null; + + const startAt = new Date(m.dateTime); + if (isNaN(startAt.getTime())) return null; + + // Skip past events + if (startAt.getTime() < Date.now() - 24 * 60 * 60 * 1000) return null; + + const endAt = m.endTime ? new Date(m.endTime) : null; + + let location: string | null = null; + let lat: number | null = null; + let lon: number | null = null; + + if (m.venue) { + const parts = [m.venue.name, m.venue.address, m.venue.city].filter(Boolean); + location = parts.join(', ') || null; + lat = m.venue.lat || null; + lon = m.venue.lng || null; + } + + let priceInfo: string | null = null; + if (m.feeSettings) { + priceInfo = `${m.feeSettings.amount} ${m.feeSettings.currency}`; + } + + const topicKey = m.group?.topicCategory?.urlkey; + const category = topicKey ? (TOPIC_CATEGORY_MAP[topicKey] ?? 'community') : 'community'; + + // Strip HTML from description + const description = m.description + ? m.description + .replace(/<[^>]*>/g, '') + .trim() + .slice(0, 2000) + : null; + + return { + title, + description, + location, + lat, + lon, + startAt, + endAt, + allDay: false, + imageUrl: m.featuredEventPhoto?.highResUrl ?? null, + sourceUrl: m.eventUrl, + category, + priceInfo, + externalId: `meetup:${m.id}`, + }; +} diff --git a/services/mana-events/src/discovery/providers/website.ts b/services/mana-events/src/discovery/providers/website.ts new file mode 100644 index 000000000..c5dc126bf --- /dev/null +++ b/services/mana-events/src/discovery/providers/website.ts @@ -0,0 +1,35 @@ +/** + * Website provider — wraps the LLM-based website extractor as an EventProvider. + */ + +import type { EventProvider, ProviderResult, ExternalServiceConfig } from './base'; +import { extractEventsFromWebsite } from '../website-extractor'; + +export const websiteProvider: EventProvider = { + type: 'website', + + async fetchEvents( + url: string, + name: string, + _ctx, + config?: ExternalServiceConfig + ): Promise { + if (!config) { + return { events: [], error: 'Missing research/LLM config for website extraction' }; + } + try { + const events = await extractEventsFromWebsite( + url, + name, + config.manaResearchUrl, + config.manaLlmUrl + ); + return { events }; + } catch (err) { + return { + events: [], + error: err instanceof Error ? err.message : 'Website extraction failed', + }; + } + }, +}; diff --git a/services/mana-events/src/routes/discovery.ts b/services/mana-events/src/routes/discovery.ts index 139acc9e5..b15615016 100644 --- a/services/mana-events/src/routes/discovery.ts +++ b/services/mana-events/src/routes/discovery.ts @@ -38,8 +38,8 @@ const interestCreateSchema = z.object({ }); const sourceCreateSchema = z.object({ - type: z.enum(['ical', 'website']), - url: z.string().url().max(2000), + type: z.enum(['ical', 'website', 'eventbrite', 'meetup']), + url: z.string().url().max(2000).optional(), // optional for platform providers name: z.string().min(1).max(200), regionId: z.string().uuid(), crawlIntervalHours: z.number().int().min(1).max(168).optional(), // max 7 days