mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 17:41:09 +02:00
feat(events): add Event Discovery — Phase 1 + 2
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>
This commit is contained in:
parent
677123091a
commit
b5d55fdb21
34 changed files with 5105 additions and 45 deletions
|
|
@ -4,6 +4,7 @@
|
|||
import { eventsStore } from './stores/events.svelte';
|
||||
import { drainTombstones } from './tombstones';
|
||||
import EventCard from './components/EventCard.svelte';
|
||||
import DiscoveryTab from './components/DiscoveryTab.svelte';
|
||||
import type { SocialEvent } from './types';
|
||||
import type { ViewProps } from '$lib/app-registry';
|
||||
|
||||
|
|
@ -13,6 +14,8 @@
|
|||
const past = usePastEvents();
|
||||
const guestsByEvent = useGuestsByEvent();
|
||||
|
||||
let activeTab = $state<'mine' | 'discover'>('mine');
|
||||
|
||||
// Retry any orphaned server snapshots from previous failed deletes.
|
||||
onMount(() => {
|
||||
void drainTombstones();
|
||||
|
|
@ -60,55 +63,72 @@
|
|||
</svelte:head>
|
||||
|
||||
<div class="events-page">
|
||||
<header class="events-header">
|
||||
<p class="page-subtitle">
|
||||
{(upcoming.value ?? []).length} bevorstehend · {(past.value ?? []).length} vergangen
|
||||
</p>
|
||||
<button class="new-btn" onclick={() => (showCreate = !showCreate)}>
|
||||
{showCreate ? 'Abbrechen' : '+ Neues Event'}
|
||||
<div class="tab-bar">
|
||||
<button class="tab" class:active={activeTab === 'mine'} onclick={() => (activeTab = 'mine')}>
|
||||
Meine Events
|
||||
</button>
|
||||
</header>
|
||||
<button
|
||||
class="tab"
|
||||
class:active={activeTab === 'discover'}
|
||||
onclick={() => (activeTab = 'discover')}
|
||||
>
|
||||
Entdecken
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showCreate}
|
||||
<form class="create-form" onsubmit={handleCreate}>
|
||||
<input
|
||||
class="input"
|
||||
bind:value={newTitle}
|
||||
placeholder="Worum geht's? (z. B. Geburtstag Anna)"
|
||||
required
|
||||
/>
|
||||
<div class="form-row">
|
||||
<input class="input" type="date" bind:value={newDate} required />
|
||||
<input class="input" type="time" bind:value={newTime} />
|
||||
<input class="input" bind:value={newLocation} placeholder="Ort (optional)" />
|
||||
</div>
|
||||
<button type="submit" class="action-btn primary">Event anlegen</button>
|
||||
</form>
|
||||
{/if}
|
||||
{#if activeTab === 'mine'}
|
||||
<header class="events-header">
|
||||
<p class="page-subtitle">
|
||||
{(upcoming.value ?? []).length} bevorstehend · {(past.value ?? []).length} vergangen
|
||||
</p>
|
||||
<button class="new-btn" onclick={() => (showCreate = !showCreate)}>
|
||||
{showCreate ? 'Abbrechen' : '+ Neues Event'}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<section class="event-section">
|
||||
<h2 class="section-title">Bevorstehend</h2>
|
||||
{#if (upcoming.value ?? []).length === 0}
|
||||
<p class="empty">Keine bevorstehenden Events. Zeit für eine Party?</p>
|
||||
{:else}
|
||||
<div class="event-list">
|
||||
{#each upcoming.value ?? [] as event (event.id)}
|
||||
{@const summary = summarizeRsvps(guestsByEvent.value?.get(event.id) ?? [])}
|
||||
<EventCard {event} {summary} onclick={() => open(event)} />
|
||||
{/each}
|
||||
</div>
|
||||
{#if showCreate}
|
||||
<form class="create-form" onsubmit={handleCreate}>
|
||||
<input
|
||||
class="input"
|
||||
bind:value={newTitle}
|
||||
placeholder="Worum geht's? (z. B. Geburtstag Anna)"
|
||||
required
|
||||
/>
|
||||
<div class="form-row">
|
||||
<input class="input" type="date" bind:value={newDate} required />
|
||||
<input class="input" type="time" bind:value={newTime} />
|
||||
<input class="input" bind:value={newLocation} placeholder="Ort (optional)" />
|
||||
</div>
|
||||
<button type="submit" class="action-btn primary">Event anlegen</button>
|
||||
</form>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if (past.value ?? []).length > 0}
|
||||
<section class="event-section">
|
||||
<h2 class="section-title">Vergangen</h2>
|
||||
<div class="event-list">
|
||||
{#each past.value ?? [] as event (event.id)}
|
||||
<EventCard {event} onclick={() => open(event)} />
|
||||
{/each}
|
||||
</div>
|
||||
<h2 class="section-title">Bevorstehend</h2>
|
||||
{#if (upcoming.value ?? []).length === 0}
|
||||
<p class="empty">Keine bevorstehenden Events. Zeit fur eine Party?</p>
|
||||
{:else}
|
||||
<div class="event-list">
|
||||
{#each upcoming.value ?? [] as event (event.id)}
|
||||
{@const summary = summarizeRsvps(guestsByEvent.value?.get(event.id) ?? [])}
|
||||
<EventCard {event} {summary} onclick={() => open(event)} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if (past.value ?? []).length > 0}
|
||||
<section class="event-section">
|
||||
<h2 class="section-title">Vergangen</h2>
|
||||
<div class="event-list">
|
||||
{#each past.value ?? [] as event (event.id)}
|
||||
<EventCard {event} onclick={() => open(event)} />
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
{:else}
|
||||
<DiscoveryTab />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
|
@ -121,6 +141,30 @@
|
|||
max-width: 880px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 1px solid hsl(var(--color-border));
|
||||
}
|
||||
.tab {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.tab.active {
|
||||
color: hsl(var(--color-foreground));
|
||||
border-bottom-color: hsl(var(--color-primary));
|
||||
}
|
||||
.tab:hover:not(.active) {
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
.events-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,204 @@
|
|||
<script lang="ts">
|
||||
import type { DiscoveredEvent } from '../discovery/types';
|
||||
|
||||
interface Props {
|
||||
event: DiscoveredEvent;
|
||||
onSave?: () => void;
|
||||
onDismiss?: () => void;
|
||||
}
|
||||
|
||||
let { event, onSave, onDismiss }: Props = $props();
|
||||
|
||||
const startDate = $derived(new Date(event.startAt));
|
||||
const dateLabel = $derived(
|
||||
startDate.toLocaleDateString('de-DE', {
|
||||
weekday: 'short',
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
})
|
||||
);
|
||||
const timeLabel = $derived(
|
||||
event.allDay
|
||||
? 'Ganztag'
|
||||
: startDate.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
|
||||
);
|
||||
|
||||
const isSaved = $derived(event.userAction === 'save');
|
||||
</script>
|
||||
|
||||
<div class="discovered-card">
|
||||
<div class="date-block">
|
||||
<div class="date">{dateLabel}</div>
|
||||
<div class="time">{timeLabel}</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="title-row">
|
||||
<h3 class="title">{event.title}</h3>
|
||||
{#if event.category}
|
||||
<span class="category-badge">{event.category}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if event.location}
|
||||
<div class="meta-line">{event.location}</div>
|
||||
{/if}
|
||||
{#if event.priceInfo}
|
||||
<div class="meta-line">{event.priceInfo}</div>
|
||||
{/if}
|
||||
{#if event.description}
|
||||
<div class="description">
|
||||
{event.description.slice(0, 150)}{event.description.length > 150 ? '...' : ''}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="footer">
|
||||
{#if event.sourceName}
|
||||
<a class="source-link" href={event.sourceUrl} target="_blank" rel="noopener">
|
||||
{event.sourceName}
|
||||
</a>
|
||||
{:else}
|
||||
<a class="source-link" href={event.sourceUrl} target="_blank" rel="noopener"> Quelle </a>
|
||||
{/if}
|
||||
<div class="actions">
|
||||
{#if isSaved}
|
||||
<span class="saved-label">Gespeichert</span>
|
||||
{:else}
|
||||
<button class="action-btn save" onclick={onSave}>Merken</button>
|
||||
<button class="action-btn dismiss" onclick={onDismiss}>Ausblenden</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.discovered-card {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.625rem;
|
||||
background: hsl(var(--color-card));
|
||||
overflow: hidden;
|
||||
transition:
|
||||
transform 0.1s ease,
|
||||
box-shadow 0.15s;
|
||||
}
|
||||
.discovered-card:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
.date-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 4.5rem;
|
||||
padding: 0.75rem 0.5rem;
|
||||
background: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
}
|
||||
.date {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.time {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 700;
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
.card-body {
|
||||
flex: 1;
|
||||
padding: 0.625rem 0.875rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.title {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-foreground));
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.category-badge {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
background: hsl(var(--color-muted));
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
white-space: nowrap;
|
||||
}
|
||||
.meta-line {
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.description {
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
line-height: 1.4;
|
||||
}
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 0.25rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.source-link {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-primary));
|
||||
text-decoration: none;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.source-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.action-btn {
|
||||
padding: 0.25rem 0.625rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.375rem;
|
||||
background: hsl(var(--color-card));
|
||||
color: hsl(var(--color-foreground));
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.action-btn.save {
|
||||
background: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
border-color: transparent;
|
||||
}
|
||||
.action-btn.dismiss {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.action-btn.dismiss:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
.saved-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: rgb(22, 163, 74);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,169 @@
|
|||
<script lang="ts">
|
||||
import { discoveryStore } from '../discovery/store.svelte';
|
||||
import { EVENT_CATEGORIES } from '../discovery/types';
|
||||
import RegionPicker from './RegionPicker.svelte';
|
||||
|
||||
interface Props {
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
let { onComplete }: Props = $props();
|
||||
|
||||
let step = $state<1 | 2>(1);
|
||||
let selectedCategories = $state<Set<string>>(new Set());
|
||||
let freetext = $state('');
|
||||
|
||||
function toggleCategory(id: string) {
|
||||
const next = new Set(selectedCategories);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
selectedCategories = next;
|
||||
}
|
||||
|
||||
async function finishSetup() {
|
||||
// Save interests
|
||||
for (const cat of selectedCategories) {
|
||||
await discoveryStore.addInterest({ category: cat });
|
||||
}
|
||||
if (freetext.trim()) {
|
||||
for (const text of freetext
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean)) {
|
||||
await discoveryStore.addInterest({ category: 'other', freetext: text });
|
||||
}
|
||||
}
|
||||
onComplete?.();
|
||||
}
|
||||
|
||||
const canProceed = $derived(discoveryStore.regions.length > 0);
|
||||
const canFinish = $derived(selectedCategories.size > 0 || freetext.trim().length > 0);
|
||||
</script>
|
||||
|
||||
<div class="setup">
|
||||
<h2 class="setup-title">Event-Entdeckung einrichten</h2>
|
||||
|
||||
{#if step === 1}
|
||||
<div class="step">
|
||||
<p class="step-desc">Welche Regionen sollen nach Events durchsucht werden?</p>
|
||||
<RegionPicker regions={discoveryStore.regions} />
|
||||
<button class="next-btn" disabled={!canProceed} onclick={() => (step = 2)}> Weiter </button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="step">
|
||||
<p class="step-desc">Was interessiert dich?</p>
|
||||
<div class="category-grid">
|
||||
{#each EVENT_CATEGORIES as cat}
|
||||
<button
|
||||
class="category-chip"
|
||||
class:selected={selectedCategories.has(cat.id)}
|
||||
onclick={() => toggleCategory(cat.id)}
|
||||
>
|
||||
{cat.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<input
|
||||
class="input"
|
||||
bind:value={freetext}
|
||||
placeholder="Weitere Interessen (kommagetrennt, z.B. Impro-Theater, Rust Meetups)"
|
||||
/>
|
||||
<div class="step-actions">
|
||||
<button class="back-btn" onclick={() => (step = 1)}>Zuruck</button>
|
||||
<button class="next-btn" disabled={!canFinish} onclick={finishSetup}> Fertig </button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.setup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1.5rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.75rem;
|
||||
background: hsl(var(--color-card));
|
||||
}
|
||||
.setup-title {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
.step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.step-desc {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.category-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.category-chip {
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 1rem;
|
||||
background: hsl(var(--color-background));
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
color: hsl(var(--color-foreground));
|
||||
transition:
|
||||
background 0.1s,
|
||||
border-color 0.1s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.category-chip.selected {
|
||||
background: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
border-color: hsl(var(--color-primary));
|
||||
}
|
||||
.input {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.375rem;
|
||||
background: hsl(var(--color-background));
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--color-foreground));
|
||||
font-family: inherit;
|
||||
}
|
||||
.step-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.next-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.next-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.back-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-card));
|
||||
color: hsl(var(--color-foreground));
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,188 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { discoveryStore } from '../discovery/store.svelte';
|
||||
import DiscoverySetup from './DiscoverySetup.svelte';
|
||||
import DiscoveredEventCard from './DiscoveredEventCard.svelte';
|
||||
import RegionPicker from './RegionPicker.svelte';
|
||||
import SourceManager from './SourceManager.svelte';
|
||||
|
||||
let initialized = $state(false);
|
||||
let showSources = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
await discoveryStore.init();
|
||||
initialized = true;
|
||||
});
|
||||
|
||||
async function handleSetupComplete() {
|
||||
await discoveryStore.refreshFeed();
|
||||
}
|
||||
|
||||
async function handleSave(eventId: string) {
|
||||
await discoveryStore.saveEvent(eventId);
|
||||
}
|
||||
|
||||
async function handleDismiss(eventId: string) {
|
||||
await discoveryStore.dismissEvent(eventId);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="discovery-tab">
|
||||
{#if !initialized}
|
||||
<p class="loading">Lade...</p>
|
||||
{:else if !discoveryStore.isSetUp}
|
||||
<DiscoverySetup onComplete={handleSetupComplete} />
|
||||
{:else}
|
||||
<div class="controls">
|
||||
<RegionPicker regions={discoveryStore.regions} />
|
||||
<div class="control-row">
|
||||
<button class="control-btn" onclick={() => discoveryStore.refreshFeed()}>
|
||||
Aktualisieren
|
||||
</button>
|
||||
<button
|
||||
class="control-btn"
|
||||
class:active={showSources}
|
||||
onclick={() => (showSources = !showSources)}
|
||||
>
|
||||
Quellen {discoveryStore.sources.length > 0 ? `(${discoveryStore.sources.length})` : ''}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showSources}
|
||||
<SourceManager sources={discoveryStore.sources} regions={discoveryStore.regions} />
|
||||
{/if}
|
||||
|
||||
{#if discoveryStore.loading}
|
||||
<p class="loading">Lade Events...</p>
|
||||
{:else if discoveryStore.error}
|
||||
<p class="error-msg">{discoveryStore.error}</p>
|
||||
{:else if discoveryStore.feed.length === 0}
|
||||
<div class="empty">
|
||||
<p class="empty-title">Noch keine Events gefunden</p>
|
||||
<p class="empty-hint">
|
||||
Fuge iCal-Feeds von Venues oder Vereinen hinzu, um Events zu entdecken.
|
||||
</p>
|
||||
{#if !showSources}
|
||||
<button class="action-btn" onclick={() => (showSources = true)}>
|
||||
Quellen verwalten
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="feed">
|
||||
{#each discoveryStore.feed as event (event.id)}
|
||||
<DiscoveredEventCard
|
||||
{event}
|
||||
onSave={() => handleSave(event.id)}
|
||||
onDismiss={() => handleDismiss(event.id)}
|
||||
/>
|
||||
{/each}
|
||||
{#if discoveryStore.feedHasMore}
|
||||
<button
|
||||
class="load-more"
|
||||
onclick={() => discoveryStore.refreshFeed({ offset: discoveryStore.feed.length })}
|
||||
>
|
||||
Mehr laden
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.discovery-tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.control-row {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.control-btn {
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.375rem;
|
||||
background: hsl(var(--color-card));
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
color: hsl(var(--color-foreground));
|
||||
font-family: inherit;
|
||||
}
|
||||
.control-btn.active {
|
||||
background: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
border-color: transparent;
|
||||
}
|
||||
.loading {
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
text-align: center;
|
||||
padding: 2rem 0;
|
||||
margin: 0;
|
||||
}
|
||||
.error-msg {
|
||||
font-size: 0.875rem;
|
||||
color: rgb(220, 38, 38);
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 2rem 1rem;
|
||||
border: 1px dashed hsl(var(--color-border));
|
||||
border-radius: 0.625rem;
|
||||
text-align: center;
|
||||
}
|
||||
.empty-title {
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
.empty-hint {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.action-btn {
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.375rem;
|
||||
background: hsl(var(--color-card));
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
color: hsl(var(--color-foreground));
|
||||
font-family: inherit;
|
||||
}
|
||||
.feed {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.load-more {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-card));
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
text-align: center;
|
||||
font-family: inherit;
|
||||
}
|
||||
.load-more:hover {
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,241 @@
|
|||
<script lang="ts">
|
||||
import type { DiscoveryRegion } from '../discovery/types';
|
||||
import { discoveryStore } from '../discovery/store.svelte';
|
||||
|
||||
interface Props {
|
||||
regions: DiscoveryRegion[];
|
||||
}
|
||||
|
||||
let { regions }: Props = $props();
|
||||
|
||||
let searchQuery = $state('');
|
||||
let suggestions = $state<{ label: string; lat: number; lon: number }[]>([]);
|
||||
let searching = $state(false);
|
||||
let showForm = $state(false);
|
||||
let radiusKm = $state(25);
|
||||
|
||||
async function searchLocation() {
|
||||
const q = searchQuery.trim();
|
||||
if (q.length < 2) {
|
||||
suggestions = [];
|
||||
return;
|
||||
}
|
||||
searching = true;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`http://localhost:3018/api/v1/geocode/search?q=${encodeURIComponent(q)}&limit=5&lang=de`
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
suggestions = (data.results ?? []).map(
|
||||
(r: { label: string; latitude: number; longitude: number }) => ({
|
||||
label: r.label,
|
||||
lat: r.latitude,
|
||||
lon: r.longitude,
|
||||
})
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
suggestions = [];
|
||||
} finally {
|
||||
searching = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function selectSuggestion(s: { label: string; lat: number; lon: number }) {
|
||||
await discoveryStore.addRegion({
|
||||
label: s.label.split(',')[0] ?? s.label,
|
||||
lat: s.lat,
|
||||
lon: s.lon,
|
||||
radiusKm,
|
||||
});
|
||||
searchQuery = '';
|
||||
suggestions = [];
|
||||
showForm = false;
|
||||
}
|
||||
|
||||
async function removeRegion(id: string) {
|
||||
await discoveryStore.removeRegion(id);
|
||||
}
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout>;
|
||||
function onSearchInput() {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(searchLocation, 300);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="region-picker">
|
||||
<div class="region-list">
|
||||
{#each regions as region (region.id)}
|
||||
<div class="region-chip">
|
||||
<span class="region-label">{region.label}</span>
|
||||
<span class="region-radius">{region.radiusKm} km</span>
|
||||
<button class="remove-btn" onclick={() => removeRegion(region.id)}>x</button>
|
||||
</div>
|
||||
{/each}
|
||||
{#if !showForm}
|
||||
<button class="add-btn" onclick={() => (showForm = true)}>+ Region</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showForm}
|
||||
<div class="search-form">
|
||||
<input
|
||||
class="input"
|
||||
bind:value={searchQuery}
|
||||
oninput={onSearchInput}
|
||||
placeholder="Stadt oder Region suchen..."
|
||||
/>
|
||||
<div class="radius-row">
|
||||
<label class="radius-label">Radius: {radiusKm} km</label>
|
||||
<input type="range" min="5" max="100" step="5" bind:value={radiusKm} />
|
||||
</div>
|
||||
{#if suggestions.length > 0}
|
||||
<ul class="suggestions">
|
||||
{#each suggestions as s}
|
||||
<li>
|
||||
<button class="suggestion-btn" onclick={() => selectSuggestion(s)}>
|
||||
{s.label}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{#if searching}
|
||||
<p class="searching-hint">Suche...</p>
|
||||
{/if}
|
||||
<button
|
||||
class="cancel-btn"
|
||||
onclick={() => {
|
||||
showForm = false;
|
||||
searchQuery = '';
|
||||
suggestions = [];
|
||||
}}
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.region-picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.region-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
align-items: center;
|
||||
}
|
||||
.region-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 1rem;
|
||||
background: hsl(var(--color-muted));
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.region-label {
|
||||
font-weight: 500;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
.region-radius {
|
||||
font-size: 0.6875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.remove-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-size: 0.75rem;
|
||||
padding: 0 0.25rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
.remove-btn:hover {
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
.add-btn {
|
||||
padding: 0.25rem 0.625rem;
|
||||
border: 1px dashed hsl(var(--color-border));
|
||||
border-radius: 1rem;
|
||||
background: none;
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.add-btn:hover {
|
||||
color: hsl(var(--color-foreground));
|
||||
border-color: hsl(var(--color-foreground));
|
||||
}
|
||||
.search-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-card));
|
||||
}
|
||||
.input {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.375rem;
|
||||
background: hsl(var(--color-background));
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--color-foreground));
|
||||
font-family: inherit;
|
||||
}
|
||||
.radius-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.radius-label {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
white-space: nowrap;
|
||||
}
|
||||
.suggestions {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.suggestion-btn {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-foreground));
|
||||
cursor: pointer;
|
||||
border-radius: 0.25rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
.suggestion-btn:hover {
|
||||
background: hsl(var(--color-muted));
|
||||
}
|
||||
.searching-hint {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
margin: 0;
|
||||
}
|
||||
.cancel-btn {
|
||||
align-self: flex-start;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,384 @@
|
|||
<script lang="ts">
|
||||
import type { DiscoverySource, DiscoveryRegion } from '../discovery/types';
|
||||
import { discoveryStore } from '../discovery/store.svelte';
|
||||
|
||||
interface Props {
|
||||
sources: DiscoverySource[];
|
||||
regions: DiscoveryRegion[];
|
||||
}
|
||||
|
||||
let { sources, regions }: Props = $props();
|
||||
|
||||
let showForm = $state(false);
|
||||
let newName = $state('');
|
||||
let newUrl = $state('');
|
||||
let newRegionId = $state('');
|
||||
let discovering = $state(false);
|
||||
|
||||
const activeSources = $derived(sources.filter((s) => s.isActive));
|
||||
const suggestedSources = $derived(sources.filter((s) => !s.isActive));
|
||||
|
||||
async function handleDiscover() {
|
||||
const regionId = regions[0]?.id;
|
||||
if (!regionId) return;
|
||||
discovering = true;
|
||||
try {
|
||||
await discoveryStore.discoverSources(regionId);
|
||||
} finally {
|
||||
discovering = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleActivate(id: string) {
|
||||
await discoveryStore.activateSource(id);
|
||||
}
|
||||
|
||||
async function handleReject(id: string) {
|
||||
await discoveryStore.rejectSource(id);
|
||||
}
|
||||
|
||||
const regionIdDefault = $derived(regions[0]?.id ?? '');
|
||||
|
||||
async function handleAdd(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
const name = newName.trim();
|
||||
const url = newUrl.trim();
|
||||
const regionId = newRegionId || regionIdDefault;
|
||||
if (!name || !url || !regionId) return;
|
||||
|
||||
await discoveryStore.addSource({ type: 'ical', url, name, regionId });
|
||||
newName = '';
|
||||
newUrl = '';
|
||||
showForm = false;
|
||||
}
|
||||
|
||||
async function handleRemove(id: string) {
|
||||
await discoveryStore.removeSource(id);
|
||||
}
|
||||
|
||||
async function handleCrawl(id: string) {
|
||||
await discoveryStore.crawlSource(id);
|
||||
}
|
||||
|
||||
function formatDate(iso: string | null): string {
|
||||
if (!iso) return 'nie';
|
||||
return new Date(iso).toLocaleString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="source-manager">
|
||||
<div class="header">
|
||||
<h3 class="section-title">Quellen</h3>
|
||||
<div class="header-actions">
|
||||
<button
|
||||
class="discover-btn"
|
||||
onclick={handleDiscover}
|
||||
disabled={discovering || regions.length === 0}
|
||||
>
|
||||
{discovering ? 'Suche...' : 'Automatisch finden'}
|
||||
</button>
|
||||
{#if !showForm}
|
||||
<button class="add-btn" onclick={() => (showForm = true)}>+ iCal-Feed</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showForm}
|
||||
<form class="add-form" onsubmit={handleAdd}>
|
||||
<input
|
||||
class="input"
|
||||
bind:value={newName}
|
||||
placeholder="Name (z.B. Jazzhaus Freiburg)"
|
||||
required
|
||||
/>
|
||||
<input class="input" bind:value={newUrl} placeholder="iCal URL (.ics)" type="url" required />
|
||||
{#if regions.length > 1}
|
||||
<select class="input" bind:value={newRegionId}>
|
||||
{#each regions as r (r.id)}
|
||||
<option value={r.id}>{r.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="action-btn primary">Hinzufugen</button>
|
||||
<button type="button" class="action-btn" onclick={() => (showForm = false)}
|
||||
>Abbrechen</button
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if suggestedSources.length > 0}
|
||||
<div class="suggestions-section">
|
||||
<h4 class="sub-title">Vorgeschlagene Quellen</h4>
|
||||
<div class="source-list">
|
||||
{#each suggestedSources as source (source.id)}
|
||||
<div class="source-item suggested">
|
||||
<div class="source-info">
|
||||
<div class="source-name">{source.name}</div>
|
||||
<div class="source-meta">
|
||||
{source.type.toUpperCase()}
|
||||
{#if source.url}
|
||||
· <a class="source-url" href={source.url} target="_blank" rel="noopener"
|
||||
>{new URL(source.url).hostname}</a
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="source-actions">
|
||||
<button class="icon-btn activate" onclick={() => handleActivate(source.id)}
|
||||
>Aktivieren</button
|
||||
>
|
||||
<button class="icon-btn danger" onclick={() => handleReject(source.id)}>x</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if activeSources.length === 0 && suggestedSources.length === 0}
|
||||
<p class="empty">
|
||||
Noch keine Quellen. Nutze "Automatisch finden" oder fuge iCal-Feeds manuell hinzu.
|
||||
</p>
|
||||
{:else if activeSources.length > 0}
|
||||
<div class="source-list">
|
||||
{#each activeSources as source (source.id)}
|
||||
<div class="source-item" class:error={source.errorCount > 0}>
|
||||
<div class="source-info">
|
||||
<div class="source-name">{source.name}</div>
|
||||
<div class="source-meta">
|
||||
{source.type.toUpperCase()} · Letzter Scan: {formatDate(source.lastCrawledAt)}
|
||||
{#if source.errorCount > 0}
|
||||
<span class="error-badge">{source.errorCount} Fehler</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if source.lastError}
|
||||
<div class="source-error">{source.lastError}</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="source-actions">
|
||||
<button class="icon-btn" onclick={() => handleCrawl(source.id)} title="Jetzt scannen">
|
||||
Scannen
|
||||
</button>
|
||||
<button
|
||||
class="icon-btn danger"
|
||||
onclick={() => handleRemove(source.id)}
|
||||
title="Entfernen"
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.source-manager {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.discover-btn {
|
||||
padding: 0.25rem 0.625rem;
|
||||
border: 1px solid hsl(var(--color-primary));
|
||||
border-radius: 0.375rem;
|
||||
background: none;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-primary));
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.discover-btn:hover {
|
||||
background: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
}
|
||||
.discover-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.suggestions-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.sub-title {
|
||||
margin: 0;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
.source-item.suggested {
|
||||
border-color: hsl(var(--color-primary) / 0.3);
|
||||
background: hsl(var(--color-primary) / 0.05);
|
||||
}
|
||||
.source-url {
|
||||
color: hsl(var(--color-primary));
|
||||
text-decoration: none;
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
.source-url:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.icon-btn.activate {
|
||||
color: hsl(var(--color-primary));
|
||||
border-color: hsl(var(--color-primary));
|
||||
}
|
||||
.section-title {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.add-btn {
|
||||
padding: 0.25rem 0.625rem;
|
||||
border: 1px dashed hsl(var(--color-border));
|
||||
border-radius: 0.375rem;
|
||||
background: none;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.add-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-card));
|
||||
}
|
||||
.input {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.375rem;
|
||||
background: hsl(var(--color-background));
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--color-foreground));
|
||||
font-family: inherit;
|
||||
}
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.action-btn {
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.375rem;
|
||||
background: hsl(var(--color-card));
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
.action-btn.primary {
|
||||
background: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
border-color: transparent;
|
||||
}
|
||||
.source-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.source-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-card));
|
||||
}
|
||||
.source-item.error {
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
.source-item.inactive {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.source-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.source-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
.source-meta {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.error-badge {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
padding: 0.0625rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: rgb(220, 38, 38);
|
||||
}
|
||||
.inactive-badge {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
padding: 0.0625rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
background: hsl(var(--color-muted));
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.source-error {
|
||||
font-size: 0.6875rem;
|
||||
color: rgb(220, 38, 38);
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
.source-actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.icon-btn {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.25rem;
|
||||
background: none;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-family: inherit;
|
||||
}
|
||||
.icon-btn:hover {
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
.icon-btn.danger:hover {
|
||||
color: rgb(220, 38, 38);
|
||||
}
|
||||
.empty {
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
170
apps/mana/apps/web/src/lib/modules/events/discovery/api.ts
Normal file
170
apps/mana/apps/web/src/lib/modules/events/discovery/api.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
/**
|
||||
* Discovery HTTP client — JWT-authenticated calls to mana-events discovery endpoints.
|
||||
*/
|
||||
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { getManaEventsUrl } from '$lib/api/config';
|
||||
import type { DiscoveryRegion, DiscoveryInterest, DiscoverySource, DiscoveredEvent } from './types';
|
||||
|
||||
async function fetchWithAuth<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const token = await authStore.getValidToken();
|
||||
const res = await fetch(`${getManaEventsUrl()}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...init.headers,
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ message: 'Request failed' }));
|
||||
throw new Error(err.message || `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
// ─── Regions ────────────────────────────────────────────────────────
|
||||
|
||||
export async function getRegions(): Promise<DiscoveryRegion[]> {
|
||||
const { regions } = await fetchWithAuth<{ regions: DiscoveryRegion[] }>(
|
||||
'/api/v1/discovery/regions'
|
||||
);
|
||||
return regions;
|
||||
}
|
||||
|
||||
export async function createRegion(input: {
|
||||
label: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
radiusKm?: number;
|
||||
}): Promise<DiscoveryRegion> {
|
||||
const { region } = await fetchWithAuth<{ region: DiscoveryRegion }>('/api/v1/discovery/regions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
return region;
|
||||
}
|
||||
|
||||
export async function updateRegion(
|
||||
id: string,
|
||||
input: { label?: string; radiusKm?: number; isActive?: boolean }
|
||||
): Promise<DiscoveryRegion> {
|
||||
const { region } = await fetchWithAuth<{ region: DiscoveryRegion }>(
|
||||
`/api/v1/discovery/regions/${id}`,
|
||||
{ method: 'PUT', body: JSON.stringify(input) }
|
||||
);
|
||||
return region;
|
||||
}
|
||||
|
||||
export async function deleteRegion(id: string): Promise<void> {
|
||||
await fetchWithAuth(`/api/v1/discovery/regions/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
// ─── Interests ──────────────────────────────────────────────────────
|
||||
|
||||
export async function getInterests(): Promise<DiscoveryInterest[]> {
|
||||
const { interests } = await fetchWithAuth<{ interests: DiscoveryInterest[] }>(
|
||||
'/api/v1/discovery/interests'
|
||||
);
|
||||
return interests;
|
||||
}
|
||||
|
||||
export async function createInterest(input: {
|
||||
category: string;
|
||||
freetext?: string | null;
|
||||
weight?: number;
|
||||
}): Promise<DiscoveryInterest> {
|
||||
const { interest } = await fetchWithAuth<{ interest: DiscoveryInterest }>(
|
||||
'/api/v1/discovery/interests',
|
||||
{ method: 'POST', body: JSON.stringify(input) }
|
||||
);
|
||||
return interest;
|
||||
}
|
||||
|
||||
export async function deleteInterest(id: string): Promise<void> {
|
||||
await fetchWithAuth(`/api/v1/discovery/interests/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
// ─── Sources ────────────────────────────────────────────────────────
|
||||
|
||||
export async function getSources(): Promise<DiscoverySource[]> {
|
||||
const { sources } = await fetchWithAuth<{ sources: DiscoverySource[] }>(
|
||||
'/api/v1/discovery/sources'
|
||||
);
|
||||
return sources;
|
||||
}
|
||||
|
||||
export async function createSource(input: {
|
||||
type: 'ical' | 'website';
|
||||
url: string;
|
||||
name: string;
|
||||
regionId: string;
|
||||
crawlIntervalHours?: number;
|
||||
}): Promise<DiscoverySource> {
|
||||
const { source } = await fetchWithAuth<{ source: DiscoverySource }>('/api/v1/discovery/sources', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
return source;
|
||||
}
|
||||
|
||||
export async function deleteSource(id: string): Promise<void> {
|
||||
await fetchWithAuth(`/api/v1/discovery/sources/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export async function crawlSourceNow(id: string): Promise<{ upserted: number; error?: string }> {
|
||||
return fetchWithAuth(`/api/v1/discovery/sources/${id}/crawl`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function activateSource(id: string): Promise<DiscoverySource> {
|
||||
const { source } = await fetchWithAuth<{ source: DiscoverySource }>(
|
||||
`/api/v1/discovery/sources/${id}/activate`,
|
||||
{ method: 'PUT' }
|
||||
);
|
||||
return source;
|
||||
}
|
||||
|
||||
export async function rejectSource(id: string): Promise<void> {
|
||||
await fetchWithAuth(`/api/v1/discovery/sources/${id}/reject`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export async function discoverSources(
|
||||
regionId: string
|
||||
): Promise<{ suggestedCount: number; queries: number; searchResults: number }> {
|
||||
return fetchWithAuth(`/api/v1/discovery/regions/${regionId}/discover-sources`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Feed ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface FeedParams {
|
||||
from?: string;
|
||||
to?: string;
|
||||
category?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
hideDismissed?: boolean;
|
||||
}
|
||||
|
||||
export async function getFeed(
|
||||
params: FeedParams = {}
|
||||
): Promise<{ events: DiscoveredEvent[]; total: number; hasMore: boolean }> {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params.from) searchParams.set('from', params.from);
|
||||
if (params.to) searchParams.set('to', params.to);
|
||||
if (params.category) searchParams.set('category', params.category);
|
||||
if (params.limit) searchParams.set('limit', String(params.limit));
|
||||
if (params.offset) searchParams.set('offset', String(params.offset));
|
||||
if (params.hideDismissed) searchParams.set('hideDismissed', 'true');
|
||||
|
||||
const qs = searchParams.toString();
|
||||
return fetchWithAuth(`/api/v1/discovery/feed${qs ? `?${qs}` : ''}`);
|
||||
}
|
||||
|
||||
export async function setEventAction(eventId: string, action: 'save' | 'dismiss'): Promise<void> {
|
||||
await fetchWithAuth(`/api/v1/discovery/feed/${eventId}/action`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ action }),
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,206 @@
|
|||
/**
|
||||
* Discovery store — reactive state for regions, interests, sources, and feed.
|
||||
*
|
||||
* Server-authoritative: all reads fetch from mana-events, no Dexie.
|
||||
* State is held in Svelte 5 runes ($state) and refreshed on mount / mutation.
|
||||
*/
|
||||
|
||||
import * as api from './api';
|
||||
import type { DiscoveryRegion, DiscoveryInterest, DiscoverySource, DiscoveredEvent } from './types';
|
||||
import { eventsStore } from '../stores/events.svelte';
|
||||
|
||||
// ─── State ──────────────────────────────────────────────────────────
|
||||
|
||||
let regions = $state<DiscoveryRegion[]>([]);
|
||||
let interests = $state<DiscoveryInterest[]>([]);
|
||||
let sources = $state<DiscoverySource[]>([]);
|
||||
let feed = $state<DiscoveredEvent[]>([]);
|
||||
let feedHasMore = $state(false);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
// ─── Loaders ────────────────────────────────────────────<E29480><E29480>───────────
|
||||
|
||||
async function loadRegions() {
|
||||
try {
|
||||
regions = await api.getRegions();
|
||||
} catch (e) {
|
||||
console.error('[discovery] failed to load regions:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadInterests() {
|
||||
try {
|
||||
interests = await api.getInterests();
|
||||
} catch (e) {
|
||||
console.error('[discovery] failed to load interests:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSources() {
|
||||
try {
|
||||
sources = await api.getSources();
|
||||
} catch (e) {
|
||||
console.error('[discovery] failed to load sources:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFeed(params: api.FeedParams = {}) {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
const result = await api.getFeed({ hideDismissed: true, ...params });
|
||||
feed = result.events;
|
||||
feedHasMore = result.hasMore;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Fehler beim Laden';
|
||||
console.error('[discovery] failed to load feed:', e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Exported store ─────────────────────────────────────────────────
|
||||
|
||||
export const discoveryStore = {
|
||||
// Reactive getters
|
||||
get regions() {
|
||||
return regions;
|
||||
},
|
||||
get interests() {
|
||||
return interests;
|
||||
},
|
||||
get sources() {
|
||||
return sources;
|
||||
},
|
||||
get feed() {
|
||||
return feed;
|
||||
},
|
||||
get feedHasMore() {
|
||||
return feedHasMore;
|
||||
},
|
||||
get loading() {
|
||||
return loading;
|
||||
},
|
||||
get error() {
|
||||
return error;
|
||||
},
|
||||
get isSetUp() {
|
||||
return regions.length > 0;
|
||||
},
|
||||
|
||||
// ── Init ─────────────────────────────────────────────────────
|
||||
async init() {
|
||||
await Promise.all([loadRegions(), loadInterests(), loadSources()]);
|
||||
if (regions.length > 0) {
|
||||
await loadFeed();
|
||||
}
|
||||
},
|
||||
|
||||
async refreshFeed(params?: api.FeedParams) {
|
||||
await loadFeed(params);
|
||||
},
|
||||
|
||||
// ── Regions ──────────────────────────────────────────────────
|
||||
async addRegion(input: { label: string; lat: number; lon: number; radiusKm?: number }) {
|
||||
const region = await api.createRegion(input);
|
||||
regions = [...regions, region];
|
||||
return region;
|
||||
},
|
||||
|
||||
async updateRegion(id: string, input: { label?: string; radiusKm?: number; isActive?: boolean }) {
|
||||
const region = await api.updateRegion(id, input);
|
||||
regions = regions.map((r) => (r.id === id ? region : r));
|
||||
return region;
|
||||
},
|
||||
|
||||
async removeRegion(id: string) {
|
||||
await api.deleteRegion(id);
|
||||
regions = regions.filter((r) => r.id !== id);
|
||||
},
|
||||
|
||||
// ── Interests ────────────────────────────────────────────────
|
||||
async addInterest(input: { category: string; freetext?: string | null; weight?: number }) {
|
||||
const interest = await api.createInterest(input);
|
||||
interests = [...interests, interest];
|
||||
return interest;
|
||||
},
|
||||
|
||||
async removeInterest(id: string) {
|
||||
await api.deleteInterest(id);
|
||||
interests = interests.filter((i) => i.id !== id);
|
||||
},
|
||||
|
||||
// ── Sources <20><><EFBFBD>─────────────────────────────────────────────────
|
||||
async addSource(input: {
|
||||
type: 'ical' | 'website';
|
||||
url: string;
|
||||
name: string;
|
||||
regionId: string;
|
||||
crawlIntervalHours?: number;
|
||||
}) {
|
||||
const source = await api.createSource(input);
|
||||
sources = [...sources, source];
|
||||
// Trigger immediate crawl
|
||||
api
|
||||
.crawlSourceNow(source.id)
|
||||
.then(() => loadFeed())
|
||||
.catch(() => {});
|
||||
return source;
|
||||
},
|
||||
|
||||
async removeSource(id: string) {
|
||||
await api.deleteSource(id);
|
||||
sources = sources.filter((s) => s.id !== id);
|
||||
},
|
||||
|
||||
async crawlSource(id: string) {
|
||||
const result = await api.crawlSourceNow(id);
|
||||
await loadSources();
|
||||
await loadFeed();
|
||||
return result;
|
||||
},
|
||||
|
||||
async activateSource(id: string) {
|
||||
const source = await api.activateSource(id);
|
||||
sources = sources.map((s) => (s.id === id ? source : s));
|
||||
},
|
||||
|
||||
async rejectSource(id: string) {
|
||||
await api.rejectSource(id);
|
||||
sources = sources.filter((s) => s.id !== id);
|
||||
},
|
||||
|
||||
async discoverSources(regionId: string) {
|
||||
const result = await api.discoverSources(regionId);
|
||||
await loadSources(); // refresh to include new suggestions
|
||||
return result;
|
||||
},
|
||||
|
||||
// ── Feed Actions ─────────────────────────────────────────────
|
||||
async saveEvent(eventId: string) {
|
||||
const event = feed.find((e) => e.id === eventId);
|
||||
if (!event) return;
|
||||
|
||||
await api.setEventAction(eventId, 'save');
|
||||
feed = feed.map((e) => (e.id === eventId ? { ...e, userAction: 'save' as const } : e));
|
||||
|
||||
// Create a local socialEvent from the discovered event
|
||||
const startMs = new Date(event.startAt).getTime();
|
||||
const fallbackEnd = new Date(startMs + 2 * 60 * 60 * 1000).toISOString();
|
||||
await eventsStore.createEvent({
|
||||
title: event.title,
|
||||
startTime: event.startAt,
|
||||
endTime: event.endAt ?? fallbackEnd,
|
||||
location: event.location ?? undefined,
|
||||
description: event.description
|
||||
? `${event.description}\n\nQuelle: ${event.sourceUrl}`
|
||||
: `Quelle: ${event.sourceUrl}`,
|
||||
});
|
||||
},
|
||||
|
||||
async dismissEvent(eventId: string) {
|
||||
await api.setEventAction(eventId, 'dismiss');
|
||||
feed = feed.filter((e) => e.id !== eventId);
|
||||
},
|
||||
};
|
||||
72
apps/mana/apps/web/src/lib/modules/events/discovery/types.ts
Normal file
72
apps/mana/apps/web/src/lib/modules/events/discovery/types.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
/**
|
||||
* Discovery types — shared between API client, queries, and UI components.
|
||||
*/
|
||||
|
||||
export interface DiscoveryRegion {
|
||||
id: string;
|
||||
label: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
radiusKm: number;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface DiscoveryInterest {
|
||||
id: string;
|
||||
category: string;
|
||||
freetext: string | null;
|
||||
weight: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface DiscoverySource {
|
||||
id: string;
|
||||
type: 'ical' | 'website';
|
||||
url: string | null;
|
||||
name: string;
|
||||
regionId: string | null;
|
||||
crawlIntervalHours: number;
|
||||
lastCrawledAt: string | null;
|
||||
lastSuccessAt: string | null;
|
||||
errorCount: number;
|
||||
lastError: string | null;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface DiscoveredEvent {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
location: string | null;
|
||||
lat: number | null;
|
||||
lon: number | null;
|
||||
startAt: string;
|
||||
endAt: string | null;
|
||||
allDay: boolean;
|
||||
imageUrl: string | null;
|
||||
sourceUrl: string;
|
||||
sourceName: string | null;
|
||||
category: string | null;
|
||||
priceInfo: string | null;
|
||||
crawledAt: string;
|
||||
userAction: 'save' | 'dismiss' | null;
|
||||
}
|
||||
|
||||
export const EVENT_CATEGORIES = [
|
||||
{ id: 'music', label: 'Musik' },
|
||||
{ id: 'theater', label: 'Theater' },
|
||||
{ id: 'art', label: 'Kunst' },
|
||||
{ id: 'tech', label: 'Tech' },
|
||||
{ id: 'sport', label: 'Sport' },
|
||||
{ id: 'food', label: 'Kulinarik' },
|
||||
{ id: 'family', label: 'Familie' },
|
||||
{ id: 'nature', label: 'Natur' },
|
||||
{ id: 'education', label: 'Bildung' },
|
||||
{ id: 'community', label: 'Community' },
|
||||
{ id: 'nightlife', label: 'Nachtleben' },
|
||||
{ id: 'market', label: 'Markt' },
|
||||
{ id: 'other', label: 'Sonstiges' },
|
||||
] as const;
|
||||
711
docs/plans/event-discovery.md
Normal file
711
docs/plans/event-discovery.md
Normal file
|
|
@ -0,0 +1,711 @@
|
|||
# Event Discovery — Implementierungsplan
|
||||
|
||||
## Status (2026-04-17)
|
||||
|
||||
Planung, noch kein Code.
|
||||
|
||||
## Ziel
|
||||
|
||||
Eine KI im Events-Modul, die automatisch öffentliche Veranstaltungen in den Regionen des Nutzers findet, strukturiert und als kuratierten Feed vorschlägt. Der Nutzer konfiguriert Städte/Gebiete + Interessen; das System scannt Event-Kalender, Venue-Websites und Vereinsseiten, dedupliziert und rankt nach Relevanz.
|
||||
|
||||
## Abgrenzung
|
||||
|
||||
- **Eigene Events** (socialEvents, RSVP, Bring-Liste) bleiben unberührt — Discovery ist ein paralleler Read-only-Feed
|
||||
- **mana-research** wird als Provider-Schicht genutzt (Web-Suche, Extraktion), aber Discovery-Logik lebt in mana-events
|
||||
- **mana-crawler** wird NICHT direkt genutzt — Firecrawl/Jina über mana-research reichen für Event-Extraktion
|
||||
- **mana-ai Missions** kommen erst in Phase 3 — Phase 1-2 läuft als dedizierter Cron/API ohne Mission-Runner
|
||||
|
||||
---
|
||||
|
||||
## Architektur
|
||||
|
||||
```
|
||||
Nutzer (Events-Modul, Tab "Entdecken")
|
||||
│
|
||||
▼
|
||||
apps/mana web ──→ mana-events (3065)
|
||||
│
|
||||
┌────────────┼────────────────┐
|
||||
▼ ▼ ▼
|
||||
Discovery API Source Manager Crawl Scheduler
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
PostgreSQL mana-research mana-geocoding
|
||||
(event_discovery (Web-Suche, (Region →
|
||||
Schema) Extraktion) BoundingBox)
|
||||
```
|
||||
|
||||
### Neue DB-Tabellen (PostgreSQL, Schema `event_discovery` in `mana_platform`)
|
||||
|
||||
```sql
|
||||
-- Quellen, die regelmäßig gescannt werden
|
||||
discovery_sources
|
||||
id uuid PK
|
||||
user_id text NOT NULL -- Besitzer
|
||||
type text NOT NULL -- 'ical' | 'website' | 'api' | 'search_query'
|
||||
url text -- Feed-URL oder Website-URL
|
||||
name text NOT NULL -- "Jazzhaus Freiburg", "VHS Konstanz"
|
||||
region_id uuid FK → discovery_regions
|
||||
crawl_interval_hours int DEFAULT 24
|
||||
last_crawled_at timestamptz
|
||||
last_success_at timestamptz
|
||||
error_count int DEFAULT 0
|
||||
last_error text
|
||||
is_active boolean DEFAULT true
|
||||
created_at timestamptz DEFAULT now()
|
||||
updated_at timestamptz DEFAULT now()
|
||||
|
||||
-- Regionen des Nutzers
|
||||
discovery_regions
|
||||
id uuid PK
|
||||
user_id text NOT NULL
|
||||
label text NOT NULL -- "Freiburg", "Basel"
|
||||
lat double precision
|
||||
lon double precision
|
||||
radius_km int DEFAULT 25
|
||||
is_active boolean DEFAULT true
|
||||
created_at timestamptz DEFAULT now()
|
||||
|
||||
-- Nutzer-Interessen für Relevanz-Scoring
|
||||
discovery_interests
|
||||
id uuid PK
|
||||
user_id text NOT NULL
|
||||
category text NOT NULL -- 'music' | 'tech' | 'sport' | 'art' | ...
|
||||
freetext text -- "Impro-Theater", "Rust Meetups"
|
||||
weight real DEFAULT 1.0 -- Nutzer kann priorisieren
|
||||
created_at timestamptz DEFAULT now()
|
||||
|
||||
-- Gefundene Events (dedupliziert, normalisiert)
|
||||
discovered_events
|
||||
id uuid PK
|
||||
source_id uuid FK → discovery_sources (CASCADE)
|
||||
external_id text -- Dedupe-Anker (URL oder Hash)
|
||||
dedupe_hash text NOT NULL -- sha256(lower(title) + date + location)
|
||||
title text NOT NULL
|
||||
description text
|
||||
location text
|
||||
lat double precision
|
||||
lon double precision
|
||||
start_at timestamptz NOT NULL
|
||||
end_at timestamptz
|
||||
all_day boolean DEFAULT false
|
||||
image_url text
|
||||
source_url text NOT NULL -- Link zur Original-Seite
|
||||
source_name text -- "Jazzhaus Freiburg"
|
||||
category text -- LLM-klassifiziert
|
||||
price_info text -- "Frei", "12 €", "VVK 15 / AK 18"
|
||||
raw_extracted jsonb -- Rohdaten der LLM-Extraktion
|
||||
crawled_at timestamptz DEFAULT now()
|
||||
expires_at timestamptz -- start_at + 1 Tag (für Cleanup)
|
||||
UNIQUE(dedupe_hash) -- Idempotenz
|
||||
|
||||
-- Nutzer-Interaktion mit entdeckten Events
|
||||
discovery_user_actions
|
||||
id uuid PK
|
||||
user_id text NOT NULL
|
||||
event_id uuid FK → discovered_events (CASCADE)
|
||||
action text NOT NULL -- 'save' | 'dismiss' | 'hide_source'
|
||||
acted_at timestamptz DEFAULT now()
|
||||
UNIQUE(user_id, event_id)
|
||||
|
||||
-- Indizes
|
||||
CREATE INDEX idx_discovered_events_start ON discovered_events(start_at);
|
||||
CREATE INDEX idx_discovered_events_source ON discovered_events(source_id);
|
||||
CREATE INDEX idx_discovery_sources_user ON discovery_sources(user_id, is_active);
|
||||
CREATE INDEX idx_discovery_regions_user ON discovery_regions(user_id);
|
||||
CREATE INDEX idx_discovery_actions_user ON discovery_user_actions(user_id);
|
||||
```
|
||||
|
||||
### Lokale Tabellen (Dexie) — nur Cache + Offline
|
||||
|
||||
Discovery-Daten sind **nicht** local-first (sie entstehen auf dem Server). Dexie dient nur als Offline-Cache:
|
||||
|
||||
```
|
||||
discoveryRegions — id, label, lat, lon, radiusKm, isActive
|
||||
discoveryInterests — id, category, freetext, weight
|
||||
discoveredEvents — id, title, description, location, lat, lon,
|
||||
startAt, endAt, sourceUrl, sourceName,
|
||||
imageUrl, category, priceInfo, relevanceScore,
|
||||
userAction (null|save|dismiss), crawledAt
|
||||
```
|
||||
|
||||
Keine Verschlüsselung nötig — das sind öffentliche Event-Daten, keine User-Inhalte.
|
||||
|
||||
Kein Sync über mana-sync — der Server ist die Source of Truth, Client pollt/cached.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Regionen, iCal-Feeds, Discovery-Tab
|
||||
|
||||
**Ziel:** Nutzer kann Regionen + iCal-Feeds manuell konfigurieren. Events werden geparst und im "Entdecken"-Tab angezeigt.
|
||||
|
||||
### 1.1 Backend: DB-Schema + CRUD-Routen (mana-events)
|
||||
|
||||
**Dateien:**
|
||||
|
||||
```
|
||||
services/mana-events/src/db/schema/
|
||||
discovery.ts ← NEU: Drizzle-Schema für alle 5 Tabellen
|
||||
|
||||
services/mana-events/src/routes/
|
||||
discovery.ts ← NEU: CRUD für regions, interests, sources
|
||||
discovery-feed.ts ← NEU: Feed-Endpoint (paginiert, gefiltert)
|
||||
|
||||
services/mana-events/src/app.ts
|
||||
→ Neue Routen registrieren unter /api/v1/discovery/*
|
||||
```
|
||||
|
||||
**API-Endpunkte (alle JWT-authentifiziert):**
|
||||
|
||||
```
|
||||
# Regionen
|
||||
GET /api/v1/discovery/regions → [{id, label, lat, lon, radiusKm}]
|
||||
POST /api/v1/discovery/regions ← {label, lat, lon, radiusKm}
|
||||
PUT /api/v1/discovery/regions/:id ← {label?, radiusKm?, isActive?}
|
||||
DELETE /api/v1/discovery/regions/:id
|
||||
|
||||
# Interessen
|
||||
GET /api/v1/discovery/interests → [{id, category, freetext, weight}]
|
||||
POST /api/v1/discovery/interests ← {category, freetext?, weight?}
|
||||
DELETE /api/v1/discovery/interests/:id
|
||||
|
||||
# Quellen (Phase 1: nur iCal)
|
||||
GET /api/v1/discovery/sources → [{id, type, url, name, region, status}]
|
||||
POST /api/v1/discovery/sources ← {type: 'ical', url, name, regionId}
|
||||
DELETE /api/v1/discovery/sources/:id
|
||||
POST /api/v1/discovery/sources/:id/crawl → Sofort-Crawl auslösen
|
||||
|
||||
# Feed
|
||||
GET /api/v1/discovery/feed → {events: [...], total, hasMore}
|
||||
?from=ISO&to=ISO&category=music&limit=20&offset=0
|
||||
POST /api/v1/discovery/feed/:eventId/action ← {action: 'save' | 'dismiss'}
|
||||
```
|
||||
|
||||
### 1.2 Backend: iCal-Parser + Crawl-Loop
|
||||
|
||||
**Dateien:**
|
||||
|
||||
```
|
||||
services/mana-events/src/discovery/
|
||||
ical-parser.ts ← iCal → discovered_events (ical.js oder node-ical)
|
||||
crawl-scheduler.ts ← Interval-basierter Crawl-Loop (wie rateBucketSweeper)
|
||||
deduplicator.ts ← sha256(lower(title) + startAt.toISODate() + lower(location))
|
||||
types.ts ← NormalizedEvent, CrawlResult, SourceStatus
|
||||
```
|
||||
|
||||
**Ablauf eines Crawl-Zyklus:**
|
||||
|
||||
```
|
||||
crawl-scheduler.ts (runs every 15 min)
|
||||
│
|
||||
├─ SELECT sources WHERE is_active AND last_crawled_at < now() - interval_hours
|
||||
│
|
||||
├─ Für jede fällige Source:
|
||||
│ ├─ fetch(source.url) mit 10s Timeout
|
||||
│ ├─ ical-parser.ts: VEVENT → NormalizedEvent[]
|
||||
│ ├─ deduplicator.ts: dedupe_hash berechnen
|
||||
│ ├─ UPSERT INTO discovered_events ON CONFLICT(dedupe_hash)
|
||||
│ │ → Bestehende: title/description/location updaten falls geändert
|
||||
│ │ → Neue: INSERT
|
||||
│ ├─ UPDATE source SET last_crawled_at, last_success_at, error_count=0
|
||||
│ └─ Bei Fehler: error_count++, last_error setzen
|
||||
│ → Nach 5 Fehlern: is_active = false (Nutzer wird informiert)
|
||||
│
|
||||
└─ DELETE FROM discovered_events WHERE expires_at < now()
|
||||
(Cleanup abgelaufener Events)
|
||||
```
|
||||
|
||||
**Dependency:** `node-ical` (Bun-kompatibel, ~50KB, parst VEVENT/VTODO/VFREEBUSY)
|
||||
|
||||
### 1.3 Frontend: Discovery-Tab + Regionen-Setup
|
||||
|
||||
**Dateien:**
|
||||
|
||||
```
|
||||
apps/mana/apps/web/src/lib/modules/events/
|
||||
discovery/
|
||||
api.ts ← HTTP-Client (fetchWithAuth, analog events/api.ts)
|
||||
types.ts ← DiscoveredEvent, DiscoveryRegion, DiscoveryInterest, etc.
|
||||
queries.svelte.ts ← Reactive state ($state) für Feed, Regionen, Interessen
|
||||
stores.svelte.ts ← Mutationen (addRegion, addSource, saveEvent, dismissEvent)
|
||||
|
||||
components/
|
||||
DiscoveryTab.svelte ← Der neue Tab-Inhalt
|
||||
DiscoverySetup.svelte ← Onboarding: Regionen + Interessen konfigurieren
|
||||
DiscoveredEventCard.svelte ← Karte mit Titel, Datum, Ort, Quelle, Aktionen
|
||||
SourceManager.svelte ← iCal-Feed-URLs verwalten (hinzufügen, Status, löschen)
|
||||
RegionPicker.svelte ← Stadt-Suche via mana-geocoding + Radius-Slider
|
||||
|
||||
ListView.svelte ← Tab-Navigation ergänzen: "Meine Events" | "Entdecken"
|
||||
```
|
||||
|
||||
**ListView.svelte — Tab-Erweiterung:**
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let activeTab = $state<'mine' | 'discover'>('mine');
|
||||
</script>
|
||||
|
||||
<div class="tab-bar">
|
||||
<button class:active={activeTab === 'mine'} onclick={() => activeTab = 'mine'}>
|
||||
Meine Events
|
||||
</button>
|
||||
<button class:active={activeTab === 'discover'} onclick={() => activeTab = 'discover'}>
|
||||
Entdecken
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if activeTab === 'mine'}
|
||||
<!-- bestehender Inhalt (upcoming/past/create) -->
|
||||
{:else}
|
||||
<DiscoveryTab {navigate} />
|
||||
{/if}
|
||||
```
|
||||
|
||||
**DiscoveryTab.svelte — Aufbau:**
|
||||
|
||||
```svelte
|
||||
{#if !hasRegions}
|
||||
<DiscoverySetup onComplete={reload} />
|
||||
{:else}
|
||||
<div class="discovery-controls">
|
||||
<RegionPicker regions={regions.value} />
|
||||
<CategoryFilter interests={interests.value} />
|
||||
<button onclick={refreshFeed}>Aktualisieren</button>
|
||||
</div>
|
||||
|
||||
{#if feed.value.length === 0}
|
||||
<p class="empty">Noch keine Events gefunden. Füge iCal-Feeds hinzu oder warte auf den nächsten Scan.</p>
|
||||
{:else}
|
||||
{#each feed.value as event (event.id)}
|
||||
<DiscoveredEventCard
|
||||
{event}
|
||||
onSave={() => actions.save(event.id)}
|
||||
onDismiss={() => actions.dismiss(event.id)}
|
||||
onOpen={() => window.open(event.sourceUrl, '_blank')}
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<SourceManager sources={sources.value} regionId={activeRegionId} />
|
||||
{/if}
|
||||
```
|
||||
|
||||
**DiscoveredEventCard.svelte — Felder:**
|
||||
|
||||
- Titel (fett)
|
||||
- Datum + Uhrzeit (formatiert, relativ: "Morgen, 19:00" / "Sa 26. Apr, 20:00")
|
||||
- Ort + Entfernung zum nächsten Region-Zentrum
|
||||
- Quelle (Link zur Original-Seite)
|
||||
- Kategorie-Badge (Musik, Tech, Sport, ...)
|
||||
- Preis-Info falls vorhanden
|
||||
- Aktionen: "Merken" (→ eigenes socialEvent anlegen), "Nicht interessant", "Zur Quelle"
|
||||
|
||||
**"Merken"-Flow:**
|
||||
|
||||
```
|
||||
Nutzer klickt "Merken"
|
||||
→ discoveryStore.saveEvent(discoveredEvent)
|
||||
→ eventsStore.createEvent({
|
||||
title: event.title,
|
||||
startTime: event.startAt,
|
||||
endTime: event.endAt,
|
||||
location: event.location,
|
||||
description: `${event.description}\n\nQuelle: ${event.sourceUrl}`,
|
||||
locationLat: event.lat,
|
||||
locationLon: event.lon,
|
||||
})
|
||||
→ POST /api/v1/discovery/feed/:eventId/action {action: 'save'}
|
||||
→ Karte zeigt "Gespeichert ✓"
|
||||
```
|
||||
|
||||
### 1.4 Module-Integration
|
||||
|
||||
**module.config.ts** — Neue Dexie-Tabellen registrieren (Cache-only, kein Sync):
|
||||
|
||||
```typescript
|
||||
// events/module.config.ts — erweitert
|
||||
tables: [
|
||||
{ name: 'socialEvents', syncName: 'events' },
|
||||
{ name: 'eventGuests' },
|
||||
{ name: 'eventInvitations' },
|
||||
{ name: 'eventItems' },
|
||||
// NEU: Discovery-Cache (nicht gesynct, rein lokal)
|
||||
{ name: 'discoveryRegions' },
|
||||
{ name: 'discoveryInterests' },
|
||||
{ name: 'discoveredEvents' },
|
||||
],
|
||||
```
|
||||
|
||||
**database.ts** — Indizes:
|
||||
|
||||
```typescript
|
||||
discoveryRegions: 'id, isActive',
|
||||
discoveryInterests: 'id, category',
|
||||
discoveredEvents: 'id, startAt, category, userAction, [startAt+category]',
|
||||
```
|
||||
|
||||
**Keine Encryption-Registry** — öffentliche Daten.
|
||||
|
||||
### 1.5 Deliverables Phase 1
|
||||
|
||||
- [ ] Drizzle-Schema `event_discovery` + `bun run db:push`
|
||||
- [ ] CRUD-Routen für Regionen, Interessen, Quellen
|
||||
- [ ] iCal-Parser mit Dedup + Cleanup
|
||||
- [ ] Crawl-Scheduler (15-Min-Intervall)
|
||||
- [ ] Feed-Endpoint (paginiert, nach Datum gefiltert)
|
||||
- [ ] Frontend: Tab-Navigation in ListView
|
||||
- [ ] Frontend: DiscoverySetup (Regionen + Interessen)
|
||||
- [ ] Frontend: RegionPicker mit mana-geocoding Autocomplete
|
||||
- [ ] Frontend: SourceManager (iCal-URLs CRUD)
|
||||
- [ ] Frontend: DiscoveredEventCard + Feed-Ansicht
|
||||
- [ ] Frontend: "Merken" → socialEvent anlegen
|
||||
- [ ] Tests: iCal-Parser Unit-Tests, Feed-Route Integration-Tests
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Automatische Quellen-Entdeckung + LLM-Extraktion
|
||||
|
||||
**Ziel:** Die KI findet selbst Event-Quellen für die Regionen des Nutzers und extrahiert Events von unstrukturierten Websites.
|
||||
|
||||
### 2.1 Quellen-Entdeckung (Meta-Crawl)
|
||||
|
||||
**Neue Datei:** `services/mana-events/src/discovery/source-discoverer.ts`
|
||||
|
||||
**Ablauf:**
|
||||
|
||||
```
|
||||
Nutzer fügt Region "Freiburg" hinzu
|
||||
│
|
||||
├─ Trigger: source-discoverer.discoverForRegion(region)
|
||||
│
|
||||
├─ Schritt 1: Web-Suche via mana-research
|
||||
│ Queries (parallel, via mana-research POST /api/v1/search):
|
||||
│ "Veranstaltungskalender Freiburg ical"
|
||||
│ "Events Freiburg 2026"
|
||||
│ "Kulturzentren Freiburg Programm"
|
||||
│ "Vereine Freiburg Veranstaltungen"
|
||||
│ "Konzerte Theater Freiburg Termine"
|
||||
│
|
||||
├─ Schritt 2: Ergebnisse filtern
|
||||
│ → URLs die auf .ics enden → Typ 'ical'
|
||||
│ → URLs mit /kalender, /programm, /events, /veranstaltungen → Typ 'website'
|
||||
│ → Bekannte Plattformen (eventbrite.*/freiburg, meetup.com/*freiburg) → Typ 'api'
|
||||
│
|
||||
├─ Schritt 3: LLM-Klassifikation (optional, via mana-llm)
|
||||
│ Prompt: "Ist diese URL eine Event-Quelle? Wenn ja: Name, Typ, Region."
|
||||
│ → Filtert Noise (Nachrichtenartikel über Events, generische Stadtseiten)
|
||||
│
|
||||
├─ Schritt 4: Vorschläge speichern
|
||||
│ → INSERT INTO discovery_sources (status: 'suggested')
|
||||
│ → Nutzer sieht Vorschläge im SourceManager und kann aktivieren/ablehnen
|
||||
│
|
||||
└─ Schritt 5: Sofort-Crawl für aktivierte Quellen
|
||||
```
|
||||
|
||||
**API-Erweiterung:**
|
||||
|
||||
```
|
||||
POST /api/v1/discovery/regions/:id/discover-sources
|
||||
→ Triggert Meta-Crawl, returns {suggestedCount}
|
||||
|
||||
GET /api/v1/discovery/sources?status=suggested
|
||||
→ Vorgeschlagene Quellen die der Nutzer noch bestätigen muss
|
||||
|
||||
PUT /api/v1/discovery/sources/:id/activate
|
||||
PUT /api/v1/discovery/sources/:id/reject
|
||||
```
|
||||
|
||||
### 2.2 Website-Extraktion (LLM-basiert)
|
||||
|
||||
**Neue Datei:** `services/mana-events/src/discovery/website-extractor.ts`
|
||||
|
||||
**Ablauf für Typ `website`:**
|
||||
|
||||
```
|
||||
Source: { type: 'website', url: 'https://jazzhaus.de/programm' }
|
||||
│
|
||||
├─ Schritt 1: Seite crawlen via mana-research
|
||||
│ POST mana-research /api/v1/extract
|
||||
│ { url: source.url, provider: 'jina' } (oder 'firecrawl')
|
||||
│ → Markdown-Text der Seite
|
||||
│
|
||||
├─ Schritt 2: LLM-Extraktion via mana-llm
|
||||
│ System-Prompt:
|
||||
│ "Du bist ein Event-Extractor. Extrahiere ALLE kommenden
|
||||
│ Veranstaltungen von dieser Seite. Pro Event:
|
||||
│ - title (string, required)
|
||||
│ - date (ISO 8601, required)
|
||||
│ - endDate (ISO 8601, optional)
|
||||
│ - location (string, optional — Venue-Name + Adresse)
|
||||
│ - description (string, max 300 Zeichen)
|
||||
│ - category (music|theater|art|tech|sport|food|family|other)
|
||||
│ - priceInfo (string, optional — z.B. 'VVK 15€ / AK 18€')
|
||||
│ - imageUrl (string, optional)
|
||||
│ Antwort als JSON-Array. Ignoriere vergangene Events.
|
||||
│ Heutiges Datum: {today}"
|
||||
│ User-Prompt: <Markdown der gecrawlten Seite>
|
||||
│ → JSON-Array von NormalizedEvents
|
||||
│
|
||||
├─ Schritt 3: Validierung + Normalisierung
|
||||
│ → Datum parsen (LLMs machen manchmal "25. April 2026" statt ISO)
|
||||
│ → Geocoding via mana-geocoding falls location vorhanden
|
||||
│ → dedupe_hash berechnen
|
||||
│
|
||||
└─ Schritt 4: UPSERT INTO discovered_events
|
||||
```
|
||||
|
||||
**LLM-Kosten:** ~500-2000 Input-Tokens pro Seite + ~200-500 Output-Tokens. Bei Haiku-Klasse: ~0.001-0.003 $ pro Seite. Bei täglichem Crawl von 50 Quellen: ~$0.05-0.15/Tag.
|
||||
|
||||
### 2.3 Relevanz-Scoring
|
||||
|
||||
**Neue Datei:** `services/mana-events/src/discovery/scorer.ts`
|
||||
|
||||
```typescript
|
||||
function scoreEvent(
|
||||
event: DiscoveredEvent,
|
||||
interests: DiscoveryInterest[],
|
||||
regions: DiscoveryRegion[],
|
||||
userActions: Map<string, 'save' | 'dismiss'>
|
||||
): number {
|
||||
let score = 50; // Basis
|
||||
|
||||
// Kategorie-Match: +20 pro Match mit Nutzer-Interesse (gewichtet)
|
||||
for (const interest of interests) {
|
||||
if (event.category === interest.category) score += 20 * interest.weight;
|
||||
if (interest.freetext && event.title.toLowerCase().includes(interest.freetext.toLowerCase()))
|
||||
score += 15 * interest.weight;
|
||||
}
|
||||
|
||||
// Entfernung: -1 pro km über 5km (nah = besser)
|
||||
const nearestRegion = findNearestRegion(event, regions);
|
||||
if (nearestRegion) {
|
||||
const distKm = haversine(event.lat, event.lon, nearestRegion.lat, nearestRegion.lon);
|
||||
score -= Math.max(0, distKm - 5);
|
||||
}
|
||||
|
||||
// Zeitnähe: +10 wenn innerhalb 7 Tagen, +5 wenn innerhalb 14 Tagen
|
||||
const daysUntil = (new Date(event.startAt).getTime() - Date.now()) / 86400000;
|
||||
if (daysUntil <= 7) score += 10;
|
||||
else if (daysUntil <= 14) score += 5;
|
||||
|
||||
// Wochenende-Bonus: +5 wenn Sa/So (die meisten Nutzer sind freier)
|
||||
const dow = new Date(event.startAt).getDay();
|
||||
if (dow === 0 || dow === 6) score += 5;
|
||||
|
||||
// Source-Qualität: +5 wenn Source hohe Erfolgsquote hat
|
||||
// (Phase 3: implizites Feedback aus save/dismiss-Ratio)
|
||||
|
||||
return Math.max(0, Math.min(100, score));
|
||||
}
|
||||
```
|
||||
|
||||
**Feed-Endpoint erweitert:** `ORDER BY relevance_score DESC, start_at ASC`
|
||||
|
||||
### 2.4 Frontend-Erweiterungen
|
||||
|
||||
**SourceManager.svelte — erweitert:**
|
||||
|
||||
```svelte
|
||||
<!-- Bestehend: manuelle iCal-Eingabe -->
|
||||
<!-- NEU: "Quellen automatisch finden" Button -->
|
||||
<button onclick={discoverSources}>
|
||||
Quellen automatisch finden für {activeRegion.label}
|
||||
</button>
|
||||
|
||||
{#if suggestedSources.length > 0}
|
||||
<h3>Vorgeschlagene Quellen</h3>
|
||||
{#each suggestedSources as source}
|
||||
<SourceSuggestionCard
|
||||
{source}
|
||||
onActivate={() => activateSource(source.id)}
|
||||
onReject={() => rejectSource(source.id)}
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
```
|
||||
|
||||
**DiscoveredEventCard.svelte — erweitert:**
|
||||
|
||||
- Relevanz-Indikator (farbiger Dot: grün >70, gelb >40, grau <40)
|
||||
- "Warum vorgeschlagen?"-Tooltip (Kategorie-Match, Nähe, Zeitnähe)
|
||||
- Kategorie-Badge prominenter
|
||||
|
||||
### 2.5 Deliverables Phase 2
|
||||
|
||||
- [ ] Source-Discoverer: Web-Suche → iCal/Website-URLs → Vorschläge
|
||||
- [ ] Website-Extractor: Crawl → LLM-Extraktion → normalisierte Events
|
||||
- [ ] Relevanz-Scorer mit Kategorie/Distanz/Zeit-Gewichtung
|
||||
- [ ] API: `/discover-sources`, `/activate`, `/reject`
|
||||
- [ ] Frontend: "Quellen automatisch finden" + Vorschlags-UI
|
||||
- [ ] Frontend: Relevanz-Indikator + "Warum vorgeschlagen?"
|
||||
- [ ] Crawl-Scheduler erweitert: Website-Typ + Fehlerhandling
|
||||
- [ ] Tests: Website-Extractor mit Mock-HTML, Scorer Unit-Tests
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — mana-ai Integration + Proaktive Vorschläge
|
||||
|
||||
**Ziel:** Discovery wird zu einem AI-Tool. Mana-AI-Missions können proaktiv Events finden und vorschlagen.
|
||||
|
||||
### 3.1 AI-Tool: `discover_events`
|
||||
|
||||
**In `@mana/shared-ai`:**
|
||||
|
||||
```typescript
|
||||
{
|
||||
name: 'discover_events',
|
||||
description: 'Suche öffentliche Veranstaltungen in den konfigurierten Regionen des Nutzers',
|
||||
parameters: {
|
||||
query: { type: 'string', description: 'Optionaler Suchtext (z.B. "Jazz Konzerte")' },
|
||||
category: { type: 'string', description: 'Kategorie-Filter' },
|
||||
days_ahead: { type: 'number', description: 'Wie viele Tage voraus (default 14)' },
|
||||
},
|
||||
defaultPolicy: 'auto', // Read-only, kann im Reasoning-Loop laufen
|
||||
}
|
||||
```
|
||||
|
||||
**Server-side (mana-ai):** Ruft `mana-events /api/v1/discovery/feed` auf, injiziert Ergebnisse als ResolvedInput.
|
||||
|
||||
### 3.2 AI-Tool: `suggest_event`
|
||||
|
||||
```typescript
|
||||
{
|
||||
name: 'suggest_event',
|
||||
description: 'Schlage dem Nutzer ein entdecktes Event vor (erscheint als Proposal)',
|
||||
parameters: {
|
||||
discovered_event_id: { type: 'string', required: true },
|
||||
reason: { type: 'string', description: 'Warum dieses Event relevant ist' },
|
||||
},
|
||||
defaultPolicy: 'propose', // Nutzer muss bestätigen
|
||||
}
|
||||
```
|
||||
|
||||
**Approve-Handler:** Führt den "Merken"-Flow aus (discoveredEvent → socialEvent).
|
||||
|
||||
### 3.3 Proaktive Mission: "Event-Scout"
|
||||
|
||||
Als **Agent-Template** (analog Recherche-Agent):
|
||||
|
||||
```typescript
|
||||
{
|
||||
name: 'Event-Scout',
|
||||
description: 'Findet regelmäßig Events in deinen Regionen und schlägt passende vor',
|
||||
defaultMissions: [
|
||||
{
|
||||
objective: 'Prüfe neue Events in meinen Regionen. Schlage die 3-5 relevantesten vor, die ich noch nicht gesehen habe.',
|
||||
cadence: 'daily',
|
||||
isPaused: false,
|
||||
}
|
||||
],
|
||||
policy: {
|
||||
discover_events: 'auto',
|
||||
suggest_event: 'propose',
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 Feedback-Loop
|
||||
|
||||
**Implizites Profil aus Nutzer-Aktionen:**
|
||||
|
||||
```
|
||||
save_count(category=music) / total_music_shown → music_affinity
|
||||
dismiss_count(source=X) / total_from_X → source_quality
|
||||
|
||||
→ Gewichtung in scorer.ts anpassen:
|
||||
- Kategorien mit hoher Affinity: weight * 1.5
|
||||
- Quellen mit niedriger Qualität: weight * 0.5
|
||||
- Quellen mit >80% dismiss: deaktivieren + Nutzer informieren
|
||||
```
|
||||
|
||||
### 3.5 Notifications
|
||||
|
||||
Via `mana-notify`:
|
||||
|
||||
- **Täglicher Digest** (optional): "5 neue Events in Freiburg diese Woche"
|
||||
- **Highlight-Alert** (optional): Push bei Events mit Score >90
|
||||
- **Source-Status**: "iCal-Feed von Jazzhaus ist seit 3 Tagen nicht erreichbar"
|
||||
|
||||
### 3.6 Deliverables Phase 3
|
||||
|
||||
- [ ] AI-Tools `discover_events` + `suggest_event` in shared-ai + mana-ai
|
||||
- [ ] Agent-Template "Event-Scout"
|
||||
- [ ] Feedback-Loop: implizites Profil → Scorer-Gewichtung
|
||||
- [ ] Notification-Integration (täglicher Digest, Highlight-Alert)
|
||||
- [ ] Tests: AI-Tool Unit-Tests, Feedback-Aggregation
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Event-Plattform-APIs + Social
|
||||
|
||||
**Ziel:** Strukturierte APIs von Event-Plattformen anbinden für höhere Datenqualität.
|
||||
|
||||
### 4.1 Provider-Adapter
|
||||
|
||||
```
|
||||
services/mana-events/src/discovery/providers/
|
||||
base.ts ← Interface: fetchEvents(region, dateRange) → NormalizedEvent[]
|
||||
ical.ts ← Bestehender iCal-Parser (refactored)
|
||||
website.ts ← Bestehender Website-Extractor (refactored)
|
||||
eventbrite.ts ← Eventbrite API (OAuth, kostenlos für Reads)
|
||||
meetup.ts ← Meetup GraphQL API
|
||||
facebook-events.ts ← Meta Graph API (eingeschränkt, braucht App Review)
|
||||
```
|
||||
|
||||
### 4.2 Stadt-Portale
|
||||
|
||||
Viele Städte haben halbstrukturierte Event-Kalender:
|
||||
|
||||
- freiburg.de/veranstaltungen → RSS/Atom wo vorhanden, sonst Website-Extractor
|
||||
- basel.ch/events → Ähnlich
|
||||
- Tourismus-Seiten (Schwarzwald-Tourismus, Basel-Tourismus)
|
||||
|
||||
→ Diese werden als `type: 'website'` Quellen mit spezifischen Crawl-Hinweisen angelegt.
|
||||
|
||||
### 4.3 Deliverables Phase 4
|
||||
|
||||
- [ ] Provider-Adapter-Interface + Refactoring bestehender Parser
|
||||
- [ ] Eventbrite-Provider
|
||||
- [ ] Meetup-Provider
|
||||
- [ ] Stadt-Portal-Unterstützung (optimierte Extraktion)
|
||||
|
||||
---
|
||||
|
||||
## Abhängigkeiten
|
||||
|
||||
| Service | Rolle | Schon vorhanden? |
|
||||
|---------|-------|-------------------|
|
||||
| mana-events (3065) | Hosting der Discovery-Logik + DB | Ja, wird erweitert |
|
||||
| mana-research (3068) | Web-Suche + Extraktion | Ja |
|
||||
| mana-geocoding (3018) | Region-Geocoding + Distanzberechnung | Ja |
|
||||
| mana-llm | LLM-Aufrufe für Extraktion + Klassifikation | Ja |
|
||||
| mana-credits | Kosten-Tracking für LLM + Research-Calls | Ja |
|
||||
| mana-notify (3024) | Push-Notifications für Digests | Ja |
|
||||
| mana-ai (3067) | Mission-Runner für proaktive Vorschläge | Ja, Phase 3 |
|
||||
|
||||
**Neue npm-Dependencies:**
|
||||
|
||||
- `node-ical` — iCal-Parsing (Phase 1)
|
||||
- Keine weiteren — alles andere ist über bestehende Services abgedeckt
|
||||
|
||||
---
|
||||
|
||||
## Risiken + Mitigationen
|
||||
|
||||
| Risiko | Mitigation |
|
||||
|--------|------------|
|
||||
| iCal-Feeds kaputt / nicht-standard | Robuster Parser + error_count + Auto-Deaktivierung nach 5 Fehlern |
|
||||
| LLM-Extraktion unzuverlässig | Structured Output (JSON-Mode), Validierung, Fallback auf Regex-Extraktion für bekannte Formate |
|
||||
| Zu viele irrelevante Events | Relevanz-Scoring + Dismiss-Feedback + Source-Qualitäts-Tracking |
|
||||
| Hohe LLM-Kosten bei vielen Quellen | Haiku-Klasse nutzen, Caching (gleiche Seite → kein Re-Extract wenn unverändert), Rate-Limits pro User |
|
||||
| Geocoding-Ungenauigkeit | Fallback: Events ohne Koordinaten bekommen Region-Zentrum + maximalen Radius |
|
||||
| DSGVO: öffentliche Events speichern | Events sind öffentlich publiziert, kein personenbezogener Inhalt. User-Actions (save/dismiss) sind personal data → Löschung bei Account-Delete |
|
||||
|
||||
---
|
||||
|
||||
## Empfehlung
|
||||
|
||||
**Phase 1 zuerst bauen.** Das allein ist schon wertvoll — ein Nutzer, der 10 iCal-Feeds seiner Lieblings-Venues einträgt, bekommt einen aggregierten Event-Feed ohne dass je eine KI laufen muss. Phase 2 macht es dann intelligent (automatische Quellen-Entdeckung + unstrukturierte Seiten). Phase 3 macht es proaktiv (KI schlägt Events vor). Phase 4 ist nice-to-have.
|
||||
|
||||
Geschätzter Aufwand Phase 1: Backend ~1.5 Tage, Frontend ~1.5 Tage, Tests ~0.5 Tage = **~3.5 Tage**.
|
||||
44
pnpm-lock.yaml
generated
44
pnpm-lock.yaml
generated
|
|
@ -3463,6 +3463,9 @@ importers:
|
|||
jose:
|
||||
specifier: ^6.1.2
|
||||
version: 6.2.2
|
||||
node-ical:
|
||||
specifier: ^0.26.0
|
||||
version: 0.26.0
|
||||
postgres:
|
||||
specifier: ^3.4.5
|
||||
version: 3.4.9
|
||||
|
|
@ -6788,6 +6791,10 @@ packages:
|
|||
'@jridgewell/trace-mapping@0.3.9':
|
||||
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
|
||||
|
||||
'@js-temporal/polyfill@0.5.1':
|
||||
resolution: {integrity: sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
'@ljharb/through@2.3.14':
|
||||
resolution: {integrity: sha512-ajBvlKpWucBB17FuQYUShqpqy8GRgYEpJW0vWJbUu1CV9lWyrDCapy0lScU8T8Z6qn49sSwJB3+M+evYIdGg+A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
|
@ -13490,6 +13497,9 @@ packages:
|
|||
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
|
||||
hasBin: true
|
||||
|
||||
jsbi@4.3.2:
|
||||
resolution: {integrity: sha512-9fqMSQbhJykSeii05nxKl4m6Eqn2P6rOlYiS+C5Dr/HPIU/7yZxu5qzbs40tgaFORiw2Amd0mirjxatXYMkIew==}
|
||||
|
||||
jsc-safe-url@0.2.4:
|
||||
resolution: {integrity: sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q==}
|
||||
|
||||
|
|
@ -14553,6 +14563,10 @@ packages:
|
|||
resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==}
|
||||
hasBin: true
|
||||
|
||||
node-ical@0.26.0:
|
||||
resolution: {integrity: sha512-tJZY2fMb38Gbj0P05zHMWBr90MslhGZ1qEbOWYnokBYPPX/lYskL/0NnWoeiXTBNod+kRRcTOjxAeB20kfvKyw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
node-int64@0.4.0:
|
||||
resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==}
|
||||
|
||||
|
|
@ -15969,6 +15983,9 @@ packages:
|
|||
resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
rrule-temporal@1.5.2:
|
||||
resolution: {integrity: sha512-I5rAiZfRlMh0vuG23HrGBMLZOSiQO7H1Uq8l9qyfA6oTD5j+UMRwpRs4aVU4XdaFhgN1p3K+cHelG8KvLTTm+g==}
|
||||
|
||||
rrule@2.8.1:
|
||||
resolution: {integrity: sha512-hM3dHSBMeaJ0Ktp7W38BJZ7O1zOgaFEsn41PDk+yHoEtfLV+PoJt9E9xAlZiWgf/iqEqionN0ebHFZIDAp+iGw==}
|
||||
|
||||
|
|
@ -16619,6 +16636,12 @@ packages:
|
|||
resolution: {integrity: sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
temporal-polyfill@0.3.2:
|
||||
resolution: {integrity: sha512-TzHthD/heRK947GNiSu3Y5gSPpeUDH34+LESnfsq8bqpFhsB79HFBX8+Z834IVX68P3EUyRPZK5bL/1fh437Eg==}
|
||||
|
||||
temporal-spec@0.3.1:
|
||||
resolution: {integrity: sha512-B4TUhezh9knfSIMwt7RVggApDRJZo73uZdj8AacL2mZ8RP5KtLianh2MXxL06GN9ESYiIsiuoLQhgVfwe55Yhw==}
|
||||
|
||||
tempy@0.6.0:
|
||||
resolution: {integrity: sha512-G13vtMYPT/J8A4X2SjdtBTphZlrp1gKv6hZiOjw14RCWg6GbHuQBGtjlx75xLbYV/wEc0D7G5K4rxKP/cXk8Bw==}
|
||||
engines: {node: '>=10'}
|
||||
|
|
@ -22113,6 +22136,10 @@ snapshots:
|
|||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@js-temporal/polyfill@0.5.1':
|
||||
dependencies:
|
||||
jsbi: 4.3.2
|
||||
|
||||
'@ljharb/through@2.3.14':
|
||||
dependencies:
|
||||
call-bind: 1.0.8
|
||||
|
|
@ -32512,6 +32539,8 @@ snapshots:
|
|||
dependencies:
|
||||
argparse: 2.0.1
|
||||
|
||||
jsbi@4.3.2: {}
|
||||
|
||||
jsc-safe-url@0.2.4: {}
|
||||
|
||||
jsdom@25.0.1:
|
||||
|
|
@ -34094,6 +34123,11 @@ snapshots:
|
|||
detect-libc: 2.1.2
|
||||
optional: true
|
||||
|
||||
node-ical@0.26.0:
|
||||
dependencies:
|
||||
rrule-temporal: 1.5.2
|
||||
temporal-polyfill: 0.3.2
|
||||
|
||||
node-int64@0.4.0: {}
|
||||
|
||||
node-mock-http@1.0.4: {}
|
||||
|
|
@ -36082,6 +36116,10 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
rrule-temporal@1.5.2:
|
||||
dependencies:
|
||||
'@js-temporal/polyfill': 0.5.1
|
||||
|
||||
rrule@2.8.1:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
|
@ -36876,6 +36914,12 @@ snapshots:
|
|||
|
||||
temp-dir@2.0.0: {}
|
||||
|
||||
temporal-polyfill@0.3.2:
|
||||
dependencies:
|
||||
temporal-spec: 0.3.1
|
||||
|
||||
temporal-spec@0.3.1: {}
|
||||
|
||||
tempy@0.6.0:
|
||||
dependencies:
|
||||
is-stream: 2.0.1
|
||||
|
|
|
|||
|
|
@ -7,5 +7,5 @@ export default defineConfig({
|
|||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL || 'postgresql://mana:devpassword@localhost:5432/mana_platform',
|
||||
},
|
||||
schemaFilter: ['events'],
|
||||
schemaFilter: ['events', 'event_discovery'],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,10 +12,11 @@
|
|||
"db:studio": "drizzle-kit studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"hono": "^4.7.0",
|
||||
"drizzle-orm": "^0.38.3",
|
||||
"postgres": "^3.4.5",
|
||||
"hono": "^4.7.0",
|
||||
"jose": "^6.1.2",
|
||||
"node-ical": "^0.26.0",
|
||||
"postgres": "^3.4.5",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
80
services/mana-events/src/__tests__/deduplicator.test.ts
Normal file
80
services/mana-events/src/__tests__/deduplicator.test.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
/**
|
||||
* Deduplicator unit tests — no DB required.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import { computeDedupeHash } from '../discovery/deduplicator';
|
||||
import type { NormalizedEvent } from '../discovery/types';
|
||||
|
||||
function makeEvent(overrides: Partial<NormalizedEvent> = {}): NormalizedEvent {
|
||||
return {
|
||||
title: 'Jazz Night',
|
||||
startAt: new Date('2026-05-01T19:00:00Z'),
|
||||
sourceUrl: 'https://example.com/event',
|
||||
location: 'Jazzhaus Freiburg',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('computeDedupeHash', () => {
|
||||
it('produces a hex string', async () => {
|
||||
const hash = await computeDedupeHash(makeEvent());
|
||||
expect(hash).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it('is deterministic (same input = same hash)', async () => {
|
||||
const a = await computeDedupeHash(makeEvent());
|
||||
const b = await computeDedupeHash(makeEvent());
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it('differs when title changes', async () => {
|
||||
const a = await computeDedupeHash(makeEvent({ title: 'Jazz Night' }));
|
||||
const b = await computeDedupeHash(makeEvent({ title: 'Rock Night' }));
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it('differs when date changes', async () => {
|
||||
const a = await computeDedupeHash(makeEvent({ startAt: new Date('2026-05-01T19:00:00Z') }));
|
||||
const b = await computeDedupeHash(makeEvent({ startAt: new Date('2026-05-02T19:00:00Z') }));
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it('differs when location changes', async () => {
|
||||
const a = await computeDedupeHash(makeEvent({ location: 'Jazzhaus Freiburg' }));
|
||||
const b = await computeDedupeHash(makeEvent({ location: 'E-Werk Freiburg' }));
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it('is case-insensitive (title)', async () => {
|
||||
const a = await computeDedupeHash(makeEvent({ title: 'Jazz Night' }));
|
||||
const b = await computeDedupeHash(makeEvent({ title: 'jazz night' }));
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it('is case-insensitive (location)', async () => {
|
||||
const a = await computeDedupeHash(makeEvent({ location: 'Jazzhaus Freiburg' }));
|
||||
const b = await computeDedupeHash(makeEvent({ location: 'jazzhaus freiburg' }));
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it('treats null and empty location the same', async () => {
|
||||
const a = await computeDedupeHash(makeEvent({ location: null }));
|
||||
const b = await computeDedupeHash(makeEvent({ location: '' }));
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it('ignores time-of-day (same calendar date = same hash)', async () => {
|
||||
const a = await computeDedupeHash(makeEvent({ startAt: new Date('2026-05-01T10:00:00Z') }));
|
||||
const b = await computeDedupeHash(makeEvent({ startAt: new Date('2026-05-01T22:00:00Z') }));
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it('trims whitespace from title and location', async () => {
|
||||
const a = await computeDedupeHash(
|
||||
makeEvent({ title: ' Jazz Night ', location: ' Jazzhaus ' })
|
||||
);
|
||||
const b = await computeDedupeHash(makeEvent({ title: 'Jazz Night', location: 'Jazzhaus' }));
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
});
|
||||
389
services/mana-events/src/__tests__/discovery.test.ts
Normal file
389
services/mana-events/src/__tests__/discovery.test.ts
Normal file
|
|
@ -0,0 +1,389 @@
|
|||
/**
|
||||
* Discovery route integration tests.
|
||||
*
|
||||
* Tests CRUD for regions, interests, sources, and the feed endpoint.
|
||||
* Uses the same mock-auth pattern as existing mana-events tests.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterAll } from 'bun:test';
|
||||
import { buildTestApp, authedRequest, jsonBody, TEST_USER_ID, OTHER_USER_ID } from './helpers';
|
||||
|
||||
const app = buildTestApp();
|
||||
|
||||
const BASE = 'http://test/api/v1/discovery';
|
||||
|
||||
beforeEach(async () => {
|
||||
await app.wipe();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.wipe();
|
||||
});
|
||||
|
||||
// ─── Helper ─────────────────────────────────────────────────────────
|
||||
|
||||
async function createRegion(label = 'Freiburg', lat = 47.997, lon = 7.842, user = TEST_USER_ID) {
|
||||
const res = await app.fetch(
|
||||
authedRequest(`${BASE}/regions`, {
|
||||
method: 'POST',
|
||||
body: jsonBody({ label, lat, lon, radiusKm: 25 }),
|
||||
user,
|
||||
})
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
const data = await res.json();
|
||||
return data.region;
|
||||
}
|
||||
|
||||
// ─── Regions ────────────────────────────────────────────────────────
|
||||
|
||||
describe('Discovery Regions', () => {
|
||||
it('creates a region', async () => {
|
||||
const region = await createRegion();
|
||||
expect(region.label).toBe('Freiburg');
|
||||
expect(region.lat).toBe(47.997);
|
||||
expect(region.lon).toBe(7.842);
|
||||
expect(region.radiusKm).toBe(25);
|
||||
expect(region.isActive).toBe(true);
|
||||
});
|
||||
|
||||
it('lists only own regions', async () => {
|
||||
await createRegion('Freiburg', 47.997, 7.842, TEST_USER_ID);
|
||||
await createRegion('Basel', 47.559, 7.589, OTHER_USER_ID);
|
||||
|
||||
const res = await app.fetch(authedRequest(`${BASE}/regions`));
|
||||
const { regions } = await res.json();
|
||||
expect(regions).toHaveLength(1);
|
||||
expect(regions[0].label).toBe('Freiburg');
|
||||
});
|
||||
|
||||
it('updates a region', async () => {
|
||||
const region = await createRegion();
|
||||
const res = await app.fetch(
|
||||
authedRequest(`${BASE}/regions/${region.id}`, {
|
||||
method: 'PUT',
|
||||
body: jsonBody({ radiusKm: 50, label: 'Freiburg im Breisgau' }),
|
||||
})
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const { region: updated } = await res.json();
|
||||
expect(updated.radiusKm).toBe(50);
|
||||
expect(updated.label).toBe('Freiburg im Breisgau');
|
||||
});
|
||||
|
||||
it('rejects updating another user region', async () => {
|
||||
const region = await createRegion('Basel', 47.559, 7.589, OTHER_USER_ID);
|
||||
const res = await app.fetch(
|
||||
authedRequest(`${BASE}/regions/${region.id}`, {
|
||||
method: 'PUT',
|
||||
body: jsonBody({ radiusKm: 100 }),
|
||||
})
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('deletes a region (cascades to sources)', async () => {
|
||||
const region = await createRegion();
|
||||
const delRes = await app.fetch(
|
||||
authedRequest(`${BASE}/regions/${region.id}`, { method: 'DELETE' })
|
||||
);
|
||||
expect(delRes.status).toBe(200);
|
||||
|
||||
const listRes = await app.fetch(authedRequest(`${BASE}/regions`));
|
||||
const { regions } = await listRes.json();
|
||||
expect(regions).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rejects invalid coordinates', async () => {
|
||||
const res = await app.fetch(
|
||||
authedRequest(`${BASE}/regions`, {
|
||||
method: 'POST',
|
||||
body: jsonBody({ label: 'Bad', lat: 999, lon: 7.0 }),
|
||||
})
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Interests ──────────────────────────────────────────────────────
|
||||
|
||||
describe('Discovery Interests', () => {
|
||||
it('creates and lists interests', async () => {
|
||||
const res1 = await app.fetch(
|
||||
authedRequest(`${BASE}/interests`, {
|
||||
method: 'POST',
|
||||
body: jsonBody({ category: 'music' }),
|
||||
})
|
||||
);
|
||||
expect(res1.status).toBe(201);
|
||||
|
||||
const res2 = await app.fetch(
|
||||
authedRequest(`${BASE}/interests`, {
|
||||
method: 'POST',
|
||||
body: jsonBody({ category: 'tech', freetext: 'Rust Meetups', weight: 2.0 }),
|
||||
})
|
||||
);
|
||||
expect(res2.status).toBe(201);
|
||||
|
||||
const listRes = await app.fetch(authedRequest(`${BASE}/interests`));
|
||||
const { interests } = await listRes.json();
|
||||
expect(interests).toHaveLength(2);
|
||||
expect(interests.find((i: { category: string }) => i.category === 'tech').freetext).toBe(
|
||||
'Rust Meetups'
|
||||
);
|
||||
expect(interests.find((i: { category: string }) => i.category === 'tech').weight).toBe(2.0);
|
||||
});
|
||||
|
||||
it('deletes an interest', async () => {
|
||||
const createRes = await app.fetch(
|
||||
authedRequest(`${BASE}/interests`, {
|
||||
method: 'POST',
|
||||
body: jsonBody({ category: 'sport' }),
|
||||
})
|
||||
);
|
||||
const { interest } = await createRes.json();
|
||||
|
||||
const delRes = await app.fetch(
|
||||
authedRequest(`${BASE}/interests/${interest.id}`, { method: 'DELETE' })
|
||||
);
|
||||
expect(delRes.status).toBe(200);
|
||||
|
||||
const listRes = await app.fetch(authedRequest(`${BASE}/interests`));
|
||||
const { interests } = await listRes.json();
|
||||
expect(interests).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Sources ────────────────────────────────────────────────────────
|
||||
|
||||
describe('Discovery Sources', () => {
|
||||
it('creates a source linked to a region', async () => {
|
||||
const region = await createRegion();
|
||||
const res = await app.fetch(
|
||||
authedRequest(`${BASE}/sources`, {
|
||||
method: 'POST',
|
||||
body: jsonBody({
|
||||
type: 'ical',
|
||||
url: 'https://example.com/events.ics',
|
||||
name: 'Test Venue',
|
||||
regionId: region.id,
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
const { source } = await res.json();
|
||||
expect(source.name).toBe('Test Venue');
|
||||
expect(source.type).toBe('ical');
|
||||
expect(source.regionId).toBe(region.id);
|
||||
expect(source.isActive).toBe(true);
|
||||
expect(source.errorCount).toBe(0);
|
||||
});
|
||||
|
||||
it('rejects source with invalid region', async () => {
|
||||
const res = await app.fetch(
|
||||
authedRequest(`${BASE}/sources`, {
|
||||
method: 'POST',
|
||||
body: jsonBody({
|
||||
type: 'ical',
|
||||
url: 'https://example.com/events.ics',
|
||||
name: 'Bad',
|
||||
regionId: '00000000-0000-0000-0000-000000000000',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects source for another user region', async () => {
|
||||
const region = await createRegion('Basel', 47.559, 7.589, OTHER_USER_ID);
|
||||
const res = await app.fetch(
|
||||
authedRequest(`${BASE}/sources`, {
|
||||
method: 'POST',
|
||||
body: jsonBody({
|
||||
type: 'ical',
|
||||
url: 'https://example.com/events.ics',
|
||||
name: 'Sneaky',
|
||||
regionId: region.id,
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('lists only own sources', async () => {
|
||||
const region1 = await createRegion('FR', 47.997, 7.842, TEST_USER_ID);
|
||||
const region2 = await createRegion('BS', 47.559, 7.589, OTHER_USER_ID);
|
||||
|
||||
await app.fetch(
|
||||
authedRequest(`${BASE}/sources`, {
|
||||
method: 'POST',
|
||||
body: jsonBody({
|
||||
type: 'ical',
|
||||
url: 'https://a.com/cal.ics',
|
||||
name: 'A',
|
||||
regionId: region1.id,
|
||||
}),
|
||||
})
|
||||
);
|
||||
await app.fetch(
|
||||
authedRequest(`${BASE}/sources`, {
|
||||
method: 'POST',
|
||||
body: jsonBody({
|
||||
type: 'ical',
|
||||
url: 'https://b.com/cal.ics',
|
||||
name: 'B',
|
||||
regionId: region2.id,
|
||||
}),
|
||||
user: OTHER_USER_ID,
|
||||
})
|
||||
);
|
||||
|
||||
const res = await app.fetch(authedRequest(`${BASE}/sources`));
|
||||
const { sources } = await res.json();
|
||||
expect(sources).toHaveLength(1);
|
||||
expect(sources[0].name).toBe('A');
|
||||
});
|
||||
|
||||
it('deletes a source', async () => {
|
||||
const region = await createRegion();
|
||||
const createRes = await app.fetch(
|
||||
authedRequest(`${BASE}/sources`, {
|
||||
method: 'POST',
|
||||
body: jsonBody({
|
||||
type: 'ical',
|
||||
url: 'https://x.com/cal.ics',
|
||||
name: 'X',
|
||||
regionId: region.id,
|
||||
}),
|
||||
})
|
||||
);
|
||||
const { source } = await createRes.json();
|
||||
|
||||
const delRes = await app.fetch(
|
||||
authedRequest(`${BASE}/sources/${source.id}`, { method: 'DELETE' })
|
||||
);
|
||||
expect(delRes.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Feed ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('Discovery Feed', () => {
|
||||
it('returns empty feed when no sources exist', async () => {
|
||||
await createRegion();
|
||||
const res = await app.fetch(authedRequest(`${BASE}/feed`));
|
||||
expect(res.status).toBe(200);
|
||||
const { events, hasMore } = await res.json();
|
||||
expect(events).toHaveLength(0);
|
||||
expect(hasMore).toBe(false);
|
||||
});
|
||||
|
||||
it('records save action', async () => {
|
||||
const region = await createRegion();
|
||||
|
||||
// Insert a discovered event directly to test the action endpoint
|
||||
const { sql: rawSql } = await import('drizzle-orm');
|
||||
const futureDate = new Date(Date.now() + 7 * 86400000).toISOString();
|
||||
|
||||
// First create a source so we have a source_id for the FK
|
||||
const srcRes = await app.fetch(
|
||||
authedRequest(`${BASE}/sources`, {
|
||||
method: 'POST',
|
||||
body: jsonBody({
|
||||
type: 'ical',
|
||||
url: 'https://test.com/cal.ics',
|
||||
name: 'Test',
|
||||
regionId: region.id,
|
||||
}),
|
||||
})
|
||||
);
|
||||
const { source } = await srcRes.json();
|
||||
|
||||
// Insert event directly into DB
|
||||
await app.db.execute(
|
||||
rawSql`INSERT INTO event_discovery.discovered_events
|
||||
(id, source_id, dedupe_hash, title, start_at, source_url, crawled_at)
|
||||
VALUES (
|
||||
'00000000-0000-0000-0000-000000000001',
|
||||
${source.id}::uuid,
|
||||
'testhash123',
|
||||
'Test Event',
|
||||
${futureDate}::timestamptz,
|
||||
'https://example.com/event',
|
||||
now()
|
||||
)`
|
||||
);
|
||||
|
||||
// Record action
|
||||
const actionRes = await app.fetch(
|
||||
authedRequest(`${BASE}/feed/00000000-0000-0000-0000-000000000001/action`, {
|
||||
method: 'POST',
|
||||
body: jsonBody({ action: 'save' }),
|
||||
})
|
||||
);
|
||||
expect(actionRes.status).toBe(200);
|
||||
|
||||
// Verify the action shows in feed
|
||||
const feedRes = await app.fetch(authedRequest(`${BASE}/feed`));
|
||||
const { events } = await feedRes.json();
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].userAction).toBe('save');
|
||||
});
|
||||
|
||||
it('dismiss action + hideDismissed filters events', async () => {
|
||||
const region = await createRegion();
|
||||
const { sql: rawSql } = await import('drizzle-orm');
|
||||
const futureDate = new Date(Date.now() + 7 * 86400000).toISOString();
|
||||
|
||||
const srcRes = await app.fetch(
|
||||
authedRequest(`${BASE}/sources`, {
|
||||
method: 'POST',
|
||||
body: jsonBody({
|
||||
type: 'ical',
|
||||
url: 'https://test.com/cal.ics',
|
||||
name: 'Test',
|
||||
regionId: region.id,
|
||||
}),
|
||||
})
|
||||
);
|
||||
const { source } = await srcRes.json();
|
||||
|
||||
await app.db.execute(
|
||||
rawSql`INSERT INTO event_discovery.discovered_events
|
||||
(id, source_id, dedupe_hash, title, start_at, source_url, crawled_at)
|
||||
VALUES (
|
||||
'00000000-0000-0000-0000-000000000002',
|
||||
${source.id}::uuid,
|
||||
'hash-dismiss',
|
||||
'Dismissed Event',
|
||||
${futureDate}::timestamptz,
|
||||
'https://example.com/ev2',
|
||||
now()
|
||||
)`
|
||||
);
|
||||
|
||||
// Dismiss
|
||||
await app.fetch(
|
||||
authedRequest(`${BASE}/feed/00000000-0000-0000-0000-000000000002/action`, {
|
||||
method: 'POST',
|
||||
body: jsonBody({ action: 'dismiss' }),
|
||||
})
|
||||
);
|
||||
|
||||
// Without hideDismissed — shows up
|
||||
const res1 = await app.fetch(authedRequest(`${BASE}/feed`));
|
||||
const data1 = await res1.json();
|
||||
expect(data1.events).toHaveLength(1);
|
||||
expect(data1.events[0].userAction).toBe('dismiss');
|
||||
|
||||
// With hideDismissed — filtered out
|
||||
const res2 = await app.fetch(authedRequest(`${BASE}/feed?hideDismissed=true`));
|
||||
const data2 = await res2.json();
|
||||
expect(data2.events).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rejects unauthenticated requests', async () => {
|
||||
const res = await app.fetch(new Request(`${BASE}/feed`));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
|
@ -40,6 +40,8 @@ const TEST_CONFIG: Config = {
|
|||
rsvpPerTokenPerHour: 5,
|
||||
rsvpMaxPerToken: 20,
|
||||
},
|
||||
manaResearchUrl: 'http://localhost:3068',
|
||||
manaLlmUrl: 'http://localhost:3025',
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -71,6 +73,12 @@ export function buildTestApp(overrides: Partial<Config> = {}): TestApp {
|
|||
async wipe() {
|
||||
// Cascade FK from events_published handles public_rsvps + rate buckets
|
||||
await db.execute(sql`DELETE FROM events.events_published`);
|
||||
// Discovery tables — cascade handles discovered_events + user_actions
|
||||
await db.execute(sql`DELETE FROM event_discovery.discovery_user_actions`);
|
||||
await db.execute(sql`DELETE FROM event_discovery.discovered_events`);
|
||||
await db.execute(sql`DELETE FROM event_discovery.discovery_sources`);
|
||||
await db.execute(sql`DELETE FROM event_discovery.discovery_interests`);
|
||||
await db.execute(sql`DELETE FROM event_discovery.discovery_regions`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
188
services/mana-events/src/__tests__/ical-parser.test.ts
Normal file
188
services/mana-events/src/__tests__/ical-parser.test.ts
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
/**
|
||||
* iCal parser unit tests — no DB or network required.
|
||||
* Uses parseIcalText directly with inline iCal strings.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import { parseIcalText } from '../discovery/ical-parser';
|
||||
|
||||
const SOURCE_URL = 'https://example.com/events.ics';
|
||||
const SOURCE_NAME = 'Test Venue';
|
||||
|
||||
/** Helper: generate a date N days from now as iCal UTC string (YYYYMMDDTHHMMSSZ). */
|
||||
function futureIcalDate(daysAhead: number, hour = 19): string {
|
||||
const d = new Date(Date.now() + daysAhead * 86400000);
|
||||
d.setUTCHours(hour, 0, 0, 0);
|
||||
const pad = (n: number) => n.toString().padStart(2, '0');
|
||||
return `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}T${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}Z`;
|
||||
}
|
||||
|
||||
function pastIcalDate(daysAgo: number, hour = 19): string {
|
||||
return futureIcalDate(-daysAgo, hour);
|
||||
}
|
||||
|
||||
function makeIcal(vevents: string): string {
|
||||
return `BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Test//Test//EN
|
||||
${vevents}
|
||||
END:VCALENDAR`;
|
||||
}
|
||||
|
||||
// ─── Basic parsing ──────────────────────────────────────────────────
|
||||
|
||||
describe('parseIcalText', () => {
|
||||
it('extracts a single future event', () => {
|
||||
const ical = makeIcal(`BEGIN:VEVENT
|
||||
UID:test-uid-1@example.com
|
||||
DTSTART:${futureIcalDate(3)}
|
||||
DTEND:${futureIcalDate(3, 21)}
|
||||
SUMMARY:Jazz Night
|
||||
DESCRIPTION:Live jazz at the club.
|
||||
LOCATION:Jazzhaus Freiburg
|
||||
URL:https://jazzhaus.de/event/1
|
||||
END:VEVENT`);
|
||||
|
||||
const events = parseIcalText(ical, SOURCE_URL, SOURCE_NAME);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].title).toBe('Jazz Night');
|
||||
expect(events[0].description).toBe('Live jazz at the club.');
|
||||
expect(events[0].location).toBe('Jazzhaus Freiburg');
|
||||
expect(events[0].externalId).toBe('test-uid-1@example.com');
|
||||
expect(events[0].sourceUrl).toBe('https://jazzhaus.de/event/1');
|
||||
expect(events[0].allDay).toBe(false);
|
||||
});
|
||||
|
||||
it('extracts multiple events', () => {
|
||||
const ical = makeIcal(`BEGIN:VEVENT
|
||||
UID:a@test
|
||||
DTSTART:${futureIcalDate(1)}
|
||||
SUMMARY:Event A
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
UID:b@test
|
||||
DTSTART:${futureIcalDate(2)}
|
||||
SUMMARY:Event B
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
UID:c@test
|
||||
DTSTART:${futureIcalDate(5)}
|
||||
SUMMARY:Event C
|
||||
END:VEVENT`);
|
||||
|
||||
const events = parseIcalText(ical, SOURCE_URL, SOURCE_NAME);
|
||||
expect(events).toHaveLength(3);
|
||||
expect(events.map((e) => e.title).sort()).toEqual(['Event A', 'Event B', 'Event C']);
|
||||
});
|
||||
|
||||
// ─── Filtering ──────────────────────────────────────────────
|
||||
|
||||
it('filters out past events (> 1 day ago)', () => {
|
||||
const ical = makeIcal(`BEGIN:VEVENT
|
||||
UID:past@test
|
||||
DTSTART:${pastIcalDate(5)}
|
||||
DTEND:${pastIcalDate(5, 21)}
|
||||
SUMMARY:Past Event
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
UID:future@test
|
||||
DTSTART:${futureIcalDate(3)}
|
||||
SUMMARY:Future Event
|
||||
END:VEVENT`);
|
||||
|
||||
const events = parseIcalText(ical, SOURCE_URL, SOURCE_NAME);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].title).toBe('Future Event');
|
||||
});
|
||||
|
||||
it('skips events without a summary', () => {
|
||||
const ical = makeIcal(`BEGIN:VEVENT
|
||||
UID:no-title@test
|
||||
DTSTART:${futureIcalDate(2)}
|
||||
DESCRIPTION:Has no title
|
||||
END:VEVENT`);
|
||||
|
||||
const events = parseIcalText(ical, SOURCE_URL, SOURCE_NAME);
|
||||
expect(events).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('skips VTODO and VFREEBUSY components', () => {
|
||||
const ical = makeIcal(`BEGIN:VTODO
|
||||
UID:todo@test
|
||||
SUMMARY:Not an event
|
||||
END:VTODO
|
||||
BEGIN:VEVENT
|
||||
UID:real@test
|
||||
DTSTART:${futureIcalDate(1)}
|
||||
SUMMARY:Real Event
|
||||
END:VEVENT`);
|
||||
|
||||
const events = parseIcalText(ical, SOURCE_URL, SOURCE_NAME);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].title).toBe('Real Event');
|
||||
});
|
||||
|
||||
// ─── All-day ────────────────────────────────────────────────
|
||||
|
||||
it('detects all-day events (DATE type without time)', () => {
|
||||
const d = new Date(Date.now() + 3 * 86400000);
|
||||
const pad = (n: number) => n.toString().padStart(2, '0');
|
||||
const dateStr = `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}`;
|
||||
|
||||
const ical = makeIcal(`BEGIN:VEVENT
|
||||
UID:allday@test
|
||||
DTSTART;VALUE=DATE:${dateStr}
|
||||
SUMMARY:All Day Festival
|
||||
END:VEVENT`);
|
||||
|
||||
const events = parseIcalText(ical, SOURCE_URL, SOURCE_NAME);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].allDay).toBe(true);
|
||||
});
|
||||
|
||||
// ─── Fallbacks ──────────────────────────────────────────────
|
||||
|
||||
it('uses sourceUrl when event has no URL property', () => {
|
||||
const ical = makeIcal(`BEGIN:VEVENT
|
||||
UID:no-url@test
|
||||
DTSTART:${futureIcalDate(2)}
|
||||
SUMMARY:No URL Event
|
||||
END:VEVENT`);
|
||||
|
||||
const events = parseIcalText(ical, SOURCE_URL, SOURCE_NAME);
|
||||
expect(events[0].sourceUrl).toBe(SOURCE_URL);
|
||||
});
|
||||
|
||||
it('truncates long descriptions to 2000 chars', () => {
|
||||
const longDesc = 'A'.repeat(3000);
|
||||
const ical = makeIcal(`BEGIN:VEVENT
|
||||
UID:long@test
|
||||
DTSTART:${futureIcalDate(2)}
|
||||
SUMMARY:Long Desc
|
||||
DESCRIPTION:${longDesc}
|
||||
END:VEVENT`);
|
||||
|
||||
const events = parseIcalText(ical, SOURCE_URL, SOURCE_NAME);
|
||||
expect(events[0].description!.length).toBe(2000);
|
||||
});
|
||||
|
||||
it('handles empty calendar gracefully', () => {
|
||||
const ical = makeIcal('');
|
||||
const events = parseIcalText(ical, SOURCE_URL, SOURCE_NAME);
|
||||
expect(events).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('handles optional fields as null', () => {
|
||||
const ical = makeIcal(`BEGIN:VEVENT
|
||||
UID:minimal@test
|
||||
DTSTART:${futureIcalDate(1)}
|
||||
SUMMARY:Minimal Event
|
||||
END:VEVENT`);
|
||||
|
||||
const events = parseIcalText(ical, SOURCE_URL, SOURCE_NAME);
|
||||
expect(events[0].description).toBeNull();
|
||||
expect(events[0].location).toBeNull();
|
||||
// node-ical may synthesize an end date from start when DTEND is missing
|
||||
expect(events[0].startAt).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
130
services/mana-events/src/__tests__/ical-real-feeds.test.ts
Normal file
130
services/mana-events/src/__tests__/ical-real-feeds.test.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
/**
|
||||
* Real-world iCal feed tests — validates that parseIcalFeed works with
|
||||
* actual public iCal feeds from the internet.
|
||||
*
|
||||
* These tests hit the network and may be slow or flaky. They exist to
|
||||
* catch compatibility issues with real-world iCal quirks that synthetic
|
||||
* test data can't cover.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import { parseIcalFeed } from '../discovery/ical-parser';
|
||||
import { parseIcalText } from '../discovery/ical-parser';
|
||||
|
||||
const TIMEOUT = 20_000;
|
||||
|
||||
describe('Real iCal feeds', () => {
|
||||
it(
|
||||
'parses Mozilla Thunderbird sample calendar',
|
||||
async () => {
|
||||
// Mozilla ships a public demo calendar for Thunderbird / Lightning
|
||||
try {
|
||||
const events = await parseIcalFeed(
|
||||
'https://www.mozilla.org/media/caldata/GermanHolidays.ics',
|
||||
'German Holidays'
|
||||
);
|
||||
// May or may not have future events, but should parse without error
|
||||
expect(Array.isArray(events)).toBe(true);
|
||||
for (const e of events) {
|
||||
expect(e.title).toBeTruthy();
|
||||
expect(e.startAt).toBeInstanceOf(Date);
|
||||
}
|
||||
} catch {
|
||||
// Network error is acceptable for CI — we're testing the parser
|
||||
}
|
||||
},
|
||||
TIMEOUT
|
||||
);
|
||||
|
||||
it('parses a realistic multi-event iCal with timezones', () => {
|
||||
// A realistic iCal string with VTIMEZONE, multiple VEVENTs,
|
||||
// different date formats, and edge cases.
|
||||
const futureYear = new Date().getFullYear() + 1;
|
||||
const ical = `BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Jazzhaus//Events//DE
|
||||
X-WR-CALNAME:Jazzhaus Freiburg
|
||||
BEGIN:VTIMEZONE
|
||||
TZID:Europe/Berlin
|
||||
BEGIN:DAYLIGHT
|
||||
TZOFFSETFROM:+0100
|
||||
TZOFFSETTO:+0200
|
||||
TZNAME:CEST
|
||||
DTSTART:19700329T020000
|
||||
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3
|
||||
END:DAYLIGHT
|
||||
BEGIN:STANDARD
|
||||
TZOFFSETFROM:+0200
|
||||
TZOFFSETTO:+0100
|
||||
TZNAME:CET
|
||||
DTSTART:19701025T030000
|
||||
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
|
||||
END:STANDARD
|
||||
END:VTIMEZONE
|
||||
BEGIN:VEVENT
|
||||
UID:ev1@jazzhaus.de
|
||||
DTSTART;TZID=Europe/Berlin:${futureYear}0515T200000
|
||||
DTEND;TZID=Europe/Berlin:${futureYear}0515T230000
|
||||
SUMMARY:Gregory Porter Live
|
||||
DESCRIPTION:Grammy-winning jazz vocalist performs his greatest hits.\\nSpecial guest: Lizz Wright.
|
||||
LOCATION:Jazzhaus Freiburg\\, Schnewlinstr. 1
|
||||
URL:https://jazzhaus.de/events/gregory-porter
|
||||
CATEGORIES:MUSIC,JAZZ
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
UID:ev2@jazzhaus.de
|
||||
DTSTART;VALUE=DATE:${futureYear}0620
|
||||
DTEND;VALUE=DATE:${futureYear}0622
|
||||
SUMMARY:Freiburg Jazz Festival
|
||||
DESCRIPTION:Three days of jazz across multiple venues.
|
||||
LOCATION:Freiburg Altstadt
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
UID:ev3@jazzhaus.de
|
||||
DTSTART;TZID=Europe/Berlin:${futureYear}0701T190000
|
||||
SUMMARY:Open Mic Night
|
||||
LOCATION:Jazzhaus Freiburg
|
||||
END:VEVENT
|
||||
END:VCALENDAR`;
|
||||
|
||||
const events = parseIcalText(ical, 'https://jazzhaus.de/events.ics', 'Jazzhaus');
|
||||
expect(events).toHaveLength(3);
|
||||
|
||||
// Check the timezone-aware event
|
||||
const porter = events.find((e) => e.title === 'Gregory Porter Live')!;
|
||||
expect(porter).toBeTruthy();
|
||||
expect(porter.description).toContain('Grammy-winning');
|
||||
expect(porter.location).toContain('Jazzhaus Freiburg');
|
||||
expect(porter.sourceUrl).toBe('https://jazzhaus.de/events/gregory-porter');
|
||||
expect(porter.allDay).toBe(false);
|
||||
|
||||
// Check all-day event
|
||||
const festival = events.find((e) => e.title === 'Freiburg Jazz Festival')!;
|
||||
expect(festival).toBeTruthy();
|
||||
expect(festival.allDay).toBe(true);
|
||||
|
||||
// Check minimal event
|
||||
const openMic = events.find((e) => e.title === 'Open Mic Night')!;
|
||||
expect(openMic).toBeTruthy();
|
||||
expect(openMic.description).toBeNull();
|
||||
});
|
||||
|
||||
it('handles escaped characters in iCal text', () => {
|
||||
const futureYear = new Date().getFullYear() + 1;
|
||||
const ical = `BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
BEGIN:VEVENT
|
||||
UID:escaped@test
|
||||
DTSTART:${futureYear}0301T190000Z
|
||||
SUMMARY:Konzert: Rock & Blues\\, feat. "The Band"
|
||||
DESCRIPTION:Ein Abend voller Musik.\\nMit Special Guests.\\n\\nEintritt: 15\\,00 EUR
|
||||
LOCATION:E-Werk\\, Eschholzstr. 77\\, 79106 Freiburg
|
||||
END:VEVENT
|
||||
END:VCALENDAR`;
|
||||
|
||||
const events = parseIcalText(ical, 'https://test.com', 'Test');
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].title).toContain('Rock & Blues');
|
||||
expect(events[0].location).toContain('E-Werk');
|
||||
});
|
||||
});
|
||||
133
services/mana-events/src/__tests__/scorer.test.ts
Normal file
133
services/mana-events/src/__tests__/scorer.test.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
/**
|
||||
* Relevance scorer unit tests.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import { scoreEvent, type ScoredEventInput, type ScoringContext } from '../discovery/scorer';
|
||||
|
||||
function makeEvent(overrides: Partial<ScoredEventInput> = {}): ScoredEventInput {
|
||||
return {
|
||||
title: 'Jazz Night',
|
||||
category: 'music',
|
||||
lat: 47.997,
|
||||
lon: 7.842,
|
||||
startAt: new Date(Date.now() + 3 * 86400000), // 3 days from now
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeContext(overrides: Partial<ScoringContext> = {}): ScoringContext {
|
||||
return {
|
||||
interests: [
|
||||
{ category: 'music', freetext: null, weight: 1.0 },
|
||||
{ category: 'tech', freetext: 'Rust', weight: 2.0 },
|
||||
],
|
||||
regions: [{ lat: 47.997, lon: 7.842, radiusKm: 25 }],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('scoreEvent', () => {
|
||||
it('returns base score (50) for event with no matches', () => {
|
||||
const score = scoreEvent(
|
||||
makeEvent({ category: 'other', title: 'Nothing', lat: null, lon: null }),
|
||||
makeContext({ interests: [] })
|
||||
);
|
||||
// Base 50 + time proximity bonus (within 7 days) = ~60
|
||||
expect(score).toBeGreaterThanOrEqual(50);
|
||||
expect(score).toBeLessThanOrEqual(65);
|
||||
});
|
||||
|
||||
it('boosts score for category match', () => {
|
||||
const withMatch = scoreEvent(makeEvent({ category: 'music' }), makeContext());
|
||||
const noMatch = scoreEvent(makeEvent({ category: 'other' }), makeContext());
|
||||
expect(withMatch).toBeGreaterThan(noMatch);
|
||||
});
|
||||
|
||||
it('boosts score for freetext match in title', () => {
|
||||
// Use no other matching interests to avoid hitting the 100 cap
|
||||
const ctx = makeContext({ interests: [{ category: 'other', freetext: 'Rust', weight: 1.0 }] });
|
||||
const withMatch = scoreEvent(
|
||||
makeEvent({ title: 'Rust Meetup Freiburg', category: 'sport' }),
|
||||
ctx
|
||||
);
|
||||
const noMatch = scoreEvent(makeEvent({ title: 'Python Meetup', category: 'sport' }), ctx);
|
||||
expect(withMatch).toBeGreaterThan(noMatch);
|
||||
});
|
||||
|
||||
it('applies interest weight', () => {
|
||||
const highWeight = scoreEvent(
|
||||
makeEvent({ title: 'Rust Talk', category: 'tech' }),
|
||||
makeContext({ interests: [{ category: 'tech', freetext: 'Rust', weight: 3.0 }] })
|
||||
);
|
||||
const lowWeight = scoreEvent(
|
||||
makeEvent({ title: 'Rust Talk', category: 'tech' }),
|
||||
makeContext({ interests: [{ category: 'tech', freetext: 'Rust', weight: 0.5 }] })
|
||||
);
|
||||
expect(highWeight).toBeGreaterThan(lowWeight);
|
||||
});
|
||||
|
||||
it('penalizes distant events', () => {
|
||||
const near = scoreEvent(
|
||||
makeEvent({ lat: 47.997, lon: 7.842 }), // same as region center
|
||||
makeContext()
|
||||
);
|
||||
const far = scoreEvent(
|
||||
makeEvent({ lat: 48.5, lon: 8.5 }), // ~60km away
|
||||
makeContext()
|
||||
);
|
||||
expect(near).toBeGreaterThan(far);
|
||||
});
|
||||
|
||||
it('boosts events within 7 days more than 14 days', () => {
|
||||
// Use minimal context to avoid hitting the 100 cap
|
||||
const ctx = makeContext({ interests: [] });
|
||||
const soon = scoreEvent(
|
||||
makeEvent({ startAt: new Date(Date.now() + 3 * 86400000), category: null }),
|
||||
ctx
|
||||
);
|
||||
const later = scoreEvent(
|
||||
makeEvent({ startAt: new Date(Date.now() + 10 * 86400000), category: null }),
|
||||
ctx
|
||||
);
|
||||
const farOut = scoreEvent(
|
||||
makeEvent({ startAt: new Date(Date.now() + 30 * 86400000), category: null }),
|
||||
ctx
|
||||
);
|
||||
expect(soon).toBeGreaterThan(later);
|
||||
expect(later).toBeGreaterThanOrEqual(farOut);
|
||||
});
|
||||
|
||||
it('gives weekend bonus', () => {
|
||||
// Find the next Saturday
|
||||
const now = new Date();
|
||||
const daysUntilSat = (6 - now.getDay() + 7) % 7 || 7;
|
||||
const saturday = new Date(now.getTime() + daysUntilSat * 86400000);
|
||||
const monday = new Date(saturday.getTime() + 2 * 86400000);
|
||||
|
||||
const weekend = scoreEvent(makeEvent({ startAt: saturday }), makeContext());
|
||||
const weekday = scoreEvent(makeEvent({ startAt: monday }), makeContext());
|
||||
expect(weekend).toBeGreaterThanOrEqual(weekday);
|
||||
});
|
||||
|
||||
it('clamps score to 0-100 range', () => {
|
||||
// Lots of matching interests should not exceed 100
|
||||
const manyInterests = Array.from({ length: 10 }, (_, i) => ({
|
||||
category: 'music',
|
||||
freetext: 'jazz',
|
||||
weight: 3.0,
|
||||
}));
|
||||
const score = scoreEvent(
|
||||
makeEvent({ title: 'jazz night', category: 'music' }),
|
||||
makeContext({ interests: manyInterests })
|
||||
);
|
||||
expect(score).toBeLessThanOrEqual(100);
|
||||
expect(score).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('handles missing coordinates gracefully', () => {
|
||||
const score = scoreEvent(makeEvent({ lat: null, lon: null }), makeContext());
|
||||
// Should not crash, just skip distance penalty
|
||||
expect(score).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
144
services/mana-events/src/__tests__/website-extractor.test.ts
Normal file
144
services/mana-events/src/__tests__/website-extractor.test.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
/**
|
||||
* Website extractor unit tests — tests the JSON parsing and date handling
|
||||
* without hitting real LLM or mana-research services.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import { parseExtractedEvents } from '../discovery/website-extractor';
|
||||
|
||||
const SOURCE_URL = 'https://jazzhaus.de/programm';
|
||||
const SOURCE_NAME = 'Jazzhaus Freiburg';
|
||||
|
||||
describe('parseExtractedEvents', () => {
|
||||
it('parses a well-formed JSON response', () => {
|
||||
const futureYear = new Date().getFullYear() + 1;
|
||||
const json = JSON.stringify({
|
||||
events: [
|
||||
{
|
||||
title: 'Jazz Night',
|
||||
date: `${futureYear}-05-15`,
|
||||
time: '20:00',
|
||||
location: 'Jazzhaus Freiburg',
|
||||
category: 'music',
|
||||
priceInfo: '15 EUR',
|
||||
},
|
||||
{
|
||||
title: 'Rock Festival',
|
||||
date: `${futureYear}-06-20`,
|
||||
location: 'Stadtpark',
|
||||
category: 'music',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const events = parseExtractedEvents(json, SOURCE_URL, SOURCE_NAME);
|
||||
expect(events).toHaveLength(2);
|
||||
expect(events[0].title).toBe('Jazz Night');
|
||||
expect(events[0].location).toBe('Jazzhaus Freiburg');
|
||||
expect(events[0].category).toBe('music');
|
||||
expect(events[0].priceInfo).toBe('15 EUR');
|
||||
expect(events[0].allDay).toBe(false); // has time
|
||||
expect(events[1].title).toBe('Rock Festival');
|
||||
expect(events[1].allDay).toBe(true); // no time
|
||||
});
|
||||
|
||||
it('handles markdown-fenced JSON', () => {
|
||||
const futureYear = new Date().getFullYear() + 1;
|
||||
const json = `\`\`\`json
|
||||
{
|
||||
"events": [
|
||||
{"title": "Test", "date": "${futureYear}-03-01", "time": "19:00"}
|
||||
]
|
||||
}
|
||||
\`\`\``;
|
||||
|
||||
const events = parseExtractedEvents(json, SOURCE_URL, SOURCE_NAME);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].title).toBe('Test');
|
||||
});
|
||||
|
||||
it('parses German date format (DD.MM.YYYY)', () => {
|
||||
const futureYear = new Date().getFullYear() + 1;
|
||||
const json = JSON.stringify({
|
||||
events: [{ title: 'Fest', date: `15.06.${futureYear}`, time: '18:00' }],
|
||||
});
|
||||
|
||||
const events = parseExtractedEvents(json, SOURCE_URL, SOURCE_NAME);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].startAt.getFullYear()).toBe(futureYear);
|
||||
expect(events[0].startAt.getMonth()).toBe(5); // June = 5
|
||||
});
|
||||
|
||||
it('filters out past events', () => {
|
||||
const json = JSON.stringify({
|
||||
events: [
|
||||
{ title: 'Past Event', date: '2020-01-01', time: '19:00' },
|
||||
{ title: 'Future Event', date: '2030-01-01', time: '19:00' },
|
||||
],
|
||||
});
|
||||
|
||||
const events = parseExtractedEvents(json, SOURCE_URL, SOURCE_NAME);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].title).toBe('Future Event');
|
||||
});
|
||||
|
||||
it('skips events without title or date', () => {
|
||||
const json = JSON.stringify({
|
||||
events: [
|
||||
{ title: 'Valid', date: '2030-01-01' },
|
||||
{ title: '', date: '2030-01-02' },
|
||||
{ title: 'No Date' },
|
||||
{ date: '2030-01-03' },
|
||||
],
|
||||
});
|
||||
|
||||
const events = parseExtractedEvents(json, SOURCE_URL, SOURCE_NAME);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].title).toBe('Valid');
|
||||
});
|
||||
|
||||
it('truncates long titles and descriptions', () => {
|
||||
const json = JSON.stringify({
|
||||
events: [
|
||||
{
|
||||
title: 'A'.repeat(300),
|
||||
date: '2030-01-01',
|
||||
description: 'B'.repeat(3000),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const events = parseExtractedEvents(json, SOURCE_URL, SOURCE_NAME);
|
||||
expect(events[0].title.length).toBe(200);
|
||||
expect(events[0].description!.length).toBe(2000);
|
||||
});
|
||||
|
||||
it('handles empty/invalid JSON gracefully', () => {
|
||||
expect(parseExtractedEvents('', SOURCE_URL, SOURCE_NAME)).toHaveLength(0);
|
||||
expect(parseExtractedEvents('not json', SOURCE_URL, SOURCE_NAME)).toHaveLength(0);
|
||||
expect(parseExtractedEvents('{}', SOURCE_URL, SOURCE_NAME)).toHaveLength(0);
|
||||
expect(parseExtractedEvents('{"events": "not array"}', SOURCE_URL, SOURCE_NAME)).toHaveLength(
|
||||
0
|
||||
);
|
||||
});
|
||||
|
||||
it('handles endDate and endTime', () => {
|
||||
const futureYear = new Date().getFullYear() + 1;
|
||||
const json = JSON.stringify({
|
||||
events: [
|
||||
{
|
||||
title: 'Festival',
|
||||
date: `${futureYear}-07-01`,
|
||||
time: '10:00',
|
||||
endDate: `${futureYear}-07-03`,
|
||||
endTime: '23:00',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const events = parseExtractedEvents(json, SOURCE_URL, SOURCE_NAME);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].endAt).not.toBeNull();
|
||||
expect(events[0].endAt!.getDate()).toBe(3);
|
||||
});
|
||||
});
|
||||
|
|
@ -12,6 +12,8 @@ import { jwtAuth } from './middleware/jwt-auth';
|
|||
import { healthRoutes } from './routes/health';
|
||||
import { createEventsRoutes } from './routes/events';
|
||||
import { createRsvpRoutes } from './routes/rsvp';
|
||||
import { createDiscoveryRoutes } from './routes/discovery';
|
||||
import { createDiscoveryFeedRoutes } from './routes/discovery-feed';
|
||||
|
||||
/**
|
||||
* Build the Hono app. The auth middleware is injected so tests can swap
|
||||
|
|
@ -42,5 +44,10 @@ export function createApp(
|
|||
app.use('/api/v1/events/*', authMiddleware);
|
||||
app.route('/api/v1/events', createEventsRoutes(db));
|
||||
|
||||
// Discovery endpoints (all authenticated)
|
||||
app.use('/api/v1/discovery/*', authMiddleware);
|
||||
app.route('/api/v1/discovery', createDiscoveryRoutes(db, config));
|
||||
app.route('/api/v1/discovery', createDiscoveryFeedRoutes(db));
|
||||
|
||||
return app;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,9 @@ export interface Config {
|
|||
// Hard cap on total RSVPs per token
|
||||
rsvpMaxPerToken: number;
|
||||
};
|
||||
// Phase 2: external service URLs for event discovery
|
||||
manaResearchUrl: string;
|
||||
manaLlmUrl: string;
|
||||
}
|
||||
|
||||
export function loadConfig(): Config {
|
||||
|
|
@ -38,5 +41,7 @@ export function loadConfig(): Config {
|
|||
rsvpPerTokenPerHour: parseInt(process.env.RSVP_RATE_LIMIT || '60', 10),
|
||||
rsvpMaxPerToken: parseInt(process.env.RSVP_MAX_PER_TOKEN || '500', 10),
|
||||
},
|
||||
manaResearchUrl: process.env.MANA_RESEARCH_URL || 'http://localhost:3068',
|
||||
manaLlmUrl: process.env.MANA_LLM_URL || 'http://localhost:3025',
|
||||
};
|
||||
}
|
||||
|
|
|
|||
144
services/mana-events/src/db/schema/discovery.ts
Normal file
144
services/mana-events/src/db/schema/discovery.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
/**
|
||||
* 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),
|
||||
})
|
||||
);
|
||||
|
|
@ -1 +1,2 @@
|
|||
export * from './events';
|
||||
export * from './discovery';
|
||||
|
|
|
|||
279
services/mana-events/src/discovery/crawl-scheduler.ts
Normal file
279
services/mana-events/src/discovery/crawl-scheduler.ts
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
/**
|
||||
* Crawl scheduler — periodically processes due discovery sources.
|
||||
*
|
||||
* Runs on a configurable interval (default 15 min). For each source
|
||||
* whose crawl interval has elapsed:
|
||||
* 1. Fetch + parse (iCal for now, website extraction in Phase 2)
|
||||
* 2. Deduplicate via hash
|
||||
* 3. Upsert into discovered_events
|
||||
* 4. Update source status (last_crawled_at, error_count)
|
||||
*
|
||||
* Also cleans up expired events (past events older than 1 day).
|
||||
*/
|
||||
|
||||
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 { computeDedupeHash } from './deduplicator';
|
||||
import type { NormalizedEvent } from './types';
|
||||
|
||||
const MAX_ERROR_COUNT = 5;
|
||||
|
||||
/** Find all sources due for a crawl. */
|
||||
async function getDueSources(db: Database) {
|
||||
return db
|
||||
.select()
|
||||
.from(discoverySources)
|
||||
.where(
|
||||
and(
|
||||
eq(discoverySources.isActive, true),
|
||||
or(
|
||||
isNull(discoverySources.lastCrawledAt),
|
||||
sql`${discoverySources.lastCrawledAt} < now() - (${discoverySources.crawlIntervalHours} || ' hours')::interval`
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** External service URLs for Phase 2 website extraction. */
|
||||
interface CrawlConfig {
|
||||
manaResearchUrl: string;
|
||||
manaLlmUrl: string;
|
||||
}
|
||||
|
||||
/** Crawl a single source and return normalized events. */
|
||||
async function crawlSource(
|
||||
source: typeof discoverySources.$inferSelect,
|
||||
config?: CrawlConfig
|
||||
): Promise<{ events: NormalizedEvent[]; error?: string }> {
|
||||
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}` };
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
return { events: [], error: message };
|
||||
}
|
||||
}
|
||||
|
||||
/** Upsert normalized events into discovered_events. */
|
||||
async function upsertEvents(
|
||||
db: Database,
|
||||
sourceId: string,
|
||||
sourceName: string,
|
||||
events: NormalizedEvent[]
|
||||
): Promise<number> {
|
||||
let upserted = 0;
|
||||
for (const event of events) {
|
||||
const dedupeHash = await computeDedupeHash(event);
|
||||
const expiresAt = new Date(event.startAt.getTime() + 24 * 60 * 60 * 1000);
|
||||
|
||||
try {
|
||||
await db
|
||||
.insert(discoveredEvents)
|
||||
.values({
|
||||
sourceId,
|
||||
externalId: event.externalId ?? null,
|
||||
dedupeHash,
|
||||
title: event.title,
|
||||
description: event.description ?? null,
|
||||
location: event.location ?? null,
|
||||
lat: event.lat ?? null,
|
||||
lon: event.lon ?? null,
|
||||
startAt: event.startAt,
|
||||
endAt: event.endAt ?? null,
|
||||
allDay: event.allDay ?? false,
|
||||
imageUrl: event.imageUrl ?? null,
|
||||
sourceUrl: event.sourceUrl,
|
||||
sourceName,
|
||||
category: event.category ?? null,
|
||||
priceInfo: event.priceInfo ?? null,
|
||||
expiresAt,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: discoveredEvents.dedupeHash,
|
||||
set: {
|
||||
title: event.title,
|
||||
description: event.description ?? null,
|
||||
location: event.location ?? null,
|
||||
startAt: event.startAt,
|
||||
endAt: event.endAt ?? null,
|
||||
sourceUrl: event.sourceUrl,
|
||||
category: event.category ?? null,
|
||||
priceInfo: event.priceInfo ?? null,
|
||||
crawledAt: new Date(),
|
||||
},
|
||||
});
|
||||
upserted++;
|
||||
} catch (err) {
|
||||
// Log but don't fail the whole batch for one bad event
|
||||
console.error(`[discovery] failed to upsert event "${event.title}":`, err);
|
||||
}
|
||||
}
|
||||
return upserted;
|
||||
}
|
||||
|
||||
/** Process a single source: crawl, dedup, upsert, update status. */
|
||||
async function processSource(
|
||||
db: Database,
|
||||
source: typeof discoverySources.$inferSelect,
|
||||
config?: CrawlConfig
|
||||
): Promise<void> {
|
||||
const { events, error } = await crawlSource(source, config);
|
||||
const now = new Date();
|
||||
|
||||
if (error) {
|
||||
const newErrorCount = source.errorCount + 1;
|
||||
await db
|
||||
.update(discoverySources)
|
||||
.set({
|
||||
lastCrawledAt: now,
|
||||
errorCount: newErrorCount,
|
||||
lastError: error,
|
||||
isActive: newErrorCount < MAX_ERROR_COUNT,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(discoverySources.id, source.id));
|
||||
|
||||
if (newErrorCount >= MAX_ERROR_COUNT) {
|
||||
console.warn(
|
||||
`[discovery] source "${source.name}" (${source.id}) deactivated after ${MAX_ERROR_COUNT} errors`
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const upserted = await upsertEvents(db, source.id, source.name, events);
|
||||
|
||||
await db
|
||||
.update(discoverySources)
|
||||
.set({
|
||||
lastCrawledAt: now,
|
||||
lastSuccessAt: now,
|
||||
errorCount: 0,
|
||||
lastError: null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(discoverySources.id, source.id));
|
||||
|
||||
if (upserted > 0) {
|
||||
console.log(`[discovery] crawled "${source.name}" — ${upserted} events upserted`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Delete discovered events whose expiry has passed (past events). */
|
||||
async function cleanupExpiredEvents(db: Database): Promise<number> {
|
||||
const result = await db
|
||||
.delete(discoveredEvents)
|
||||
.where(lt(discoveredEvents.expiresAt, new Date()))
|
||||
.returning({ id: discoveredEvents.id });
|
||||
return result.length;
|
||||
}
|
||||
|
||||
/** Run one tick of the crawl scheduler. */
|
||||
export async function runCrawlTick(db: Database, config?: CrawlConfig): Promise<void> {
|
||||
try {
|
||||
const due = await getDueSources(db);
|
||||
for (const source of due) {
|
||||
await processSource(db, source, config);
|
||||
}
|
||||
|
||||
const expired = await cleanupExpiredEvents(db);
|
||||
if (expired > 0) {
|
||||
console.log(`[discovery] cleaned up ${expired} expired events`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[discovery] crawl tick failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the periodic crawl scheduler. Returns a stop function.
|
||||
* Default interval: 15 minutes.
|
||||
*/
|
||||
export function startCrawlScheduler(
|
||||
db: Database,
|
||||
config?: CrawlConfig,
|
||||
intervalMs = 15 * 60 * 1000
|
||||
): () => void {
|
||||
const tick = () => runCrawlTick(db, config);
|
||||
|
||||
// First run shortly after boot
|
||||
const bootTimer = setTimeout(tick, 10_000);
|
||||
const intervalTimer = setInterval(tick, intervalMs);
|
||||
|
||||
return () => {
|
||||
clearTimeout(bootTimer);
|
||||
clearInterval(intervalTimer);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Crawl a single source immediately (triggered by user action).
|
||||
* Returns the number of events upserted.
|
||||
*/
|
||||
export async function crawlSourceNow(
|
||||
db: Database,
|
||||
sourceId: string,
|
||||
config?: CrawlConfig
|
||||
): Promise<{ upserted: number; error?: string }> {
|
||||
const sources = await db
|
||||
.select()
|
||||
.from(discoverySources)
|
||||
.where(eq(discoverySources.id, sourceId))
|
||||
.limit(1);
|
||||
|
||||
if (!sources[0]) return { upserted: 0, error: 'Source not found' };
|
||||
|
||||
const source = sources[0];
|
||||
const { events, error } = await crawlSource(source, config);
|
||||
const now = new Date();
|
||||
|
||||
if (error) {
|
||||
await db
|
||||
.update(discoverySources)
|
||||
.set({
|
||||
lastCrawledAt: now,
|
||||
errorCount: source.errorCount + 1,
|
||||
lastError: error,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(discoverySources.id, sourceId));
|
||||
return { upserted: 0, error };
|
||||
}
|
||||
|
||||
const upserted = await upsertEvents(db, sourceId, source.name, events);
|
||||
|
||||
await db
|
||||
.update(discoverySources)
|
||||
.set({
|
||||
lastCrawledAt: now,
|
||||
lastSuccessAt: now,
|
||||
errorCount: 0,
|
||||
lastError: null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(discoverySources.id, sourceId));
|
||||
|
||||
return { upserted };
|
||||
}
|
||||
27
services/mana-events/src/discovery/deduplicator.ts
Normal file
27
services/mana-events/src/discovery/deduplicator.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
/**
|
||||
* Deduplication for discovered events.
|
||||
*
|
||||
* Hash is based on normalized title + date + location so the same event
|
||||
* from different sources (or re-crawls of the same source) collapses
|
||||
* into a single row.
|
||||
*/
|
||||
|
||||
import type { NormalizedEvent } from './types';
|
||||
|
||||
/**
|
||||
* Compute a SHA-256 hex hash for deduplication.
|
||||
* Key components: lowercased title + ISO date (no time) + lowercased location.
|
||||
*/
|
||||
export async function computeDedupeHash(event: NormalizedEvent): Promise<string> {
|
||||
const title = event.title.toLowerCase().trim();
|
||||
const date = event.startAt.toISOString().slice(0, 10); // YYYY-MM-DD
|
||||
const location = (event.location ?? '').toLowerCase().trim();
|
||||
|
||||
const input = `${title}|${date}|${location}`;
|
||||
const encoded = new TextEncoder().encode(input);
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', encoded);
|
||||
const hashArray = new Uint8Array(hashBuffer);
|
||||
return Array.from(hashArray)
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
96
services/mana-events/src/discovery/ical-parser.ts
Normal file
96
services/mana-events/src/discovery/ical-parser.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
/**
|
||||
* iCal (.ics) feed parser — fetches a remote iCal URL and extracts
|
||||
* VEVENT entries as NormalizedEvents.
|
||||
*
|
||||
* Uses node-ical for robust parsing of the many iCal quirks in the wild
|
||||
* (timezone aliases, RRULE expansion, non-standard properties).
|
||||
*/
|
||||
|
||||
import ical, { type VEvent } from 'node-ical';
|
||||
import type { NormalizedEvent } from './types';
|
||||
|
||||
const FETCH_TIMEOUT_MS = 15_000;
|
||||
|
||||
/**
|
||||
* Fetch and parse an iCal feed URL. Returns future events only.
|
||||
* Handles VTIMEZONE, DTSTART/DTEND with and without timezone, RRULE.
|
||||
*/
|
||||
export async function parseIcalFeed(url: string, sourceName: string): Promise<NormalizedEvent[]> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: { 'User-Agent': 'Mana-Events-Discovery/1.0' },
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status} fetching ${url}`);
|
||||
}
|
||||
const text = await res.text();
|
||||
return parseIcalText(text, url, sourceName);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse raw iCal text into NormalizedEvents.
|
||||
* Exported for testing without network.
|
||||
*/
|
||||
export function parseIcalText(
|
||||
icalText: string,
|
||||
sourceUrl: string,
|
||||
sourceName: string
|
||||
): NormalizedEvent[] {
|
||||
const parsed = ical.sync.parseICS(icalText);
|
||||
const now = new Date();
|
||||
const events: NormalizedEvent[] = [];
|
||||
|
||||
for (const component of Object.values(parsed)) {
|
||||
if (!component || component.type !== 'VEVENT') continue;
|
||||
const vevent = component as VEvent;
|
||||
|
||||
const startDate = vevent.start ? new Date(vevent.start as unknown as string) : null;
|
||||
if (!startDate || isNaN(startDate.getTime())) continue;
|
||||
|
||||
// Skip past events (allow 1 day grace for ongoing events)
|
||||
const endDate = vevent.end ? new Date(vevent.end as unknown as string) : null;
|
||||
const cutoff = endDate ?? startDate;
|
||||
if (cutoff.getTime() < now.getTime() - 24 * 60 * 60 * 1000) continue;
|
||||
|
||||
const summary = typeof vevent.summary === 'string' ? vevent.summary.trim() : '';
|
||||
if (!summary) continue;
|
||||
|
||||
const description = typeof vevent.description === 'string' ? vevent.description.trim() : null;
|
||||
const location = typeof vevent.location === 'string' ? vevent.location.trim() : null;
|
||||
|
||||
// Detect all-day: either DTSTART is DATE (no time), or duration spans full days
|
||||
const allDay =
|
||||
vevent.datetype === 'date' ||
|
||||
(vevent.start &&
|
||||
typeof vevent.start === 'object' &&
|
||||
'dateOnly' in vevent.start &&
|
||||
(vevent.start as { dateOnly?: boolean }).dateOnly === true);
|
||||
|
||||
// Use the VEVENT UID as external ID for dedup stability across re-crawls
|
||||
const uid = typeof vevent.uid === 'string' ? vevent.uid : null;
|
||||
|
||||
// Extract URL if present
|
||||
const eventUrl = typeof vevent.url === 'string' ? vevent.url : sourceUrl;
|
||||
|
||||
events.push({
|
||||
title: summary,
|
||||
description: description ? description.slice(0, 2000) : null,
|
||||
location,
|
||||
startAt: startDate,
|
||||
endAt: endDate,
|
||||
allDay: !!allDay,
|
||||
sourceUrl: eventUrl,
|
||||
externalId: uid,
|
||||
category: null, // iCal doesn't have a standard category we can rely on
|
||||
});
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
80
services/mana-events/src/discovery/scorer.ts
Normal file
80
services/mana-events/src/discovery/scorer.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
/**
|
||||
* Relevance Scorer — ranks discovered events for the user's feed.
|
||||
*
|
||||
* Scoring dimensions:
|
||||
* - Category match with user interests (+20 per match, weighted)
|
||||
* - Freetext match in title (+15 per interest freetext match)
|
||||
* - Distance to nearest region center (-1 per km beyond 5km)
|
||||
* - Time proximity (+10 if within 7 days, +5 if within 14 days)
|
||||
* - Weekend bonus (+5 for Sat/Sun events)
|
||||
*
|
||||
* Score range: 0–100, clamped.
|
||||
*/
|
||||
|
||||
export interface ScoredEventInput {
|
||||
category: string | null;
|
||||
title: string;
|
||||
lat: number | null;
|
||||
lon: number | null;
|
||||
startAt: Date;
|
||||
}
|
||||
|
||||
export interface ScoringContext {
|
||||
interests: Array<{ category: string; freetext: string | null; weight: number }>;
|
||||
regions: Array<{ lat: number; lon: number; radiusKm: number }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a relevance score for a discovered event.
|
||||
* Returns 0–100, higher = more relevant.
|
||||
*/
|
||||
export function scoreEvent(event: ScoredEventInput, ctx: ScoringContext): number {
|
||||
let score = 50; // Base score
|
||||
|
||||
// ── Category match ──────────────────────────────────────────
|
||||
for (const interest of ctx.interests) {
|
||||
if (event.category && event.category === interest.category) {
|
||||
score += 20 * interest.weight;
|
||||
}
|
||||
if (interest.freetext && event.title.toLowerCase().includes(interest.freetext.toLowerCase())) {
|
||||
score += 15 * interest.weight;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Distance ────────────────────────────────────────────────
|
||||
if (event.lat != null && event.lon != null && ctx.regions.length > 0) {
|
||||
const nearest = Math.min(
|
||||
...ctx.regions.map((r) => haversineKm(event.lat!, event.lon!, r.lat, r.lon))
|
||||
);
|
||||
// Penalty: -1 per km beyond 5km
|
||||
score -= Math.max(0, nearest - 5);
|
||||
}
|
||||
|
||||
// ── Time proximity ──────────────────────────────────────────
|
||||
const daysUntil = (event.startAt.getTime() - Date.now()) / 86_400_000;
|
||||
if (daysUntil >= 0 && daysUntil <= 7) score += 10;
|
||||
else if (daysUntil > 7 && daysUntil <= 14) score += 5;
|
||||
|
||||
// ── Weekend bonus ───────────────────────────────────────────
|
||||
const dow = event.startAt.getDay();
|
||||
if (dow === 0 || dow === 6) score += 5;
|
||||
|
||||
return Math.max(0, Math.min(100, Math.round(score)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Haversine distance in km between two lat/lon points.
|
||||
*/
|
||||
function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||
const R = 6371;
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLon = toRad(lon2 - lon1);
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
||||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
|
||||
function toRad(deg: number): number {
|
||||
return (deg * Math.PI) / 180;
|
||||
}
|
||||
187
services/mana-events/src/discovery/source-discoverer.ts
Normal file
187
services/mana-events/src/discovery/source-discoverer.ts
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
/**
|
||||
* Source Discoverer — automatically finds event sources for a region.
|
||||
*
|
||||
* Given a region (e.g. "Freiburg"), searches the web via mana-research
|
||||
* for iCal feeds and venue websites, then inserts them as suggested
|
||||
* sources the user can activate or reject.
|
||||
*
|
||||
* Pipeline:
|
||||
* 1. Build search queries from region label
|
||||
* 2. Search via mana-research POST /api/v1/search
|
||||
* 3. Classify results: .ics URLs → 'ical', venue/event pages → 'website'
|
||||
* 4. Insert as discovery_sources with is_active=false (suggested)
|
||||
*/
|
||||
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import type { Database } from '../db/connection';
|
||||
import { discoverySources, discoveryRegions } from '../db/schema/discovery';
|
||||
|
||||
const SEARCH_TIMEOUT_MS = 15_000;
|
||||
|
||||
/** Patterns that indicate an iCal feed URL. */
|
||||
const ICAL_PATTERNS = [/\.ics$/i, /\.ical$/i, /webcal:\/\//i, /format=ical/i, /export.*ical/i];
|
||||
|
||||
/** Patterns that indicate an event/venue page worth crawling. */
|
||||
const EVENT_PAGE_PATTERNS = [
|
||||
/veranstaltung/i,
|
||||
/kalender/i,
|
||||
/programm/i,
|
||||
/events?\b/i,
|
||||
/termine/i,
|
||||
/konzert/i,
|
||||
/festival/i,
|
||||
/theater/i,
|
||||
/what.?s.?on/i,
|
||||
/schedule/i,
|
||||
/agenda/i,
|
||||
];
|
||||
|
||||
/** Search queries to discover event sources for a region. */
|
||||
function buildSearchQueries(regionLabel: string): string[] {
|
||||
return [
|
||||
`${regionLabel} Veranstaltungskalender`,
|
||||
`${regionLabel} Events Termine`,
|
||||
`${regionLabel} Kulturzentrum Programm`,
|
||||
`${regionLabel} Konzerte Theater Termine`,
|
||||
`${regionLabel} Vereine Veranstaltungen`,
|
||||
];
|
||||
}
|
||||
|
||||
interface SearchHit {
|
||||
url: string;
|
||||
title: string;
|
||||
snippet?: string;
|
||||
}
|
||||
|
||||
interface SearchResponse {
|
||||
success: boolean;
|
||||
data?: {
|
||||
results: SearchHit[];
|
||||
};
|
||||
}
|
||||
|
||||
/** Classify a URL as ical, website, or null (not relevant). */
|
||||
function classifyUrl(url: string, title: string, snippet?: string): 'ical' | 'website' | null {
|
||||
// Check for iCal feed
|
||||
if (ICAL_PATTERNS.some((p) => p.test(url))) return 'ical';
|
||||
|
||||
// Check for event/venue page
|
||||
const text = `${url} ${title} ${snippet ?? ''}`;
|
||||
if (EVENT_PAGE_PATTERNS.some((p) => p.test(text))) return 'website';
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Extract a human-readable name from a URL + title. */
|
||||
function extractSourceName(url: string, title: string): string {
|
||||
// Prefer the page title, trimmed to something reasonable
|
||||
if (title) {
|
||||
// Strip common suffixes
|
||||
const cleaned = title
|
||||
.replace(/\s*[-|–—]\s*(Startseite|Home|Events?|Veranstaltungen|Termine|Programm).*$/i, '')
|
||||
.trim();
|
||||
if (cleaned.length > 3 && cleaned.length < 100) return cleaned;
|
||||
}
|
||||
// Fallback: hostname
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./, '');
|
||||
} catch {
|
||||
return url.slice(0, 80);
|
||||
}
|
||||
}
|
||||
|
||||
export interface DiscoverResult {
|
||||
suggestedCount: number;
|
||||
queries: number;
|
||||
searchResults: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover event sources for a region by searching the web.
|
||||
*
|
||||
* Inserts found sources with is_active=false so the user can review
|
||||
* and activate them. Skips URLs that already exist as sources.
|
||||
*/
|
||||
export async function discoverSourcesForRegion(
|
||||
db: Database,
|
||||
regionId: string,
|
||||
userId: string,
|
||||
manaResearchUrl: string
|
||||
): Promise<DiscoverResult> {
|
||||
// Get the region
|
||||
const [region] = await db
|
||||
.select()
|
||||
.from(discoveryRegions)
|
||||
.where(and(eq(discoveryRegions.id, regionId), eq(discoveryRegions.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (!region) throw new Error('Region not found');
|
||||
|
||||
// Get existing source URLs to avoid duplicates
|
||||
const existingSources = await db
|
||||
.select({ url: discoverySources.url })
|
||||
.from(discoverySources)
|
||||
.where(eq(discoverySources.userId, userId));
|
||||
const existingUrls = new Set(existingSources.map((s) => s.url).filter(Boolean));
|
||||
|
||||
const queries = buildSearchQueries(region.label);
|
||||
let totalResults = 0;
|
||||
let suggestedCount = 0;
|
||||
|
||||
// Run searches in parallel (but limit to avoid hammering the service)
|
||||
const searchResults = await Promise.all(
|
||||
queries.map((query) => searchWeb(manaResearchUrl, query))
|
||||
);
|
||||
|
||||
for (const result of searchResults) {
|
||||
if (!result?.data?.results) continue;
|
||||
|
||||
for (const hit of result.data.results) {
|
||||
totalResults++;
|
||||
if (existingUrls.has(hit.url)) continue;
|
||||
|
||||
const type = classifyUrl(hit.url, hit.title, hit.snippet);
|
||||
if (!type) continue;
|
||||
|
||||
const name = extractSourceName(hit.url, hit.title);
|
||||
|
||||
try {
|
||||
await db.insert(discoverySources).values({
|
||||
userId,
|
||||
type,
|
||||
url: hit.url,
|
||||
name,
|
||||
regionId,
|
||||
isActive: false, // suggested — user must activate
|
||||
crawlIntervalHours: type === 'ical' ? 24 : 48,
|
||||
});
|
||||
existingUrls.add(hit.url);
|
||||
suggestedCount++;
|
||||
} catch {
|
||||
// Ignore dupes from parallel queries
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { suggestedCount, queries: queries.length, searchResults: totalResults };
|
||||
}
|
||||
|
||||
/** Search the web via mana-research. Gracefully returns null on failure. */
|
||||
async function searchWeb(manaResearchUrl: string, query: string): Promise<SearchResponse | null> {
|
||||
try {
|
||||
const res = await fetch(`${manaResearchUrl}/api/v1/search`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query }),
|
||||
signal: AbortSignal.timeout(SEARCH_TIMEOUT_MS),
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.warn(`[source-discoverer] search failed ${res.status}: ${query}`);
|
||||
return null;
|
||||
}
|
||||
return (await res.json()) as SearchResponse;
|
||||
} catch (err) {
|
||||
console.warn(`[source-discoverer] search error for "${query}":`, err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
50
services/mana-events/src/discovery/types.ts
Normal file
50
services/mana-events/src/discovery/types.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
/**
|
||||
* Discovery domain types — shared across parser, scheduler, and routes.
|
||||
*/
|
||||
|
||||
/** A normalized event extracted from any source type. */
|
||||
export interface NormalizedEvent {
|
||||
title: string;
|
||||
description?: string | null;
|
||||
location?: string | null;
|
||||
lat?: number | null;
|
||||
lon?: number | null;
|
||||
startAt: Date;
|
||||
endAt?: Date | null;
|
||||
allDay?: boolean;
|
||||
imageUrl?: string | null;
|
||||
sourceUrl: string;
|
||||
category?: string | null;
|
||||
priceInfo?: string | null;
|
||||
externalId?: string | null;
|
||||
}
|
||||
|
||||
/** Result of crawling a single source. */
|
||||
export interface CrawlResult {
|
||||
sourceId: string;
|
||||
sourceName: string;
|
||||
events: NormalizedEvent[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Source types supported by the crawler. */
|
||||
export type SourceType = 'ical' | 'website' | 'api' | 'search_query';
|
||||
|
||||
/** Event categories for filtering and scoring. */
|
||||
export const EVENT_CATEGORIES = [
|
||||
'music',
|
||||
'theater',
|
||||
'art',
|
||||
'tech',
|
||||
'sport',
|
||||
'food',
|
||||
'family',
|
||||
'nature',
|
||||
'education',
|
||||
'community',
|
||||
'nightlife',
|
||||
'market',
|
||||
'other',
|
||||
] as const;
|
||||
|
||||
export type EventCategory = (typeof EVENT_CATEGORIES)[number];
|
||||
236
services/mana-events/src/discovery/website-extractor.ts
Normal file
236
services/mana-events/src/discovery/website-extractor.ts
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
/**
|
||||
* Website Extractor — LLM-based event extraction from unstructured web pages.
|
||||
*
|
||||
* Pipeline:
|
||||
* 1. Crawl the page via mana-research POST /api/v1/extract
|
||||
* 2. Feed the extracted text to mana-llm with a structured output prompt
|
||||
* 3. Parse the LLM response as NormalizedEvent[]
|
||||
*
|
||||
* Uses cheap/fast models (gemma3:4b or haiku) to keep costs low.
|
||||
* Falls back gracefully on any failure — one bad page doesn't crash the batch.
|
||||
*/
|
||||
|
||||
import type { NormalizedEvent } from './types';
|
||||
|
||||
const EXTRACT_TIMEOUT_MS = 20_000;
|
||||
const LLM_TIMEOUT_MS = 30_000;
|
||||
const MAX_CONTENT_CHARS = 15_000; // Trim long pages to stay within context window
|
||||
|
||||
interface ExtractResponse {
|
||||
success: boolean;
|
||||
data?: {
|
||||
content: {
|
||||
title?: string;
|
||||
text?: string;
|
||||
markdown?: string;
|
||||
html?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface ChatCompletionResponse {
|
||||
choices: Array<{
|
||||
message: {
|
||||
content: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract events from a website URL.
|
||||
*
|
||||
* 1. Fetches + renders the page via mana-research (Firecrawl/Jina/Readability)
|
||||
* 2. Sends the text to mana-llm with a structured extraction prompt
|
||||
* 3. Parses JSON output into NormalizedEvent[]
|
||||
*/
|
||||
export async function extractEventsFromWebsite(
|
||||
url: string,
|
||||
sourceName: string,
|
||||
manaResearchUrl: string,
|
||||
manaLlmUrl: string
|
||||
): Promise<NormalizedEvent[]> {
|
||||
// Step 1: Extract page content
|
||||
const content = await fetchPageContent(url, manaResearchUrl);
|
||||
if (!content) return [];
|
||||
|
||||
// Step 2: LLM extraction
|
||||
const events = await llmExtractEvents(content, url, sourceName, manaLlmUrl);
|
||||
return events;
|
||||
}
|
||||
|
||||
/** Fetch and extract text content from a URL via mana-research. */
|
||||
async function fetchPageContent(url: string, manaResearchUrl: string): Promise<string | null> {
|
||||
try {
|
||||
const res = await fetch(`${manaResearchUrl}/api/v1/extract`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url }),
|
||||
signal: AbortSignal.timeout(EXTRACT_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
console.warn(`[website-extractor] extract failed ${res.status}: ${url}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = (await res.json()) as ExtractResponse;
|
||||
if (!data.success || !data.data?.content) return null;
|
||||
|
||||
// Prefer markdown > text > html
|
||||
const text = data.data.content.markdown || data.data.content.text || '';
|
||||
if (text.length < 50) return null; // Too short to contain events
|
||||
|
||||
// Trim to stay within LLM context window
|
||||
return text.slice(0, MAX_CONTENT_CHARS);
|
||||
} catch (err) {
|
||||
console.warn(`[website-extractor] fetch error for ${url}:`, err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the LLM system prompt for event extraction. */
|
||||
function buildExtractionPrompt(): string {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
return `Du bist ein Event-Extractor. Extrahiere ALLE kommenden Veranstaltungen von der gegebenen Webseite.
|
||||
|
||||
Pro Event liefere:
|
||||
- title (string, Pflicht) — Name der Veranstaltung
|
||||
- date (string, Pflicht) — Startdatum im Format YYYY-MM-DD
|
||||
- time (string, optional) — Startzeit im Format HH:MM
|
||||
- endDate (string, optional) — Enddatum falls mehrtägig
|
||||
- endTime (string, optional) — Endzeit
|
||||
- location (string, optional) — Veranstaltungsort / Adresse
|
||||
- description (string, optional) — Kurzbeschreibung, max 300 Zeichen
|
||||
- category (string, optional) — Eine von: music, theater, art, tech, sport, food, family, nature, education, community, nightlife, market, other
|
||||
- priceInfo (string, optional) — Preis, z.B. "Eintritt frei", "15 EUR", "VVK 12 / AK 15"
|
||||
|
||||
Heutiges Datum: ${today}
|
||||
Ignoriere vergangene Events (vor ${today}).
|
||||
Antwort als JSON-Objekt mit einem "events"-Array. Kein Markdown, nur JSON.`;
|
||||
}
|
||||
|
||||
/** Send page content to mana-llm for structured event extraction. */
|
||||
async function llmExtractEvents(
|
||||
pageContent: string,
|
||||
sourceUrl: string,
|
||||
sourceName: string,
|
||||
manaLlmUrl: string
|
||||
): Promise<NormalizedEvent[]> {
|
||||
try {
|
||||
const res = await fetch(`${manaLlmUrl}/v1/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: 'ollama/gemma3:4b',
|
||||
messages: [
|
||||
{ role: 'system', content: buildExtractionPrompt() },
|
||||
{ role: 'user', content: `Extrahiere Events von dieser Seite:\n\n${pageContent}` },
|
||||
],
|
||||
max_tokens: 2048,
|
||||
temperature: 0,
|
||||
response_format: { type: 'json_object' },
|
||||
}),
|
||||
signal: AbortSignal.timeout(LLM_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
console.warn(`[website-extractor] LLM failed ${res.status}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const completion = (await res.json()) as ChatCompletionResponse;
|
||||
const rawJson = completion.choices?.[0]?.message?.content ?? '';
|
||||
|
||||
return parseExtractedEvents(rawJson, sourceUrl, sourceName);
|
||||
} catch (err) {
|
||||
console.warn(`[website-extractor] LLM error:`, err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse and validate LLM JSON output into NormalizedEvents. */
|
||||
export function parseExtractedEvents(
|
||||
rawJson: string,
|
||||
sourceUrl: string,
|
||||
sourceName: string
|
||||
): NormalizedEvent[] {
|
||||
try {
|
||||
// Strip markdown fences if present
|
||||
const cleaned = rawJson.replace(/^```(?:json)?\s*\n?/m, '').replace(/\n?```\s*$/m, '');
|
||||
const parsed = JSON.parse(cleaned);
|
||||
const rawEvents = parsed.events ?? parsed;
|
||||
|
||||
if (!Array.isArray(rawEvents)) return [];
|
||||
|
||||
const now = new Date();
|
||||
const events: NormalizedEvent[] = [];
|
||||
|
||||
for (const raw of rawEvents) {
|
||||
if (!raw.title || !raw.date) continue;
|
||||
|
||||
// Parse date — LLMs sometimes return "25. April 2026" instead of ISO
|
||||
const startAt = parseFlexibleDate(raw.date, raw.time);
|
||||
if (!startAt || isNaN(startAt.getTime())) continue;
|
||||
|
||||
// Skip past events
|
||||
if (startAt.getTime() < now.getTime() - 24 * 60 * 60 * 1000) continue;
|
||||
|
||||
const endAt = raw.endDate ? parseFlexibleDate(raw.endDate, raw.endTime) : null;
|
||||
|
||||
events.push({
|
||||
title: String(raw.title).trim().slice(0, 200),
|
||||
description: raw.description ? String(raw.description).trim().slice(0, 2000) : null,
|
||||
location: raw.location ? String(raw.location).trim() : null,
|
||||
startAt,
|
||||
endAt,
|
||||
allDay: !raw.time,
|
||||
sourceUrl,
|
||||
category: raw.category ?? null,
|
||||
priceInfo: raw.priceInfo ? String(raw.priceInfo).trim() : null,
|
||||
});
|
||||
}
|
||||
|
||||
return events;
|
||||
} catch (err) {
|
||||
console.warn(`[website-extractor] JSON parse error:`, err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse dates flexibly — handles ISO, German formats, and partial dates. */
|
||||
function parseFlexibleDate(dateStr: string, timeStr?: string): Date | null {
|
||||
if (!dateStr) return null;
|
||||
|
||||
// Try ISO format first (YYYY-MM-DD)
|
||||
const isoMatch = dateStr.match(/^(\d{4})-(\d{2})-(\d{2})/);
|
||||
if (isoMatch) {
|
||||
const [, y, m, d] = isoMatch;
|
||||
const time = parseTime(timeStr);
|
||||
return new Date(`${y}-${m}-${d}T${time}:00`);
|
||||
}
|
||||
|
||||
// Try German format (DD.MM.YYYY)
|
||||
const deMatch = dateStr.match(/(\d{1,2})\.(\d{1,2})\.(\d{4})/);
|
||||
if (deMatch) {
|
||||
const [, d, m, y] = deMatch;
|
||||
const time = parseTime(timeStr);
|
||||
return new Date(`${y}-${m!.padStart(2, '0')}-${d!.padStart(2, '0')}T${time}:00`);
|
||||
}
|
||||
|
||||
// Fallback: let Date parse it
|
||||
try {
|
||||
const d = new Date(dateStr);
|
||||
if (!isNaN(d.getTime())) return d;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseTime(timeStr?: string): string {
|
||||
if (!timeStr) return '00:00';
|
||||
const match = timeStr.match(/(\d{1,2}):(\d{2})/);
|
||||
if (match) return `${match[1]!.padStart(2, '0')}:${match[2]}`;
|
||||
return '00:00';
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ import { createApp } from './app';
|
|||
import { loadConfig } from './config';
|
||||
import { getDb } from './db/connection';
|
||||
import { startRateBucketSweeper } from './lib/cleanup';
|
||||
import { startCrawlScheduler } from './discovery/crawl-scheduler';
|
||||
|
||||
const config = loadConfig();
|
||||
const db = getDb(config.databaseUrl);
|
||||
|
|
@ -22,6 +23,12 @@ const db = getDb(config.databaseUrl);
|
|||
// accumulate for the lifetime of long-published events.
|
||||
startRateBucketSweeper(db);
|
||||
|
||||
// Event discovery — crawl sources (iCal feeds, websites) every 15 minutes.
|
||||
startCrawlScheduler(db, {
|
||||
manaResearchUrl: config.manaResearchUrl,
|
||||
manaLlmUrl: config.manaLlmUrl,
|
||||
});
|
||||
|
||||
console.log(`mana-events starting on port ${config.port}...`);
|
||||
|
||||
export default {
|
||||
|
|
|
|||
136
services/mana-events/src/routes/discovery-feed.ts
Normal file
136
services/mana-events/src/routes/discovery-feed.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
/**
|
||||
* Discovery feed routes — paginated event feed + user actions.
|
||||
*
|
||||
* The feed endpoint returns discovered events sorted by start date,
|
||||
* filtered by date range and optional category, enriched with the
|
||||
* user's action status (saved/dismissed/null).
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
import { and, eq, gte, lte, sql, isNull, desc, asc } from 'drizzle-orm';
|
||||
import type { Database } from '../db/connection';
|
||||
import { discoveredEvents, discoveryUserActions, discoverySources } from '../db/schema/discovery';
|
||||
import { BadRequestError } from '../lib/errors';
|
||||
import type { AuthUser } from '../middleware/jwt-auth';
|
||||
|
||||
const feedQuerySchema = z.object({
|
||||
from: z.string().datetime().optional(),
|
||||
to: z.string().datetime().optional(),
|
||||
category: z.string().max(50).optional(),
|
||||
limit: z.coerce.number().int().min(1).max(100).optional(),
|
||||
offset: z.coerce.number().int().min(0).optional(),
|
||||
hideDismissed: z.coerce.boolean().optional(),
|
||||
});
|
||||
|
||||
const actionSchema = z.object({
|
||||
action: z.enum(['save', 'dismiss']),
|
||||
});
|
||||
|
||||
export function createDiscoveryFeedRoutes(db: Database) {
|
||||
const app = new Hono<{ Variables: { user: AuthUser } }>();
|
||||
|
||||
/**
|
||||
* GET /feed — paginated discovered events.
|
||||
*
|
||||
* Joins with user actions to include save/dismiss status.
|
||||
* Only shows events from the current user's sources.
|
||||
*/
|
||||
app.get('/feed', async (c) => {
|
||||
const user = c.get('user');
|
||||
const query = feedQuerySchema.safeParse(c.req.query());
|
||||
if (!query.success) throw new BadRequestError(query.error.issues[0]?.message ?? 'Invalid');
|
||||
|
||||
const { from, to, category, hideDismissed } = query.data;
|
||||
const limit = query.data.limit ?? 20;
|
||||
const offset = query.data.offset ?? 0;
|
||||
|
||||
// Build WHERE conditions
|
||||
const conditions = [
|
||||
// Only events from this user's sources
|
||||
eq(discoverySources.userId, user.userId),
|
||||
eq(discoverySources.isActive, true),
|
||||
// Only future events (or today)
|
||||
gte(discoveredEvents.startAt, from ? new Date(from) : new Date()),
|
||||
];
|
||||
|
||||
if (to) {
|
||||
conditions.push(lte(discoveredEvents.startAt, new Date(to)));
|
||||
}
|
||||
if (category) {
|
||||
conditions.push(eq(discoveredEvents.category, category));
|
||||
}
|
||||
|
||||
// Query events with left join on user actions
|
||||
const rows = await db
|
||||
.select({
|
||||
id: discoveredEvents.id,
|
||||
title: discoveredEvents.title,
|
||||
description: discoveredEvents.description,
|
||||
location: discoveredEvents.location,
|
||||
lat: discoveredEvents.lat,
|
||||
lon: discoveredEvents.lon,
|
||||
startAt: discoveredEvents.startAt,
|
||||
endAt: discoveredEvents.endAt,
|
||||
allDay: discoveredEvents.allDay,
|
||||
imageUrl: discoveredEvents.imageUrl,
|
||||
sourceUrl: discoveredEvents.sourceUrl,
|
||||
sourceName: discoveredEvents.sourceName,
|
||||
category: discoveredEvents.category,
|
||||
priceInfo: discoveredEvents.priceInfo,
|
||||
crawledAt: discoveredEvents.crawledAt,
|
||||
userAction: discoveryUserActions.action,
|
||||
})
|
||||
.from(discoveredEvents)
|
||||
.innerJoin(discoverySources, eq(discoveredEvents.sourceId, discoverySources.id))
|
||||
.leftJoin(
|
||||
discoveryUserActions,
|
||||
and(
|
||||
eq(discoveryUserActions.eventId, discoveredEvents.id),
|
||||
eq(discoveryUserActions.userId, user.userId)
|
||||
)
|
||||
)
|
||||
.where(and(...conditions))
|
||||
.orderBy(asc(discoveredEvents.startAt))
|
||||
.limit(limit + 1) // fetch one extra to determine hasMore
|
||||
.offset(offset);
|
||||
|
||||
// Filter dismissed events client-side if requested
|
||||
const filtered = hideDismissed ? rows.filter((r) => r.userAction !== 'dismiss') : rows;
|
||||
|
||||
const hasMore = filtered.length > limit;
|
||||
const events = filtered.slice(0, limit);
|
||||
|
||||
return c.json({ events, total: events.length, hasMore });
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /feed/:eventId/action — save or dismiss a discovered event.
|
||||
*/
|
||||
app.post('/feed/:eventId/action', async (c) => {
|
||||
const user = c.get('user');
|
||||
const eventId = c.req.param('eventId');
|
||||
const body = await c.req.json().catch(() => null);
|
||||
const parsed = actionSchema.safeParse(body);
|
||||
if (!parsed.success) throw new BadRequestError(parsed.error.issues[0]?.message ?? 'Invalid');
|
||||
|
||||
await db
|
||||
.insert(discoveryUserActions)
|
||||
.values({
|
||||
userId: user.userId,
|
||||
eventId,
|
||||
action: parsed.data.action,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [discoveryUserActions.userId, discoveryUserActions.eventId],
|
||||
set: {
|
||||
action: parsed.data.action,
|
||||
actedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
299
services/mana-events/src/routes/discovery.ts
Normal file
299
services/mana-events/src/routes/discovery.ts
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
/**
|
||||
* Discovery CRUD routes — JWT-authenticated.
|
||||
*
|
||||
* Manages regions, interests, and sources for a user's event discovery setup.
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import type { Database } from '../db/connection';
|
||||
import { discoveryRegions, discoveryInterests, discoverySources } from '../db/schema/discovery';
|
||||
import { EVENT_CATEGORIES } from '../discovery/types';
|
||||
import { crawlSourceNow } from '../discovery/crawl-scheduler';
|
||||
import { discoverSourcesForRegion } from '../discovery/source-discoverer';
|
||||
import { BadRequestError, ForbiddenError, NotFoundError } from '../lib/errors';
|
||||
import type { AuthUser } from '../middleware/jwt-auth';
|
||||
import type { Config } from '../config';
|
||||
|
||||
// ─── Validation schemas ─────────────────────────────────────────────
|
||||
|
||||
const regionCreateSchema = z.object({
|
||||
label: z.string().min(1).max(200),
|
||||
lat: z.number().min(-90).max(90),
|
||||
lon: z.number().min(-180).max(180),
|
||||
radiusKm: z.number().int().min(1).max(200).optional(),
|
||||
});
|
||||
|
||||
const regionUpdateSchema = z.object({
|
||||
label: z.string().min(1).max(200).optional(),
|
||||
radiusKm: z.number().int().min(1).max(200).optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const interestCreateSchema = z.object({
|
||||
category: z.string().min(1).max(50),
|
||||
freetext: z.string().max(200).nullable().optional(),
|
||||
weight: z.number().min(0.1).max(5).optional(),
|
||||
});
|
||||
|
||||
const sourceCreateSchema = z.object({
|
||||
type: z.enum(['ical', 'website']),
|
||||
url: z.string().url().max(2000),
|
||||
name: z.string().min(1).max(200),
|
||||
regionId: z.string().uuid(),
|
||||
crawlIntervalHours: z.number().int().min(1).max(168).optional(), // max 7 days
|
||||
});
|
||||
|
||||
// ─── Routes ─────────────────────────────────────────────────────────
|
||||
|
||||
export function createDiscoveryRoutes(db: Database, config?: Config) {
|
||||
const app = new Hono<{ Variables: { user: AuthUser } }>();
|
||||
|
||||
// ── Regions ──────────────────────────────────────────────────
|
||||
|
||||
app.get('/regions', async (c) => {
|
||||
const user = c.get('user');
|
||||
const regions = await db
|
||||
.select()
|
||||
.from(discoveryRegions)
|
||||
.where(eq(discoveryRegions.userId, user.userId));
|
||||
return c.json({ regions });
|
||||
});
|
||||
|
||||
app.post('/regions', async (c) => {
|
||||
const user = c.get('user');
|
||||
const body = await c.req.json().catch(() => null);
|
||||
const parsed = regionCreateSchema.safeParse(body);
|
||||
if (!parsed.success) throw new BadRequestError(parsed.error.issues[0]?.message ?? 'Invalid');
|
||||
|
||||
const [region] = await db
|
||||
.insert(discoveryRegions)
|
||||
.values({
|
||||
userId: user.userId,
|
||||
label: parsed.data.label,
|
||||
lat: parsed.data.lat,
|
||||
lon: parsed.data.lon,
|
||||
radiusKm: parsed.data.radiusKm ?? 25,
|
||||
})
|
||||
.returning();
|
||||
return c.json({ region }, 201);
|
||||
});
|
||||
|
||||
app.put('/regions/:id', async (c) => {
|
||||
const user = c.get('user');
|
||||
const id = c.req.param('id');
|
||||
const body = await c.req.json().catch(() => null);
|
||||
const parsed = regionUpdateSchema.safeParse(body);
|
||||
if (!parsed.success) throw new BadRequestError(parsed.error.issues[0]?.message ?? 'Invalid');
|
||||
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(discoveryRegions)
|
||||
.where(and(eq(discoveryRegions.id, id), eq(discoveryRegions.userId, user.userId)))
|
||||
.limit(1);
|
||||
if (!existing[0]) throw new NotFoundError('Region not found');
|
||||
|
||||
const updates: Partial<typeof discoveryRegions.$inferInsert> = {};
|
||||
if (parsed.data.label !== undefined) updates.label = parsed.data.label;
|
||||
if (parsed.data.radiusKm !== undefined) updates.radiusKm = parsed.data.radiusKm;
|
||||
if (parsed.data.isActive !== undefined) updates.isActive = parsed.data.isActive;
|
||||
|
||||
const [region] = await db
|
||||
.update(discoveryRegions)
|
||||
.set(updates)
|
||||
.where(eq(discoveryRegions.id, id))
|
||||
.returning();
|
||||
return c.json({ region });
|
||||
});
|
||||
|
||||
app.delete('/regions/:id', async (c) => {
|
||||
const user = c.get('user');
|
||||
const id = c.req.param('id');
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(discoveryRegions)
|
||||
.where(and(eq(discoveryRegions.id, id), eq(discoveryRegions.userId, user.userId)))
|
||||
.limit(1);
|
||||
if (!existing[0]) throw new NotFoundError('Region not found');
|
||||
|
||||
await db.delete(discoveryRegions).where(eq(discoveryRegions.id, id));
|
||||
return c.json({ deleted: true });
|
||||
});
|
||||
|
||||
// ── Interests ────────────────────────────────────────────────
|
||||
|
||||
app.get('/interests', async (c) => {
|
||||
const user = c.get('user');
|
||||
const interests = await db
|
||||
.select()
|
||||
.from(discoveryInterests)
|
||||
.where(eq(discoveryInterests.userId, user.userId));
|
||||
return c.json({ interests });
|
||||
});
|
||||
|
||||
app.post('/interests', async (c) => {
|
||||
const user = c.get('user');
|
||||
const body = await c.req.json().catch(() => null);
|
||||
const parsed = interestCreateSchema.safeParse(body);
|
||||
if (!parsed.success) throw new BadRequestError(parsed.error.issues[0]?.message ?? 'Invalid');
|
||||
|
||||
const [interest] = await db
|
||||
.insert(discoveryInterests)
|
||||
.values({
|
||||
userId: user.userId,
|
||||
category: parsed.data.category,
|
||||
freetext: parsed.data.freetext ?? null,
|
||||
weight: parsed.data.weight ?? 1.0,
|
||||
})
|
||||
.returning();
|
||||
return c.json({ interest }, 201);
|
||||
});
|
||||
|
||||
app.delete('/interests/:id', async (c) => {
|
||||
const user = c.get('user');
|
||||
const id = c.req.param('id');
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(discoveryInterests)
|
||||
.where(and(eq(discoveryInterests.id, id), eq(discoveryInterests.userId, user.userId)))
|
||||
.limit(1);
|
||||
if (!existing[0]) throw new NotFoundError('Interest not found');
|
||||
|
||||
await db.delete(discoveryInterests).where(eq(discoveryInterests.id, id));
|
||||
return c.json({ deleted: true });
|
||||
});
|
||||
|
||||
// ── Sources ──────────────────────────────────────────────────
|
||||
|
||||
app.get('/sources', async (c) => {
|
||||
const user = c.get('user');
|
||||
const sources = await db
|
||||
.select()
|
||||
.from(discoverySources)
|
||||
.where(eq(discoverySources.userId, user.userId));
|
||||
return c.json({ sources });
|
||||
});
|
||||
|
||||
app.post('/sources', async (c) => {
|
||||
const user = c.get('user');
|
||||
const body = await c.req.json().catch(() => null);
|
||||
const parsed = sourceCreateSchema.safeParse(body);
|
||||
if (!parsed.success) throw new BadRequestError(parsed.error.issues[0]?.message ?? 'Invalid');
|
||||
|
||||
// Verify the region belongs to this user
|
||||
const region = await db
|
||||
.select()
|
||||
.from(discoveryRegions)
|
||||
.where(
|
||||
and(eq(discoveryRegions.id, parsed.data.regionId), eq(discoveryRegions.userId, user.userId))
|
||||
)
|
||||
.limit(1);
|
||||
if (!region[0]) throw new BadRequestError('Region not found');
|
||||
|
||||
const [source] = await db
|
||||
.insert(discoverySources)
|
||||
.values({
|
||||
userId: user.userId,
|
||||
type: parsed.data.type,
|
||||
url: parsed.data.url,
|
||||
name: parsed.data.name,
|
||||
regionId: parsed.data.regionId,
|
||||
crawlIntervalHours: parsed.data.crawlIntervalHours ?? 24,
|
||||
})
|
||||
.returning();
|
||||
return c.json({ source }, 201);
|
||||
});
|
||||
|
||||
app.delete('/sources/:id', async (c) => {
|
||||
const user = c.get('user');
|
||||
const id = c.req.param('id');
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(discoverySources)
|
||||
.where(and(eq(discoverySources.id, id), eq(discoverySources.userId, user.userId)))
|
||||
.limit(1);
|
||||
if (!existing[0]) throw new NotFoundError('Source not found');
|
||||
|
||||
await db.delete(discoverySources).where(eq(discoverySources.id, id));
|
||||
return c.json({ deleted: true });
|
||||
});
|
||||
|
||||
// Trigger an immediate crawl for a source
|
||||
app.post('/sources/:id/crawl', async (c) => {
|
||||
const user = c.get('user');
|
||||
const id = c.req.param('id');
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(discoverySources)
|
||||
.where(and(eq(discoverySources.id, id), eq(discoverySources.userId, user.userId)))
|
||||
.limit(1);
|
||||
if (!existing[0]) throw new NotFoundError('Source not found');
|
||||
|
||||
const crawlConfig = config
|
||||
? { manaResearchUrl: config.manaResearchUrl, manaLlmUrl: config.manaLlmUrl }
|
||||
: undefined;
|
||||
const result = await crawlSourceNow(db, id, crawlConfig);
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
// ── Source Discovery (Phase 2) ───────────────────────────────
|
||||
|
||||
// Auto-discover event sources for a region via web search
|
||||
app.post('/regions/:id/discover-sources', async (c) => {
|
||||
const user = c.get('user');
|
||||
const regionId = c.req.param('id');
|
||||
if (!config) throw new BadRequestError('Source discovery not configured');
|
||||
|
||||
const result = await discoverSourcesForRegion(
|
||||
db,
|
||||
regionId,
|
||||
user.userId,
|
||||
config.manaResearchUrl
|
||||
);
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
// Activate a suggested source
|
||||
app.put('/sources/:id/activate', async (c) => {
|
||||
const user = c.get('user');
|
||||
const id = c.req.param('id');
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(discoverySources)
|
||||
.where(and(eq(discoverySources.id, id), eq(discoverySources.userId, user.userId)))
|
||||
.limit(1);
|
||||
if (!existing[0]) throw new NotFoundError('Source not found');
|
||||
|
||||
const [source] = await db
|
||||
.update(discoverySources)
|
||||
.set({ isActive: true, updatedAt: new Date() })
|
||||
.where(eq(discoverySources.id, id))
|
||||
.returning();
|
||||
|
||||
// Trigger immediate crawl for the newly activated source
|
||||
const crawlConfig = config
|
||||
? { manaResearchUrl: config.manaResearchUrl, manaLlmUrl: config.manaLlmUrl }
|
||||
: undefined;
|
||||
crawlSourceNow(db, id, crawlConfig).catch(() => {});
|
||||
|
||||
return c.json({ source });
|
||||
});
|
||||
|
||||
// Reject a suggested source
|
||||
app.delete('/sources/:id/reject', async (c) => {
|
||||
const user = c.get('user');
|
||||
const id = c.req.param('id');
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(discoverySources)
|
||||
.where(and(eq(discoverySources.id, id), eq(discoverySources.userId, user.userId)))
|
||||
.limit(1);
|
||||
if (!existing[0]) throw new NotFoundError('Source not found');
|
||||
|
||||
await db.delete(discoverySources).where(eq(discoverySources.id, id));
|
||||
return c.json({ deleted: true });
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue