mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 01:01:09 +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
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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`);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
12
apps/mana/apps/web/src/lib/modules/events/module.config.ts
Normal file
12
apps/mana/apps/web/src/lib/modules/events/module.config.ts
Normal 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' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
115
apps/mana/apps/web/src/lib/modules/events/stores/items.svelte.ts
Normal file
115
apps/mana/apps/web/src/lib/modules/events/stores/items.svelte.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 l’oggetto 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 d’envoyer',
|
genericError: 'Impossible d’envoyer',
|
||||||
poweredBy: 'Propulsé par',
|
poweredBy: 'Propulsé par',
|
||||||
dateLocale: 'fr-FR',
|
dateLocale: 'fr-FR',
|
||||||
|
bringListHeading: 'Qui apporte quoi ?',
|
||||||
|
bringListEmpty: 'Rien sur la liste pour l’instant.',
|
||||||
|
claimButton: 'Je m’en charge',
|
||||||
|
claimedBy: (name) => `${name} l’apporte`,
|
||||||
|
claimNamePrompt: 'Quel est ton nom ? Nous te réservons l’élément :',
|
||||||
|
claimAlreadyTaken: 'Déjà pris — recharge la page.',
|
||||||
},
|
},
|
||||||
es: {
|
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.',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
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. */
|
/** 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',
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue