mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-26 07:04:38 +02:00
Phase 1: Manual iCal feeds + Discovery tab - 5 new DB tables in event_discovery schema (regions, interests, sources, discovered_events, user_actions) - iCal parser (node-ical) with deduplication (SHA-256 hash) - Crawl scheduler (15-min interval, auto-deactivate after 5 errors) - CRUD routes for regions, interests, sources + paginated feed endpoint - Frontend: "Meine Events" / "Entdecken" tab navigation in ListView - Discovery setup wizard (regions via mana-geocoding + interests) - DiscoveredEventCard with save/dismiss, SourceManager for iCal feeds - "Merken" creates a local socialEvent from discovered event Phase 2: Auto source discovery + LLM extraction + relevance scoring - Source discoverer: web search via mana-research to auto-find iCal feeds and venue websites for a region - Website extractor: crawl via mana-research /extract, then LLM-based event extraction via mana-llm with structured JSON output - Flexible date parsing (ISO, DD.MM.YYYY), markdown fence stripping - Relevance scorer: category match, freetext match, haversine distance, time proximity, weekend bonus (0-100 clamped) - Routes: POST regions/:id/discover-sources, PUT/DELETE sources/:id/activate|reject - Frontend: "Automatisch finden" button, suggested vs active sources UI 107 tests (all passing), no regressions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
144 lines
5.6 KiB
TypeScript
144 lines
5.6 KiB
TypeScript
/**
|
|
* Event Discovery schema — regions, interests, sources, discovered events,
|
|
* and user actions (save/dismiss).
|
|
*
|
|
* All tables live in the `event_discovery` Postgres schema inside `mana_platform`.
|
|
* Data is server-authoritative (not local-first) — the client caches results
|
|
* but the server owns the crawl loop and deduplication.
|
|
*/
|
|
|
|
import {
|
|
pgSchema,
|
|
uuid,
|
|
integer,
|
|
text,
|
|
real,
|
|
timestamp,
|
|
boolean,
|
|
doublePrecision,
|
|
uniqueIndex,
|
|
index,
|
|
jsonb,
|
|
} from 'drizzle-orm/pg-core';
|
|
|
|
export const discoverySchema = pgSchema('event_discovery');
|
|
|
|
// ─── Regions ────────────────────────────────────────────────────────
|
|
|
|
/** Geographic areas the user wants to discover events in. */
|
|
export const discoveryRegions = discoverySchema.table(
|
|
'discovery_regions',
|
|
{
|
|
id: uuid('id').defaultRandom().primaryKey(),
|
|
userId: text('user_id').notNull(),
|
|
label: text('label').notNull(),
|
|
lat: doublePrecision('lat').notNull(),
|
|
lon: doublePrecision('lon').notNull(),
|
|
radiusKm: integer('radius_km').default(25).notNull(),
|
|
isActive: boolean('is_active').default(true).notNull(),
|
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
|
},
|
|
(t) => ({
|
|
userIdx: index('discovery_regions_user_idx').on(t.userId),
|
|
})
|
|
);
|
|
|
|
// ─── Interests ──────────────────────────────────────────────────────
|
|
|
|
/** User interests for relevance scoring. */
|
|
export const discoveryInterests = discoverySchema.table(
|
|
'discovery_interests',
|
|
{
|
|
id: uuid('id').defaultRandom().primaryKey(),
|
|
userId: text('user_id').notNull(),
|
|
category: text('category').notNull(),
|
|
freetext: text('freetext'),
|
|
weight: real('weight').default(1.0).notNull(),
|
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
|
},
|
|
(t) => ({
|
|
userIdx: index('discovery_interests_user_idx').on(t.userId),
|
|
})
|
|
);
|
|
|
|
// ─── Sources ────────────────────────────────────────────────────────
|
|
|
|
/** Event sources that are periodically crawled (iCal feeds, websites, …). */
|
|
export const discoverySources = discoverySchema.table(
|
|
'discovery_sources',
|
|
{
|
|
id: uuid('id').defaultRandom().primaryKey(),
|
|
userId: text('user_id').notNull(),
|
|
type: text('type').notNull(), // 'ical' | 'website' | 'api' | 'search_query'
|
|
url: text('url'),
|
|
name: text('name').notNull(),
|
|
regionId: uuid('region_id').references(() => discoveryRegions.id, { onDelete: 'cascade' }),
|
|
crawlIntervalHours: integer('crawl_interval_hours').default(24).notNull(),
|
|
lastCrawledAt: timestamp('last_crawled_at', { withTimezone: true }),
|
|
lastSuccessAt: timestamp('last_success_at', { withTimezone: true }),
|
|
errorCount: integer('error_count').default(0).notNull(),
|
|
lastError: text('last_error'),
|
|
isActive: boolean('is_active').default(true).notNull(),
|
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
|
},
|
|
(t) => ({
|
|
userActiveIdx: index('discovery_sources_user_active_idx').on(t.userId, t.isActive),
|
|
})
|
|
);
|
|
|
|
// ─── Discovered Events ──────────────────────────────────────────────
|
|
|
|
/** Normalized events found by crawling sources. Deduplicated by hash. */
|
|
export const discoveredEvents = discoverySchema.table(
|
|
'discovered_events',
|
|
{
|
|
id: uuid('id').defaultRandom().primaryKey(),
|
|
sourceId: uuid('source_id')
|
|
.notNull()
|
|
.references(() => discoverySources.id, { onDelete: 'cascade' }),
|
|
externalId: text('external_id'),
|
|
dedupeHash: text('dedupe_hash').notNull(),
|
|
title: text('title').notNull(),
|
|
description: text('description'),
|
|
location: text('location'),
|
|
lat: doublePrecision('lat'),
|
|
lon: doublePrecision('lon'),
|
|
startAt: timestamp('start_at', { withTimezone: true }).notNull(),
|
|
endAt: timestamp('end_at', { withTimezone: true }),
|
|
allDay: boolean('all_day').default(false).notNull(),
|
|
imageUrl: text('image_url'),
|
|
sourceUrl: text('source_url').notNull(),
|
|
sourceName: text('source_name'),
|
|
category: text('category'),
|
|
priceInfo: text('price_info'),
|
|
rawExtracted: jsonb('raw_extracted'),
|
|
crawledAt: timestamp('crawled_at', { withTimezone: true }).defaultNow().notNull(),
|
|
expiresAt: timestamp('expires_at', { withTimezone: true }),
|
|
},
|
|
(t) => ({
|
|
dedupeIdx: uniqueIndex('discovered_events_dedupe_idx').on(t.dedupeHash),
|
|
startIdx: index('discovered_events_start_idx').on(t.startAt),
|
|
sourceIdx: index('discovered_events_source_idx').on(t.sourceId),
|
|
})
|
|
);
|
|
|
|
// ─── User Actions ───────────────────────────────────────────────────
|
|
|
|
/** Tracks user interaction with discovered events (save, dismiss). */
|
|
export const discoveryUserActions = discoverySchema.table(
|
|
'discovery_user_actions',
|
|
{
|
|
id: uuid('id').defaultRandom().primaryKey(),
|
|
userId: text('user_id').notNull(),
|
|
eventId: uuid('event_id')
|
|
.notNull()
|
|
.references(() => discoveredEvents.id, { onDelete: 'cascade' }),
|
|
action: text('action').notNull(), // 'save' | 'dismiss'
|
|
actedAt: timestamp('acted_at', { withTimezone: true }).defaultNow().notNull(),
|
|
},
|
|
(t) => ({
|
|
userEventUnique: uniqueIndex('discovery_user_actions_user_event_idx').on(t.userId, t.eventId),
|
|
userIdx: index('discovery_user_actions_user_idx').on(t.userId),
|
|
})
|
|
);
|