feat(events): bring list (wer bringt was?) — Phase 2

Add an "eventItems" mini-collection attached to each social event so
hosts can track what each guest is bringing, and so public visitors
on the share-link page can claim an item without an account.

Local-first side
- New eventItems table (Dexie v11), module config update for sync.
- LocalEventItem type + EventItem domain type, useEventItems query.
- eventItemsStore: addItem / updateItem / toggleDone / assign /
  deleteItem. Every mutation pushes the full list to the server
  snapshot via eventsStore.syncItems if the event is published.
- BringListEditor component on the host DetailView with assign-to-
  guest dropdown, quantity, and done-checkbox.
- eventsStore.syncItems + a syncItems call in publishEvent so the
  public page sees pre-existing items as soon as the event ships.

Server side
- New event_items_published table (FK cascade from events_published
  so unpublishing wipes the bring list along with the snapshot).
- Host endpoints PUT/GET /events/:eventId/items: full-replace upsert
  that preserves any existing claimed_by_name across host edits, max
  100 items, ownership check.
- Public POST /rsvp/:token/items/:itemId/claim: name-only claim, 1×
  per item (first write wins), shares the per-token hourly rate
  bucket with RSVP submissions to keep the abuse surface uniform.
- GET /rsvp/:token now also returns the bring list (sorted) so the
  public page renders in a single round-trip.

Public RSVP page
- Renders the bring list with claim buttons; clicking prompts for a
  name and POSTs the claim, then optimistically updates the UI.
- New bring-list i18n keys for all five locales (de/en/it/fr/es).

Tests
- 15 new server tests covering host PUT/GET (insert / update / prune /
  ownership / claimed-name preservation / cascade), GET /rsvp item
  exposure, and POST /claim (success / double-claim / cross-token /
  cancelled / validation). 50 server tests total, all green.
- E2E spec scoped to .guest-editor where the new BringListEditor
  introduced a duplicate "Hinzufügen" button label.
This commit is contained in:
Till JS 2026-04-07 19:31:39 +02:00
parent af92720a62
commit 6a60e22a31
19 changed files with 1296 additions and 16 deletions

View file

