diff --git a/apps/mana/apps/web/e2e/events.spec.ts b/apps/mana/apps/web/e2e/events.spec.ts index 051c9ce88..9490bfb6e 100644 --- a/apps/mana/apps/web/e2e/events.spec.ts +++ b/apps/mana/apps/web/e2e/events.spec.ts @@ -49,13 +49,12 @@ test.describe('Events module — local flow', () => { await expect(page.getByRole('heading', { name: title })).toBeVisible(); await expect(page.getByText('Café am See')).toBeVisible(); - // 4. Add a guest - await page.getByPlaceholder('Name', { exact: false }).first().fill('Tante Erika'); - await page - .getByPlaceholder(/E-Mail/i) - .first() - .fill('erika@example.com'); - await page.getByRole('button', { name: 'Hinzufügen' }).click(); + // 4. Add a guest — scope to the guest editor since the bring-list + // editor below it uses the same "Hinzufügen" button text. + const guestEditor = page.locator('.guest-editor'); + await guestEditor.getByPlaceholder('Name', { exact: false }).fill('Tante Erika'); + await guestEditor.getByPlaceholder(/E-Mail/i).fill('erika@example.com'); + await guestEditor.getByRole('button', { name: 'Hinzufügen' }).click(); // Guest row should appear await expect(page.getByText('Tante Erika')).toBeVisible(); @@ -68,10 +67,10 @@ test.describe('Events module — local flow', () => { // Summary should reflect 1 yes / 1 attending (no plus-ones) await expect(page.locator('.rsvp-summary .badge.yes .count')).toHaveText('1'); - // Set 2 plus-ones - await page.locator('input[type="number"]').first().fill('2'); - // Blur to commit (onchange) - await page.locator('input[type="number"]').first().blur(); + // Set 2 plus-ones (scoped to the guest editor — bring list also has a number input) + const plusOnes = guestEditor.locator('input[type="number"]').first(); + await plusOnes.fill('2'); + await plusOnes.blur(); // totalAttending should now be 3 (1 + 2 plus-ones) await expect(page.locator('.rsvp-summary .total strong')).toHaveText('3'); diff --git a/apps/mana/apps/web/src/lib/data/database.ts b/apps/mana/apps/web/src/lib/data/database.ts index 8b78c1a85..9f9e7ab18 100644 --- a/apps/mana/apps/web/src/lib/data/database.ts +++ b/apps/mana/apps/web/src/lib/data/database.ts @@ -512,6 +512,16 @@ db.version(10).stores({ '++id, createdAt, appId, collection, recordId, op, [appId+createdAt], [collection+recordId], userId', }); +// ─── Version 11: Events bring-list (eventItems) ─────────────── +// Adds the "wer bringt was?" table attached to social events. +// `assignedGuestId` points at a local guest the host picked manually; +// `claimedByName` is set by a public RSVP visitor who reserved the +// item from the share-link page. + +db.version(11).stores({ + eventItems: 'id, eventId, assignedGuestId, done, order, [eventId+order], [eventId+done]', +}); + // ─── Sync Routing ────────────────────────────────────────── // SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE, // toSyncName() and fromSyncName() are now derived from per-module diff --git a/apps/mana/apps/web/src/lib/modules/events/api.ts b/apps/mana/apps/web/src/lib/modules/events/api.ts index 9e1c3c790..dec1e7644 100644 --- a/apps/mana/apps/web/src/lib/modules/events/api.ts +++ b/apps/mana/apps/web/src/lib/modules/events/api.ts @@ -48,6 +48,23 @@ async function fetchWithAuth(path: string, init: RequestInit = {}): Promise; } +export interface PublishedItemSnapshot { + id: string; + label: string; + quantity?: number | null; + order: number; + done?: boolean; +} + +export interface PublishedItemRecord { + id: string; + label: string; + quantity: number | null; + sortOrder: number; + done: boolean; + claimedByName: string | null; +} + export const eventsApi = { async publish(input: PublishedSnapshotInput): Promise<{ token: string; isNew: boolean }> { return fetchWithAuth('/api/v1/events/publish', { @@ -73,4 +90,18 @@ export const eventsApi = { async getRsvps(eventId: string): Promise<{ token: string; rsvps: PublicRsvpRecord[] }> { return fetchWithAuth(`/api/v1/events/${eventId}/rsvps`); }, + + async syncItems( + eventId: string, + items: PublishedItemSnapshot[] + ): Promise<{ ok: true; count: number }> { + return fetchWithAuth(`/api/v1/events/${eventId}/items`, { + method: 'PUT', + body: JSON.stringify({ items }), + }); + }, + + async getItems(eventId: string): Promise<{ items: PublishedItemRecord[] }> { + return fetchWithAuth(`/api/v1/events/${eventId}/items`); + }, }; diff --git a/apps/mana/apps/web/src/lib/modules/events/collections.ts b/apps/mana/apps/web/src/lib/modules/events/collections.ts index 48ce7401e..12dee0a7c 100644 --- a/apps/mana/apps/web/src/lib/modules/events/collections.ts +++ b/apps/mana/apps/web/src/lib/modules/events/collections.ts @@ -3,8 +3,14 @@ */ import { db } from '$lib/data/database'; -import type { LocalSocialEvent, LocalEventGuest, LocalEventInvitation } from './types'; +import type { + LocalSocialEvent, + LocalEventGuest, + LocalEventInvitation, + LocalEventItem, +} from './types'; export const socialEventTable = db.table('socialEvents'); export const eventGuestTable = db.table('eventGuests'); export const eventInvitationTable = db.table('eventInvitations'); +export const eventItemTable = db.table('eventItems'); diff --git a/apps/mana/apps/web/src/lib/modules/events/components/BringListEditor.svelte b/apps/mana/apps/web/src/lib/modules/events/components/BringListEditor.svelte new file mode 100644 index 000000000..bbced59c7 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/events/components/BringListEditor.svelte @@ -0,0 +1,232 @@ + + +
+
+ + + +
+ +
    + {#each items.value ?? [] as item (item.id)} + {@const assignee = assigneeLabel(item)} +
  • + + +
    +
    + {item.label} + {#if item.quantity} + ×{item.quantity} + {/if} +
    + {#if assignee} +
    → {assignee}
    + {/if} +
    + + + + +
  • + {/each} + + {#if (items.value ?? []).length === 0} +
  • Noch nichts auf der Liste.
  • + {/if} +
+
+ + diff --git a/apps/mana/apps/web/src/lib/modules/events/index.ts b/apps/mana/apps/web/src/lib/modules/events/index.ts index 46f9ca728..46435191c 100644 --- a/apps/mana/apps/web/src/lib/modules/events/index.ts +++ b/apps/mana/apps/web/src/lib/modules/events/index.ts @@ -3,4 +3,5 @@ export * from './collections'; export * from './queries'; export { eventsStore } from './stores/events.svelte'; export { eventGuestsStore } from './stores/guests.svelte'; +export { eventItemsStore } from './stores/items.svelte'; export { drainTombstones, recordTombstone } from './tombstones'; diff --git a/apps/mana/apps/web/src/lib/modules/events/module.config.ts b/apps/mana/apps/web/src/lib/modules/events/module.config.ts new file mode 100644 index 000000000..97bfac2d5 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/events/module.config.ts @@ -0,0 +1,12 @@ +import type { ModuleConfig } from '$lib/data/module-registry'; + +export const eventsModuleConfig: ModuleConfig = { + appId: 'events', + tables: [ + // `socialEvents` is renamed in unified DB to avoid collision with calendar.events. + { name: 'socialEvents', syncName: 'events' }, + { name: 'eventGuests' }, + { name: 'eventInvitations' }, + { name: 'eventItems' }, + ], +}; diff --git a/apps/mana/apps/web/src/lib/modules/events/queries.ts b/apps/mana/apps/web/src/lib/modules/events/queries.ts index 5bc4f493c..f419b694a 100644 --- a/apps/mana/apps/web/src/lib/modules/events/queries.ts +++ b/apps/mana/apps/web/src/lib/modules/events/queries.ts @@ -11,8 +11,10 @@ import type { LocalTimeBlock } from '$lib/data/time-blocks/types'; import type { LocalSocialEvent, LocalEventGuest, + LocalEventItem, SocialEvent, EventGuest, + EventItem, RsvpSummary, } from './types'; @@ -42,6 +44,23 @@ export function toSocialEvent(local: LocalSocialEvent, block: LocalTimeBlock | n }; } +export function toEventItem(local: LocalEventItem): EventItem { + const now = new Date().toISOString(); + return { + id: local.id, + eventId: local.eventId, + label: local.label, + quantity: local.quantity ?? null, + order: local.order ?? 0, + done: local.done ?? false, + assignedGuestId: local.assignedGuestId ?? null, + claimedByName: local.claimedByName ?? null, + claimedAt: local.claimedAt ?? null, + createdAt: local.createdAt ?? now, + updatedAt: local.updatedAt ?? now, + }; +} + export function toEventGuest(local: LocalEventGuest): EventGuest { const now = new Date().toISOString(); return { @@ -148,6 +167,23 @@ export function useEventGuests(eventId: () => string) { }, [] as EventGuest[]); } +/** Bring-list items for a single event, sorted by order. */ +export function useEventItems(eventId: () => string) { + return useLiveQueryWithDefault(async () => { + const id = eventId(); + if (!id) return []; + const items = await db + .table('eventItems') + .where('eventId') + .equals(id) + .toArray(); + return items + .filter((i) => !i.deletedAt) + .map(toEventItem) + .sort((a, b) => a.order - b.order); + }, [] as EventItem[]); +} + // ─── Pure Helpers ────────────────────────────────────────── export function summarizeRsvps(guests: EventGuest[]): RsvpSummary { 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 19ac068dc..f47b35347 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 @@ -8,7 +8,7 @@ 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 type { LocalSocialEvent, LocalEventItem, EventStatus } from '../types'; import { eventsApi } from '../api'; import { recordTombstone } from '../tombstones'; @@ -192,6 +192,9 @@ export const eventsStore = { status: 'published' satisfies EventStatus, updatedAt: new Date().toISOString(), }); + // Push any pre-existing bring-list items right away so the + // public page shows them on first open. + await this.syncItems(id); return { success: true as const, token }; } catch (e) { error = e instanceof Error ? e.message : 'Failed to publish event'; @@ -229,6 +232,7 @@ export const eventsStore = { /** * Push the latest local state of a published event to the server snapshot. * Called after an updateEvent() if the event is currently published. + * Also pushes the bring-list items so the public page stays in sync. */ async syncSnapshotIfPublished(id: string) { try { @@ -249,8 +253,39 @@ export const eventsStore = { color: event.color ?? null, capacity: event.capacity ?? null, }); + // Items are independent of the snapshot fields above but the + // host always wants them in sync after any edit. + await this.syncItems(id); } catch (e) { console.warn('Snapshot sync failed:', e); } }, + + /** + * Push the local bring list to the server snapshot. Safe to call + * for unpublished events — it just no-ops. + */ + async syncItems(id: string) { + try { + const event = await db.table('socialEvents').get(id); + if (!event || !event.isPublished) return; + const items = await db + .table('eventItems') + .where('eventId') + .equals(id) + .toArray(); + const payload = items + .filter((i) => !i.deletedAt) + .map((i) => ({ + id: i.id, + label: i.label, + quantity: i.quantity ?? null, + order: i.order ?? 0, + done: i.done ?? false, + })); + await eventsApi.syncItems(id, payload); + } catch (e) { + console.warn('Item sync failed:', e); + } + }, }; diff --git a/apps/mana/apps/web/src/lib/modules/events/stores/items.svelte.ts b/apps/mana/apps/web/src/lib/modules/events/stores/items.svelte.ts new file mode 100644 index 000000000..2ce6146b0 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/events/stores/items.svelte.ts @@ -0,0 +1,115 @@ +/** + * Bring-list items store — mutation-only service for the "wer bringt was?" + * list attached to an event. + */ + +import { db } from '$lib/data/database'; +import type { LocalEventItem } from '../types'; +import { eventsStore } from './events.svelte'; + +let error = $state(null); + +async function nextOrder(eventId: string): Promise { + const existing = await db + .table('eventItems') + .where('eventId') + .equals(eventId) + .toArray(); + const max = existing.filter((i) => !i.deletedAt).reduce((m, i) => Math.max(m, i.order ?? 0), -1); + return max + 1; +} + +export const eventItemsStore = { + get error() { + return error; + }, + + async addItem(input: { + eventId: string; + label: string; + quantity?: number | null; + assignedGuestId?: string | null; + }) { + error = null; + try { + const id = crypto.randomUUID(); + const order = await nextOrder(input.eventId); + const newItem: LocalEventItem = { + id, + eventId: input.eventId, + label: input.label.trim(), + quantity: input.quantity ?? null, + order, + done: false, + assignedGuestId: input.assignedGuestId ?? null, + claimedByName: null, + claimedAt: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + await db.table('eventItems').add(newItem); + void eventsStore.syncItems(input.eventId); + return { success: true as const, id }; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to add item'; + return { success: false as const, error }; + } + }, + + async updateItem( + id: string, + input: Partial<{ + label: string; + quantity: number | null; + done: boolean; + assignedGuestId: string | null; + claimedByName: string | null; + claimedAt: string | null; + }> + ) { + error = null; + try { + await db.table('eventItems').update(id, { + ...input, + updatedAt: new Date().toISOString(), + }); + // Push the updated bring list to the server. We need the + // parent eventId, so re-read the row first. + const item = await db.table('eventItems').get(id); + if (item) void eventsStore.syncItems(item.eventId); + return { success: true as const }; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to update item'; + return { success: false as const, error }; + } + }, + + async toggleDone(id: string, done: boolean) { + return this.updateItem(id, { done }); + }, + + async assign(id: string, guestId: string | null) { + return this.updateItem(id, { + assignedGuestId: guestId, + // Clearing the public-claim when the host takes over the assignment + // avoids double-counting in the host's view. + ...(guestId ? { claimedByName: null, claimedAt: null } : {}), + }); + }, + + async deleteItem(id: string) { + error = null; + try { + const item = await db.table('eventItems').get(id); + await db.table('eventItems').update(id, { + deletedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + if (item) void eventsStore.syncItems(item.eventId); + return { success: true as const }; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to delete item'; + return { success: false as const, error }; + } + }, +}; diff --git a/apps/mana/apps/web/src/lib/modules/events/types.ts b/apps/mana/apps/web/src/lib/modules/events/types.ts index f968a50d4..a4c19cc5c 100644 --- a/apps/mana/apps/web/src/lib/modules/events/types.ts +++ b/apps/mana/apps/web/src/lib/modules/events/types.ts @@ -52,6 +52,19 @@ export interface LocalEventInvitation extends BaseRecord { token: string; } +export interface LocalEventItem extends BaseRecord { + eventId: string; + label: string; + quantity?: number | null; + order: number; + done: boolean; + // Either a local guest the host assigned… + assignedGuestId?: string | null; + // …or a public visitor who claimed it via the share link. + claimedByName?: string | null; + claimedAt?: string | null; +} + // ─── Domain (UI-facing) ──────────────────────────────────── export interface SocialEvent { @@ -97,3 +110,17 @@ export interface RsvpSummary { pending: number; totalAttending: number; // yes + plusOnes } + +export interface EventItem { + id: string; + eventId: string; + label: string; + quantity: number | null; + order: number; + done: boolean; + assignedGuestId: string | null; + claimedByName: string | null; + claimedAt: string | null; + createdAt: string; + updatedAt: string; +} 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 c291b045e..9ee23151e 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 @@ -4,6 +4,7 @@ import GuestListEditor from '../components/GuestListEditor.svelte'; import RsvpSummaryView from '../components/RsvpSummary.svelte'; import PublicRsvpList from '../components/PublicRsvpList.svelte'; + import BringListEditor from '../components/BringListEditor.svelte'; interface Props { eventId: string; @@ -166,6 +167,11 @@ +
+

Bring-Liste

+ +
+ {#if event.isPublished}
diff --git a/apps/mana/apps/web/src/routes/rsvp/[token]/+page.server.ts b/apps/mana/apps/web/src/routes/rsvp/[token]/+page.server.ts index 16e6c52b5..8e36ac7f2 100644 --- a/apps/mana/apps/web/src/routes/rsvp/[token]/+page.server.ts +++ b/apps/mana/apps/web/src/routes/rsvp/[token]/+page.server.ts @@ -48,6 +48,15 @@ interface RsvpSummary { totalAttending: number; } +interface BringItem { + id: string; + label: string; + quantity: number | null; + sortOrder: number; + done: boolean; + claimedByName: string | null; +} + export const load: PageServerLoad = async ({ params, fetch, request }) => { const token = params.token; if (!token) throw error(404, 'Not found'); @@ -78,8 +87,9 @@ export const load: PageServerLoad = async ({ params, fetch, request }) => { event: EventSnapshot; summary: RsvpSummary | null; cancelled?: boolean; + items?: BringItem[]; }; - return { token, ...data, eventsUrl: EVENTS_URL, lang }; + return { token, ...data, items: data.items ?? [], eventsUrl: EVENTS_URL, lang }; } catch (e) { if (e && typeof e === 'object' && 'status' in e) throw e; throw error(500, errorMsg); diff --git a/apps/mana/apps/web/src/routes/rsvp/[token]/+page.svelte b/apps/mana/apps/web/src/routes/rsvp/[token]/+page.svelte index 644668e46..a858e1735 100644 --- a/apps/mana/apps/web/src/routes/rsvp/[token]/+page.svelte +++ b/apps/mana/apps/web/src/routes/rsvp/[token]/+page.svelte @@ -15,6 +15,48 @@ let submitted = $state(false); let errorMessage = $state(null); + // Local mirror of items so claims can update the UI without a page + // reload. SSR data is the initial source of truth. + let items = $state(data.items); + let claimingItemId = $state(null); + let claimError = $state(null); + + async function claimItem(itemId: string) { + if (claimingItemId) return; + const claimerName = window.prompt(t.claimNamePrompt, name)?.trim(); + if (!claimerName) return; + + claimingItemId = itemId; + claimError = null; + try { + const res = await fetch( + `${getManaEventsUrl()}/api/v1/rsvp/${data.token}/items/${itemId}/claim`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: claimerName }), + } + ); + if (!res.ok) { + if (res.status === 400) { + claimError = t.claimAlreadyTaken; + } else { + const err = await res.json().catch(() => ({ message: 'Fehler' })); + throw new Error(err.message || `HTTP ${res.status}`); + } + return; + } + // Optimistically reflect the claim locally + items = items.map((it) => (it.id === itemId ? { ...it, claimedByName: claimerName } : it)); + // Pre-fill the RSVP name field for convenience + if (!name) name = claimerName; + } catch (e) { + claimError = e instanceof Error ? e.message : t.genericError; + } finally { + claimingItemId = null; + } + } + const startDate = $derived(new Date(data.event.startAt)); const dateLabel = $derived( startDate.toLocaleDateString(t.dateLocale, { @@ -108,6 +150,40 @@

{data.event.description}

{/if} + {#if items.length > 0 && !data.cancelled} +
+

{t.bringListHeading}

+
    + {#each items as item (item.id)} +
  • +
    + {item.label} + {#if item.quantity} + ×{item.quantity} + {/if} + {#if item.claimedByName} + {t.claimedBy(item.claimedByName)} + {/if} +
    + {#if !item.claimedByName} + + {/if} +
  • + {/each} +
+ {#if claimError} +

{claimError}

+ {/if} +
+ {/if} + {#if data.cancelled}
{t.cancelledNotice}
{:else if submitted} @@ -275,6 +351,89 @@ white-space: pre-wrap; line-height: 1.5; } + .bring-list { + margin: 0 0 1.5rem; + padding: 1rem; + background: #fef9fb; + border: 1px solid #fce4eb; + border-radius: 0.625rem; + } + .bring-list h2 { + margin: 0 0 0.625rem; + font-size: 0.8125rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #be123c; + } + .bring-list ul { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 0.375rem; + } + .bring-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + background: white; + border: 1px solid #fce4eb; + border-radius: 0.5rem; + } + .bring-item.claimed { + background: #f9fafb; + border-color: #e5e7eb; + } + .bring-info { + flex: 1; + display: flex; + flex-wrap: wrap; + gap: 0.375rem; + align-items: baseline; + font-size: 0.875rem; + } + .bring-label { + font-weight: 500; + color: #111827; + } + .bring-item.claimed .bring-label { + color: #6b7280; + } + .bring-qty { + font-size: 0.75rem; + color: #6b7280; + } + .bring-claimed { + font-size: 0.75rem; + color: #16a34a; + font-style: italic; + } + .claim-btn { + padding: 0.375rem 0.75rem; + border: 1px solid #f43f5e; + border-radius: 0.375rem; + background: white; + color: #be123c; + font-size: 0.75rem; + font-weight: 600; + cursor: pointer; + } + .claim-btn:hover:not(:disabled) { + background: #fff1f3; + } + .claim-btn:disabled { + opacity: 0.5; + cursor: wait; + } + .claim-error { + margin: 0.5rem 0 0; + font-size: 0.75rem; + color: #dc2626; + } + .cancelled { padding: 1rem; border-radius: 0.5rem; diff --git a/apps/mana/apps/web/src/routes/rsvp/[token]/strings.ts b/apps/mana/apps/web/src/routes/rsvp/[token]/strings.ts index 4b7bf6d13..9f6398aa4 100644 --- a/apps/mana/apps/web/src/routes/rsvp/[token]/strings.ts +++ b/apps/mana/apps/web/src/routes/rsvp/[token]/strings.ts @@ -35,6 +35,12 @@ interface Strings { genericError: string; poweredBy: string; dateLocale: string; + bringListHeading: string; + bringListEmpty: string; + claimButton: string; + claimedBy: (name: string) => string; + claimNamePrompt: string; + claimAlreadyTaken: string; } const DICTS: Record = { @@ -69,6 +75,12 @@ const DICTS: Record = { genericError: 'Konnte nicht senden', poweredBy: 'Powered by', dateLocale: 'de-DE', + bringListHeading: 'Wer bringt was?', + bringListEmpty: 'Noch nichts auf der Liste.', + claimButton: 'Übernehmen', + claimedBy: (name) => `${name} bringt das mit`, + claimNamePrompt: 'Wie heißt du? Wir reservieren das Item für dich:', + claimAlreadyTaken: 'Schon vergeben — bitte Seite neu laden.', }, en: { rsvpTitle: 'Please RSVP', @@ -100,6 +112,12 @@ const DICTS: Record = { genericError: 'Could not send', poweredBy: 'Powered by', dateLocale: 'en-US', + bringListHeading: 'Who brings what?', + bringListEmpty: 'Nothing on the list yet.', + claimButton: 'Claim', + claimedBy: (name) => `${name} is bringing this`, + claimNamePrompt: "What's your name? We'll reserve the item for you:", + claimAlreadyTaken: 'Already taken — please reload the page.', }, it: { rsvpTitle: 'Conferma la tua presenza', @@ -132,6 +150,12 @@ const DICTS: Record = { genericError: 'Impossibile inviare', poweredBy: 'Powered by', dateLocale: 'it-IT', + bringListHeading: 'Chi porta cosa?', + bringListEmpty: 'Nulla nella lista per ora.', + claimButton: 'Riserva', + claimedBy: (name) => `${name} lo porta`, + claimNamePrompt: 'Come ti chiami? Riserveremo l’oggetto per te:', + claimAlreadyTaken: 'Già preso — ricarica la pagina.', }, fr: { rsvpTitle: 'Confirmez votre présence', @@ -163,6 +187,12 @@ const DICTS: Record = { genericError: 'Impossible d’envoyer', poweredBy: 'Propulsé par', dateLocale: 'fr-FR', + bringListHeading: 'Qui apporte quoi ?', + bringListEmpty: 'Rien sur la liste pour l’instant.', + claimButton: 'Je m’en charge', + claimedBy: (name) => `${name} l’apporte`, + claimNamePrompt: 'Quel est ton nom ? Nous te réservons l’élément :', + claimAlreadyTaken: 'Déjà pris — recharge la page.', }, es: { rsvpTitle: 'Confirma tu asistencia', @@ -195,6 +225,12 @@ const DICTS: Record = { genericError: 'No se pudo enviar', poweredBy: 'Powered by', dateLocale: 'es-ES', + bringListHeading: '¿Quién trae qué?', + bringListEmpty: 'Nada en la lista todavía.', + claimButton: 'Lo traigo yo', + claimedBy: (name) => `${name} lo trae`, + claimNamePrompt: '¿Cómo te llamas? Te reservamos el artículo:', + claimAlreadyTaken: 'Ya lo cogió alguien — recarga la página.', }, }; diff --git a/services/mana-events/src/__tests__/items.test.ts b/services/mana-events/src/__tests__/items.test.ts new file mode 100644 index 000000000..6fcfa3737 --- /dev/null +++ b/services/mana-events/src/__tests__/items.test.ts @@ -0,0 +1,343 @@ +/** + * Bring-list endpoint tests — host PUT/GET /events/:id/items and the + * public POST /rsvp/:token/items/:itemId/claim flow. + */ + +import { describe, it, expect, beforeEach, afterAll } from 'bun:test'; +import { sql } from 'drizzle-orm'; +import { + buildTestApp, + authedRequest, + publicRequest, + jsonBody, + TEST_USER_ID, + OTHER_USER_ID, +} from './helpers'; + +const app = buildTestApp(); + +const futureIso = (daysAhead: number) => + new Date(Date.now() + daysAhead * 24 * 60 * 60 * 1000).toISOString(); + +const EVENT_ID = '00000000-0000-0000-0000-0000000ffeed'; + +async function publishEvent(userId = TEST_USER_ID) { + const res = await app.fetch( + authedRequest('http://test/api/v1/events/publish', { + method: 'POST', + user: userId, + body: jsonBody({ + eventId: EVENT_ID, + title: 'Bring test', + startAt: futureIso(7), + }), + }) + ); + const body = (await res.json()) as { token: string }; + return body.token; +} + +beforeEach(async () => { + await app.wipe(); +}); + +afterAll(async () => { + await app.wipe(); +}); + +// ─── PUT /events/:id/items ──────────────────────────────────────── + +describe('PUT /api/v1/events/:eventId/items', () => { + it('rejects unauthenticated callers with 401', async () => { + const res = await app.fetch( + new Request(`http://test/api/v1/events/${EVENT_ID}/items`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: jsonBody({ items: [] }), + }) + ); + expect(res.status).toBe(401); + }); + + it('rejects items for unpublished events with 404', async () => { + const res = await app.fetch( + authedRequest(`http://test/api/v1/events/${EVENT_ID}/items`, { + method: 'PUT', + body: jsonBody({ items: [{ id: 'a', label: 'A', order: 0 }] }), + }) + ); + expect(res.status).toBe(404); + }); + + it('inserts new items on first push', async () => { + await publishEvent(); + const res = await app.fetch( + authedRequest(`http://test/api/v1/events/${EVENT_ID}/items`, { + method: 'PUT', + body: jsonBody({ + items: [ + { id: 'item-a', label: 'Salat', quantity: 2, order: 0 }, + { id: 'item-b', label: 'Wein', order: 1 }, + ], + }), + }) + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { count: number }; + expect(body.count).toBe(2); + + const get = await app.fetch(authedRequest(`http://test/api/v1/events/${EVENT_ID}/items`)); + const list = (await get.json()) as { + items: { label: string; quantity: number | null }[]; + }; + expect(list.items.length).toBe(2); + expect(list.items.map((i) => i.label).sort()).toEqual(['Salat', 'Wein']); + }); + + it('updates existing items in place + deletes ones the host removed', async () => { + await publishEvent(); + // Initial push + await app.fetch( + authedRequest(`http://test/api/v1/events/${EVENT_ID}/items`, { + method: 'PUT', + body: jsonBody({ + items: [ + { id: 'a', label: 'Salat', order: 0 }, + { id: 'b', label: 'Wein', order: 1 }, + ], + }), + }) + ); + // Second push: rename a, drop b, add c + await app.fetch( + authedRequest(`http://test/api/v1/events/${EVENT_ID}/items`, { + method: 'PUT', + body: jsonBody({ + items: [ + { id: 'a', label: 'Großer Salat', order: 0 }, + { id: 'c', label: 'Brot', order: 1 }, + ], + }), + }) + ); + + const list = await app.fetch(authedRequest(`http://test/api/v1/events/${EVENT_ID}/items`)); + const body = (await list.json()) as { items: { id: string; label: string }[] }; + expect(body.items.length).toBe(2); + const byId = new Map(body.items.map((i) => [i.id, i.label])); + expect(byId.get('a')).toBe('Großer Salat'); + expect(byId.get('c')).toBe('Brot'); + expect(byId.has('b')).toBe(false); + }); + + it('preserves an existing claimed_by_name across host edits', async () => { + const token = await publishEvent(); + await app.fetch( + authedRequest(`http://test/api/v1/events/${EVENT_ID}/items`, { + method: 'PUT', + body: jsonBody({ items: [{ id: 'a', label: 'Salat', order: 0 }] }), + }) + ); + // A guest claims it + await app.fetch( + publicRequest(`http://test/api/v1/rsvp/${token}/items/a/claim`, { + method: 'POST', + body: jsonBody({ name: 'Anna' }), + }) + ); + // Host renames the item + await app.fetch( + authedRequest(`http://test/api/v1/events/${EVENT_ID}/items`, { + method: 'PUT', + body: jsonBody({ items: [{ id: 'a', label: 'Großer Salat', order: 0 }] }), + }) + ); + + const rows = await app.db.execute<{ claimed_by_name: string | null }>( + sql`SELECT claimed_by_name FROM events.event_items_published WHERE id = 'a'` + ); + expect(rows[0]?.claimed_by_name).toBe('Anna'); + }); + + it('rejects attempts to push items for someone else’s event with 403', async () => { + await publishEvent(); + const res = await app.fetch( + authedRequest(`http://test/api/v1/events/${EVENT_ID}/items`, { + method: 'PUT', + user: OTHER_USER_ID, + body: jsonBody({ items: [{ id: 'x', label: 'Hijack', order: 0 }] }), + }) + ); + expect(res.status).toBe(403); + }); + + it('rejects payloads with too many items (max 100)', async () => { + await publishEvent(); + const items = Array.from({ length: 101 }).map((_, i) => ({ + id: `i${i}`, + label: `Item ${i}`, + order: i, + })); + const res = await app.fetch( + authedRequest(`http://test/api/v1/events/${EVENT_ID}/items`, { + method: 'PUT', + body: jsonBody({ items }), + }) + ); + expect(res.status).toBe(400); + }); + + it('cascade-deletes items when the snapshot is deleted', async () => { + await publishEvent(); + await app.fetch( + authedRequest(`http://test/api/v1/events/${EVENT_ID}/items`, { + method: 'PUT', + body: jsonBody({ items: [{ id: 'a', label: 'Salat', order: 0 }] }), + }) + ); + await app.fetch(authedRequest(`http://test/api/v1/events/${EVENT_ID}`, { method: 'DELETE' })); + const rows = await app.db.execute<{ count: number }>( + sql`SELECT count(*)::int AS count FROM events.event_items_published WHERE id = 'a'` + ); + expect(rows[0]?.count).toBe(0); + }); +}); + +// ─── GET /rsvp/:token (now also returns items) ──────────────────── + +describe('GET /api/v1/rsvp/:token (with items)', () => { + it('exposes the bring list ordered by sort_order', async () => { + const token = await publishEvent(); + await app.fetch( + authedRequest(`http://test/api/v1/events/${EVENT_ID}/items`, { + method: 'PUT', + body: jsonBody({ + items: [ + { id: 'b', label: 'Wein', order: 1 }, + { id: 'a', label: 'Salat', order: 0 }, + ], + }), + }) + ); + + const res = await app.fetch(publicRequest(`http://test/api/v1/rsvp/${token}`)); + const body = (await res.json()) as { + items: { id: string; label: string; sortOrder: number; claimedByName: string | null }[]; + }; + expect(body.items.length).toBe(2); + expect(body.items.map((i) => i.id)).toEqual(['a', 'b']); + }); + + it('returns an empty items array when nothing on the list', async () => { + await publishEvent(); + const token = ( + await app.db.execute<{ token: string }>( + sql`SELECT token FROM events.events_published WHERE event_id = ${EVENT_ID}` + ) + )[0]!.token; + const res = await app.fetch(publicRequest(`http://test/api/v1/rsvp/${token}`)); + const body = (await res.json()) as { items: unknown[] }; + expect(body.items).toEqual([]); + }); +}); + +// ─── POST /rsvp/:token/items/:itemId/claim ──────────────────────── + +describe('POST /api/v1/rsvp/:token/items/:itemId/claim', () => { + let token: string; + + beforeEach(async () => { + token = await publishEvent(); + await app.fetch( + authedRequest(`http://test/api/v1/events/${EVENT_ID}/items`, { + method: 'PUT', + body: jsonBody({ + items: [ + { id: 'salat', label: 'Salat', order: 0 }, + { id: 'wein', label: 'Wein', order: 1 }, + ], + }), + }) + ); + }); + + it('claims an unclaimed item and stores the name', async () => { + const res = await app.fetch( + publicRequest(`http://test/api/v1/rsvp/${token}/items/salat/claim`, { + method: 'POST', + body: jsonBody({ name: 'Anna' }), + }) + ); + expect(res.status).toBe(200); + + const get = await app.fetch(publicRequest(`http://test/api/v1/rsvp/${token}`)); + const body = (await get.json()) as { + items: { id: string; claimedByName: string | null }[]; + }; + expect(body.items.find((i) => i.id === 'salat')?.claimedByName).toBe('Anna'); + }); + + it('rejects a second claim on the same item with 400', async () => { + await app.fetch( + publicRequest(`http://test/api/v1/rsvp/${token}/items/salat/claim`, { + method: 'POST', + body: jsonBody({ name: 'Anna' }), + }) + ); + const res = await app.fetch( + publicRequest(`http://test/api/v1/rsvp/${token}/items/salat/claim`, { + method: 'POST', + body: jsonBody({ name: 'Bob' }), + }) + ); + expect(res.status).toBe(400); + }); + + it('rejects claiming an item from a different token with 404', async () => { + // Seed a second event + const secondEventId = '00000000-0000-0000-0000-000000ffeed1'; + const secondTokenRes = await app.fetch( + authedRequest('http://test/api/v1/events/publish', { + method: 'POST', + body: jsonBody({ + eventId: secondEventId, + title: 'Other event', + startAt: futureIso(14), + }), + }) + ); + const { token: otherToken } = (await secondTokenRes.json()) as { token: string }; + + // Try to claim "salat" (which belongs to the FIRST token) via the other token + const res = await app.fetch( + publicRequest(`http://test/api/v1/rsvp/${otherToken}/items/salat/claim`, { + method: 'POST', + body: jsonBody({ name: 'X' }), + }) + ); + expect(res.status).toBe(404); + }); + + it('rejects claims to a cancelled event with 400', async () => { + await app.db.execute( + sql`UPDATE events.events_published SET is_cancelled = true WHERE token = ${token}` + ); + const res = await app.fetch( + publicRequest(`http://test/api/v1/rsvp/${token}/items/salat/claim`, { + method: 'POST', + body: jsonBody({ name: 'Anna' }), + }) + ); + expect(res.status).toBe(400); + }); + + it('rejects malformed bodies with 400', async () => { + const res = await app.fetch( + publicRequest(`http://test/api/v1/rsvp/${token}/items/salat/claim`, { + method: 'POST', + body: jsonBody({}), + }) + ); + expect(res.status).toBe(400); + }); +}); diff --git a/services/mana-events/src/db/schema/events.ts b/services/mana-events/src/db/schema/events.ts index 0172c4f00..d5d947897 100644 --- a/services/mana-events/src/db/schema/events.ts +++ b/services/mana-events/src/db/schema/events.ts @@ -70,6 +70,37 @@ export const publicRsvps = eventsSchema.table( }) ); +/** + * Bring-list items attached to a published event. The host pushes the + * full list whenever it changes (small payload). Each row is owned by + * its parent events_published row via FK cascade so it disappears + * when the snapshot is deleted. + * + * `claimed_by_name` is set when a public RSVP visitor reserves the + * item from the share-link page. Only one claim per item — we don't + * support unclaim-then-reclaim conflict resolution; the host can + * always overwrite via a republish. + */ +export const eventItemsPublished = eventsSchema.table( + 'event_items_published', + { + id: text('id').primaryKey(), + token: text('token') + .notNull() + .references(() => eventsPublished.token, { onDelete: 'cascade' }), + label: text('label').notNull(), + quantity: integer('quantity'), + sortOrder: integer('sort_order').default(0).notNull(), + done: boolean('done').default(false).notNull(), + claimedByName: text('claimed_by_name'), + claimedAt: timestamp('claimed_at', { withTimezone: true }), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + }, + (t) => ({ + tokenIdx: index('event_items_published_token_idx').on(t.token), + }) +); + /** Per-token rate limit bucket — token + hour-bucket → submission count. */ export const rsvpRateBuckets = eventsSchema.table( 'rsvp_rate_buckets', diff --git a/services/mana-events/src/routes/events.ts b/services/mana-events/src/routes/events.ts index 36676062e..c6e3c37ec 100644 --- a/services/mana-events/src/routes/events.ts +++ b/services/mana-events/src/routes/events.ts @@ -9,7 +9,7 @@ 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 { eventsPublished, publicRsvps, eventItemsPublished } from '../db/schema/events'; import { ForbiddenError, NotFoundError, BadRequestError } from '../lib/errors'; import type { AuthUser } from '../middleware/jwt-auth'; @@ -31,6 +31,20 @@ const snapshotUpdateSchema = snapshotSchema.partial().extend({ eventId: z.string().uuid(), // still required so we can verify ownership }); +const itemsBodySchema = z.object({ + items: z + .array( + z.object({ + id: z.string().min(1).max(64), + label: z.string().min(1).max(200), + quantity: z.number().int().positive().nullable().optional(), + order: z.number().int().min(0), + done: z.boolean().optional(), + }) + ) + .max(100), +}); + function generateToken(): string { // 24-char URL-safe random const bytes = new Uint8Array(18); @@ -152,6 +166,92 @@ export function createEventsRoutes(db: Database) { return c.json({ deleted: true }); }); + // PUT /events/:eventId/items — full-replace the bring list snapshot. + // Items the host doesn't include get deleted (cascade picks them up + // only via snapshot delete, so we need an explicit prune here). + app.put('/:eventId/items', async (c) => { + const user = c.get('user'); + const eventId = c.req.param('eventId'); + const body = await c.req.json().catch(() => null); + const parsed = itemsBodySchema.safeParse(body); + 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 token = existing[0].token; + const now = new Date(); + const incomingIds = new Set(parsed.data.items.map((i) => i.id)); + + // Load currently-stored items so we can preserve `claimed_by_name` + // across host edits — the host shouldn't accidentally wipe a public + // guest's claim just because they renamed an item. + const existingItems = await db + .select() + .from(eventItemsPublished) + .where(eq(eventItemsPublished.token, token)); + const existingById = new Map(existingItems.map((it) => [it.id, it])); + + // Delete items the host removed + for (const it of existingItems) { + if (!incomingIds.has(it.id)) { + await db.delete(eventItemsPublished).where(eq(eventItemsPublished.id, it.id)); + } + } + + // Upsert each incoming item + for (const item of parsed.data.items) { + const prior = existingById.get(item.id); + if (prior) { + await db + .update(eventItemsPublished) + .set({ + label: item.label, + quantity: item.quantity ?? null, + sortOrder: item.order, + done: item.done ?? false, + updatedAt: now, + }) + .where(eq(eventItemsPublished.id, item.id)); + } else { + await db.insert(eventItemsPublished).values({ + id: item.id, + token, + label: item.label, + quantity: item.quantity ?? null, + sortOrder: item.order, + done: item.done ?? false, + }); + } + } + + return c.json({ ok: true, count: parsed.data.items.length }); + }); + + // GET /events/:eventId/items — read back items + claims for the host + app.get('/:eventId/items', 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 items = await db + .select() + .from(eventItemsPublished) + .where(eq(eventItemsPublished.token, existing[0].token)); + return c.json({ items }); + }); + // GET /events/:eventId/rsvps — list all RSVPs for the host app.get('/:eventId/rsvps', async (c) => { const user = c.get('user'); diff --git a/services/mana-events/src/routes/rsvp.ts b/services/mana-events/src/routes/rsvp.ts index 613890d40..e076b35ae 100644 --- a/services/mana-events/src/routes/rsvp.ts +++ b/services/mana-events/src/routes/rsvp.ts @@ -9,7 +9,12 @@ 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 { + eventsPublished, + publicRsvps, + rsvpRateBuckets, + eventItemsPublished, +} from '../db/schema/events'; import { NotFoundError, BadRequestError, TooManyRequestsError } from '../lib/errors'; import type { Config } from '../config'; @@ -59,6 +64,21 @@ export function createRsvpRoutes(db: Database, config: Config) { else if (r.status === 'maybe') summary.maybe++; } + // Public bring-list. Only the visitor's own claim name is included + // — that's the same name they typed when claiming, so no PII leak. + const items = await db + .select({ + id: eventItemsPublished.id, + label: eventItemsPublished.label, + quantity: eventItemsPublished.quantity, + sortOrder: eventItemsPublished.sortOrder, + done: eventItemsPublished.done, + claimedByName: eventItemsPublished.claimedByName, + }) + .from(eventItemsPublished) + .where(eq(eventItemsPublished.token, token)); + items.sort((a, b) => a.sortOrder - b.sortOrder); + return c.json({ event: { token: event.token, @@ -74,9 +94,80 @@ export function createRsvpRoutes(db: Database, config: Config) { capacity: event.capacity, }, summary, + items, }); }); + // POST /rsvp/:token/items/:itemId/claim — public bring-list claim. + // First-write wins. No auth, but rate-limited via the same per-token + // hourly bucket as RSVPs to keep the abuse surface uniform. + const claimBodySchema = z.object({ + name: z.string().min(1).max(100), + }); + + app.post('/:token/items/:itemId/claim', async (c) => { + const token = c.req.param('token'); + const itemId = c.req.param('itemId'); + const body = await c.req.json().catch(() => null); + const parsed = claimBodySchema.safeParse(body); + if (!parsed.success) throw new BadRequestError(parsed.error.issues[0]?.message ?? 'Invalid'); + + 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'); + + // Per-token hourly rate limit (shared with RSVP submissions) + 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'); + } + + // Verify the item exists and belongs to this token (cross-token + // claims would be a quiet authz hole otherwise). + const itemRows = await db + .select() + .from(eventItemsPublished) + .where(and(eq(eventItemsPublished.id, itemId), eq(eventItemsPublished.token, token))) + .limit(1); + const item = itemRows[0]; + if (!item) throw new NotFoundError('Item not found'); + if (item.claimedByName) { + throw new BadRequestError('Item already claimed'); + } + + await db + .update(eventItemsPublished) + .set({ + claimedByName: parsed.data.name, + claimedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(eventItemsPublished.id, itemId)); + + // 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 }); + }); + // POST /rsvp/:token — submit/update an RSVP app.post('/:token', async (c) => { const token = c.req.param('token');