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

@ -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. */
export const rsvpRateBuckets = eventsSchema.table(
'rsvp_rate_buckets',

View file

@ -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');

View file

@ -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');