From 216746721e1edd8afe61fa19c4ca7139e44ef10a Mon Sep 17 00:00:00 2001 From: Till JS Date: Tue, 7 Apr 2026 14:27:48 +0200 Subject: [PATCH] feat(events): add mana-events service + public RSVP flow (Phase 1b) New Hono+Bun service at services/mana-events on port 3065 with two schemas in mana_platform: events_published (snapshots) and public_rsvps (unauthenticated responses), plus a per-token hourly rate-limit bucket. - Host endpoints (JWT) for publish/update/unpublish/list-rsvps - Public endpoints for snapshot fetch + RSVP upsert with rate limiting - New /rsvp/[token] page outside the auth gate, SSR-loads the snapshot - Client store wires publishEvent/unpublishEvent to the server, syncs snapshot updates after edits, and deletes the snapshot on event delete - DetailView polls GET /events/:id/rsvps every 30s while open and lets hosts import a public response into their local guest list - generate-env, setup-databases.sh, .env.development, hooks.server.ts, package.json wired for local dev --- .env.development | 3 + apps/mana/apps/web/src/hooks.server.ts | 4 + apps/mana/apps/web/src/lib/api/config.ts | 12 + .../apps/web/src/lib/modules/events/api.ts | 76 ++++ .../events/components/PublicRsvpList.svelte | 252 +++++++++++ .../modules/events/stores/events.svelte.ts | 73 ++- .../modules/events/views/DetailView.svelte | 10 +- .../src/routes/rsvp/[token]/+page.server.ts | 54 +++ .../web/src/routes/rsvp/[token]/+page.svelte | 416 ++++++++++++++++++ package.json | 1 + scripts/setup-databases.sh | 8 +- services/mana-events/Dockerfile | 16 + services/mana-events/bun.lock | 165 +++++++ services/mana-events/drizzle.config.ts | 11 + services/mana-events/package.json | 24 + services/mana-events/src/config.ts | 42 ++ services/mana-events/src/db/connection.ts | 19 + services/mana-events/src/db/schema/events.ts | 84 ++++ services/mana-events/src/db/schema/index.ts | 1 + services/mana-events/src/index.ts | 47 ++ services/mana-events/src/lib/errors.ts | 31 ++ .../src/middleware/error-handler.ts | 10 + .../mana-events/src/middleware/jwt-auth.ts | 50 +++ services/mana-events/src/routes/events.ts | 175 ++++++++ services/mana-events/src/routes/health.ts | 5 + services/mana-events/src/routes/rsvp.ts | 169 +++++++ services/mana-events/tsconfig.json | 17 + 27 files changed, 1764 insertions(+), 11 deletions(-) create mode 100644 apps/mana/apps/web/src/lib/modules/events/api.ts create mode 100644 apps/mana/apps/web/src/lib/modules/events/components/PublicRsvpList.svelte create mode 100644 apps/mana/apps/web/src/routes/rsvp/[token]/+page.server.ts create mode 100644 apps/mana/apps/web/src/routes/rsvp/[token]/+page.svelte create mode 100644 services/mana-events/Dockerfile create mode 100644 services/mana-events/bun.lock create mode 100644 services/mana-events/drizzle.config.ts create mode 100644 services/mana-events/package.json create mode 100644 services/mana-events/src/config.ts create mode 100644 services/mana-events/src/db/connection.ts create mode 100644 services/mana-events/src/db/schema/events.ts create mode 100644 services/mana-events/src/db/schema/index.ts create mode 100644 services/mana-events/src/index.ts create mode 100644 services/mana-events/src/lib/errors.ts create mode 100644 services/mana-events/src/middleware/error-handler.ts create mode 100644 services/mana-events/src/middleware/jwt-auth.ts create mode 100644 services/mana-events/src/routes/events.ts create mode 100644 services/mana-events/src/routes/health.ts create mode 100644 services/mana-events/src/routes/rsvp.ts create mode 100644 services/mana-events/tsconfig.json diff --git a/.env.development b/.env.development index 25247117d..94f4bbc46 100644 --- a/.env.development +++ b/.env.development @@ -27,6 +27,9 @@ MANA_AUTH_URL=http://localhost:3001 MANA_CREDITS_URL=http://localhost:3061 # Mana Media Service (CAS, thumbnails, Photos gallery) MANA_MEDIA_URL=http://localhost:3015 +# Mana Events Service (public RSVP & event sharing) +MANA_EVENTS_URL=http://localhost:3065 +PUBLIC_MANA_EVENTS_URL=http://localhost:3065 # Service key for service-to-service communication MANA_SERVICE_KEY=dev-service-key-for-bot-sso-2024 diff --git a/apps/mana/apps/web/src/hooks.server.ts b/apps/mana/apps/web/src/hooks.server.ts index a207012bf..bcbb42100 100644 --- a/apps/mana/apps/web/src/hooks.server.ts +++ b/apps/mana/apps/web/src/hooks.server.ts @@ -48,6 +48,8 @@ const PUBLIC_MANA_MEDIA_URL_CLIENT = process.env.PUBLIC_MANA_MEDIA_URL_CLIENT || process.env.PUBLIC_MANA_MEDIA_URL || ''; const PUBLIC_MANA_LLM_URL_CLIENT = process.env.PUBLIC_MANA_LLM_URL_CLIENT || process.env.PUBLIC_MANA_LLM_URL || ''; +const PUBLIC_MANA_EVENTS_URL_CLIENT = + process.env.PUBLIC_MANA_EVENTS_URL_CLIENT || process.env.PUBLIC_MANA_EVENTS_URL || ''; // Map of app subdomains to internal paths const APP_SUBDOMAINS = new Set([ @@ -106,6 +108,7 @@ window.__PUBLIC_ULOAD_SERVER_URL__ = ${JSON.stringify(PUBLIC_ULOAD_SERVER_URL_CL window.__PUBLIC_MEMORO_SERVER_URL__ = ${JSON.stringify(PUBLIC_MEMORO_SERVER_URL_CLIENT)}; window.__PUBLIC_MANA_MEDIA_URL__ = ${JSON.stringify(PUBLIC_MANA_MEDIA_URL_CLIENT)}; window.__PUBLIC_MANA_LLM_URL__ = ${JSON.stringify(PUBLIC_MANA_LLM_URL_CLIENT)}; +window.__PUBLIC_MANA_EVENTS_URL__ = ${JSON.stringify(PUBLIC_MANA_EVENTS_URL_CLIENT)}; window.__PUBLIC_GLITCHTIP_DSN__ = ${JSON.stringify(PUBLIC_GLITCHTIP_DSN)}; `; return injectUmamiAnalytics(html.replace('', `${envScript}`)); @@ -130,6 +133,7 @@ window.__PUBLIC_GLITCHTIP_DSN__ = ${JSON.stringify(PUBLIC_GLITCHTIP_DSN)}; PUBLIC_MEMORO_SERVER_URL_CLIENT, PUBLIC_MANA_MEDIA_URL_CLIENT, PUBLIC_MANA_LLM_URL_CLIENT, + PUBLIC_MANA_EVENTS_URL_CLIENT, 'wss://sync.mana.how', // Allow all localhost ports in development ...(isDev ? ['http://localhost:*', 'ws://localhost:*'] : []), diff --git a/apps/mana/apps/web/src/lib/api/config.ts b/apps/mana/apps/web/src/lib/api/config.ts index 25d04ef07..8874ba75e 100644 --- a/apps/mana/apps/web/src/lib/api/config.ts +++ b/apps/mana/apps/web/src/lib/api/config.ts @@ -21,3 +21,15 @@ export function getManaAuthUrl(): string { // Server-side (SSR): use environment variable return process.env.PUBLIC_MANA_AUTH_URL || 'http://localhost:3001'; } + +/** + * Get the mana-events service URL (Phase 1b: public RSVP backend). + */ +export function getManaEventsUrl(): string { + if (browser && typeof window !== 'undefined') { + const injected = (window as unknown as { __PUBLIC_MANA_EVENTS_URL__?: string }) + .__PUBLIC_MANA_EVENTS_URL__; + return injected || 'http://localhost:3065'; + } + return process.env.PUBLIC_MANA_EVENTS_URL || 'http://localhost:3065'; +} diff --git a/apps/mana/apps/web/src/lib/modules/events/api.ts b/apps/mana/apps/web/src/lib/modules/events/api.ts new file mode 100644 index 000000000..9e1c3c790 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/events/api.ts @@ -0,0 +1,76 @@ +/** + * mana-events HTTP client (host-side, JWT-authenticated). + */ + +import { authStore } from '$lib/stores/auth.svelte'; +import { getManaEventsUrl } from '$lib/api/config'; + +export interface PublishedSnapshotInput { + eventId: string; + title: string; + description?: string | null; + location?: string | null; + locationUrl?: string | null; + startAt: string; + endAt?: string | null; + allDay?: boolean; + coverImageUrl?: string | null; + color?: string | null; + capacity?: number | null; +} + +export interface PublicRsvpRecord { + id: string; + token: string; + name: string; + email: string | null; + status: 'yes' | 'no' | 'maybe'; + plusOnes: number; + note: string | null; + createdAt: string; + updatedAt: string; +} + +async function fetchWithAuth(path: string, init: RequestInit = {}): Promise { + const token = await authStore.getAccessToken(); + 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; +} + +export const eventsApi = { + async publish(input: PublishedSnapshotInput): Promise<{ token: string; isNew: boolean }> { + return fetchWithAuth('/api/v1/events/publish', { + method: 'POST', + body: JSON.stringify(input), + }); + }, + + async updateSnapshot( + eventId: string, + input: Partial + ): Promise<{ token: string }> { + return fetchWithAuth(`/api/v1/events/${eventId}/snapshot`, { + method: 'PUT', + body: JSON.stringify(input), + }); + }, + + async unpublish(eventId: string): Promise<{ deleted: boolean }> { + return fetchWithAuth(`/api/v1/events/${eventId}`, { method: 'DELETE' }); + }, + + async getRsvps(eventId: string): Promise<{ token: string; rsvps: PublicRsvpRecord[] }> { + return fetchWithAuth(`/api/v1/events/${eventId}/rsvps`); + }, +}; diff --git a/apps/mana/apps/web/src/lib/modules/events/components/PublicRsvpList.svelte b/apps/mana/apps/web/src/lib/modules/events/components/PublicRsvpList.svelte new file mode 100644 index 000000000..6a530df35 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/events/components/PublicRsvpList.svelte @@ -0,0 +1,252 @@ + + +{#if isPublished} +
+
+

Antworten via Link

+ +
+ + {#if lastError} +

{lastError}

+ {:else if rsvps.length === 0 && !loading} +

Noch keine Antworten via Share-Link.

+ {:else} +
    + {#each rsvps as r (r.id)} +
  • +
    +
    + {r.name} + + {r.status === 'yes' ? 'Ja' : r.status === 'no' ? 'Nein' : 'Vielleicht'} + + {#if r.plusOnes > 0} + +{r.plusOnes} + {/if} +
    + {#if r.email} + + {/if} + {#if r.note} +
    „{r.note}“
    + {/if} +
    + +
  • + {/each} +
+ {/if} + + {#if lastFetchedAt} +
+ Aktualisiert um {lastFetchedAt.toLocaleTimeString('de-DE', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + })} · Auto-Refresh alle 30s +
+ {/if} +
+{/if} + + diff --git a/apps/mana/apps/web/src/lib/modules/events/stores/events.svelte.ts b/apps/mana/apps/web/src/lib/modules/events/stores/events.svelte.ts index 05536f1f8..d1c172094 100644 --- a/apps/mana/apps/web/src/lib/modules/events/stores/events.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/events/stores/events.svelte.ts @@ -7,7 +7,9 @@ import { db } from '$lib/data/database'; import { createBlock, updateBlock, deleteBlock } from '$lib/data/time-blocks/service'; +import { timeBlockTable } from '$lib/data/time-blocks/collections'; import type { LocalSocialEvent, EventStatus } from '../types'; +import { eventsApi } from '../api'; let error = $state(null); @@ -119,6 +121,8 @@ export const eventsStore = { if (input.coverImage !== undefined) localData.coverImage = input.coverImage; await db.table('socialEvents').update(id, localData); + // Fire-and-forget snapshot sync if this event is published + void this.syncSnapshotIfPublished(id); return { success: true as const }; } catch (e) { error = e instanceof Error ? e.message : 'Failed to update event'; @@ -133,6 +137,13 @@ export const eventsStore = { if (event?.timeBlockId) { await deleteBlock(event.timeBlockId); } + if (event?.isPublished) { + try { + await eventsApi.unpublish(id); + } catch (e) { + console.warn('Failed to delete server snapshot during deleteEvent:', e); + } + } await db.table('socialEvents').update(id, { deletedAt: new Date().toISOString(), updatedAt: new Date().toISOString(), @@ -145,17 +156,30 @@ export const eventsStore = { }, /** - * Local-only "publish" stub for Phase 1a. - * Just flips isPublished + assigns a placeholder token. Phase 1b will - * push the snapshot to mana-events and use a real server-issued token. + * Publish event — pushes a snapshot to mana-events and stores the + * server-issued token locally. Public RSVP page will read the snapshot. */ async publishEvent(id: string) { error = null; try { - const token = - typeof crypto !== 'undefined' && 'randomUUID' in crypto - ? crypto.randomUUID().replace(/-/g, '').slice(0, 24) - : Math.random().toString(36).slice(2, 26); + const event = await db.table('socialEvents').get(id); + if (!event) return { success: false as const, error: 'Event not found' }; + const block = await timeBlockTable.get(event.timeBlockId); + if (!block) return { success: false as const, error: 'TimeBlock missing for event' }; + + const { token } = await eventsApi.publish({ + eventId: id, + title: event.title, + description: event.description ?? null, + location: event.location ?? null, + locationUrl: event.locationUrl ?? null, + startAt: block.startDate, + endAt: block.endDate ?? null, + allDay: block.allDay ?? false, + coverImageUrl: event.coverImage ?? null, + color: event.color ?? null, + capacity: event.capacity ?? null, + }); await db.table('socialEvents').update(id, { isPublished: true, @@ -173,6 +197,13 @@ export const eventsStore = { async unpublishEvent(id: string) { error = null; try { + // Best-effort delete on the server. If the network fails we still + // flip the local flag — host clearly intended to unpublish. + try { + await eventsApi.unpublish(id); + } catch (e) { + console.warn('Failed to delete server snapshot during unpublish:', e); + } await db.table('socialEvents').update(id, { isPublished: false, publicToken: null, @@ -185,4 +216,32 @@ export const eventsStore = { return { success: false as const, error }; } }, + + /** + * Push the latest local state of a published event to the server snapshot. + * Called after an updateEvent() if the event is currently published. + */ + async syncSnapshotIfPublished(id: string) { + try { + const event = await db.table('socialEvents').get(id); + if (!event || !event.isPublished) return; + const block = await timeBlockTable.get(event.timeBlockId); + if (!block) return; + await eventsApi.updateSnapshot(id, { + eventId: id, + title: event.title, + description: event.description ?? null, + location: event.location ?? null, + locationUrl: event.locationUrl ?? null, + startAt: block.startDate, + endAt: block.endDate ?? null, + allDay: block.allDay ?? false, + coverImageUrl: event.coverImage ?? null, + color: event.color ?? null, + capacity: event.capacity ?? null, + }); + } catch (e) { + console.warn('Snapshot sync failed:', e); + } + }, }; diff --git a/apps/mana/apps/web/src/lib/modules/events/views/DetailView.svelte b/apps/mana/apps/web/src/lib/modules/events/views/DetailView.svelte index 3a089ca53..62a4bdae9 100644 --- a/apps/mana/apps/web/src/lib/modules/events/views/DetailView.svelte +++ b/apps/mana/apps/web/src/lib/modules/events/views/DetailView.svelte @@ -3,6 +3,7 @@ import { eventsStore } from '../stores/events.svelte'; import GuestListEditor from '../components/GuestListEditor.svelte'; import RsvpSummaryView from '../components/RsvpSummary.svelte'; + import PublicRsvpList from '../components/PublicRsvpList.svelte'; interface Props { eventId: string; @@ -154,6 +155,12 @@ + {#if event.isPublished} +
+ +
+ {/if} +

Teilen

{#if event.isPublished && event.publicToken} @@ -163,8 +170,7 @@ {:else} + + {:else} +
+

Sag bitte zu

+ + + + + +
+ Kommst du? +
+ + + +
+
+ + {#if status === 'yes'} + + {/if} + + + + {#if errorMessage} +
{errorMessage}
+ {/if} + + +
+ {/if} + + +
+ Powered by Mana +
+ + + diff --git a/package.json b/package.json index 8f189541d..269453e15 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "dev:chat:landing": "pnpm --filter @chat/landing dev", "dev:chat:app": "concurrently -n api,web -c yellow,cyan \"pnpm dev:api\" \"pnpm dev:chat:web\"", "dev:auth": "cd services/mana-auth && bun run --watch src/index.ts", + "dev:events": "cd services/mana-events && bun run --watch src/index.ts", "dev:sync": "cd services/mana-sync && JWKS_URL=http://localhost:3001/api/auth/jwks DATABASE_URL=postgresql://mana:devpassword@localhost:5432/mana_sync ./server", "dev:sync:build": "cd services/mana-sync && go build -o server ./cmd/server", "dev:chat:full": "concurrently -n auth,sync,api -c blue,magenta,yellow \"pnpm dev:auth\" \"pnpm dev:sync\" \"pnpm dev:api\"", diff --git a/scripts/setup-databases.sh b/scripts/setup-databases.sh index 74bd97a22..159961da5 100755 --- a/scripts/setup-databases.sh +++ b/scripts/setup-databases.sh @@ -76,6 +76,7 @@ PLATFORM_SCHEMAS=( "presi" "uload" "cards" + "events" ) # Check if specific service requested @@ -118,9 +119,12 @@ setup_service() { cards) push_schema "@mana/cards-database" "cards" ;; + events|mana-events) + push_schema "@mana/events" "mana-events" + ;; *) echo -e "${RED}Unknown service: $service${NC}" - echo "Available services: auth, credits, user, subscriptions, analytics, media, todo, traces, presi, uload, cards" + echo "Available services: auth, credits, user, subscriptions, analytics, media, todo, traces, presi, uload, cards, events" exit 1 ;; esac @@ -150,7 +154,7 @@ done echo -e "\n${GREEN}Step 3: Pushing schemas${NC}" echo "--------------------------------------" -for service in auth credits user subscriptions analytics media todo traces presi uload cards; do +for service in auth credits user subscriptions analytics media todo traces presi uload cards events; do setup_service "$service" 2>/dev/null || true done diff --git a/services/mana-events/Dockerfile b/services/mana-events/Dockerfile new file mode 100644 index 000000000..0ade7229b --- /dev/null +++ b/services/mana-events/Dockerfile @@ -0,0 +1,16 @@ +FROM oven/bun:1 AS production + +WORKDIR /app + +COPY package.json bun.lock* ./ +RUN bun install --frozen-lockfile 2>/dev/null || bun install + +COPY src ./src +COPY tsconfig.json drizzle.config.ts ./ + +EXPOSE 3065 + +HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \ + CMD bun -e "fetch('http://localhost:3065/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))" + +CMD ["bun", "run", "src/index.ts"] diff --git a/services/mana-events/bun.lock b/services/mana-events/bun.lock new file mode 100644 index 000000000..f51149460 --- /dev/null +++ b/services/mana-events/bun.lock @@ -0,0 +1,165 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "@mana/events", + "dependencies": { + "drizzle-orm": "^0.38.3", + "hono": "^4.7.0", + "jose": "^6.1.2", + "postgres": "^3.4.5", + "zod": "^3.24.0", + }, + "devDependencies": { + "drizzle-kit": "^0.30.4", + "typescript": "^5.9.3", + }, + }, + }, + "packages": { + "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], + + "@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="], + + "@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.19.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.19.12", "", { "os": "android", "cpu": "arm" }, "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.19.12", "", { "os": "android", "cpu": "arm64" }, "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.19.12", "", { "os": "android", "cpu": "x64" }, "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.19.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.19.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.19.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.19.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.19.12", "", { "os": "linux", "cpu": "arm" }, "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.19.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.19.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.19.12", "", { "os": "linux", "cpu": "none" }, "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.19.12", "", { "os": "linux", "cpu": "none" }, "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.19.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.19.12", "", { "os": "linux", "cpu": "none" }, "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.19.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.19.12", "", { "os": "linux", "cpu": "x64" }, "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.19.12", "", { "os": "none", "cpu": "x64" }, "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.19.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.19.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.19.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.19.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.19.12", "", { "os": "win32", "cpu": "x64" }, "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA=="], + + "@petamoriken/float16": ["@petamoriken/float16@3.9.3", "", {}, "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g=="], + + "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "drizzle-kit": ["drizzle-kit@0.30.6", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.19.7", "esbuild-register": "^3.5.0", "gel": "^2.0.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-U4wWit0fyZuGuP7iNmRleQyK2V8wCuv57vf5l3MnG4z4fzNTjY/U13M8owyQ5RavqvqxBifWORaR3wIUzlN64g=="], + + "drizzle-orm": ["drizzle-orm@0.38.4", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/react": ">=18", "@types/sql.js": "*", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "react": ">=18", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/react", "@types/sql.js", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "knex", "kysely", "mysql2", "pg", "postgres", "react", "sql.js", "sqlite3"] }, "sha512-s7/5BpLKO+WJRHspvpqTydxFob8i1vo2rEx4pY6TGY7QSMuUfWUuzaY0DIpXCkgHOo37BaFC+SJQb99dDUXT3Q=="], + + "env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="], + + "esbuild": ["esbuild@0.19.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.19.12", "@esbuild/android-arm": "0.19.12", "@esbuild/android-arm64": "0.19.12", "@esbuild/android-x64": "0.19.12", "@esbuild/darwin-arm64": "0.19.12", "@esbuild/darwin-x64": "0.19.12", "@esbuild/freebsd-arm64": "0.19.12", "@esbuild/freebsd-x64": "0.19.12", "@esbuild/linux-arm": "0.19.12", "@esbuild/linux-arm64": "0.19.12", "@esbuild/linux-ia32": "0.19.12", "@esbuild/linux-loong64": "0.19.12", "@esbuild/linux-mips64el": "0.19.12", "@esbuild/linux-ppc64": "0.19.12", "@esbuild/linux-riscv64": "0.19.12", "@esbuild/linux-s390x": "0.19.12", "@esbuild/linux-x64": "0.19.12", "@esbuild/netbsd-x64": "0.19.12", "@esbuild/openbsd-x64": "0.19.12", "@esbuild/sunos-x64": "0.19.12", "@esbuild/win32-arm64": "0.19.12", "@esbuild/win32-ia32": "0.19.12", "@esbuild/win32-x64": "0.19.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg=="], + + "esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="], + + "gel": ["gel@2.2.0", "", { "dependencies": { "@petamoriken/float16": "^3.8.7", "debug": "^4.3.4", "env-paths": "^3.0.0", "semver": "^7.6.2", "shell-quote": "^1.8.1", "which": "^4.0.0" }, "bin": { "gel": "dist/cli.mjs" } }, "sha512-q0ma7z2swmoamHQusey8ayo8+ilVdzDt4WTxSPzq/yRqvucWRfymRVMvNgmSC0XK7eNjjEZEcplxpgaNojKdmQ=="], + + "get-tsconfig": ["get-tsconfig@4.13.7", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q=="], + + "hono": ["hono@4.12.12", "", {}, "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q=="], + + "isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], + + "jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "postgres": ["postgres@3.4.9", "", {}, "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw=="], + + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + + "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="], + + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + + "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], + + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.18.20", "", { "os": "android", "cpu": "x64" }, "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.18.20", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.18.20", "", { "os": "darwin", "cpu": "x64" }, "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.18.20", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.18.20", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.18.20", "", { "os": "linux", "cpu": "arm" }, "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.18.20", "", { "os": "linux", "cpu": "arm64" }, "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.18.20", "", { "os": "linux", "cpu": "ia32" }, "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.18.20", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.18.20", "", { "os": "linux", "cpu": "s390x" }, "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.18.20", "", { "os": "linux", "cpu": "x64" }, "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.18.20", "", { "os": "none", "cpu": "x64" }, "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.18.20", "", { "os": "openbsd", "cpu": "x64" }, "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.18.20", "", { "os": "sunos", "cpu": "x64" }, "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.18.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.18.20", "", { "os": "win32", "cpu": "ia32" }, "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], + } +} diff --git a/services/mana-events/drizzle.config.ts b/services/mana-events/drizzle.config.ts new file mode 100644 index 000000000..a6404d0f7 --- /dev/null +++ b/services/mana-events/drizzle.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'drizzle-kit'; + +export default defineConfig({ + schema: './src/db/schema/*.ts', + out: './drizzle', + dialect: 'postgresql', + dbCredentials: { + url: process.env.DATABASE_URL || 'postgresql://mana:devpassword@localhost:5432/mana_platform', + }, + schemaFilter: ['events'], +}); diff --git a/services/mana-events/package.json b/services/mana-events/package.json new file mode 100644 index 000000000..36ff39a07 --- /dev/null +++ b/services/mana-events/package.json @@ -0,0 +1,24 @@ +{ + "name": "@mana/events", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "bun run --watch src/index.ts", + "start": "bun run src/index.ts", + "db:push": "drizzle-kit push", + "db:generate": "drizzle-kit generate", + "db:studio": "drizzle-kit studio" + }, + "dependencies": { + "hono": "^4.7.0", + "drizzle-orm": "^0.38.3", + "postgres": "^3.4.5", + "jose": "^6.1.2", + "zod": "^3.24.0" + }, + "devDependencies": { + "drizzle-kit": "^0.30.4", + "typescript": "^5.9.3" + } +} diff --git a/services/mana-events/src/config.ts b/services/mana-events/src/config.ts new file mode 100644 index 000000000..ffafdc6fd --- /dev/null +++ b/services/mana-events/src/config.ts @@ -0,0 +1,42 @@ +/** + * Application configuration loaded from environment variables. + */ + +export interface Config { + port: number; + databaseUrl: string; + manaAuthUrl: string; + cors: { + origins: string[]; + }; + rateLimit: { + // Max public RSVP submissions per token per hour + rsvpPerTokenPerHour: number; + // Hard cap on total RSVPs per token + rsvpMaxPerToken: number; + }; +} + +export function loadConfig(): Config { + const requiredEnv = (key: string, fallback?: string): string => { + const value = process.env[key] || fallback; + if (!value) throw new Error(`Missing required env var: ${key}`); + return value; + }; + + return { + port: parseInt(process.env.PORT || '3065', 10), + databaseUrl: requiredEnv( + 'DATABASE_URL', + 'postgresql://mana:devpassword@localhost:5432/mana_platform' + ), + manaAuthUrl: requiredEnv('MANA_AUTH_URL', 'http://localhost:3001'), + cors: { + origins: (process.env.CORS_ORIGINS || 'http://localhost:5173').split(','), + }, + rateLimit: { + rsvpPerTokenPerHour: parseInt(process.env.RSVP_RATE_LIMIT || '60', 10), + rsvpMaxPerToken: parseInt(process.env.RSVP_MAX_PER_TOKEN || '500', 10), + }, + }; +} diff --git a/services/mana-events/src/db/connection.ts b/services/mana-events/src/db/connection.ts new file mode 100644 index 000000000..aa63e328e --- /dev/null +++ b/services/mana-events/src/db/connection.ts @@ -0,0 +1,19 @@ +/** + * Database connection using Drizzle ORM + postgres.js + */ + +import { drizzle } from 'drizzle-orm/postgres-js'; +import postgres from 'postgres'; +import * as schema from './schema/index'; + +let db: ReturnType> | null = null; + +export function getDb(databaseUrl: string) { + if (!db) { + const client = postgres(databaseUrl, { max: 10 }); + db = drizzle(client, { schema }); + } + return db; +} + +export type Database = ReturnType; diff --git a/services/mana-events/src/db/schema/events.ts b/services/mana-events/src/db/schema/events.ts new file mode 100644 index 000000000..e77617e9e --- /dev/null +++ b/services/mana-events/src/db/schema/events.ts @@ -0,0 +1,84 @@ +/** + * Events schema — published event snapshots and public RSVP responses. + * + * `events_published` is a server-side cache of an event's public-facing + * metadata, written by the host's client when they "publish" the event. + * It is the source of truth that the public RSVP page reads from. + */ + +import { + pgSchema, + uuid, + integer, + text, + timestamp, + boolean, + uniqueIndex, + index, +} from 'drizzle-orm/pg-core'; + +export const eventsSchema = pgSchema('events'); + +/** Published event snapshots — one per token. */ +export const eventsPublished = eventsSchema.table( + 'events_published', + { + token: text('token').primaryKey(), + eventId: uuid('event_id').notNull(), + userId: text('user_id').notNull(), // host + title: text('title').notNull(), + description: text('description'), + location: text('location'), + locationUrl: text('location_url'), + startAt: timestamp('start_at', { withTimezone: true }).notNull(), + endAt: timestamp('end_at', { withTimezone: true }), + allDay: boolean('all_day').default(false).notNull(), + coverImageUrl: text('cover_image_url'), + color: text('color'), + capacity: integer('capacity'), + isCancelled: boolean('is_cancelled').default(false).notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + }, + (t) => ({ + userIdIdx: index('events_published_user_id_idx').on(t.userId), + eventIdIdx: index('events_published_event_id_idx').on(t.eventId), + }) +); + +/** Public RSVP responses — submitted via the share link, no auth. */ +export const publicRsvps = eventsSchema.table( + 'public_rsvps', + { + id: uuid('id').defaultRandom().primaryKey(), + token: text('token') + .notNull() + .references(() => eventsPublished.token, { onDelete: 'cascade' }), + name: text('name').notNull(), + email: text('email'), + status: text('status').notNull(), // 'yes' | 'no' | 'maybe' + plusOnes: integer('plus_ones').default(0).notNull(), + note: text('note'), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + }, + (t) => ({ + tokenIdx: index('public_rsvps_token_idx').on(t.token), + // Best-effort dedup: same token + same lowercase name + same lowercase email = same person. + // Email may be null, so we coalesce to '' for the index. + uniquePerson: uniqueIndex('public_rsvps_token_name_email_unique').on(t.token, t.name, t.email), + }) +); + +/** Per-token rate limit bucket — token + hour-bucket → submission count. */ +export const rsvpRateBuckets = eventsSchema.table( + 'rsvp_rate_buckets', + { + token: text('token').notNull(), + hourBucket: text('hour_bucket').notNull(), // YYYY-MM-DDTHH + count: integer('count').default(0).notNull(), + }, + (t) => ({ + pk: uniqueIndex('rsvp_rate_buckets_pk').on(t.token, t.hourBucket), + }) +); diff --git a/services/mana-events/src/db/schema/index.ts b/services/mana-events/src/db/schema/index.ts new file mode 100644 index 000000000..7981d6b64 --- /dev/null +++ b/services/mana-events/src/db/schema/index.ts @@ -0,0 +1 @@ +export * from './events'; diff --git a/services/mana-events/src/index.ts b/services/mana-events/src/index.ts new file mode 100644 index 000000000..23e5250fe --- /dev/null +++ b/services/mana-events/src/index.ts @@ -0,0 +1,47 @@ +/** + * mana-events — Public RSVP & event sharing service. + * + * Hono + Bun runtime. Stores published event snapshots and the public + * RSVP responses they collect. Hosts authenticate via mana-auth JWT; + * RSVP endpoints are intentionally unauthenticated so anyone with a + * share link can respond. + */ + +import { Hono } from 'hono'; +import { cors } from 'hono/cors'; +import { loadConfig } from './config'; +import { getDb } from './db/connection'; +import { errorHandler } from './middleware/error-handler'; +import { jwtAuth } from './middleware/jwt-auth'; +import { healthRoutes } from './routes/health'; +import { createEventsRoutes } from './routes/events'; +import { createRsvpRoutes } from './routes/rsvp'; + +const config = loadConfig(); +const db = getDb(config.databaseUrl); + +const app = new Hono(); + +app.onError(errorHandler); +app.use( + '*', + cors({ + origin: config.cors.origins, + credentials: true, + }) +); + +// Public — no auth +app.route('/health', healthRoutes); +app.route('/api/v1/rsvp', createRsvpRoutes(db, config)); + +// Authenticated host endpoints +app.use('/api/v1/events/*', jwtAuth(config.manaAuthUrl)); +app.route('/api/v1/events', createEventsRoutes(db)); + +console.log(`mana-events starting on port ${config.port}...`); + +export default { + port: config.port, + fetch: app.fetch, +}; diff --git a/services/mana-events/src/lib/errors.ts b/services/mana-events/src/lib/errors.ts new file mode 100644 index 000000000..4dba3b87a --- /dev/null +++ b/services/mana-events/src/lib/errors.ts @@ -0,0 +1,31 @@ +import { HTTPException } from 'hono/http-exception'; + +export class BadRequestError extends HTTPException { + constructor(message: string) { + super(400, { message }); + } +} + +export class UnauthorizedError extends HTTPException { + constructor(message = 'Unauthorized') { + super(401, { message }); + } +} + +export class ForbiddenError extends HTTPException { + constructor(message = 'Forbidden') { + super(403, { message }); + } +} + +export class NotFoundError extends HTTPException { + constructor(message = 'Not found') { + super(404, { message }); + } +} + +export class TooManyRequestsError extends HTTPException { + constructor(message = 'Rate limit exceeded') { + super(429, { message }); + } +} diff --git a/services/mana-events/src/middleware/error-handler.ts b/services/mana-events/src/middleware/error-handler.ts new file mode 100644 index 000000000..a92ea9856 --- /dev/null +++ b/services/mana-events/src/middleware/error-handler.ts @@ -0,0 +1,10 @@ +import type { ErrorHandler } from 'hono'; +import { HTTPException } from 'hono/http-exception'; + +export const errorHandler: ErrorHandler = (err, c) => { + if (err instanceof HTTPException) { + return c.json({ statusCode: err.status, message: err.message }, err.status); + } + console.error('Unhandled error:', err); + return c.json({ statusCode: 500, message: 'Internal server error' }, 500); +}; diff --git a/services/mana-events/src/middleware/jwt-auth.ts b/services/mana-events/src/middleware/jwt-auth.ts new file mode 100644 index 000000000..1f21f6355 --- /dev/null +++ b/services/mana-events/src/middleware/jwt-auth.ts @@ -0,0 +1,50 @@ +/** + * JWT Authentication Middleware — validates Bearer tokens via JWKS from mana-auth. + */ + +import type { MiddlewareHandler } from 'hono'; +import { createRemoteJWKSet, jwtVerify } from 'jose'; +import { UnauthorizedError } from '../lib/errors'; + +let jwks: ReturnType | null = null; + +function getJwks(authUrl: string) { + if (!jwks) { + jwks = createRemoteJWKSet(new URL('/api/auth/jwks', authUrl)); + } + return jwks; +} + +export interface AuthUser { + userId: string; + email: string; + role: string; +} + +export function jwtAuth(authUrl: string): MiddlewareHandler { + return async (c, next) => { + const authHeader = c.req.header('Authorization'); + if (!authHeader?.startsWith('Bearer ')) { + throw new UnauthorizedError('Missing or invalid Authorization header'); + } + + const token = authHeader.slice(7); + try { + const { payload } = await jwtVerify(token, getJwks(authUrl), { + issuer: authUrl, + audience: 'mana', + }); + + const user: AuthUser = { + userId: payload.sub || '', + email: (payload.email as string) || '', + role: (payload.role as string) || 'user', + }; + + c.set('user', user); + await next(); + } catch { + throw new UnauthorizedError('Invalid or expired token'); + } + }; +} diff --git a/services/mana-events/src/routes/events.ts b/services/mana-events/src/routes/events.ts new file mode 100644 index 000000000..36676062e --- /dev/null +++ b/services/mana-events/src/routes/events.ts @@ -0,0 +1,175 @@ +/** + * Host event routes — JWT-authenticated. + * + * Lets the event organizer publish a snapshot of their event, update it, + * unpublish (delete) it, and read back the public RSVPs they've received. + */ + +import { Hono } from 'hono'; +import { z } from 'zod'; +import { and, eq } from 'drizzle-orm'; +import type { Database } from '../db/connection'; +import { eventsPublished, publicRsvps } from '../db/schema/events'; +import { ForbiddenError, NotFoundError, BadRequestError } from '../lib/errors'; +import type { AuthUser } from '../middleware/jwt-auth'; + +const snapshotSchema = z.object({ + eventId: z.string().uuid(), + title: z.string().min(1).max(200), + description: z.string().max(5000).nullable().optional(), + location: z.string().max(500).nullable().optional(), + locationUrl: z.string().url().max(2000).nullable().optional(), + startAt: z.string().datetime(), + endAt: z.string().datetime().nullable().optional(), + allDay: z.boolean().optional(), + coverImageUrl: z.string().url().max(2000).nullable().optional(), + color: z.string().max(20).nullable().optional(), + capacity: z.number().int().positive().nullable().optional(), +}); + +const snapshotUpdateSchema = snapshotSchema.partial().extend({ + eventId: z.string().uuid(), // still required so we can verify ownership +}); + +function generateToken(): string { + // 24-char URL-safe random + const bytes = new Uint8Array(18); + crypto.getRandomValues(bytes); + return btoa(String.fromCharCode(...bytes)) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') + .slice(0, 24); +} + +export function createEventsRoutes(db: Database) { + const app = new Hono<{ Variables: { user: AuthUser } }>(); + + // POST /events/publish — create a new published snapshot + app.post('/publish', async (c) => { + const user = c.get('user'); + const body = await c.req.json().catch(() => null); + const parsed = snapshotSchema.safeParse(body); + if (!parsed.success) throw new BadRequestError(parsed.error.issues[0]?.message ?? 'Invalid'); + + // Reuse existing token if this event was previously published + const existing = await db + .select() + .from(eventsPublished) + .where(eq(eventsPublished.eventId, parsed.data.eventId)) + .limit(1); + + if (existing[0]) { + if (existing[0].userId !== user.userId) throw new ForbiddenError('Not your event'); + await db + .update(eventsPublished) + .set({ + title: parsed.data.title, + description: parsed.data.description ?? null, + location: parsed.data.location ?? null, + locationUrl: parsed.data.locationUrl ?? null, + startAt: new Date(parsed.data.startAt), + endAt: parsed.data.endAt ? new Date(parsed.data.endAt) : null, + allDay: parsed.data.allDay ?? false, + coverImageUrl: parsed.data.coverImageUrl ?? null, + color: parsed.data.color ?? null, + capacity: parsed.data.capacity ?? null, + isCancelled: false, + updatedAt: new Date(), + }) + .where(eq(eventsPublished.token, existing[0].token)); + return c.json({ token: existing[0].token, isNew: false }); + } + + const token = generateToken(); + await db.insert(eventsPublished).values({ + token, + eventId: parsed.data.eventId, + userId: user.userId, + title: parsed.data.title, + description: parsed.data.description ?? null, + location: parsed.data.location ?? null, + locationUrl: parsed.data.locationUrl ?? null, + startAt: new Date(parsed.data.startAt), + endAt: parsed.data.endAt ? new Date(parsed.data.endAt) : null, + allDay: parsed.data.allDay ?? false, + coverImageUrl: parsed.data.coverImageUrl ?? null, + color: parsed.data.color ?? null, + capacity: parsed.data.capacity ?? null, + }); + return c.json({ token, isNew: true }); + }); + + // PUT /events/:eventId/snapshot — update an existing snapshot (alias of publish) + app.put('/:eventId/snapshot', async (c) => { + const user = c.get('user'); + const eventId = c.req.param('eventId'); + const body = await c.req.json().catch(() => null); + const parsed = snapshotUpdateSchema.safeParse({ ...body, eventId }); + if (!parsed.success) throw new BadRequestError(parsed.error.issues[0]?.message ?? 'Invalid'); + + const existing = await db + .select() + .from(eventsPublished) + .where(eq(eventsPublished.eventId, eventId)) + .limit(1); + if (!existing[0]) throw new NotFoundError('Event not published'); + if (existing[0].userId !== user.userId) throw new ForbiddenError('Not your event'); + + const updates: Partial = { updatedAt: new Date() }; + if (parsed.data.title !== undefined) updates.title = parsed.data.title; + if (parsed.data.description !== undefined) updates.description = parsed.data.description; + if (parsed.data.location !== undefined) updates.location = parsed.data.location; + if (parsed.data.locationUrl !== undefined) updates.locationUrl = parsed.data.locationUrl; + if (parsed.data.startAt !== undefined) updates.startAt = new Date(parsed.data.startAt); + if (parsed.data.endAt !== undefined) + updates.endAt = parsed.data.endAt ? new Date(parsed.data.endAt) : null; + if (parsed.data.allDay !== undefined) updates.allDay = parsed.data.allDay; + if (parsed.data.coverImageUrl !== undefined) updates.coverImageUrl = parsed.data.coverImageUrl; + if (parsed.data.color !== undefined) updates.color = parsed.data.color; + if (parsed.data.capacity !== undefined) updates.capacity = parsed.data.capacity; + + await db + .update(eventsPublished) + .set(updates) + .where(eq(eventsPublished.token, existing[0].token)); + return c.json({ token: existing[0].token }); + }); + + // DELETE /events/:eventId — unpublish (cascade-deletes RSVPs) + app.delete('/:eventId', async (c) => { + const user = c.get('user'); + const eventId = c.req.param('eventId'); + const existing = await db + .select() + .from(eventsPublished) + .where(eq(eventsPublished.eventId, eventId)) + .limit(1); + if (!existing[0]) return c.json({ deleted: false }); + if (existing[0].userId !== user.userId) throw new ForbiddenError('Not your event'); + + await db.delete(eventsPublished).where(eq(eventsPublished.token, existing[0].token)); + return c.json({ deleted: true }); + }); + + // GET /events/:eventId/rsvps — list all RSVPs for the host + app.get('/:eventId/rsvps', async (c) => { + const user = c.get('user'); + const eventId = c.req.param('eventId'); + const existing = await db + .select() + .from(eventsPublished) + .where(eq(eventsPublished.eventId, eventId)) + .limit(1); + if (!existing[0]) throw new NotFoundError('Event not published'); + if (existing[0].userId !== user.userId) throw new ForbiddenError('Not your event'); + + const rsvps = await db + .select() + .from(publicRsvps) + .where(eq(publicRsvps.token, existing[0].token)); + return c.json({ token: existing[0].token, rsvps }); + }); + + return app; +} diff --git a/services/mana-events/src/routes/health.ts b/services/mana-events/src/routes/health.ts new file mode 100644 index 000000000..30fffc346 --- /dev/null +++ b/services/mana-events/src/routes/health.ts @@ -0,0 +1,5 @@ +import { Hono } from 'hono'; + +export const healthRoutes = new Hono().get('/', (c) => + c.json({ status: 'ok', service: 'mana-events', timestamp: new Date().toISOString() }) +); diff --git a/services/mana-events/src/routes/rsvp.ts b/services/mana-events/src/routes/rsvp.ts new file mode 100644 index 000000000..613890d40 --- /dev/null +++ b/services/mana-events/src/routes/rsvp.ts @@ -0,0 +1,169 @@ +/** + * Public RSVP routes — no authentication. + * + * Anyone with a share link can view the event snapshot and submit an RSVP. + * Protected by per-token rate limiting and a hard total cap. + */ + +import { Hono } from 'hono'; +import { z } from 'zod'; +import { and, eq, sql } from 'drizzle-orm'; +import type { Database } from '../db/connection'; +import { eventsPublished, publicRsvps, rsvpRateBuckets } from '../db/schema/events'; +import { NotFoundError, BadRequestError, TooManyRequestsError } from '../lib/errors'; +import type { Config } from '../config'; + +const rsvpBodySchema = z.object({ + name: z.string().min(1).max(100), + email: z.string().email().max(200).optional().nullable(), + status: z.enum(['yes', 'no', 'maybe']), + plusOnes: z.number().int().min(0).max(20).optional().default(0), + note: z.string().max(1000).optional().nullable(), +}); + +function currentHourBucket(): string { + const d = new Date(); + const pad = (n: number) => n.toString().padStart(2, '0'); + return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}T${pad(d.getUTCHours())}`; +} + +export function createRsvpRoutes(db: Database, config: Config) { + const app = new Hono(); + + // GET /rsvp/:token — public event snapshot + summary + app.get('/:token', async (c) => { + const token = c.req.param('token'); + const rows = await db + .select() + .from(eventsPublished) + .where(eq(eventsPublished.token, token)) + .limit(1); + const event = rows[0]; + if (!event) throw new NotFoundError('Event not found'); + if (event.isCancelled) { + return c.json({ event: { ...event, isCancelled: true }, summary: null, cancelled: true }); + } + + // Compute summary (counts only — never expose individual responses publicly) + const all = await db + .select({ status: publicRsvps.status, plusOnes: publicRsvps.plusOnes }) + .from(publicRsvps) + .where(eq(publicRsvps.token, token)); + + const summary = { yes: 0, no: 0, maybe: 0, totalAttending: 0 }; + for (const r of all) { + if (r.status === 'yes') { + summary.yes++; + summary.totalAttending += 1 + (r.plusOnes ?? 0); + } else if (r.status === 'no') summary.no++; + else if (r.status === 'maybe') summary.maybe++; + } + + return c.json({ + event: { + token: event.token, + title: event.title, + description: event.description, + location: event.location, + locationUrl: event.locationUrl, + startAt: event.startAt, + endAt: event.endAt, + allDay: event.allDay, + coverImageUrl: event.coverImageUrl, + color: event.color, + capacity: event.capacity, + }, + summary, + }); + }); + + // POST /rsvp/:token — submit/update an RSVP + app.post('/:token', async (c) => { + const token = c.req.param('token'); + const body = await c.req.json().catch(() => null); + const parsed = rsvpBodySchema.safeParse(body); + if (!parsed.success) throw new BadRequestError(parsed.error.issues[0]?.message ?? 'Invalid'); + + // Verify event exists & isn't cancelled + const eventRows = await db + .select() + .from(eventsPublished) + .where(eq(eventsPublished.token, token)) + .limit(1); + const event = eventRows[0]; + if (!event) throw new NotFoundError('Event not found'); + if (event.isCancelled) throw new BadRequestError('Event has been cancelled'); + + // Hard total-cap check + const totalRows = await db + .select({ c: sql`count(*)::int` }) + .from(publicRsvps) + .where(eq(publicRsvps.token, token)); + const total = totalRows[0]?.c ?? 0; + if (total >= config.rateLimit.rsvpMaxPerToken) { + throw new TooManyRequestsError('Maximum RSVPs reached for this event'); + } + + // Per-token hourly rate limit + const bucket = currentHourBucket(); + const bucketRows = await db + .select() + .from(rsvpRateBuckets) + .where(and(eq(rsvpRateBuckets.token, token), eq(rsvpRateBuckets.hourBucket, bucket))) + .limit(1); + const currentCount = bucketRows[0]?.count ?? 0; + if (currentCount >= config.rateLimit.rsvpPerTokenPerHour) { + throw new TooManyRequestsError('Too many submissions, please try again later'); + } + + // Upsert RSVP — same (token, name, email) overwrites + const existing = await db + .select() + .from(publicRsvps) + .where( + and( + eq(publicRsvps.token, token), + eq(publicRsvps.name, parsed.data.name), + parsed.data.email + ? eq(publicRsvps.email, parsed.data.email) + : sql`${publicRsvps.email} is null` + ) + ) + .limit(1); + + if (existing[0]) { + await db + .update(publicRsvps) + .set({ + status: parsed.data.status, + plusOnes: parsed.data.plusOnes ?? 0, + note: parsed.data.note ?? null, + updatedAt: new Date(), + }) + .where(eq(publicRsvps.id, existing[0].id)); + } else { + await db.insert(publicRsvps).values({ + token, + name: parsed.data.name, + email: parsed.data.email ?? null, + status: parsed.data.status, + plusOnes: parsed.data.plusOnes ?? 0, + note: parsed.data.note ?? null, + }); + } + + // Increment rate bucket + if (bucketRows[0]) { + await db + .update(rsvpRateBuckets) + .set({ count: bucketRows[0].count + 1 }) + .where(and(eq(rsvpRateBuckets.token, token), eq(rsvpRateBuckets.hourBucket, bucket))); + } else { + await db.insert(rsvpRateBuckets).values({ token, hourBucket: bucket, count: 1 }); + } + + return c.json({ ok: true }); + }); + + return app; +} diff --git a/services/mana-events/tsconfig.json b/services/mana-events/tsconfig.json new file mode 100644 index 000000000..354a2c2dd --- /dev/null +++ b/services/mana-events/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src/**/*.ts"] +}