mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-22 10:26:41 +02:00
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:
parent
af92720a62
commit
6a60e22a31
19 changed files with 1296 additions and 16 deletions
343
services/mana-events/src/__tests__/items.test.ts
Normal file
343
services/mana-events/src/__tests__/items.test.ts
Normal 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 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue