mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-16 06:39:41 +02:00
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
This commit is contained in:
parent
980a5e996c
commit
216746721e
27 changed files with 1764 additions and 11 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)};
|
||||
</script>`;
|
||||
return injectUmamiAnalytics(html.replace('<head>', `<head>${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:*'] : []),
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
76
apps/mana/apps/web/src/lib/modules/events/api.ts
Normal file
76
apps/mana/apps/web/src/lib/modules/events/api.ts
Normal file
|
|
@ -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<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
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<T>;
|
||||
}
|
||||
|
||||
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<PublishedSnapshotInput>
|
||||
): 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`);
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,252 @@
|
|||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { eventsApi, type PublicRsvpRecord } from '../api';
|
||||
import { eventGuestsStore } from '../stores/guests.svelte';
|
||||
|
||||
interface Props {
|
||||
eventId: string;
|
||||
isPublished: boolean;
|
||||
}
|
||||
|
||||
let { eventId, isPublished }: Props = $props();
|
||||
|
||||
let rsvps = $state<PublicRsvpRecord[]>([]);
|
||||
let loading = $state(false);
|
||||
let lastError = $state<string | null>(null);
|
||||
let lastFetchedAt = $state<Date | null>(null);
|
||||
let pollHandle: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
async function fetchRsvps() {
|
||||
if (!isPublished) return;
|
||||
loading = true;
|
||||
try {
|
||||
const res = await eventsApi.getRsvps(eventId);
|
||||
rsvps = res.rsvps;
|
||||
lastError = null;
|
||||
lastFetchedAt = new Date();
|
||||
} catch (e) {
|
||||
lastError = e instanceof Error ? e.message : 'Fehler beim Laden';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (isPublished) {
|
||||
void fetchRsvps();
|
||||
pollHandle = setInterval(fetchRsvps, 30_000);
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (pollHandle) clearInterval(pollHandle);
|
||||
});
|
||||
|
||||
// Re-poll when isPublished flips
|
||||
$effect(() => {
|
||||
if (isPublished && !pollHandle) {
|
||||
void fetchRsvps();
|
||||
pollHandle = setInterval(fetchRsvps, 30_000);
|
||||
} else if (!isPublished && pollHandle) {
|
||||
clearInterval(pollHandle);
|
||||
pollHandle = null;
|
||||
rsvps = [];
|
||||
}
|
||||
});
|
||||
|
||||
async function importToGuestList(r: PublicRsvpRecord) {
|
||||
await eventGuestsStore.addGuest({
|
||||
eventId,
|
||||
name: r.name,
|
||||
email: r.email,
|
||||
rsvpStatus: r.status,
|
||||
plusOnes: r.plusOnes,
|
||||
note: r.note,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isPublished}
|
||||
<div class="public-rsvps">
|
||||
<div class="header-row">
|
||||
<h3>Antworten via Link</h3>
|
||||
<button class="refresh" onclick={fetchRsvps} disabled={loading}>
|
||||
{loading ? '…' : 'Neu laden'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if lastError}
|
||||
<p class="error">{lastError}</p>
|
||||
{:else if rsvps.length === 0 && !loading}
|
||||
<p class="empty">Noch keine Antworten via Share-Link.</p>
|
||||
{:else}
|
||||
<ul class="rsvp-list">
|
||||
{#each rsvps as r (r.id)}
|
||||
<li class="rsvp-row">
|
||||
<div class="info">
|
||||
<div class="name-row">
|
||||
<span class="name">{r.name}</span>
|
||||
<span class="status status-{r.status}">
|
||||
{r.status === 'yes' ? 'Ja' : r.status === 'no' ? 'Nein' : 'Vielleicht'}
|
||||
</span>
|
||||
{#if r.plusOnes > 0}
|
||||
<span class="plus">+{r.plusOnes}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if r.email}
|
||||
<div class="email">{r.email}</div>
|
||||
{/if}
|
||||
{#if r.note}
|
||||
<div class="note">„{r.note}“</div>
|
||||
{/if}
|
||||
</div>
|
||||
<button class="import-btn" onclick={() => importToGuestList(r)} title="Zur Gästeliste">
|
||||
→
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
{#if lastFetchedAt}
|
||||
<div class="meta">
|
||||
Aktualisiert um {lastFetchedAt.toLocaleTimeString('de-DE', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})} · Auto-Refresh alle 30s
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.public-rsvps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.header-row h3 {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
.refresh {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.375rem;
|
||||
background: hsl(var(--color-card));
|
||||
color: hsl(var(--color-foreground));
|
||||
cursor: pointer;
|
||||
}
|
||||
.refresh:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: wait;
|
||||
}
|
||||
.error {
|
||||
margin: 0;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: rgb(220, 38, 38);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.empty {
|
||||
margin: 0;
|
||||
padding: 0.75rem;
|
||||
text-align: center;
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-style: italic;
|
||||
}
|
||||
.rsvp-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.rsvp-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-card));
|
||||
}
|
||||
.info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.name {
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
.status {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
.status-yes {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: rgb(22, 163, 74);
|
||||
}
|
||||
.status-no {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: rgb(220, 38, 38);
|
||||
}
|
||||
.status-maybe {
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
color: rgb(202, 138, 4);
|
||||
}
|
||||
.plus {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.email {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.note {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-style: italic;
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
.import-btn {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.375rem;
|
||||
background: transparent;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.import-btn:hover {
|
||||
background: hsl(var(--color-muted));
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
.meta {
|
||||
font-size: 0.6875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -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<string | null>(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<LocalSocialEvent>('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<LocalSocialEvent>('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);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
<GuestListEditor eventId={event.id} />
|
||||
</section>
|
||||
|
||||
{#if event.isPublished}
|
||||
<section class="section">
|
||||
<PublicRsvpList eventId={event.id} isPublished={event.isPublished} />
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<section class="section">
|
||||
<h2>Teilen</h2>
|
||||
{#if event.isPublished && event.publicToken}
|
||||
|
|
@ -163,8 +170,7 @@
|
|||
<button class="action-btn" onclick={handlePublish}>Privat machen</button>
|
||||
</div>
|
||||
<p class="share-hint">
|
||||
Phase 1a: Link funktioniert noch nicht öffentlich — Gäste-Antworten musst du noch manuell
|
||||
oben in der Gästeliste eintragen.
|
||||
Antworten erscheinen automatisch unten in „Antworten via Link“ (Polling alle 30s).
|
||||
</p>
|
||||
{:else}
|
||||
<button class="action-btn primary" onclick={handlePublish}>
|
||||
|
|
|
|||
54
apps/mana/apps/web/src/routes/rsvp/[token]/+page.server.ts
Normal file
54
apps/mana/apps/web/src/routes/rsvp/[token]/+page.server.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
/**
|
||||
* Public RSVP page — server-side load.
|
||||
*
|
||||
* Fetches the published event snapshot from mana-events. No auth required.
|
||||
*/
|
||||
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
const EVENTS_URL =
|
||||
process.env.PUBLIC_MANA_EVENTS_URL_CLIENT ||
|
||||
process.env.PUBLIC_MANA_EVENTS_URL ||
|
||||
'http://localhost:3065';
|
||||
|
||||
interface EventSnapshot {
|
||||
token: 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;
|
||||
}
|
||||
|
||||
interface RsvpSummary {
|
||||
yes: number;
|
||||
no: number;
|
||||
maybe: number;
|
||||
totalAttending: number;
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ params, fetch }) => {
|
||||
const token = params.token;
|
||||
if (!token) throw error(404, 'Not found');
|
||||
|
||||
try {
|
||||
const res = await fetch(`${EVENTS_URL}/api/v1/rsvp/${encodeURIComponent(token)}`);
|
||||
if (res.status === 404) throw error(404, 'Event nicht gefunden');
|
||||
if (!res.ok) throw error(500, 'Konnte Event nicht laden');
|
||||
const data = (await res.json()) as {
|
||||
event: EventSnapshot;
|
||||
summary: RsvpSummary | null;
|
||||
cancelled?: boolean;
|
||||
};
|
||||
return { token, ...data, eventsUrl: EVENTS_URL };
|
||||
} catch (e) {
|
||||
if (e && typeof e === 'object' && 'status' in e) throw e;
|
||||
throw error(500, 'Konnte Event nicht laden');
|
||||
}
|
||||
};
|
||||
416
apps/mana/apps/web/src/routes/rsvp/[token]/+page.svelte
Normal file
416
apps/mana/apps/web/src/routes/rsvp/[token]/+page.svelte
Normal file
|
|
@ -0,0 +1,416 @@
|
|||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import { getManaEventsUrl } from '$lib/api/config';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
let name = $state('');
|
||||
let email = $state('');
|
||||
let status = $state<'yes' | 'no' | 'maybe'>('yes');
|
||||
let plusOnes = $state(0);
|
||||
let note = $state('');
|
||||
let submitting = $state(false);
|
||||
let submitted = $state(false);
|
||||
let errorMessage = $state<string | null>(null);
|
||||
|
||||
const startDate = $derived(new Date(data.event.startAt));
|
||||
const dateLabel = $derived(
|
||||
startDate.toLocaleDateString('de-DE', {
|
||||
weekday: 'long',
|
||||
day: '2-digit',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})
|
||||
);
|
||||
const timeLabel = $derived(
|
||||
data.event.allDay
|
||||
? 'Ganztägig'
|
||||
: startDate.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
|
||||
);
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
if (submitting) return;
|
||||
submitting = true;
|
||||
errorMessage = null;
|
||||
try {
|
||||
const res = await fetch(`${getManaEventsUrl()}/api/v1/rsvp/${data.token}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: name.trim(),
|
||||
email: email.trim() || null,
|
||||
status,
|
||||
plusOnes,
|
||||
note: note.trim() || null,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ message: 'Fehler' }));
|
||||
throw new Error(err.message || `HTTP ${res.status}`);
|
||||
}
|
||||
submitted = true;
|
||||
} catch (e) {
|
||||
errorMessage = e instanceof Error ? e.message : 'Konnte nicht senden';
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.event.title} — RSVP</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="rsvp-page">
|
||||
<div class="card" style:border-top-color={data.event.color ?? '#f43f5e'}>
|
||||
<h1 class="title">{data.event.title}</h1>
|
||||
|
||||
<div class="meta">
|
||||
<div class="meta-row">
|
||||
<span class="icon">📅</span>
|
||||
<div>
|
||||
<div>{dateLabel}</div>
|
||||
<div class="muted">{timeLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
{#if data.event.location}
|
||||
<div class="meta-row">
|
||||
<span class="icon">📍</span>
|
||||
<div>
|
||||
{#if data.event.locationUrl}
|
||||
<a href={data.event.locationUrl} target="_blank" rel="noopener noreferrer">
|
||||
{data.event.location}
|
||||
</a>
|
||||
{:else}
|
||||
{data.event.location}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if data.summary}
|
||||
<div class="meta-row">
|
||||
<span class="icon">👥</span>
|
||||
<div>
|
||||
<strong>{data.summary.totalAttending}</strong> Personen kommen
|
||||
{#if data.event.capacity}
|
||||
<span class="muted">/ {data.event.capacity}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if data.event.description}
|
||||
<p class="description">{data.event.description}</p>
|
||||
{/if}
|
||||
|
||||
{#if data.cancelled}
|
||||
<div class="cancelled">⚠️ Dieses Event wurde abgesagt.</div>
|
||||
{:else if submitted}
|
||||
<div class="success">
|
||||
<h2>Danke für deine Antwort!</h2>
|
||||
<p>
|
||||
Du hast mit
|
||||
<strong>
|
||||
{status === 'yes' ? '„Ja, komme“' : status === 'no' ? '„Nein“' : '„Vielleicht“'}
|
||||
</strong>
|
||||
geantwortet. Du kannst diese Seite jederzeit erneut öffnen, um deine Antwort zu ändern.
|
||||
</p>
|
||||
<button
|
||||
class="action-btn"
|
||||
onclick={() => {
|
||||
submitted = false;
|
||||
}}
|
||||
>
|
||||
Antwort ändern
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<form class="rsvp-form" onsubmit={handleSubmit}>
|
||||
<h2>Sag bitte zu</h2>
|
||||
|
||||
<label class="field">
|
||||
<span class="label">Dein Name</span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={name}
|
||||
placeholder="z. B. Anna Schmidt"
|
||||
required
|
||||
maxlength="100"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span class="label">E-Mail (optional)</span>
|
||||
<input type="email" bind:value={email} placeholder="anna@example.com" maxlength="200" />
|
||||
</label>
|
||||
|
||||
<div class="field">
|
||||
<span class="label">Kommst du?</span>
|
||||
<div class="status-pills">
|
||||
<button
|
||||
type="button"
|
||||
class="pill"
|
||||
class:active={status === 'yes'}
|
||||
onclick={() => (status = 'yes')}
|
||||
>
|
||||
✓ Ja, komme
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pill"
|
||||
class:active={status === 'maybe'}
|
||||
onclick={() => (status = 'maybe')}
|
||||
>
|
||||
? Vielleicht
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pill"
|
||||
class:active={status === 'no'}
|
||||
onclick={() => (status = 'no')}
|
||||
>
|
||||
✕ Nein
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if status === 'yes'}
|
||||
<label class="field">
|
||||
<span class="label">Bringst du jemanden mit? ({plusOnes})</span>
|
||||
<input type="range" min="0" max="10" bind:value={plusOnes} />
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
<label class="field">
|
||||
<span class="label">Notiz (optional)</span>
|
||||
<textarea
|
||||
bind:value={note}
|
||||
placeholder="z. B. „Komme erst um 20 Uhr“"
|
||||
rows="2"
|
||||
maxlength="1000"
|
||||
></textarea>
|
||||
</label>
|
||||
|
||||
{#if errorMessage}
|
||||
<div class="error">{errorMessage}</div>
|
||||
{/if}
|
||||
|
||||
<button type="submit" class="submit-btn" disabled={submitting || !name.trim()}>
|
||||
{submitting ? 'Sende...' : 'Antwort senden'}
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<footer class="footer">
|
||||
Powered by <a href="https://mana.how" target="_blank" rel="noopener">Mana</a>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:global(body) {
|
||||
background: linear-gradient(135deg, #fef3f6 0%, #fde7ec 100%);
|
||||
}
|
||||
.rsvp-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
.card {
|
||||
width: 100%;
|
||||
max-width: 540px;
|
||||
background: white;
|
||||
border-radius: 1rem;
|
||||
border-top: 6px solid #f43f5e;
|
||||
box-shadow:
|
||||
0 4px 24px rgba(0, 0, 0, 0.06),
|
||||
0 1px 4px rgba(0, 0, 0, 0.04);
|
||||
padding: 2rem;
|
||||
}
|
||||
.title {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 1.875rem;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.25rem;
|
||||
padding-bottom: 1.25rem;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
.meta-row {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.9375rem;
|
||||
color: #374151;
|
||||
}
|
||||
.meta-row .icon {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
.meta-row a {
|
||||
color: #f43f5e;
|
||||
text-decoration: none;
|
||||
}
|
||||
.meta-row a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.muted {
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.description {
|
||||
margin: 0 0 1.5rem;
|
||||
font-size: 0.9375rem;
|
||||
color: #374151;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.cancelled {
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.rsvp-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.rsvp-form h2 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
}
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #6b7280;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.field input[type='text'],
|
||||
.field input[type='email'],
|
||||
.field textarea {
|
||||
padding: 0.625rem 0.875rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.5rem;
|
||||
background: white;
|
||||
font-size: 0.9375rem;
|
||||
color: #111827;
|
||||
font-family: inherit;
|
||||
}
|
||||
.field input:focus,
|
||||
.field textarea:focus {
|
||||
outline: none;
|
||||
border-color: #f43f5e;
|
||||
box-shadow: 0 0 0 3px rgba(244, 63, 94, 0.15);
|
||||
}
|
||||
.field textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
.status-pills {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.pill {
|
||||
flex: 1;
|
||||
padding: 0.625rem 0.5rem;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 0.625rem;
|
||||
background: white;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
color: #6b7280;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.pill:hover {
|
||||
border-color: #d1d5db;
|
||||
}
|
||||
.pill.active {
|
||||
border-color: #f43f5e;
|
||||
background: #fff1f3;
|
||||
color: #be123c;
|
||||
}
|
||||
.field input[type='range'] {
|
||||
width: 100%;
|
||||
accent-color: #f43f5e;
|
||||
}
|
||||
.error {
|
||||
padding: 0.625rem 0.875rem;
|
||||
border-radius: 0.5rem;
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.submit-btn {
|
||||
padding: 0.875rem;
|
||||
border: none;
|
||||
border-radius: 0.625rem;
|
||||
background: linear-gradient(135deg, #f43f5e, #be123c);
|
||||
color: white;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.submit-btn:hover:not(:disabled) {
|
||||
opacity: 0.95;
|
||||
}
|
||||
.submit-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.success {
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
background: #f0fdf4;
|
||||
border-radius: 0.625rem;
|
||||
color: #166534;
|
||||
}
|
||||
.success h2 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.25rem;
|
||||
color: #15803d;
|
||||
}
|
||||
.success p {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.action-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid #15803d;
|
||||
border-radius: 0.5rem;
|
||||
background: white;
|
||||
color: #15803d;
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 1.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
.footer a {
|
||||
color: #f43f5e;
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -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\"",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
16
services/mana-events/Dockerfile
Normal file
16
services/mana-events/Dockerfile
Normal file
|
|
@ -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"]
|
||||
165
services/mana-events/bun.lock
Normal file
165
services/mana-events/bun.lock
Normal file
|
|
@ -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=="],
|
||||
}
|
||||
}
|
||||
11
services/mana-events/drizzle.config.ts
Normal file
11
services/mana-events/drizzle.config.ts
Normal file
|
|
@ -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'],
|
||||
});
|
||||
24
services/mana-events/package.json
Normal file
24
services/mana-events/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
42
services/mana-events/src/config.ts
Normal file
42
services/mana-events/src/config.ts
Normal file
|
|
@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
19
services/mana-events/src/db/connection.ts
Normal file
19
services/mana-events/src/db/connection.ts
Normal file
|
|
@ -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<typeof drizzle<typeof schema>> | 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<typeof getDb>;
|
||||
84
services/mana-events/src/db/schema/events.ts
Normal file
84
services/mana-events/src/db/schema/events.ts
Normal file
|
|
@ -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),
|
||||
})
|
||||
);
|
||||
1
services/mana-events/src/db/schema/index.ts
Normal file
1
services/mana-events/src/db/schema/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './events';
|
||||
47
services/mana-events/src/index.ts
Normal file
47
services/mana-events/src/index.ts
Normal file
|
|
@ -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,
|
||||
};
|
||||
31
services/mana-events/src/lib/errors.ts
Normal file
31
services/mana-events/src/lib/errors.ts
Normal file
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
10
services/mana-events/src/middleware/error-handler.ts
Normal file
10
services/mana-events/src/middleware/error-handler.ts
Normal file
|
|
@ -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);
|
||||
};
|
||||
50
services/mana-events/src/middleware/jwt-auth.ts
Normal file
50
services/mana-events/src/middleware/jwt-auth.ts
Normal file
|
|
@ -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<typeof createRemoteJWKSet> | 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');
|
||||
}
|
||||
};
|
||||
}
|
||||
175
services/mana-events/src/routes/events.ts
Normal file
175
services/mana-events/src/routes/events.ts
Normal file
|
|
@ -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<typeof eventsPublished.$inferInsert> = { 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;
|
||||
}
|
||||
5
services/mana-events/src/routes/health.ts
Normal file
5
services/mana-events/src/routes/health.ts
Normal file
|
|
@ -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() })
|
||||
);
|
||||
169
services/mana-events/src/routes/rsvp.ts
Normal file
169
services/mana-events/src/routes/rsvp.ts
Normal file
|
|
@ -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<number>`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;
|
||||
}
|
||||
17
services/mana-events/tsconfig.json
Normal file
17
services/mana-events/tsconfig.json
Normal file
|
|
@ -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"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue