diff --git a/src/features/api/index.ts b/src/features/api/index.ts index b9468f57..8df488b3 100644 --- a/src/features/api/index.ts +++ b/src/features/api/index.ts @@ -197,7 +197,12 @@ const handleCheckAvailability = withActiveEvent(async (request, event) => { const quantity = Math.max(1, Number.isNaN(parsed) ? 1 : parsed); const date = url.searchParams.get("date") || undefined; return apiResponse({ - available: await hasAvailableSpots(event.id, quantity, date), + available: await hasAvailableSpots( + event.id, + quantity, + date, + event.duration_days, + ), }); }); diff --git a/src/features/api/webhooks.ts b/src/features/api/webhooks.ts index 4c151829..e51802ce 100644 --- a/src/features/api/webhooks.ts +++ b/src/features/api/webhooks.ts @@ -28,7 +28,10 @@ import { capacityErrorFormatter, isRegistrationClosed, } from "#routes/format.ts"; -import { ensureAllBookings } from "#routes/public/ticket-payment.ts"; +import { + bookingDateFields, + ensureAllBookings, +} from "#routes/public/ticket-payment.ts"; import { getFromEmailIfConfigured } from "#routes/public/ticket-routes.ts"; import { htmlResponse, @@ -502,17 +505,21 @@ type CreatedAttendee = Extract< type CreatedEntry = { attendee: CreatedAttendee; event: EventWithCount }; -/** Create the attendee plus per-event bookings atomically. */ +/** + * Create the attendee plus per-event bookings atomically. + * durationDays is event-scoped and re-read here at finalize time so the + * stored range always matches the event's current duration policy. + */ const createAttendeeForSession = async ( session: ValidatedPaymentSession, intent: BookingIntent, validatedItems: ValidatedItem[], ): Promise<{ ok: true; entries: CreatedEntry[] } | PaymentResult> => { const bookings = validatedItems.map(({ item, event }) => ({ - date: event.event_type === "daily" ? intent.date : null, eventId: item.e, pricePaid: item.p, quantity: item.q, + ...bookingDateFields(event, intent.date), })); const result = await createAttendeeAtomic({ diff --git a/src/features/public/ticket-payment.ts b/src/features/public/ticket-payment.ts index 7ceb415a..bfb9fb2b 100644 --- a/src/features/public/ticket-payment.ts +++ b/src/features/public/ticket-payment.ts @@ -127,6 +127,19 @@ export const checkAvailability = ( date, ); +/** + * Shared booking-date fields (date + durationDays). Keeps the payment and + * webhook flows aligned: both read duration from the event at insert time. + */ +export const bookingDateFields = ( + event: Pick, + date: string | null, +): { date: string | null; durationDays: number } => ({ + date: event.event_type === "daily" ? date : null, + durationDays: + event.event_type === "daily" ? Math.max(1, event.duration_days) : 1, +}); + /** Build registration items from events and quantities */ export const buildRegistrationItems = ( events: TicketEvent[], @@ -171,11 +184,16 @@ export const handlePaymentFlow = ( const buildBookings = ( selected: EventQty[], date: string | null, -): { eventId: number; quantity: number; date: string | null }[] => +): { + eventId: number; + quantity: number; + date: string | null; + durationDays: number; +}[] => selected.map(({ event, qty }) => ({ - date: event.event_type === "daily" ? date : null, eventId: event.id, quantity: qty, + ...bookingDateFields(event, date), })); /** diff --git a/src/shared/booking.ts b/src/shared/booking.ts index 9eb65d59..8fe7adeb 100644 --- a/src/shared/booking.ts +++ b/src/shared/booking.ts @@ -49,7 +49,12 @@ export const processBooking = async ( (customUnitPrice !== undefined && customUnitPrice > 0 && paymentsEnabled); if (needsPayment) { - const available = await hasAvailableSpots(event.id, quantity, date); + const available = await hasAvailableSpots( + event.id, + quantity, + date, + event.duration_days, + ); if (!available) return { type: "sold_out" }; // Provider is guaranteed to exist when isPaymentsEnabled() is true @@ -84,7 +89,14 @@ export const processBooking = async ( // Free event — create attendee atomically const result = await createAttendeeAtomic({ ...contact, - bookings: [{ date, eventId: event.id, quantity }], + bookings: [ + { + date, + durationDays: event.duration_days, + eventId: event.id, + quantity, + }, + ], }); if (!result.success) { diff --git a/src/shared/db/attendees/capacity.ts b/src/shared/db/attendees/capacity.ts index e8bb1ed7..2093ec14 100644 --- a/src/shared/db/attendees/capacity.ts +++ b/src/shared/db/attendees/capacity.ts @@ -1,9 +1,16 @@ /** * Capacity checks and availability queries for attendees/event_attendees. + * + * Multi-day daily bookings are enforced via per-day expansion: every day in + * `[start, start + duration_days)` must independently pass event + group caps. + * This file contains the JS preflight (`checkEventAvailability`, + * `checkBatchAvailabilityImpl`) — the inline SQL safety net lives in + * `#shared/db/capacity.ts` and runs in the same statement as the INSERT/UPDATE. */ import type { InValue } from "@libsql/client"; import { unique } from "#fp"; +import { addDays } from "#shared/dates.ts"; import type { BatchAvailabilityItem, EventBooking, @@ -14,7 +21,7 @@ import { buildGroupAttendeePredicate, dateToRange, } from "#shared/db/capacity.ts"; -import { inPlaceholders, queryAll } from "#shared/db/client.ts"; +import { inPlaceholders, queryAll, queryOne } from "#shared/db/client.ts"; import { getEventWithCount, invalidateEventsCache } from "#shared/db/events.ts"; import type { EventType } from "#shared/types.ts"; @@ -27,59 +34,34 @@ export const CAPACITY_EXCEEDED = { /** Convert nullable date to start_at/end_at (null-safe wrapper around dateToRange) */ export const dateToStartEnd = ( date: string | null, + durationDays = 1, ): { startAt: string | null; endAt: string | null } => { if (!date) return { endAt: null, startAt: null }; - const range = dateToRange(date); + const range = dateToRange(date, durationDays); return { endAt: range.endAt, startAt: range.startAt }; }; -/** Get the total attendee quantity for a specific event + date */ +/** + * Get the total attendee quantity for a specific event + date, optionally + * excluding one attendee (used when an admin edits their own booking so the + * row being updated doesn't fight itself in the capacity check). + */ export const getDateAttendeeCount = async ( eventId: number, date: string, + excludeAttendeeId?: number, ): Promise => { const { startAt, endAt } = dateToRange(date); - const rows = await queryAll<{ count: number }>( - "SELECT COALESCE(SUM(quantity), 0) as count FROM event_attendees WHERE event_id = ? AND start_at < ? AND end_at > ?", - [eventId, endAt, startAt], - ); + const sql = excludeAttendeeId + ? "SELECT COALESCE(SUM(quantity), 0) as count FROM event_attendees WHERE event_id = ? AND attendee_id != ? AND start_at < ? AND end_at > ?" + : "SELECT COALESCE(SUM(quantity), 0) as count FROM event_attendees WHERE event_id = ? AND start_at < ? AND end_at > ?"; + const args: InValue[] = excludeAttendeeId + ? [eventId, excludeAttendeeId, endAt, startAt] + : [eventId, endAt, startAt]; + const rows = await queryAll<{ count: number }>(sql, args); return rows[0]!.count; }; -/** - * Build a capacity-checked INSERT into event_attendees. - * @param attendeeIdExpr - SQL expression for attendee_id (e.g. "last_insert_rowid()" or "?") - * @param attendeeIdArg - Argument for "?" expr, omit for last_insert_rowid() - */ -export const buildCapacityCheckedInsert = ( - booking: EventBooking, - attendeeIdExpr = "last_insert_rowid()", - attendeeIdArg?: number, -): { sql: string; args: InValue[] } => { - const { eventId, quantity: qty = 1, pricePaid = 0, date = null } = booking; - const condition = buildCapacityCondition(eventId, qty, date); - const { startAt, endAt } = dateToStartEnd(date); - const args: InValue[] = [eventId]; - if (attendeeIdArg !== undefined) args.push(attendeeIdArg); - args.push(startAt, endAt, qty, pricePaid, ...condition.args); - - return { - args, - sql: `INSERT INTO event_attendees (event_id, attendee_id, start_at, end_at, quantity, price_paid) - SELECT ?, ${attendeeIdExpr}, ?, ?, ?, ? - WHERE ${condition.sql}`, - }; -}; - -/** Check a capacity-guarded write result and invalidate cache on success */ -export const checkCapacityResult = (result: { - rowsAffected: number; -}): UpdateEventLinkResult => { - if (!result.rowsAffected) return CAPACITY_EXCEEDED; - invalidateEventsCache(); - return { success: true }; -}; - type RemainingMap = Map; /** @@ -87,14 +69,20 @@ type RemainingMap = Map; * are omitted from the map. With `date = null`, daily-event attendees count * cumulatively — correct for booking-time enforcement after upstream date * validation, misleading for display. + * + * Optional `excludeAttendeeId` skips rows belonging to that attendee so an + * admin moving their own booking doesn't fight themselves. */ export const getGroupRemainingByGroupId = async ( groupIds: number[], date: string | null = null, + excludeAttendeeId?: number, ): Promise => { const ids = unique(groupIds.filter((id) => id > 0)); if (ids.length === 0) return new Map(); const predicate = buildGroupAttendeePredicate("e", "ea", date); + const excludeClause = excludeAttendeeId ? "AND ea.attendee_id != ?" : ""; + const excludeArgs: InValue[] = excludeAttendeeId ? [excludeAttendeeId] : []; const rows = await queryAll<{ group_id: number; max_attendees: number; @@ -105,10 +93,11 @@ export const getGroupRemainingByGroupId = async ( FROM groups g LEFT JOIN events e ON e.group_id = g.id LEFT JOIN event_attendees ea ON ea.event_id = e.id + ${excludeClause} AND ${predicate.sql} WHERE g.id IN (${inPlaceholders(ids)}) AND g.max_attendees > 0 GROUP BY g.id`, - [...predicate.args, ...ids], + [...excludeArgs, ...predicate.args, ...ids], ); return new Map( rows.map((r) => [r.group_id, Math.max(0, r.max_attendees - r.count)]), @@ -156,80 +145,260 @@ export const getGroupRemainingForEvent = async ( }; /** - * Check availability for multiple events in a single query. - * Uses a JOIN with conditional date filtering: daily events check per-date - * capacity while standard events check total capacity. + * Build a capacity-checked INSERT into event_attendees. + * @param attendeeIdExpr - SQL expression for attendee_id (e.g. "last_insert_rowid()" or "?") + * @param attendeeIdArg - Argument for "?" expr, omit for last_insert_rowid() + */ +export const buildCapacityCheckedInsert = ( + booking: EventBooking, + attendeeIdExpr = "last_insert_rowid()", + attendeeIdArg?: number, +): { sql: string; args: InValue[] } => { + const { + eventId, + quantity: qty = 1, + pricePaid = 0, + date = null, + durationDays = 1, + } = booking; + const condition = buildCapacityCondition( + eventId, + qty, + date, + undefined, + durationDays, + ); + const { startAt, endAt } = dateToStartEnd(date, durationDays); + const args: InValue[] = [eventId]; + if (attendeeIdArg !== undefined) args.push(attendeeIdArg); + args.push(startAt, endAt, qty, pricePaid, ...condition.args); + + return { + args, + sql: `INSERT INTO event_attendees (event_id, attendee_id, start_at, end_at, quantity, price_paid) + SELECT ?, ${attendeeIdExpr}, ?, ?, ?, ? + WHERE ${condition.sql}`, + }; +}; + +/** Check a capacity-guarded write result and invalidate cache on success */ +export const checkCapacityResult = (result: { + rowsAffected: number; +}): UpdateEventLinkResult => { + if (!result.rowsAffected) return CAPACITY_EXCEEDED; + invalidateEventsCache(); + return { success: true }; +}; + +// --------------------------------------------------------------------------- +// Per-day preflight helpers used by hasAvailableSpots / addEventLink / +// updateEventLink. They mirror the per-day SQL safety net but in JS so we +// can self-exclude an attendee row cleanly. +// --------------------------------------------------------------------------- + +/** Expand a daily-event range into individual day strings. + * Clamps duration to >= 1 to defend against bogus 0/negative inputs. */ +const expandDailyRange = (date: string, durationDays: number): string[] => { + const duration = Math.max(1, Math.floor(durationDays)); + return Array.from({ length: duration }, (_, i) => addDays(date, i)); +}; + +/** Async every-day predicate: short-circuits to false on the first failed day. */ +const everyDay = async ( + days: T[], + check: (day: T) => Promise, +): Promise => { + for (const day of days) { + if (!(await check(day))) return false; + } + return true; +}; + +/** Enumerate every day in [date, date + durationDays) for per-day checks, + * or a single [null] for non-daily / date-less bookings. */ +const capacityCheckDays = ( + isDaily: boolean, + date: string | null | undefined, + durationDays: number, +): (string | null)[] => + !isDaily || !date ? [null] : expandDailyRange(date, durationDays); + +const loadForDay = async ( + eventId: number, + day: string | null, + excludeAttendeeId: number | undefined, + attendeeCount: number, +): Promise => { + if (day) return getDateAttendeeCount(eventId, day, excludeAttendeeId); + if (!excludeAttendeeId) return attendeeCount; + return (await queryOne<{ count: number }>( + "SELECT COALESCE(SUM(quantity), 0) as count FROM event_attendees WHERE event_id = ? AND attendee_id != ?", + [eventId, excludeAttendeeId], + ))!.count; +}; + +/** + * Accurate per-day availability check for a single-event booking, shared by + * `hasAvailableSpots`, `addEventLink`, and `updateEventLink`. + * + * Walks every day in `[date, date + durationDays)` and checks event cap + + * group cap per-day. The atomic SQL still runs its own WHERE-guarded check + * as a race-free safety net; this preflight ensures we don't false-reject + * multi-day ranges with non-overlapping existing bookings. + */ +export const checkEventAvailability = async ( + eventId: number, + quantity: number, + date: string | null | undefined, + excludeAttendeeId?: number, + durationDays = 1, +): Promise => { + const event = await getEventWithCount(eventId); + if (!event) return false; + const days = capacityCheckDays( + event.event_type === "daily", + date, + durationDays, + ); + const eventOk = await everyDay(days, async (day) => { + const load = await loadForDay( + eventId, + day, + excludeAttendeeId, + event.attendee_count, + ); + return load + quantity <= event.max_attendees; + }); + if (!eventOk) return false; + if (event.group_id <= 0) return true; + return everyDay(days, async (day) => { + const remaining = ( + await getGroupRemainingByGroupId([event.group_id], day, excludeAttendeeId) + ).get(event.group_id); + return remaining === undefined || quantity <= remaining; + }); +}; + +type EventRow = { + id: number; + max_attendees: number; + group_id: number; + event_type: EventType; + attendee_count: number; +}; + +type DemandBucket = { perDay: Map; total: number }; + +/** + * Aggregate batch items into per-key demand buckets. The `keyOf(event)` + * callback selects which bucket each item contributes to (returning null + * skips the item). Daily events with a date contribute per-day; everything + * else contributes to a single total per bucket. + * + * Used twice: once keyed by event id (for event-cap checks), once keyed by + * group id (for group-cap checks). + */ +const aggregateDemand = ( + items: BatchAvailabilityItem[], + eventsById: Map, + date: string | null | undefined, + keyOf: (ev: EventRow) => K | null, +): Map => { + const buckets = new Map(); + for (const item of items) { + const ev = eventsById.get(item.eventId)!; + const key = keyOf(ev); + if (key === null) continue; + let bucket = buckets.get(key); + if (!bucket) { + bucket = { perDay: new Map(), total: 0 }; + buckets.set(key, bucket); + } + const duration = Math.max(1, item.durationDays ?? 1); + if (ev.event_type === "daily" && date) { + for (const day of expandDailyRange(date, duration)) { + bucket.perDay.set(day, (bucket.perDay.get(day) ?? 0) + item.quantity); + } + } else { + bucket.total += item.quantity; + } + } + return buckets; +}; + +/** + * Check availability for multiple events in a single preflight pass. + * For multi-day daily events, expands each booking into per-day demand so + * that every day in the range is checked independently. Group caps are + * similarly evaluated per-day across all events in each group. */ export const checkBatchAvailabilityImpl = async ( items: BatchAvailabilityItem[], date?: string | null, ): Promise => { if (items.length === 0) return true; + // Reject negative quantities outright — would otherwise offset positive + // rows and bypass the cap. Form validation clamps upstream; defensive. + if (items.some((i) => i.quantity < 0)) return false; const eventIds = items.map((i) => i.eventId); - const range = date ? dateToRange(date) : null; - const rows = await queryAll<{ - id: number; - max_attendees: number; - current_count: number; - group_id: number; - }>( - `SELECT e.id, e.max_attendees, - COALESCE(SUM(ea.quantity), 0) as current_count, - e.group_id - FROM events e - LEFT JOIN event_attendees ea ON ea.event_id = e.id - AND (e.event_type != 'daily' OR (ea.start_at < ? AND ea.end_at > ?)) - WHERE e.id IN (${inPlaceholders(eventIds)}) - GROUP BY e.id`, - [range?.endAt ?? null, range?.startAt ?? null, ...eventIds], - ); - const counts = new Map(rows.map((r) => [r.id, r])); - // Per-event capacity check - const eventOk = items.every((item) => { - const row = counts.get(item.eventId); - return row ? row.current_count + item.quantity <= row.max_attendees : false; - }); - if (!eventOk) return false; - const groupIds = unique( - rows.filter((r) => r.group_id > 0).map((r) => r.group_id), + const eventRows = await queryAll( + `SELECT e.id, e.max_attendees, e.group_id, e.event_type, + COALESCE(SUM(ea.quantity), 0) as attendee_count + FROM events e + LEFT JOIN event_attendees ea ON ea.event_id = e.id + WHERE e.id IN (${inPlaceholders(eventIds)}) + GROUP BY e.id`, + eventIds, ); - const remainingByGroupId = await getGroupRemainingByGroupId( - groupIds, - date ?? null, + const eventsById = new Map(eventRows.map((r) => [r.id, r])); + + // Every item must reference a known event. + if (items.some((i) => !eventsById.has(i.eventId))) return false; + + // Event-cap checks: per-day where applicable, total for non-daily/date-less. + const eventDemand = aggregateDemand(items, eventsById, date, (ev) => ev.id); + for (const [eventId, bucket] of eventDemand) { + const ev = eventsById.get(eventId)!; + for (const [day, qty] of bucket.perDay) { + const existing = await getDateAttendeeCount(eventId, day); + if (existing + qty > ev.max_attendees) return false; + } + if (bucket.total > 0 && ev.attendee_count + bucket.total > ev.max_attendees) + return false; + } + + // Group-cap checks: per-day across the union of requested days in the group. + const groupDemand = aggregateDemand(items, eventsById, date, (ev) => + ev.group_id > 0 ? ev.group_id : null, ); - for (const [groupId, remaining] of remainingByGroupId) { - const requestedInGroup = items.reduce((sum, item) => { - const row = counts.get(item.eventId); - return row && row.group_id === groupId ? sum + item.quantity : sum; - }, 0); - if (requestedInGroup > remaining) return false; + for (const [groupId, bucket] of groupDemand) { + for (const [day, qty] of bucket.perDay) { + const remaining = (await getGroupRemainingByGroupId([groupId], day)).get( + groupId, + ); + if (remaining !== undefined && qty + bucket.total > remaining) + return false; + } + // Pure non-daily demand against baseline group occupancy. + if (bucket.perDay.size === 0 && bucket.total > 0) { + const remaining = (await getGroupRemainingByGroupId([groupId], null)).get( + groupId, + ); + if (remaining !== undefined && bucket.total > remaining) return false; + } } return true; }; -/** Check if an event has available spots for the requested quantity */ -export const hasAvailableSpotsImpl = async ( +/** + * Duration-aware availability check for a single event. For daily events + * with `durationDays > 1`, every day in the range must have room. + */ +export const hasAvailableSpotsImpl = ( eventId: number, quantity = 1, date?: string | null, -): Promise => { - const event = await getEventWithCount(eventId); - if (!event) return false; - if (date) { - const dateCount = await getDateAttendeeCount(eventId, date); - if (dateCount + quantity > event.max_attendees) return false; - } else { - if (event.attendee_count + quantity > event.max_attendees) return false; - } - // Check group capacity if event belongs to a group with a limit - if (event.group_id > 0) { - const remainingByGroupId = await getGroupRemainingByGroupId( - [event.group_id], - date ?? null, - ); - const remaining = remainingByGroupId.get(event.group_id); - if (remaining !== undefined && quantity > remaining) return false; - } - return true; -}; + durationDays = 1, +): Promise => + checkEventAvailability(eventId, quantity, date, undefined, durationDays); diff --git a/src/shared/db/attendees/create.ts b/src/shared/db/attendees/create.ts index f6b5ee0b..fc0a2b19 100644 --- a/src/shared/db/attendees/create.ts +++ b/src/shared/db/attendees/create.ts @@ -67,6 +67,23 @@ export const createAttendeeAtomicImpl = async ( if (bookings.length === 0) { return { reason: "capacity_exceeded", success: false }; } + // Reject negative quantities outright — the atomic insert would happily + // store a negative row and skew future capacity sums. + if (bookings.some((b) => (b.quantity ?? 1) < 0)) { + return { reason: "capacity_exceeded", success: false }; + } + // Reject duplicate (event_id, date) pairs in a single cart. The + // event_attendees unique index is on (event_id, attendee_id, start_at), + // so two rows with the same tuple would violate it — silently dropping + // one insert and delivering a half-fulfilled booking. + const seenKeys = new Set(); + for (const b of bookings) { + const key = `${b.eventId}|${b.date ?? ""}`; + if (seenKeys.has(key)) { + return { reason: "capacity_exceeded", success: false }; + } + seenKeys.add(key); + } const contactInfo = { address, email, name, phone, special_instructions }; // Use first booking's pricePaid for encryption (PII blob is shared) diff --git a/src/shared/db/attendees/update.ts b/src/shared/db/attendees/update.ts index bf3220c6..852da280 100644 --- a/src/shared/db/attendees/update.ts +++ b/src/shared/db/attendees/update.ts @@ -10,7 +10,9 @@ import type { } from "#shared/db/attendee-types.ts"; import { buildCapacityCheckedInsert, + CAPACITY_EXCEEDED, checkCapacityResult, + checkEventAvailability, dateToStartEnd, } from "#shared/db/attendees/capacity.ts"; import { buildPiiBlob, encryptPiiBlob } from "#shared/db/attendees/pii.ts"; @@ -31,29 +33,17 @@ const updateEventAttendeeField = const setRefunded = updateEventAttendeeField("refunded"); const setCheckedIn = updateEventAttendeeField("checked_in"); -/** - * Mark an attendee as refunded for a specific event. - * Keeps payment_id intact so payment details can still be viewed. - */ export const markRefunded = ( attendeeId: number, eventId: number, ): Promise => setRefunded(attendeeId, eventId, 1); -/** - * Update an attendee's checked_in status for a specific event. - * Caller must be authenticated admin (public key always exists after setup) - */ export const updateCheckedIn = ( attendeeId: number, eventId: number, checkedIn: boolean, ): Promise => setCheckedIn(attendeeId, eventId, checkedIn ? 1 : 0); -/** - * Increment the attachment download counter for an attendee. - * Uses atomic SQL increment to avoid race conditions. - */ export const incrementAttachmentDownloads = async ( attendeeId: number, eventId: number, @@ -64,10 +54,6 @@ export const incrementAttachmentDownloads = async ( }); }; -/** - * Update an attendee's PII (name, email, phone, etc.) — shared across all event links. - * Caller must be authenticated admin (public key always exists after setup). - */ export const updateAttendeePII = async ( attendeeId: number, input: UpdateAttendeePIIInput, @@ -88,16 +74,34 @@ export const updateAttendeePII = async ( /** * Update a single event link's quantity and date with atomic capacity check. - * Excludes this attendee's current row from the capacity calculation. + * Self-excluding preflight first (avoids false-rejection on multi-day ranges + * that contain non-overlapping existing bookings); atomic SQL UPDATE is the + * race-free safety net. */ export const updateEventLink = async ( attendeeId: number, eventId: number, input: UpdateEventLinkInput, ): Promise => { - const { quantity: qty, date } = input; - const { startAt, endAt } = dateToStartEnd(date); - const condition = buildCapacityCondition(eventId, qty, date, attendeeId); + const { quantity: qty, date, durationDays = 1 } = input; + + const preflight = await checkEventAvailability( + eventId, + qty, + date, + attendeeId, + durationDays, + ); + if (!preflight) return CAPACITY_EXCEEDED; + + const { startAt, endAt } = dateToStartEnd(date, durationDays); + const condition = buildCapacityCondition( + eventId, + qty, + date, + attendeeId, + durationDays, + ); const result = await getDb().execute({ args: [qty, startAt, endAt, attendeeId, eventId, ...condition.args], @@ -110,12 +114,23 @@ export const updateEventLink = async ( /** * Add a new event link for an existing attendee with atomic capacity check. - * Does NOT create a new attendee or touch PII — just inserts an event_attendees row. + * Runs a per-day preflight so multi-day events aren't false-rejected by the + * SQL overlap-sum safety net. */ export const addEventLink = async ( attendeeId: number, booking: EventBooking, -): Promise => - checkCapacityResult( +): Promise => { + const preflight = await checkEventAvailability( + booking.eventId, + booking.quantity ?? 1, + booking.date ?? null, + undefined, + booking.durationDays ?? 1, + ); + if (!preflight) return CAPACITY_EXCEEDED; + + return checkCapacityResult( await getDb().execute(buildCapacityCheckedInsert(booking, "?", attendeeId)), ); +}; diff --git a/src/shared/db/capacity.ts b/src/shared/db/capacity.ts index 3cb5958b..e6b08bb4 100644 --- a/src/shared/db/capacity.ts +++ b/src/shared/db/capacity.ts @@ -4,21 +4,27 @@ * The WHERE clause produced by `buildCapacityCondition` is embedded inside * atomic INSERT/UPDATE statements on `event_attendees` so that capacity is * enforced in the same statement that mutates the row (no read-modify-write - * race). It currently handles the single-day case: for daily events we overlap - * on `start_at`/`end_at` derived from the booking date. + * race). + * + * Multi-day daily bookings emit one clause per day, AND'd together, so the + * SQL safety-net matches the per-day accuracy of the JS preflight. Range + * length is bounded (≤90 via form validation) so the SQL stays cheap. */ import type { InValue } from "@libsql/client"; +import { addDays } from "#shared/dates.ts"; export type SqlFragment = { sql: string; args: InValue[] }; -/** Convert a date string ("YYYY-MM-DD") to start_at/end_at pair for full-day range */ +/** Convert a date string ("YYYY-MM-DD") to a half-open [start, end) pair. + * With `durationDays > 1` the range spans multiple calendar days. */ export const dateToRange = ( date: string, + durationDays = 1, ): { startAt: string; endAt: string } => { const ms = new Date(`${date}T00:00:00Z`).getTime(); - const nextDay = new Date(ms + 86_400_000).toISOString(); - return { endAt: nextDay, startAt: `${date}T00:00:00Z` }; + const endIso = new Date(ms + durationDays * 86_400_000).toISOString(); + return { endAt: endIso, startAt: `${date}T00:00:00Z` }; }; /** @@ -42,59 +48,94 @@ export const buildGroupAttendeePredicate = ( }; /** - * Build the WHERE clause for capacity checking on event_attendees. - * @param excludeAttendeeId - If set, excludes this attendee's rows from the count (for updates) + * Build a single-day capacity clause (event-cap + group-cap when applicable). + * `dayRange` is null for non-daily / date-less bookings; uses `? IS NULL OR …` + * to elide the time filter in one branch rather than two SQL shapes. */ -export const buildCapacityCondition = ( +const buildDayCapacitySql = ( eventId: number, qty: number, - date: string | null, + dayRange: { startAt: string; endAt: string } | null, excludeAttendeeId?: number, ): SqlFragment => { - const range = date ? dateToRange(date) : null; - const endAt = range?.endAt ?? null; - const startAt = range?.startAt ?? null; - - const excludeClause = excludeAttendeeId ? " AND ea2.attendee_id != ?" : ""; - const capacityFilter = date - ? `SELECT COALESCE(SUM(ea2.quantity), 0) FROM event_attendees ea2 WHERE ea2.event_id = ?${excludeClause} AND ea2.start_at < ? AND ea2.end_at > ?` - : `SELECT COALESCE(SUM(ea2.quantity), 0) FROM event_attendees ea2 WHERE ea2.event_id = ?${excludeClause}`; - const capacityArgs: InValue[] = date - ? excludeAttendeeId - ? [eventId, excludeAttendeeId, endAt, startAt] - : [eventId, endAt, startAt] - : excludeAttendeeId - ? [eventId, excludeAttendeeId] - : [eventId]; + const dayDate = dayRange?.startAt.slice(0, 10) ?? null; + const startAt = dayRange?.startAt ?? null; + const endAt = dayRange?.endAt ?? null; + const excludeEa2 = excludeAttendeeId ? "AND ea2.attendee_id != ? " : ""; + const excludeEa3 = excludeAttendeeId ? "AND ea3.attendee_id != ? " : ""; + const excludeArg: InValue[] = excludeAttendeeId ? [excludeAttendeeId] : []; - const groupExclude = excludeAttendeeId - ? "AND ea3.attendee_id != ?\n " - : ""; - const groupPredicate = buildGroupAttendeePredicate("e2", "ea3", date); - const groupCapacityCheck = ` - AND ( - SELECT CASE - WHEN ev.group_id = 0 THEN 1 - WHEN COALESCE(g.max_attendees, 0) = 0 THEN 1 - WHEN ( - SELECT COALESCE(SUM(ea3.quantity), 0) - FROM event_attendees ea3 - JOIN events e2 ON e2.id = ea3.event_id - WHERE e2.group_id = ev.group_id - ${groupExclude}AND ${groupPredicate.sql} - ) + ? <= g.max_attendees THEN 1 - ELSE 0 - END - FROM events ev - LEFT JOIN groups g ON g.id = ev.group_id - WHERE ev.id = ? - ) = 1`; - const groupCapacityArgs: InValue[] = excludeAttendeeId - ? [excludeAttendeeId, ...groupPredicate.args, qty, eventId] - : [...groupPredicate.args, qty, eventId]; + const sql = `( + SELECT COALESCE(SUM(ea2.quantity), 0) + FROM event_attendees ea2 + WHERE ea2.event_id = ? ${excludeEa2} + AND (? IS NULL OR (ea2.start_at < ? AND ea2.end_at > ?)) + ) + ? <= (SELECT max_attendees FROM events WHERE id = ?) + AND ( + SELECT CASE + WHEN ev.group_id = 0 THEN 1 + WHEN COALESCE(g.max_attendees, 0) = 0 THEN 1 + WHEN ( + SELECT COALESCE(SUM(ea3.quantity), 0) + FROM event_attendees ea3 + JOIN events e2 ON e2.id = ea3.event_id + WHERE e2.group_id = ev.group_id ${excludeEa3} + AND (? IS NULL OR e2.event_type != 'daily' OR (ea3.start_at < ? AND ea3.end_at > ?)) + ) + ? <= g.max_attendees THEN 1 + ELSE 0 + END + FROM events ev + LEFT JOIN groups g ON g.id = ev.group_id + WHERE ev.id = ? + ) = 1`; return { - args: [...capacityArgs, qty, eventId, ...groupCapacityArgs], - sql: `(${capacityFilter}) + ? <= (SELECT max_attendees FROM events WHERE id = ?)${groupCapacityCheck}`, + args: [ + eventId, + ...excludeArg, + startAt, + endAt, + startAt, + qty, + eventId, + ...excludeArg, + dayDate, + endAt, + startAt, + qty, + eventId, + ], + sql, }; }; + +/** + * Build the WHERE clause for capacity checking on event_attendees. + * For multi-day daily bookings, emits one clause per day AND'd together so + * the atomic SQL guard matches the per-day accuracy of the JS preflight. + * + * @param excludeAttendeeId - If set, excludes this attendee's rows from the count (for updates) + */ +export const buildCapacityCondition = ( + eventId: number, + qty: number, + date: string | null, + excludeAttendeeId?: number, + durationDays = 1, +): SqlFragment => { + if (!date) return buildDayCapacitySql(eventId, qty, null, excludeAttendeeId); + const duration = Math.max(1, Math.floor(durationDays)); + const clauses: string[] = []; + const args: InValue[] = []; + for (let i = 0; i < duration; i++) { + const daily = buildDayCapacitySql( + eventId, + qty, + dateToRange(addDays(date, i), 1), + excludeAttendeeId, + ); + clauses.push(`(${daily.sql})`); + args.push(...daily.args); + } + return { args, sql: clauses.join(" AND ") }; +}; diff --git a/src/test-utils/db-helpers.ts b/src/test-utils/db-helpers.ts index 1a57b7f2..f550d8a1 100644 --- a/src/test-utils/db-helpers.ts +++ b/src/test-utils/db-helpers.ts @@ -53,6 +53,7 @@ const buildCreateEventForm = ( date_date: dateParts.date, date_time: dateParts.time, description: input.description ?? "", + duration_days: optionalNumber(input.durationDays), event_type: input.eventType ?? "", fields: input.fields ?? "email", group_id: String(input.groupId ?? 0), @@ -91,6 +92,9 @@ const buildUpdateNumericFields = ( updates: Partial, existing: EventWithCount, ): Record => ({ + duration_days: String( + pickField(updates.durationDays, existing.duration_days), + ), group_id: String(pickField(updates.groupId, existing.group_id)), max_attendees: String( pickField(updates.maxAttendees, existing.max_attendees), @@ -400,6 +404,7 @@ export const bookAttendee = async ( if (opts.date !== undefined) booking.date = opts.date; if (opts.quantity !== undefined) booking.quantity = opts.quantity; if (opts.pricePaid !== undefined) booking.pricePaid = opts.pricePaid; + if (opts.durationDays !== undefined) booking.durationDays = opts.durationDays; return createAttendeeAtomic({ bookings: [booking], email: opts.email ?? "x@example.com", diff --git a/test/lib/db/attendees/add-event-link.test.ts b/test/lib/db/attendees/add-event-link.test.ts new file mode 100644 index 00000000..d47db394 --- /dev/null +++ b/test/lib/db/attendees/add-event-link.test.ts @@ -0,0 +1,41 @@ +import { expect } from "@std/expect"; +import { it as test } from "@std/testing/bdd"; +import { addEventLink, getAttendeesRaw } from "#shared/db/attendees.ts"; +import { + bookAttendee, + createDailyTestEvent, + createTestEvent, + describeWithEnv, +} from "#test-utils"; + +describeWithEnv("db > attendees > addEventLink", { db: true }, () => { + test("admits a multi-day link whose range contains non-overlapping bookings", async () => { + // Per-day expansion must admit a range whose days each have room, + // even though overlap-sum sees multiple bookings inside the window. + const event = await createDailyTestEvent({ durationDays: 3, maxAttendees: 2 }); + await bookAttendee(event, { date: "2026-05-01", durationDays: 1 }); + await bookAttendee(event, { date: "2026-05-03", durationDays: 1 }); + const base = await bookAttendee(event, { date: "2026-05-20", durationDays: 1 }); + if (!base.success) throw new Error("setup failed"); + const link = await addEventLink(base.attendees[0]!.id, { + date: "2026-05-01", + durationDays: 3, + eventId: event.id, + quantity: 1, + }); + expect(link.success).toBe(true); + }); + + test("defaults quantity to 1 when omitted", async () => { + const first = await createTestEvent({ maxAttendees: 3 }); + const second = await createTestEvent({ maxAttendees: 3 }); + const base = await bookAttendee(first); + if (!base.success) throw new Error("setup failed"); + const link = await addEventLink(base.attendees[0]!.id, { + eventId: second.id, + }); + expect(link.success).toBe(true); + const rows = await getAttendeesRaw(second.id); + expect(Number(rows[0]!.quantity)).toBe(1); + }); +}); diff --git a/test/lib/db/attendees/check-batch-availability.test.ts b/test/lib/db/attendees/check-batch-availability.test.ts index adba3c2b..6ffd4372 100644 --- a/test/lib/db/attendees/check-batch-availability.test.ts +++ b/test/lib/db/attendees/check-batch-availability.test.ts @@ -1,57 +1,110 @@ import { expect } from "@std/expect"; import { it as test } from "@std/testing/bdd"; import { checkBatchAvailability } from "#shared/db/attendees.ts"; -import { bookAttendee, createTestEvent, describeWithEnv } from "#test-utils"; - -describeWithEnv("db > attendees > checkBatchAvailability", { db: true }, () => { - test("returns true for empty items", async () => { - expect(await checkBatchAvailability([])).toBe(true); - }); - - test("returns false when event not found", async () => { - expect(await checkBatchAvailability([{ eventId: 999, quantity: 1 }])).toBe( - false, - ); - }); - - test("checks per-date capacity for daily events", async () => { - const event = await createTestEvent({ - bookableDays: [ - "Monday", - "Tuesday", - "Wednesday", - "Thursday", - "Friday", - "Saturday", - "Sunday", - ], - eventType: "daily", - maxAttendees: 2, - maximumDaysAfter: 14, - minimumDaysBefore: 0, - }); - - await bookAttendee(event, { - date: "2026-05-01", - email: "filled@example.com", - name: "Filled", - quantity: 2, - }); - - // Same date is full - expect( - await checkBatchAvailability( - [{ eventId: event.id, quantity: 1 }], - "2026-05-01", - ), - ).toBe(false); - - // Different date has room - expect( - await checkBatchAvailability( - [{ eventId: event.id, quantity: 2 }], - "2026-05-02", - ), - ).toBe(true); - }); -}); +import { + bookAttendee, + createDailyTestEvent, + createTestEvent, + createTestGroup, + describeWithEnv, +} from "#test-utils"; + +describeWithEnv( + "db > attendees > checkBatchAvailability", + { db: true }, + () => { + test("returns true for empty items", async () => { + expect(await checkBatchAvailability([])).toBe(true); + }); + + test("returns false when event not found", async () => { + expect( + await checkBatchAvailability([{ eventId: 999, quantity: 1 }]), + ).toBe(false); + }); + + test("checks per-date capacity for daily events", async () => { + const event = await createDailyTestEvent({ maxAttendees: 2 }); + await bookAttendee(event, { date: "2026-05-01", quantity: 2 }); + expect( + await checkBatchAvailability( + [{ eventId: event.id, quantity: 1 }], + "2026-05-01", + ), + ).toBe(false); + expect( + await checkBatchAvailability( + [{ eventId: event.id, quantity: 2 }], + "2026-05-02", + ), + ).toBe(true); + }); + + test("rejects a multi-day booking when any day in the range is at capacity", async () => { + const event = await createDailyTestEvent({ durationDays: 3, maxAttendees: 2 }); + await bookAttendee(event, { date: "2026-05-02", durationDays: 1, quantity: 2 }); + expect( + await checkBatchAvailability( + [{ durationDays: 3, eventId: event.id, quantity: 1 }], + "2026-05-01", + ), + ).toBe(false); + }); + + test("accepts a multi-day booking when every day has room", async () => { + const event = await createDailyTestEvent({ durationDays: 3, maxAttendees: 2 }); + expect( + await checkBatchAvailability( + [{ durationDays: 3, eventId: event.id, quantity: 1 }], + "2026-05-01", + ), + ).toBe(true); + }); + + test("admits a 1-day booking in the gap between two full days", async () => { + const event = await createDailyTestEvent({ durationDays: 3, maxAttendees: 2 }); + await bookAttendee(event, { date: "2026-05-01", durationDays: 1, quantity: 2 }); + await bookAttendee(event, { date: "2026-05-03", durationDays: 1, quantity: 2 }); + expect( + await checkBatchAvailability( + [{ durationDays: 1, eventId: event.id, quantity: 2 }], + "2026-05-02", + ), + ).toBe(true); + }); + + test("enforces group per-day cap across Saturday/Sunday/combo scenario", async () => { + const group = await createTestGroup({ maxAttendees: 100 }); + const sat = await createDailyTestEvent({ groupId: group.id, maxAttendees: 100 }); + const sun = await createDailyTestEvent({ groupId: group.id, maxAttendees: 100 }); + const combo = await createDailyTestEvent({ + durationDays: 2, + groupId: group.id, + maxAttendees: 100, + }); + await bookAttendee(sat, { date: "2026-05-02", quantity: 50 }); + await bookAttendee(combo, { date: "2026-05-02", durationDays: 2, quantity: 50 }); + expect( + await checkBatchAvailability([{ eventId: sat.id, quantity: 1 }], "2026-05-02"), + ).toBe(false); + expect( + await checkBatchAvailability([{ eventId: sun.id, quantity: 50 }], "2026-05-03"), + ).toBe(true); + }); + + test("rejects negative quantities", async () => { + const event = await createTestEvent({ maxAttendees: 5 }); + expect( + await checkBatchAvailability([{ eventId: event.id, quantity: -1 }]), + ).toBe(false); + }); + + test("rejects a standard event exceeding total capacity", async () => { + const event = await createTestEvent({ eventType: "standard", maxAttendees: 2 }); + await bookAttendee(event, { quantity: 2 }); + expect( + await checkBatchAvailability([{ eventId: event.id, quantity: 1 }]), + ).toBe(false); + }); + }, +); diff --git a/test/lib/db/attendees/create-attendee-atomic.test.ts b/test/lib/db/attendees/create-attendee-atomic.test.ts index 752f51db..3f35d9af 100644 --- a/test/lib/db/attendees/create-attendee-atomic.test.ts +++ b/test/lib/db/attendees/create-attendee-atomic.test.ts @@ -5,14 +5,27 @@ import { decryptAttendees, getAttendeesRaw, } from "#shared/db/attendees.ts"; +import { dateToRange } from "#shared/db/capacity.ts"; import { getDb } from "#shared/db/client.ts"; import { CONFIG_KEYS, settings } from "#shared/db/settings.ts"; import { + createDailyTestEvent, createTestEvent, describeWithEnv, getTestPrivateKey, } from "#test-utils"; +/** Fetch raw start_at/end_at for an event (getAttendeesRaw drops them). */ +const getRange = async ( + eventId: number, +): Promise<{ start_at: string; end_at: string }> => { + const res = await getDb().execute({ + args: [eventId], + sql: "SELECT start_at, end_at FROM event_attendees WHERE event_id = ?", + }); + return res.rows[0] as unknown as { start_at: string; end_at: string }; +}; + describeWithEnv("db > attendees > createAttendeeAtomic", { db: true }, () => { test("succeeds when capacity available", async () => { const event = await createTestEvent({ @@ -149,4 +162,153 @@ describeWithEnv("db > attendees > createAttendeeAtomic", { db: true }, () => { const attendees = await decryptAttendees(raw, privateKey); expect(attendees[0]?.price_paid).toBe("2500"); }); + + test("stores end_at = start_at + duration days for daily multi-day bookings", async () => { + const event = await createDailyTestEvent({ + durationDays: 3, + maxAttendees: 5, + maximumDaysAfter: 30, + }); + await createAttendeeAtomic({ + bookings: [ + { date: "2026-05-01", durationDays: 3, eventId: event.id, quantity: 1 }, + ], + email: "range@example.com", + name: "Range", + }); + const { start_at, end_at } = await getRange(event.id); + expect(start_at).toBe("2026-05-01T00:00:00Z"); + expect(end_at).toBe("2026-05-04T00:00:00.000Z"); + }); + + test("year-boundary range stores end_at correctly", async () => { + const event = await createDailyTestEvent({ + durationDays: 7, + maxAttendees: 2, + maximumDaysAfter: 400, + }); + await createAttendeeAtomic({ + bookings: [ + { date: "2026-12-30", durationDays: 7, eventId: event.id, quantity: 1 }, + ], + email: "ny@example.com", + name: "NewYear", + }); + const { start_at, end_at } = await getRange(event.id); + expect(start_at).toBe("2026-12-30T00:00:00Z"); + expect(end_at).toBe("2027-01-06T00:00:00.000Z"); + }); + + test("boundary: day-N end does not overlap another booking starting on day N", async () => { + // Two 1-day bookings back-to-back at cap=1. start_at strict <, end_at + // strict > — the second must fit. + const event = await createDailyTestEvent({ + maxAttendees: 1, + maximumDaysAfter: 30, + }); + const a = await createAttendeeAtomic({ + bookings: [{ date: "2026-05-01", eventId: event.id, quantity: 1 }], + email: "a@example.com", + name: "A", + }); + expect(a.success).toBe(true); + const b = await createAttendeeAtomic({ + bookings: [{ date: "2026-05-02", eventId: event.id, quantity: 1 }], + email: "b@example.com", + name: "B", + }); + expect(b.success).toBe(true); + }); + + test("atomic SQL rejects a multi-day booking spanning a full day (no preflight)", async () => { + // Bypass checkBatchAvailability and stress the inline capacity check in + // the INSERT: day 2 at cap, 3-day booking starting day 1 must reject. + const event = await createDailyTestEvent({ + durationDays: 3, + maxAttendees: 2, + maximumDaysAfter: 30, + }); + await createAttendeeAtomic({ + bookings: [ + { date: "2026-05-02", durationDays: 1, eventId: event.id, quantity: 2 }, + ], + email: "mid@example.com", + name: "Mid", + }); + const result = await createAttendeeAtomic({ + bookings: [ + { date: "2026-05-01", durationDays: 3, eventId: event.id, quantity: 1 }, + ], + email: "span@example.com", + name: "Span", + }); + expect(result.success).toBe(false); + }); + + test("concurrent at-capacity inserts: only one wins", async () => { + const event = await createTestEvent({ maxAttendees: 1 }); + const [a, b] = await Promise.all([ + createAttendeeAtomic({ + bookings: [{ eventId: event.id, quantity: 1 }], + email: "a@example.com", + name: "A", + }), + createAttendeeAtomic({ + bookings: [{ eventId: event.id, quantity: 1 }], + email: "b@example.com", + name: "B", + }), + ]); + expect([a.success, b.success].filter(Boolean).length).toBe(1); + }); + + test("rejects negative quantities (defensive guard at library boundary)", async () => { + const event = await createTestEvent({ maxAttendees: 5 }); + const result = await createAttendeeAtomic({ + bookings: [{ eventId: event.id, quantity: -1 }], + email: "neg@example.com", + name: "Neg", + }); + expect(result.success).toBe(false); + }); + + test("rejects duplicate (event, date) rows in one cart", async () => { + // The event_attendees unique index is (event_id, attendee_id, start_at) + // — two rows with the same tuple would violate it and silently deliver + // a half-fulfilled booking. Reject upfront so the caller merges qty. + const event = await createDailyTestEvent({ + maxAttendees: 10, + maximumDaysAfter: 30, + }); + const dup = await createAttendeeAtomic({ + bookings: [ + { date: "2026-05-01", eventId: event.id, quantity: 1 }, + { date: "2026-05-01", eventId: event.id, quantity: 1 }, + ], + email: "dup@example.com", + name: "Dup", + }); + expect(dup.success).toBe(false); + // Different dates on the same event are fine. + const ok = await createAttendeeAtomic({ + bookings: [ + { date: "2026-05-01", eventId: event.id, quantity: 1 }, + { date: "2026-05-02", eventId: event.id, quantity: 1 }, + ], + email: "ok@example.com", + name: "Ok", + }); + expect(ok.success).toBe(true); + }); + + test("dateToRange produces half-open [start, end) with 1-day default", () => { + expect(dateToRange("2026-04-15")).toEqual({ + endAt: "2026-04-16T00:00:00.000Z", + startAt: "2026-04-15T00:00:00Z", + }); + expect(dateToRange("2026-04-15", 3)).toEqual({ + endAt: "2026-04-18T00:00:00.000Z", + startAt: "2026-04-15T00:00:00Z", + }); + }); }); diff --git a/test/lib/db/attendees/has-available-spots.test.ts b/test/lib/db/attendees/has-available-spots.test.ts index 12ce7427..c7882baa 100644 --- a/test/lib/db/attendees/has-available-spots.test.ts +++ b/test/lib/db/attendees/has-available-spots.test.ts @@ -3,8 +3,10 @@ import { it as test } from "@std/testing/bdd"; import { hasAvailableSpots } from "#shared/db/attendees.ts"; import { bookAttendee, + createDailyTestEvent, createTestAttendee, createTestEvent, + createTestGroup, describeWithEnv, } from "#test-utils"; @@ -15,64 +17,46 @@ describeWithEnv("db > attendees > hasAvailableSpots", { db: true }, () => { }); test("returns true when spots available", async () => { - const event = await createTestEvent({ - maxAttendees: 2, - thankYouUrl: "https://example.com", - }); - const result = await hasAvailableSpots(event.id); - expect(result).toBe(true); + const event = await createTestEvent({ maxAttendees: 2 }); + expect(await hasAvailableSpots(event.id)).toBe(true); }); test("returns true when some spots taken", async () => { - const event = await createTestEvent({ - maxAttendees: 2, - thankYouUrl: "https://example.com", - }); + const event = await createTestEvent({ maxAttendees: 2 }); await createTestAttendee(event.id, event.slug, "John", "john@example.com"); - - const result = await hasAvailableSpots(event.id); - expect(result).toBe(true); + expect(await hasAvailableSpots(event.id)).toBe(true); }); test("returns false when event is full", async () => { - const event = await createTestEvent({ - maxAttendees: 2, - thankYouUrl: "https://example.com", - }); + const event = await createTestEvent({ maxAttendees: 2 }); await createTestAttendee(event.id, event.slug, "John", "john@example.com"); await createTestAttendee(event.id, event.slug, "Jane", "jane@example.com"); - - const result = await hasAvailableSpots(event.id); - expect(result).toBe(false); + expect(await hasAvailableSpots(event.id)).toBe(false); }); test("checks per-date capacity for daily events", async () => { - const event = await createTestEvent({ - bookableDays: [ - "Monday", - "Tuesday", - "Wednesday", - "Thursday", - "Friday", - "Saturday", - "Sunday", - ], - eventType: "daily", - maxAttendees: 1, - maximumDaysAfter: 14, - minimumDaysBefore: 0, - }); - - await bookAttendee(event, { - date: "2026-02-10", - email: "day@example.com", - name: "Day User", - }); + const event = await createDailyTestEvent({ maxAttendees: 1 }); + await bookAttendee(event, { date: "2026-02-10" }); + expect(await hasAvailableSpots(event.id, 1, "2026-02-10")).toBe(false); + expect(await hasAvailableSpots(event.id, 1, "2026-02-11")).toBe(true); + }); - const full = await hasAvailableSpots(event.id, 1, "2026-02-10"); - expect(full).toBe(false); + test("multi-day range: every day must have room (event cap)", async () => { + const event = await createDailyTestEvent({ durationDays: 3, maxAttendees: 2 }); + await bookAttendee(event, { date: "2026-05-03", durationDays: 1, quantity: 2 }); + expect(await hasAvailableSpots(event.id, 1, "2026-05-01", 3)).toBe(false); + expect(await hasAvailableSpots(event.id, 1, "2026-05-01", 1)).toBe(true); + }); - const available = await hasAvailableSpots(event.id, 1, "2026-02-11"); - expect(available).toBe(true); + test("multi-day range: every day must have room (group cap)", async () => { + const group = await createTestGroup({ maxAttendees: 2 }); + const event = await createDailyTestEvent({ + durationDays: 2, + groupId: group.id, + maxAttendees: 100, + }); + const sibling = await createDailyTestEvent({ groupId: group.id, maxAttendees: 100 }); + await bookAttendee(sibling, { date: "2026-05-02", quantity: 2 }); + expect(await hasAvailableSpots(event.id, 1, "2026-05-01", 2)).toBe(false); }); }); diff --git a/test/lib/db/attendees/update-event-link.test.ts b/test/lib/db/attendees/update-event-link.test.ts index f72f2a10..3de0de6c 100644 --- a/test/lib/db/attendees/update-event-link.test.ts +++ b/test/lib/db/attendees/update-event-link.test.ts @@ -1,41 +1,33 @@ import { expect } from "@std/expect"; import { it as test } from "@std/testing/bdd"; import { getAttendeesRaw } from "#shared/db/attendees.ts"; -import { bookAttendee, createTestEvent, describeWithEnv } from "#test-utils"; +import { + bookAttendee, + createDailyTestEvent, + createTestEvent, + createTestGroup, + describeWithEnv, +} from "#test-utils"; describeWithEnv("db > attendees > updateEventLink", { db: true }, () => { test("updates quantity with capacity guard", async () => { const { updateEventLink } = await import("#shared/db/attendees.ts"); const event = await createTestEvent({ maxAttendees: 5 }); - const result = await bookAttendee(event, { - email: "link@test.com", - name: "Link", - quantity: 2, - }); - expect(result.success).toBe(true); - if (!result.success) return; - + const result = await bookAttendee(event, { quantity: 2 }); + if (!result.success) throw new Error("setup"); const update = await updateEventLink(result.attendees[0]!.id, event.id, { date: null, quantity: 3, }); expect(update.success).toBe(true); - - const raw = await getAttendeesRaw(event.id); - expect(raw[0]!.quantity).toBe(3); + expect((await getAttendeesRaw(event.id))[0]!.quantity).toBe(3); }); test("rejects update that would exceed capacity", async () => { const { updateEventLink } = await import("#shared/db/attendees.ts"); const event = await createTestEvent({ maxAttendees: 3 }); - const result = await bookAttendee(event, { - email: "cap@test.com", - name: "Cap", - quantity: 2, - }); - expect(result.success).toBe(true); - if (!result.success) return; - + const result = await bookAttendee(event, { quantity: 2 }); + if (!result.success) throw new Error("setup"); const update = await updateEventLink(result.attendees[0]!.id, event.id, { date: null, quantity: 4, @@ -45,25 +37,69 @@ describeWithEnv("db > attendees > updateEventLink", { db: true }, () => { test("updates date for daily event link", async () => { const { updateEventLink } = await import("#shared/db/attendees.ts"); - const event = await createTestEvent({ - eventType: "daily", - maxAttendees: 10, - }); - const result = await bookAttendee(event, { - date: "2026-04-07", - email: "daily@test.com", - name: "Daily", - }); - expect(result.success).toBe(true); - if (!result.success) return; - + const event = await createDailyTestEvent({ maxAttendees: 10 }); + const result = await bookAttendee(event, { date: "2026-04-07" }); + if (!result.success) throw new Error("setup"); const update = await updateEventLink(result.attendees[0]!.id, event.id, { date: "2026-04-08", quantity: 1, }); expect(update.success).toBe(true); + expect((await getAttendeesRaw(event.id))[0]!.date).toBe("2026-04-08"); + }); + + test("admits a multi-day update whose range contains non-overlapping bookings", async () => { + const { updateEventLink } = await import("#shared/db/attendees.ts"); + const event = await createDailyTestEvent({ durationDays: 3, maxAttendees: 2 }); + await bookAttendee(event, { date: "2026-06-01", durationDays: 1 }); + await bookAttendee(event, { date: "2026-06-03", durationDays: 1 }); + const target = await bookAttendee(event, { date: "2026-06-20", durationDays: 1 }); + if (!target.success) throw new Error("setup"); + const moved = await updateEventLink(target.attendees[0]!.id, event.id, { + date: "2026-06-01", + durationDays: 3, + quantity: 1, + }); + expect(moved.success).toBe(true); + }); + + test("returns capacity_exceeded for non-existent (attendee, event) pair", async () => { + const { updateEventLink } = await import("#shared/db/attendees.ts"); + const event = await createTestEvent({ maxAttendees: 5 }); + expect( + (await updateEventLink(999_999, event.id, { date: null, quantity: 1 })) + .success, + ).toBe(false); + }); - const raw = await getAttendeesRaw(event.id); - expect(raw[0]!.date).toBe("2026-04-08"); + test("self-excludes on a group-capped daily event", async () => { + const { updateEventLink } = await import("#shared/db/attendees.ts"); + const group = await createTestGroup({ maxAttendees: 2 }); + const event = await createDailyTestEvent({ groupId: group.id, maxAttendees: 5 }); + const own = await bookAttendee(event, { date: "2026-07-01", quantity: 2 }); + if (!own.success) throw new Error("setup"); + const moved = await updateEventLink(own.attendees[0]!.id, event.id, { + date: "2026-07-02", + durationDays: 1, + quantity: 2, + }); + expect(moved.success).toBe(true); + }); + + test("self-excludes on a group-capped standard event", async () => { + const { updateEventLink } = await import("#shared/db/attendees.ts"); + const group = await createTestGroup({ maxAttendees: 3 }); + const event = await createTestEvent({ + eventType: "standard", + groupId: group.id, + maxAttendees: 10, + }); + const own = await bookAttendee(event, { quantity: 2 }); + if (!own.success) throw new Error("setup"); + const resized = await updateEventLink(own.attendees[0]!.id, event.id, { + date: null, + quantity: 3, + }); + expect(resized.success).toBe(true); }); });