@ -49,13 +49,12 @@ test.describe('Events module — local flow', () => {
await expect(page.getByRole('heading', { name: title })).toBeVisible(); await expect(page.getByRole('heading', { name: title })).toBeVisible();
await expect(page.getByText('Café am See')).toBeVisible(); await expect(page.getByText('Café am See')).toBeVisible();
// 4. Add a guest // 4. Add a guest — scope to the guest editor since the bring-list
await page.getByPlaceholder('Name', { exact: false }).first().fill('Tante Erika'); // editor below it uses the same "Hinzufügen" button text.
await page const guestEditor = page.locator('.guest-editor');
.getByPlaceholder(/E-Mail/i) await guestEditor.getByPlaceholder('Name', { exact: false }).fill('Tante Erika');
.first() await guestEditor.getByPlaceholder(/E-Mail/i).fill('erika@example.com');
.fill('erika@example.com'); await guestEditor.getByRole('button', { name: 'Hinzufügen' }).click();
await page.getByRole('button', { name: 'Hinzufügen' }).click();
// Guest row should appear // Guest row should appear
await expect(page.getByText('Tante Erika')).toBeVisible(); 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) // Summary should reflect 1 yes / 1 attending (no plus-ones)
await expect(page.locator('.rsvp-summary .badge.yes .count')).toHaveText('1'); await expect(page.locator('.rsvp-summary .badge.yes .count')).toHaveText('1');
// Set 2 plus-ones // Set 2 plus-ones (scoped to the guest editor — bring list also has a number input)
await page.locator('input[type="number"]').first().fill('2'); const plusOnes = guestEditor.locator('input[type="number"]').first();
// Blur to commit (onchange) await plusOnes.fill('2');
await page.locator('input[type="number"]').first().blur(); await plusOnes.blur();
// totalAttending should now be 3 (1 + 2 plus-ones) // totalAttending should now be 3 (1 + 2 plus-ones)
await expect(page.locator('.rsvp-summary .total strong')).toHaveText('3'); await expect(page.locator('.rsvp-summary .total strong')).toHaveText('3');

View file

@ -512,6 +512,16 @@ db.version(10).stores({
'++id, createdAt, appId, collection, recordId, op, [appId+createdAt], [collection+recordId], userId', '++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 Routing ──────────────────────────────────────────
// SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE, // SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE,
// toSyncName() and fromSyncName() are now derived from per-module // toSyncName() and fromSyncName() are now derived from per-module

View file

@ -48,6 +48,23 @@ async function fetchWithAuth<T>(path: string, init: RequestInit = {}): Promise<T
return res.json() as Promise<T>; return res.json() as Promise<T>;
} }
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 = { export const eventsApi = {
async publish(input: PublishedSnapshotInput): Promise<{ token: string; isNew: boolean }> { async publish(input: PublishedSnapshotInput): Promise<{ token: string; isNew: boolean }> {
return fetchWithAuth('/api/v1/events/publish', { return fetchWithAuth('/api/v1/events/publish', {
@ -73,4 +90,18 @@ export const eventsApi = {
async getRsvps(eventId: string): Promise<{ token: string; rsvps: PublicRsvpRecord[] }> { async getRsvps(eventId: string): Promise<{ token: string; rsvps: PublicRsvpRecord[] }> {
return fetchWithAuth(`/api/v1/events/${eventId}/rsvps`); 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`);
},
}; };

View file

@ -3,8 +3,14 @@
*/ */
import { db } from '$lib/data/database'; 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<LocalSocialEvent>('socialEvents'); export const socialEventTable = db.table<LocalSocialEvent>('socialEvents');
export const eventGuestTable = db.table<LocalEventGuest>('eventGuests'); export const eventGuestTable = db.table<LocalEventGuest>('eventGuests');
export const eventInvitationTable = db.table<LocalEventInvitation>('eventInvitations'); export const eventInvitationTable = db.table<LocalEventInvitation>('eventInvitations');
export const eventItemTable = db.table<LocalEventItem>('eventItems');

View file

@ -0,0 +1,232 @@
<script lang="ts">
import { useEventItems, useEventGuests } from '../queries';
import { eventItemsStore } from '../stores/items.svelte';
import { eventsStore } from '../stores/events.svelte';
import type { EventItem } from '../types';
interface Props {
eventId: string;
}
let { eventId }: Props = $props();
const items = useEventItems(() => eventId);
const guests = useEventGuests(() => eventId);
let newLabel = $state('');
let newQuantity = $state<number | undefined>(undefined);
const guestNameById = $derived(new Map((guests.value ?? []).map((g) => [g.id, g.name])));
function assigneeLabel(item: EventItem): string | null {
if (item.assignedGuestId) {
return guestNameById.get(item.assignedGuestId) ?? '?';
}
if (item.claimedByName) {
return `${item.claimedByName} (via Link)`;
}
return null;
}
async function handleAdd(e: SubmitEvent) {
e.preventDefault();
const label = newLabel.trim();
if (!label) return;
await eventItemsStore.addItem({
eventId,
label,
quantity: newQuantity ?? null,
});
// Snapshot pushed afterwards so the public bring list reflects the
// addition immediately for already-published events.
void eventsStore.syncSnapshotIfPublished(eventId);
newLabel = '';
newQuantity = undefined;
}
</script>
<div class="bring-editor">
<form class="add-row" onsubmit={handleAdd}>
<input
type="text"
bind:value={newLabel}
placeholder="z. B. Salat, Wein, Lautsprecher"
class="input label-input"
required
/>
<input
type="number"
min="1"
max="999"
placeholder="Anzahl"
bind:value={newQuantity}
class="input qty-input"
/>
<button type="submit" class="add-btn">Hinzufügen</button>
</form>
<ul class="item-list">
{#each items.value ?? [] as item (item.id)}
{@const assignee = assigneeLabel(item)}
<li class="item-row" class:done={item.done}>
<label class="check">
<input
type="checkbox"
checked={item.done}
onchange={(e) => eventItemsStore.toggleDone(item.id, e.currentTarget.checked)}
/>
</label>
<div class="info">
<div class="label-row">
<span class="label">{item.label}</span>
{#if item.quantity}
<span class="qty">×{item.quantity}</span>
{/if}
</div>
{#if assignee}
<div class="assignee">{assignee}</div>
{/if}
</div>
<select
class="assign-select"
value={item.assignedGuestId ?? ''}
onchange={(e) => eventItemsStore.assign(item.id, e.currentTarget.value || null)}
>
<option value="">— Niemand —</option>
{#each guests.value ?? [] as g (g.id)}
<option value={g.id}>{g.name}</option>
{/each}
</select>
<button
class="remove-btn"
onclick={() => eventItemsStore.deleteItem(item.id)}
title="Entfernen"
>
×
</button>
</li>
{/each}
{#if (items.value ?? []).length === 0}
<li class="empty">Noch nichts auf der Liste.</li>
{/if}
</ul>
</div>
<style>
.bring-editor {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.add-row {
display: flex;
gap: 0.5rem;
}
.input {
padding: 0.5rem 0.75rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
background: hsl(var(--color-background));
font-size: 0.875rem;
color: hsl(var(--color-foreground));
}
.label-input {
flex: 1;
}
.qty-input {
width: 5rem;
}
.add-btn {
padding: 0.5rem 0.875rem;
border: none;
border-radius: 0.5rem;
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
}
.item-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.item-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));
}
.item-row.done .label {
text-decoration: line-through;
color: hsl(var(--color-muted-foreground));
}
.check input {
width: 1rem;
height: 1rem;
accent-color: hsl(var(--color-primary));
}
.info {
flex: 1;
min-width: 0;
}
.label-row {
display: flex;
gap: 0.5rem;
align-items: baseline;
}
.label {
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--color-foreground));
}
.qty {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.assignee {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
margin-top: 0.125rem;
}
.assign-select {
max-width: 9rem;
padding: 0.25rem 0.375rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.375rem;
background: hsl(var(--color-background));
font-size: 0.75rem;
color: hsl(var(--color-foreground));
}
.remove-btn {
padding: 0.125rem 0.5rem;
border: none;
background: transparent;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
font-size: 1.25rem;
line-height: 1;
border-radius: 0.25rem;
}
.remove-btn:hover {
background: hsl(var(--color-muted));
color: hsl(var(--color-foreground));
}
.empty {
padding: 1rem;
text-align: center;
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
}
</style>

View file

@ -3,4 +3,5 @@ export * from './collections';
export * from './queries'; export * from './queries';
export { eventsStore } from './stores/events.svelte'; export { eventsStore } from './stores/events.svelte';
export { eventGuestsStore } from './stores/guests.svelte'; export { eventGuestsStore } from './stores/guests.svelte';
export { eventItemsStore } from './stores/items.svelte';
export { drainTombstones, recordTombstone } from './tombstones'; export { drainTombstones, recordTombstone } from './tombstones';

View file

@ -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' },
],
};

View file

@ -11,8 +11,10 @@ import type { LocalTimeBlock } from '$lib/data/time-blocks/types';
import type { import type {
LocalSocialEvent, LocalSocialEvent,
LocalEventGuest, LocalEventGuest,
LocalEventItem,
SocialEvent, SocialEvent,
EventGuest, EventGuest,
EventItem,
RsvpSummary, RsvpSummary,
} from './types'; } 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 { export function toEventGuest(local: LocalEventGuest): EventGuest {
const now = new Date().toISOString(); const now = new Date().toISOString();
return { return {
@ -148,6 +167,23 @@ export function useEventGuests(eventId: () => string) {
}, [] as EventGuest[]); }, [] 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<LocalEventItem>('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 ────────────────────────────────────────── // ─── Pure Helpers ──────────────────────────────────────────
export function summarizeRsvps(guests: EventGuest[]): RsvpSummary { export function summarizeRsvps(guests: EventGuest[]): RsvpSummary {

View file

@ -8,7 +8,7 @@
import { db } from '$lib/data/database'; import { db } from '$lib/data/database';
import { createBlock, updateBlock, deleteBlock } from '$lib/data/time-blocks/service'; import { createBlock, updateBlock, deleteBlock } from '$lib/data/time-blocks/service';
import { timeBlockTable } from '$lib/data/time-blocks/collections'; 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 { eventsApi } from '../api';
import { recordTombstone } from '../tombstones'; import { recordTombstone } from '../tombstones';
@ -192,6 +192,9 @@ export const eventsStore = {
status: 'published' satisfies EventStatus, status: 'published' satisfies EventStatus,
updatedAt: new Date().toISOString(), 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 }; return { success: true as const, token };
} catch (e) { } catch (e) {
error = e instanceof Error ? e.message : 'Failed to publish event'; 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. * Push the latest local state of a published event to the server snapshot.
* Called after an updateEvent() if the event is currently published. * 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) { async syncSnapshotIfPublished(id: string) {
try { try {
@ -249,8 +253,39 @@ export const eventsStore = {
color: event.color ?? null, color: event.color ?? null,
capacity: event.capacity ?? 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) { } catch (e) {
console.warn('Snapshot sync failed:', 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<LocalSocialEvent>('socialEvents').get(id);
if (!event || !event.isPublished) return;
const items = await db
.table<LocalEventItem>('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);
}
},
}; };

View file

@ -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<string | null>(null);
async function nextOrder(eventId: string): Promise<number> {
const existing = await db
.table<LocalEventItem>('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<LocalEventItem>('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<LocalEventItem>('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<LocalEventItem>('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 };
}
},
};

View file

@ -52,6 +52,19 @@ export interface LocalEventInvitation extends BaseRecord {
token: string; 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) ──────────────────────────────────── // ─── Domain (UI-facing) ────────────────────────────────────
export interface SocialEvent { export interface SocialEvent {
@ -97,3 +110,17 @@ export interface RsvpSummary {
pending: number; pending: number;
totalAttending: number; // yes + plusOnes 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;
}

View file

@ -4,6 +4,7 @@
import GuestListEditor from '../components/GuestListEditor.svelte'; import GuestListEditor from '../components/GuestListEditor.svelte';
import RsvpSummaryView from '../components/RsvpSummary.svelte'; import RsvpSummaryView from '../components/RsvpSummary.svelte';
import PublicRsvpList from '../components/PublicRsvpList.svelte'; import PublicRsvpList from '../components/PublicRsvpList.svelte';
import BringListEditor from '../components/BringListEditor.svelte';
interface Props { interface Props {
eventId: string; eventId: string;
@ -166,6 +167,11 @@
<GuestListEditor eventId={event.id} /> <GuestListEditor eventId={event.id} />
</section> </section>
<section class="section">
<h2>Bring-Liste</h2>
<BringListEditor eventId={event.id} />
</section>
{#if event.isPublished} {#if event.isPublished}
<section class="section"> <section class="section">
<PublicRsvpList eventId={event.id} isPublished={event.isPublished} /> <PublicRsvpList eventId={event.id} isPublished={event.isPublished} />

View file

@ -48,6 +48,15 @@ interface RsvpSummary {
totalAttending: number; 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 }) => { export const load: PageServerLoad = async ({ params, fetch, request }) => {
const token = params.token; const token = params.token;
if (!token) throw error(404, 'Not found'); if (!token) throw error(404, 'Not found');
@ -78,8 +87,9 @@ export const load: PageServerLoad = async ({ params, fetch, request }) => {
event: EventSnapshot; event: EventSnapshot;
summary: RsvpSummary | null; summary: RsvpSummary | null;
cancelled?: boolean; cancelled?: boolean;
items?: BringItem[];
}; };
return { token, ...data, eventsUrl: EVENTS_URL, lang }; return { token, ...data, items: data.items ?? [], eventsUrl: EVENTS_URL, lang };
} catch (e) { } catch (e) {
if (e && typeof e === 'object' && 'status' in e) throw e; if (e && typeof e === 'object' && 'status' in e) throw e;
throw error(500, errorMsg); throw error(500, errorMsg);

View file

@ -15,6 +15,48 @@
let submitted = $state(false); let submitted = $state(false);
let errorMessage = $state<string | null>(null); let errorMessage = $state<string | null>(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<string | null>(null);
let claimError = $state<string | null>(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 startDate = $derived(new Date(data.event.startAt));
const dateLabel = $derived( const dateLabel = $derived(
startDate.toLocaleDateString(t.dateLocale, { startDate.toLocaleDateString(t.dateLocale, {
@ -108,6 +150,40 @@
<p class="description">{data.event.description}</p> <p class="description">{data.event.description}</p>
{/if} {/if}
{#if items.length > 0 && !data.cancelled}
<section class="bring-list">
<h2>{t.bringListHeading}</h2>
<ul>
{#each items as item (item.id)}
<li class="bring-item" class:claimed={!!item.claimedByName}>
<div class="bring-info">
<span class="bring-label">{item.label}</span>
{#if item.quantity}
<span class="bring-qty">×{item.quantity}</span>
{/if}
{#if item.claimedByName}
<span class="bring-claimed">{t.claimedBy(item.claimedByName)}</span>
{/if}
</div>
{#if !item.claimedByName}
<button
type="button"
class="claim-btn"
disabled={claimingItemId === item.id}
onclick={() => claimItem(item.id)}
>
{claimingItemId === item.id ? '…' : t.claimButton}
</button>
{/if}
</li>
{/each}
</ul>
{#if claimError}
<p class="claim-error">{claimError}</p>
{/if}
</section>
{/if}
{#if data.cancelled} {#if data.cancelled}
<div class="cancelled">{t.cancelledNotice}</div> <div class="cancelled">{t.cancelledNotice}</div>
{:else if submitted} {:else if submitted}
@ -275,6 +351,89 @@
white-space: pre-wrap; white-space: pre-wrap;
line-height: 1.5; 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 { .cancelled {
padding: 1rem; padding: 1rem;
border-radius: 0.5rem; border-radius: 0.5rem;

View file

@ -35,6 +35,12 @@ interface Strings {
genericError: string; genericError: string;
poweredBy: string; poweredBy: string;
dateLocale: string; dateLocale: string;
bringListHeading: string;
bringListEmpty: string;
claimButton: string;
claimedBy: (name: string) => string;
claimNamePrompt: string;
claimAlreadyTaken: string;
} }
const DICTS: Record<Lang, Strings> = { const DICTS: Record<Lang, Strings> = {
@ -69,6 +75,12 @@ const DICTS: Record<Lang, Strings> = {
genericError: 'Konnte nicht senden', genericError: 'Konnte nicht senden',
poweredBy: 'Powered by', poweredBy: 'Powered by',
dateLocale: 'de-DE', 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: { en: {
rsvpTitle: 'Please RSVP', rsvpTitle: 'Please RSVP',
@ -100,6 +112,12 @@ const DICTS: Record<Lang, Strings> = {
genericError: 'Could not send', genericError: 'Could not send',
poweredBy: 'Powered by', poweredBy: 'Powered by',
dateLocale: 'en-US', 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: { it: {
rsvpTitle: 'Conferma la tua presenza', rsvpTitle: 'Conferma la tua presenza',
@ -132,6 +150,12 @@ const DICTS: Record<Lang, Strings> = {
genericError: 'Impossibile inviare', genericError: 'Impossibile inviare',
poweredBy: 'Powered by', poweredBy: 'Powered by',
dateLocale: 'it-IT', 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 loggetto per te:',
claimAlreadyTaken: 'Già preso — ricarica la pagina.',
}, },
fr: { fr: {
rsvpTitle: 'Confirmez votre présence', rsvpTitle: 'Confirmez votre présence',
@ -163,6 +187,12 @@ const DICTS: Record<Lang, Strings> = {
genericError: 'Impossible denvoyer', genericError: 'Impossible denvoyer',
poweredBy: 'Propulsé par', poweredBy: 'Propulsé par',
dateLocale: 'fr-FR', dateLocale: 'fr-FR',
bringListHeading: 'Qui apporte quoi ?',
bringListEmpty: 'Rien sur la liste pour linstant.',
claimButton: 'Je men charge',
claimedBy: (name) => `${name} lapporte`,
claimNamePrompt: 'Quel est ton nom ? Nous te réservons lélément :',
claimAlreadyTaken: 'Déjà pris — recharge la page.',
}, },
es: { es: {
rsvpTitle: 'Confirma tu asistencia', rsvpTitle: 'Confirma tu asistencia',
@ -195,6 +225,12 @@ const DICTS: Record<Lang, Strings> = {
genericError: 'No se pudo enviar', genericError: 'No se pudo enviar',
poweredBy: 'Powered by', poweredBy: 'Powered by',
dateLocale: 'es-ES', 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.',
}, },
}; };

View file

@ -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 elses 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);
});
});

View file

@ -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. */ /** Per-token rate limit bucket — token + hour-bucket → submission count. */
export const rsvpRateBuckets = eventsSchema.table( export const rsvpRateBuckets = eventsSchema.table(
'rsvp_rate_buckets', 'rsvp_rate_buckets',

View file

@ -9,7 +9,7 @@ import { Hono } from 'hono';
import { z } from 'zod'; import { z } from 'zod';
import { and, eq } from 'drizzle-orm'; import { and, eq } from 'drizzle-orm';
import type { Database } from '../db/connection'; 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 { ForbiddenError, NotFoundError, BadRequestError } from '../lib/errors';
import type { AuthUser } from '../middleware/jwt-auth'; 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 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 { function generateToken(): string {
// 24-char URL-safe random // 24-char URL-safe random
const bytes = new Uint8Array(18); const bytes = new Uint8Array(18);
@ -152,6 +166,92 @@ export function createEventsRoutes(db: Database) {
return c.json({ deleted: true }); 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 // GET /events/:eventId/rsvps — list all RSVPs for the host
app.get('/:eventId/rsvps', async (c) => { app.get('/:eventId/rsvps', async (c) => {
const user = c.get('user'); const user = c.get('user');

View file

@ -9,7 +9,12 @@ import { Hono } from 'hono';
import { z } from 'zod'; import { z } from 'zod';
import { and, eq, sql } from 'drizzle-orm'; import { and, eq, sql } from 'drizzle-orm';
import type { Database } from '../db/connection'; 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 { NotFoundError, BadRequestError, TooManyRequestsError } from '../lib/errors';
import type { Config } from '../config'; import type { Config } from '../config';
@ -59,6 +64,21 @@ export function createRsvpRoutes(db: Database, config: Config) {
else if (r.status === 'maybe') summary.maybe++; 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({ return c.json({
event: { event: {
token: event.token, token: event.token,
@ -74,9 +94,80 @@ export function createRsvpRoutes(db: Database, config: Config) {
capacity: event.capacity, capacity: event.capacity,
}, },
summary, 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 // POST /rsvp/:token — submit/update an RSVP
app.post('/:token', async (c) => { app.post('/:token', async (c) => {
const token = c.req.param('token'); const token = c.req.param('token');