feat(events): Phase 4 — provider adapters for Eventbrite + Meetup

- Add EventProvider interface (base.ts) with fetchEvents(url, name, ctx, config)
- Refactor iCal parser and website extractor as provider adapters
- Add Eventbrite provider: API v3 search by location, category mapping,
  price info extraction. Requires EVENTBRITE_API_KEY env var.
- Add Meetup provider: GraphQL API search by location, topic→category
  mapping, HTML stripping. Requires MEETUP_API_KEY env var.
- Provider registry (getProvider, PROVIDER_TYPES) replaces hardcoded
  switch in crawl-scheduler
- Crawl scheduler now joins sources with regions for ProviderContext
  (lat/lon/radius/label) — platform providers need this for geo-search
- Source creation accepts 'eventbrite' and 'meetup' types (url optional)
- Both providers gracefully return empty when API keys unconfigured

116 tests (all passing), no regressions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-18 15:43:40 +02:00
parent 4d82381737
commit ed801cf725
13 changed files with 708 additions and 44 deletions

View file

@ -95,8 +95,8 @@ export async function getSources(): Promise<DiscoverySource[]> {
}
export async function createSource(input: {
type: 'ical' | 'website';
url: string;
type: 'ical' | 'website' | 'eventbrite' | 'meetup';
url?: string;
name: string;
regionId: string;
crawlIntervalHours?: number;

View file

@ -20,9 +20,11 @@ export interface DiscoveryInterest {
createdAt: string;
}
export type SourceType = 'ical' | 'website' | 'eventbrite' | 'meetup';
export interface DiscoverySource {
id: string;
type: 'ical' | 'website';
type: SourceType;
url: string | null;
name: string;
regionId: string | null;

View file

@ -42,6 +42,8 @@ const TEST_CONFIG: Config = {
},
manaResearchUrl: 'http://localhost:3068',
manaLlmUrl: 'http://localhost:3025',
eventbriteApiKey: null,
meetupApiKey: null,
};
/**

View file

@ -0,0 +1,90 @@
/**
* Provider unit tests tests the provider registry and individual
* provider adapters without hitting real APIs.
*/
import { describe, it, expect } from 'bun:test';
import { getProvider, PROVIDER_TYPES } from '../discovery/providers';
describe('Provider registry', () => {
it('has all expected provider types', () => {
expect(PROVIDER_TYPES).toContain('ical');
expect(PROVIDER_TYPES).toContain('website');
expect(PROVIDER_TYPES).toContain('eventbrite');
expect(PROVIDER_TYPES).toContain('meetup');
});
it('returns a provider for each type', () => {
for (const type of PROVIDER_TYPES) {
const provider = getProvider(type);
expect(provider).not.toBeNull();
expect(provider!.type).toBe(type);
}
});
it('returns null for unknown type', () => {
expect(getProvider('unknown')).toBeNull();
expect(getProvider('')).toBeNull();
});
});
describe('Eventbrite provider', () => {
it('returns empty with error when API key is not set', async () => {
const provider = getProvider('eventbrite')!;
const result = await provider.fetchEvents('', 'Test', {
lat: 47.997,
lon: 7.842,
radiusKm: 25,
regionLabel: 'Freiburg',
});
expect(result.events).toHaveLength(0);
expect(result.error).toContain('EVENTBRITE_API_KEY');
});
it('gracefully handles missing coordinates (after API key check)', async () => {
// Without API key, the key check fires first — that's the expected behavior
const provider = getProvider('eventbrite')!;
const result = await provider.fetchEvents('', 'Test');
expect(result.events).toHaveLength(0);
expect(result.error).toBeTruthy();
});
});
describe('Meetup provider', () => {
it('returns empty with error when API key is not set', async () => {
const provider = getProvider('meetup')!;
const result = await provider.fetchEvents('', 'Test', {
lat: 47.997,
lon: 7.842,
radiusKm: 25,
regionLabel: 'Freiburg',
});
expect(result.events).toHaveLength(0);
expect(result.error).toContain('MEETUP_API_KEY');
});
it('gracefully handles missing coordinates (after API key check)', async () => {
const provider = getProvider('meetup')!;
const result = await provider.fetchEvents('', 'Test');
expect(result.events).toHaveLength(0);
expect(result.error).toBeTruthy();
});
});
describe('iCal provider', () => {
it('returns error for invalid URL', async () => {
const provider = getProvider('ical')!;
const result = await provider.fetchEvents('not-a-url', 'Test');
expect(result.events).toHaveLength(0);
expect(result.error).toBeTruthy();
});
});
describe('Website provider', () => {
it('returns error when config is missing', async () => {
const provider = getProvider('website')!;
const result = await provider.fetchEvents('https://example.com', 'Test');
expect(result.events).toHaveLength(0);
expect(result.error).toContain('config');
});
});

View file

@ -15,9 +15,12 @@ export interface Config {
// Hard cap on total RSVPs per token
rsvpMaxPerToken: number;
};
// Phase 2: external service URLs for event discovery
// External service URLs for event discovery
manaResearchUrl: string;
manaLlmUrl: string;
// Platform API keys (optional — providers gracefully skip when unconfigured)
eventbriteApiKey: string | null;
meetupApiKey: string | null;
}
export function loadConfig(): Config {
@ -43,5 +46,7 @@ export function loadConfig(): Config {
},
manaResearchUrl: process.env.MANA_RESEARCH_URL || 'http://localhost:3068',
manaLlmUrl: process.env.MANA_LLM_URL || 'http://localhost:3025',
eventbriteApiKey: process.env.EVENTBRITE_API_KEY || null,
meetupApiKey: process.env.MEETUP_API_KEY || null,
};
}

View file

@ -13,19 +13,25 @@
import { and, eq, lt, or, isNull, sql } from 'drizzle-orm';
import type { Database } from '../db/connection';
import { discoverySources, discoveredEvents } from '../db/schema/discovery';
import { parseIcalFeed } from './ical-parser';
import { extractEventsFromWebsite } from './website-extractor';
import { discoverySources, discoveredEvents, discoveryRegions } from '../db/schema/discovery';
import { getProvider, type ExternalServiceConfig } from './providers';
import { computeDedupeHash } from './deduplicator';
import type { NormalizedEvent } from './types';
const MAX_ERROR_COUNT = 5;
/** Find all sources due for a crawl. */
/** Find all sources due for a crawl, joined with their region for context. */
async function getDueSources(db: Database) {
return db
.select()
.select({
source: discoverySources,
regionLat: discoveryRegions.lat,
regionLon: discoveryRegions.lon,
regionRadiusKm: discoveryRegions.radiusKm,
regionLabel: discoveryRegions.label,
})
.from(discoverySources)
.leftJoin(discoveryRegions, eq(discoverySources.regionId, discoveryRegions.id))
.where(
and(
eq(discoverySources.isActive, true),
@ -37,39 +43,48 @@ async function getDueSources(db: Database) {
);
}
/** External service URLs for Phase 2 website extraction. */
/** External service URLs for website extraction + LLM. */
interface CrawlConfig {
manaResearchUrl: string;
manaLlmUrl: string;
}
/** Crawl a single source and return normalized events. */
/** Crawl a single source via its provider and return normalized events. */
async function crawlSource(
source: typeof discoverySources.$inferSelect,
config?: CrawlConfig
config?: CrawlConfig,
regionCtx?: {
lat: number | null;
lon: number | null;
radiusKm: number | null;
label: string | null;
}
): Promise<{ events: NormalizedEvent[]; error?: string }> {
const provider = getProvider(source.type);
if (!provider) {
return { events: [], error: `Unknown source type: ${source.type}` };
}
if (!source.url && !['eventbrite', 'meetup'].includes(source.type)) {
return { events: [], error: 'No URL configured' };
}
try {
switch (source.type) {
case 'ical': {
if (!source.url) return { events: [], error: 'No URL configured' };
const events = await parseIcalFeed(source.url, source.name);
return { events };
}
case 'website': {
if (!source.url) return { events: [], error: 'No URL configured' };
if (!config)
return { events: [], error: 'Missing research/LLM config for website extraction' };
const events = await extractEventsFromWebsite(
source.url,
source.name,
config.manaResearchUrl,
config.manaLlmUrl
);
return { events };
}
default:
return { events: [], error: `Unsupported source type: ${source.type}` };
}
const ctx =
regionCtx?.lat != null
? {
lat: regionCtx.lat!,
lon: regionCtx.lon!,
radiusKm: regionCtx.radiusKm ?? 25,
regionLabel: regionCtx.label ?? undefined,
}
: undefined;
const extConfig: ExternalServiceConfig | undefined = config
? { manaResearchUrl: config.manaResearchUrl, manaLlmUrl: config.manaLlmUrl }
: undefined;
return await provider.fetchEvents(source.url ?? '', source.name, ctx, extConfig);
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
return { events: [], error: message };
@ -137,9 +152,15 @@ async function upsertEvents(
async function processSource(
db: Database,
source: typeof discoverySources.$inferSelect,
config?: CrawlConfig
config?: CrawlConfig,
regionCtx?: {
lat: number | null;
lon: number | null;
radiusKm: number | null;
label: string | null;
}
): Promise<void> {
const { events, error } = await crawlSource(source, config);
const { events, error } = await crawlSource(source, config, regionCtx);
const now = new Date();
if (error) {
@ -194,8 +215,13 @@ async function cleanupExpiredEvents(db: Database): Promise<number> {
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);
for (const row of due) {
await processSource(db, row.source, config, {
lat: row.regionLat,
lon: row.regionLon,
radiusKm: row.regionRadiusKm,
label: row.regionLabel,
});
}
const expired = await cleanupExpiredEvents(db);
@ -237,16 +263,29 @@ export async function crawlSourceNow(
sourceId: string,
config?: CrawlConfig
): Promise<{ upserted: number; error?: string }> {
const sources = await db
.select()
const rows = await db
.select({
source: discoverySources,
regionLat: discoveryRegions.lat,
regionLon: discoveryRegions.lon,
regionRadiusKm: discoveryRegions.radiusKm,
regionLabel: discoveryRegions.label,
})
.from(discoverySources)
.leftJoin(discoveryRegions, eq(discoverySources.regionId, discoveryRegions.id))
.where(eq(discoverySources.id, sourceId))
.limit(1);
if (!sources[0]) return { upserted: 0, error: 'Source not found' };
if (!rows[0]) return { upserted: 0, error: 'Source not found' };
const source = sources[0];
const { events, error } = await crawlSource(source, config);
const { source } = rows[0];
const regionCtx = {
lat: rows[0].regionLat,
lon: rows[0].regionLon,
radiusKm: rows[0].regionRadiusKm,
label: rows[0].regionLabel,
};
const { events, error } = await crawlSource(source, config, regionCtx);
const now = new Date();
if (error) {

View file

@ -0,0 +1,56 @@
/**
* Base interface for event discovery providers.
*
* Each provider knows how to fetch events from a specific source type
* (iCal, website, Eventbrite, Meetup, etc.). The crawl scheduler
* dispatches to the correct provider based on the source type.
*/
import type { NormalizedEvent } from '../types';
/** Context passed to providers for region-aware searches. */
export interface ProviderContext {
/** Region center latitude. */
lat?: number;
/** Region center longitude. */
lon?: number;
/** Search radius in km. */
radiusKm?: number;
/** Region label (e.g. "Freiburg"). */
regionLabel?: string;
}
/** Result from a provider fetch. */
export interface ProviderResult {
events: NormalizedEvent[];
error?: string;
}
/** Configuration for external services (mana-research, mana-llm). */
export interface ExternalServiceConfig {
manaResearchUrl: string;
manaLlmUrl: string;
}
/**
* Event provider interface all source types implement this.
*/
export interface EventProvider {
/** Source type this provider handles. */
readonly type: string;
/**
* Fetch events from a source.
*
* @param url - Source URL (iCal feed, website, or API endpoint)
* @param name - Human-readable source name
* @param ctx - Region context for location-aware providers
* @param config - External service URLs (for website/LLM providers)
*/
fetchEvents(
url: string,
name: string,
ctx?: ProviderContext,
config?: ExternalServiceConfig
): Promise<ProviderResult>;
}

View file

@ -0,0 +1,166 @@
/**
* Eventbrite provider fetches events from the Eventbrite API.
*
* Uses the public Event Search API v3 which requires an API token
* but is free for read operations.
*
* API docs: https://www.eventbrite.com/platform/api#/reference/event-search
*
* When no API key is configured, this provider is a no-op (returns empty).
*/
import type { EventProvider, ProviderContext, ProviderResult } from './base';
import type { NormalizedEvent } from '../types';
const API_BASE = 'https://www.eventbriteapi.com/v3';
const FETCH_TIMEOUT_MS = 15_000;
/** Eventbrite category ID → our category mapping. */
const CATEGORY_MAP: Record<string, string> = {
'103': 'music',
'105': 'art',
'101': 'tech',
'108': 'sport',
'110': 'food',
'115': 'family',
'113': 'community',
'109': 'nature',
'104': 'art', // film & media
'107': 'education', // health
'102': 'tech', // science
'106': 'nightlife', // fashion
'111': 'community', // charity
'112': 'community', // government
'114': 'other', // spirituality
'116': 'other', // seasonal
'117': 'education', // home & lifestyle
'199': 'other',
};
interface EventbriteEvent {
id: string;
name: { text: string; html?: string };
description: { text: string; html?: string } | null;
url: string;
start: { utc: string; local: string; timezone: string };
end: { utc: string; local: string; timezone: string } | null;
venue?: {
name: string;
address: {
localized_address_display: string;
latitude: string;
longitude: string;
};
} | null;
logo?: { url: string } | null;
category_id: string | null;
is_free: boolean;
ticket_availability?: {
minimum_ticket_price?: { display: string };
maximum_ticket_price?: { display: string };
};
}
interface EventbriteResponse {
events: EventbriteEvent[];
pagination: { page_count: number; page_number: number };
}
function getApiKey(): string | null {
return process.env.EVENTBRITE_API_KEY || null;
}
export const eventbriteProvider: EventProvider = {
type: 'eventbrite',
async fetchEvents(_url: string, _name: string, ctx?: ProviderContext): Promise<ProviderResult> {
const apiKey = getApiKey();
if (!apiKey) {
return { events: [], error: 'EVENTBRITE_API_KEY not configured' };
}
if (!ctx?.lat || !ctx?.lon) {
return { events: [], error: 'Region coordinates required for Eventbrite search' };
}
try {
const params = new URLSearchParams({
'location.latitude': String(ctx.lat),
'location.longitude': String(ctx.lon),
'location.within': `${ctx.radiusKm ?? 25}km`,
'start_date.range_start': new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'),
sort_by: 'date',
expand: 'venue,ticket_availability',
});
// Add keyword search if region label available
if (ctx.regionLabel) {
params.set('q', ctx.regionLabel);
}
const res = await fetch(`${API_BASE}/events/search/?${params}`, {
headers: { Authorization: `Bearer ${apiKey}` },
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
if (!res.ok) {
const body = await res.text().catch(() => '');
return { events: [], error: `Eventbrite API ${res.status}: ${body.slice(0, 200)}` };
}
const data = (await res.json()) as EventbriteResponse;
const events = data.events.map(toNormalizedEvent).filter(Boolean) as NormalizedEvent[];
return { events };
} catch (err) {
return {
events: [],
error: err instanceof Error ? err.message : 'Eventbrite fetch failed',
};
}
},
};
function toNormalizedEvent(eb: EventbriteEvent): NormalizedEvent | null {
const title = eb.name?.text?.trim();
if (!title) return null;
const startAt = new Date(eb.start.utc);
if (isNaN(startAt.getTime())) return null;
const endAt = eb.end ? new Date(eb.end.utc) : null;
let location: string | null = null;
let lat: number | null = null;
let lon: number | null = null;
if (eb.venue) {
location = eb.venue.name || eb.venue.address?.localized_address_display || null;
lat = eb.venue.address?.latitude ? parseFloat(eb.venue.address.latitude) : null;
lon = eb.venue.address?.longitude ? parseFloat(eb.venue.address.longitude) : null;
}
let priceInfo: string | null = null;
if (eb.is_free) {
priceInfo = 'Eintritt frei';
} else if (eb.ticket_availability?.minimum_ticket_price) {
const min = eb.ticket_availability.minimum_ticket_price.display;
const max = eb.ticket_availability.maximum_ticket_price?.display;
priceInfo = max && max !== min ? `${min} ${max}` : min;
}
return {
title,
description: eb.description?.text?.slice(0, 2000) ?? null,
location,
lat,
lon,
startAt,
endAt,
allDay: false,
imageUrl: eb.logo?.url ?? null,
sourceUrl: eb.url,
category: eb.category_id ? (CATEGORY_MAP[eb.category_id] ?? 'other') : null,
priceInfo,
externalId: `eventbrite:${eb.id}`,
};
}

View file

@ -0,0 +1,19 @@
/**
* iCal provider wraps the existing ical-parser as an EventProvider.
*/
import type { EventProvider, ProviderResult } from './base';
import { parseIcalFeed } from '../ical-parser';
export const icalProvider: EventProvider = {
type: 'ical',
async fetchEvents(url: string, name: string): Promise<ProviderResult> {
try {
const events = await parseIcalFeed(url, name);
return { events };
} catch (err) {
return { events: [], error: err instanceof Error ? err.message : 'iCal parse failed' };
}
},
};

View file

@ -0,0 +1,26 @@
/**
* Provider registry maps source types to their EventProvider implementation.
*/
import type { EventProvider } from './base';
import { icalProvider } from './ical';
import { websiteProvider } from './website';
import { eventbriteProvider } from './eventbrite';
import { meetupProvider } from './meetup';
export type { EventProvider, ProviderContext, ProviderResult, ExternalServiceConfig } from './base';
const PROVIDERS: Record<string, EventProvider> = {
ical: icalProvider,
website: websiteProvider,
eventbrite: eventbriteProvider,
meetup: meetupProvider,
};
/** Get the provider for a source type, or null if unknown. */
export function getProvider(type: string): EventProvider | null {
return PROVIDERS[type] ?? null;
}
/** All registered provider types. */
export const PROVIDER_TYPES = Object.keys(PROVIDERS);

View file

@ -0,0 +1,224 @@
/**
* Meetup provider fetches events from the Meetup GraphQL API.
*
* Uses the public GraphQL endpoint which requires an API key (OAuth token).
* Free for read operations.
*
* API docs: https://www.meetup.com/api/schema/#p02-GraphQL-API
*
* When no API key is configured, this provider is a no-op (returns empty).
*/
import type { EventProvider, ProviderContext, ProviderResult } from './base';
import type { NormalizedEvent } from '../types';
const GRAPHQL_URL = 'https://api.meetup.com/gql';
const FETCH_TIMEOUT_MS = 15_000;
/** Meetup topic category → our category mapping. */
const TOPIC_CATEGORY_MAP: Record<string, string> = {
tech: 'tech',
'science-tech': 'tech',
music: 'music',
'arts-culture': 'art',
sports: 'sport',
'food-drink': 'food',
'outdoors-adventure': 'nature',
'health-wellbeing': 'education',
'language-ethnic-identity': 'community',
'social-activities': 'community',
career: 'education',
education: 'education',
'parents-family': 'family',
};
const SEARCH_EVENTS_QUERY = `
query SearchEvents($filter: SearchConnectionFilter!) {
keywordSearch(filter: $filter) {
edges {
node {
id
result {
... on Event {
id
title
description
dateTime
endTime
eventUrl
going
venue {
name
address
city
state
lat
lng
}
featuredEventPhoto {
highResUrl
}
group {
name
topicCategory {
urlkey
}
}
feeSettings {
amount
currency
}
}
}
}
}
}
}
`;
interface MeetupEvent {
id: string;
title: string;
description: string | null;
dateTime: string;
endTime: string | null;
eventUrl: string;
going: number;
venue: {
name: string;
address: string;
city: string;
state: string;
lat: number;
lng: number;
} | null;
featuredEventPhoto: { highResUrl: string } | null;
group: {
name: string;
topicCategory: { urlkey: string } | null;
};
feeSettings: { amount: number; currency: string } | null;
}
function getApiKey(): string | null {
return process.env.MEETUP_API_KEY || null;
}
export const meetupProvider: EventProvider = {
type: 'meetup',
async fetchEvents(_url: string, _name: string, ctx?: ProviderContext): Promise<ProviderResult> {
const apiKey = getApiKey();
if (!apiKey) {
return { events: [], error: 'MEETUP_API_KEY not configured' };
}
if (!ctx?.lat || !ctx?.lon) {
return { events: [], error: 'Region coordinates required for Meetup search' };
}
try {
const res = await fetch(GRAPHQL_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
query: SEARCH_EVENTS_QUERY,
variables: {
filter: {
query: ctx.regionLabel ?? '',
lat: ctx.lat,
lon: ctx.lon,
radius: ctx.radiusKm ?? 25,
source: 'EVENTS',
startDateRange: new Date().toISOString(),
},
},
}),
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
if (!res.ok) {
const body = await res.text().catch(() => '');
return { events: [], error: `Meetup API ${res.status}: ${body.slice(0, 200)}` };
}
const data = await res.json();
const edges = data?.data?.keywordSearch?.edges ?? [];
const events: NormalizedEvent[] = [];
for (const edge of edges) {
const node = edge?.node?.result;
if (!node || !node.title) continue;
const normalized = toNormalizedEvent(node);
if (normalized) events.push(normalized);
}
return { events };
} catch (err) {
return {
events: [],
error: err instanceof Error ? err.message : 'Meetup fetch failed',
};
}
},
};
function toNormalizedEvent(m: MeetupEvent): NormalizedEvent | null {
const title = m.title?.trim();
if (!title) return null;
const startAt = new Date(m.dateTime);
if (isNaN(startAt.getTime())) return null;
// Skip past events
if (startAt.getTime() < Date.now() - 24 * 60 * 60 * 1000) return null;
const endAt = m.endTime ? new Date(m.endTime) : null;
let location: string | null = null;
let lat: number | null = null;
let lon: number | null = null;
if (m.venue) {
const parts = [m.venue.name, m.venue.address, m.venue.city].filter(Boolean);
location = parts.join(', ') || null;
lat = m.venue.lat || null;
lon = m.venue.lng || null;
}
let priceInfo: string | null = null;
if (m.feeSettings) {
priceInfo = `${m.feeSettings.amount} ${m.feeSettings.currency}`;
}
const topicKey = m.group?.topicCategory?.urlkey;
const category = topicKey ? (TOPIC_CATEGORY_MAP[topicKey] ?? 'community') : 'community';
// Strip HTML from description
const description = m.description
? m.description
.replace(/<[^>]*>/g, '')
.trim()
.slice(0, 2000)
: null;
return {
title,
description,
location,
lat,
lon,
startAt,
endAt,
allDay: false,
imageUrl: m.featuredEventPhoto?.highResUrl ?? null,
sourceUrl: m.eventUrl,
category,
priceInfo,
externalId: `meetup:${m.id}`,
};
}

View file

@ -0,0 +1,35 @@
/**
* Website provider wraps the LLM-based website extractor as an EventProvider.
*/
import type { EventProvider, ProviderResult, ExternalServiceConfig } from './base';
import { extractEventsFromWebsite } from '../website-extractor';
export const websiteProvider: EventProvider = {
type: 'website',
async fetchEvents(
url: string,
name: string,
_ctx,
config?: ExternalServiceConfig
): Promise<ProviderResult> {
if (!config) {
return { events: [], error: 'Missing research/LLM config for website extraction' };
}
try {
const events = await extractEventsFromWebsite(
url,
name,
config.manaResearchUrl,
config.manaLlmUrl
);
return { events };
} catch (err) {
return {
events: [],
error: err instanceof Error ? err.message : 'Website extraction failed',
};
}
},
};

View file

@ -38,8 +38,8 @@ const interestCreateSchema = z.object({
});
const sourceCreateSchema = z.object({
type: z.enum(['ical', 'website']),
url: z.string().url().max(2000),
type: z.enum(['ical', 'website', 'eventbrite', 'meetup']),
url: z.string().url().max(2000).optional(), // optional for platform providers
name: z.string().min(1).max(200),
regionId: z.string().uuid(),
crawlIntervalHours: z.number().int().min(1).max(168).optional(), // max 7 days