Events
Events and tickets, built on Payments. The module handles only events and tickets; money is delegated to Payments.
- Prices live in the database (minor units) and are charged through Checkout
price_data; there are no Stripe ids to maintain per environment. - Ticket state follows payment state. A paid ticket becomes
paidonly through thepayments:paidhook, fed by verified Stripe events. Free tickets (total0) are issued immediately without Stripe. - Purchased tickets are snapshots. Amount, currency and chosen addons are copied onto the ticket, which has no foreign keys to the catalogue, so editing or deleting catalogue rows never changes what someone bought.
- Staff access is the host's decision through the
events:authorizehook (denied by default).
@nuxthub/core (KV on), @frigear-nu/module-kit, @frigear-nu/auth,
@frigear-nu/mcp, @frigear-nu/users, @frigear-nu/ui,
@frigear-nu/stripe and @frigear-nu/payments.
Optional@frigear-nu/subscriptions (membership provider),
@frigear-nu/menu, @frigear-nu/widget (event page tabs) and
@frigear-nu/admin (the admin pages exist only with it), @frigear-nu/account
(the left navigation and heading around the tickets page).Quick setup
export default defineNuxtConfig({
events: {
settings: {
pages: { events: true, event: true, tickets: true },
reservationMinutes: 30,
},
},
})
Pages are opt-in and use @nuxt/ui. With @nuxtjs/i18n installed, translated texts follow the
active locale; otherwise en is used.
Options
| Option | Type | Default | Purpose |
|---|---|---|---|
settings.reservationMinutes | number | 30 | How long an unpaid ticket keeps its seat; also the Stripe session lifetime. |
settings.mcp | boolean | false | Expose the module's tools on the MCP endpoint (see MCP tools). |
settings.pages.events | string | boolean | off | Event list. true = /events. |
settings.pages.event | string | boolean | off | Event detail; path must contain :slug. true = /events/:slug. |
settings.pages.tickets | string | boolean | off | The signed-in user's tickets. true = /account/tickets. |
settings.pages.admin | string | boolean | off (needs @frigear-nu/admin) | Staff event list. true = the admin route for events, /admin/events by default. |
settings.pages.adminEvent | string | boolean | off (needs @frigear-nu/admin) | Staff event page; path must contain :id. true = /admin/events/:id. |
Pages and their patterns
The pages follow the UX patterns of the ecosystem, one per area:
- Admin (needs
@frigear-nu/admin, listed inadmin.nav): the list is aPageHeading("New event"), aTableCardwith a status badge and aRowMenuper row (Open, View public page, Duplicate | Delete), and aUEmptywhen there are no events. "New event" opensEventFormSlideover. The event page has a breadcrumb back to the list, the status as a badge, "View public page" and aRowMenuin the heading, a key-factsSectionCardand theadmin.eventstabs (Details, Tickets, Attendees). Deleting asks for confirmation; events with paid tickets cannot be deleted, archive them instead. - Account:
/account/ticketsrenders only its content, a grid ofEventTicketCards or a "No tickets yet" empty state with "Browse events". The heading and left navigation come from theaccountlayout of@frigear-nu/account. The menu item is registered in theaccountmenu (order 40) with@frigear-nu/menu. - Public:
/eventsand/events/:slugareUContainer > UPage > UPageHeaderpages (the detail page has the breadcrumb in the header's headline) with the ticket picker beside the description.
Every link to an event goes through useEventLinks(), so a visitor on /en/events stays under /en when opening an
event.
Server
Purchase rules
POST /api/events/:slug/purchase checks, in this order:
- The event exists and is not over (404 / 409).
- The ticket type exists and its sale window is open (409). A
saleSchedule, if any, counts as part of the window. - Membership at event and ticket level, then the ticket's exclusion rule (403). Without a membership provider a requirement fails closed and an exclusion excludes nobody.
- Addon rules: every addon belongs to the ticket type, required groups have a choice, single-choice groups have at most one (400).
- Addon capacity (409).
- Ticket capacity (409).
- The
events:purchase:beforehook. maxPerUser(409).
The seat is then taken. Capacity counts paid tickets plus pending ones inside the reservation window. D1 has no
interactive transactions, so after inserting the ticket the module verifies it is among the first capacity seat
holders (insertion order) and removes it if it lost a race. An expired or failed payment frees its seat.
type PurchaseBody = {
ticketTypeId: string
addonIds?: string[]
successUrl?: string
cancelUrl?: string
locale?: string // buyer's site language (e.g. 'da'), stored on the ticket for later mails and Stripe texts
}
type PurchaseResponse = {
ticket: TicketDto
url?: string // Stripe redirect; absent for free tickets, which are already `paid`
}
Check-in is idempotent: a second scan returns alreadyCheckedIn with the first scan time, and only paid tickets
validate. A refund starts at Stripe; the ticket turns refunded when charge.refunded arrives, and partial refunds
keep the ticket valid.
Seasonal ticket sales
A ticket type can carry a saleSchedule on top of saleStartsAt / saleEndsAt; both must be open for a ticket to be
sold. It uses the shared schedule of @frigear-nu/module-kit/schedule:
type Schedule =
| { type: 'yearly', timezone: string, from: { month: number, day: number, time: string }, to: { month: number, day: number, time: string } }
| { type: 'range', from: string, to: string }
// null = always. A yearly `to` before `from` wraps over new year.
Outside the schedule the ticket type is not_started (with saleStartsAt set to the next opening) and purchases are
rejected with 409.
Membership providers
This module does not know what a membership is. A provider answers two Nitro hooks (see Hooks):
events:membership:check decides whether a user holds a requirement, and events:membership:options turns on the
membership pickers in the admin UI and lists the choices. With no provider the API rejects membership rules, because a
requirement nobody can meet would silently lock the ticket.
Subscriptions is the provider out of the box: when installed, plans are the memberships and holding one means an active subscription to it. Subscriptions itself knows nothing about events. Another source (a partner list, an imported roster) can be added with its own Nitro plugin:
export default defineNitroPlugin((nitro) => {
nitro.hooks.hook('events:membership:options', (ctx) => {
ctx.available = true
ctx.options.push({ value: 'volunteer', label: 'Volunteer' })
})
nitro.hooks.hook('events:membership:check', async (ctx) => {
if (await isVolunteer(ctx.userId)) ctx.satisfied = true
})
})
Staff authorization
Staff routes need the events:manage scope and an allowing events:authorize hook. events:manage and
events:pii are only offered to users that hook lets in.
export default defineNitroPlugin((nitro) => {
nitro.hooks.hook('events:authorize', async (ctx) => {
ctx.allowed = await isEventStaff(ctx.userId)
})
})
MCP tools
With settings.mcp: true, the tools below are exposed on the MCP endpoint. Staff tools need
events:manage and the events:authorize policy; attendee names and emails also need events:pii. Prices and amounts in MCP inputs and outputs are whole currency
units (52.2 = 52.20, 500 JPY), unlike the REST API and database, which use minor units.
| Tool | Scope | Purpose |
|---|---|---|
list-events, get-event | tickets:read | Public events and details. |
my-tickets | tickets:read | The caller's tickets. |
list-all-events, get-event-definition, create-event, update-event, update-ticket-type, list-attendees | events:manage | Staff catalogue management and attendees. |
App
| Export | Description |
|---|---|
useEvents() | { events, grouped, status, refresh }; grouped splits into ongoing, upcoming, past. |
useEvent(slug) | { event, status, error, refresh, purchase(ticketTypeId, addonIds?) }. |
useMyTickets() | { tickets, status, refresh, pay(ticketId) }. |
useEventLinks() | Locale-aware links: events(), event(slug), tickets(), admin(), adminEvent(id). Follows settings.pages. |
useEventActions() | Staff actions: create() (slideover, resolves with the new event), duplicate(event), remove(event) (confirms), menu(event, { extra?, onRemoved? }) for the row menu. |
useEventsLocale() | Current locale: @nuxtjs/i18n's, else en. |
useEventsFallbackLocale() | The site's default locale, else en. |
useEventsTranslate() | tr(value): a Translated text in the current locale, falling back to the default, then en. |
Components: EventCard, EventTicketPicker, EventTicketCard (with QR code), EventFormSlideover (create an event) and
the EventAdmin* dialogs used by the admin pages. @frigear-nu/events/utils exports the DTO and hook types, translate, normalizeMembership,
DEFAULT_CURRENCY and id helpers.
Extending the admin event page
The page shows status, dates and location, and below them a tab view over the admin.events tab area of
Widget, which this module owns. It registers Details, Tickets and Attendees tabs; any
module can add more with extendTabs(nuxt, 'admin.events', { ... }). Each tab component receives event (the response
of GET /api/events/admin/events/:id) as a prop and may emit refresh. Without Widget the three built-in tabs render
stacked. Hiding a tab is not access control: authorize its API calls through events:authorize.
Hooks
Register with nitro.hooks.hook(...) in a Nitro plugin.
| Hook | When | Payload |
|---|---|---|
events:authorize | Staff gate for management routes, check-in and staff scopes. | AuthorizeContext |
events:purchase:before | During purchase, after the capacity checks; throw to deny. | PurchaseBeforeContext |
events:ticket:paid | A ticket became paid (free tickets immediately). Send confirmations here. | TicketHookContext |
events:ticket:refunded | A ticket was fully refunded. | TicketHookContext |
events:membership:check | A membership requirement is evaluated. | MembershipCheckContext |
events:membership:options | The admin UI asks which memberships can be picked. | MembershipOptionsContext |
type AuthorizeContext = { event: H3Event, userId: number, allowed: boolean } // set allowed = true
type PurchaseBeforeContext = { event: H3Event, userId: number, eventId: string, ticketTypeId: string, addonIds: string[] }
type TicketHookContext = { ticket: TicketDto }
type MembershipCheckContext = { userId: number, requirement: 'any' | string[], satisfied: boolean }
type MembershipOptionsContext = { available: boolean, options: Array<{ value: string, label: string }> }
requiresMembership and excludesMembership are null (nobody / everyone), 'any' (any active membership) or a list
of membership keys.
Routes
All paths are under /api/events. User routes also accept bearer and OAuth tokens that carry the scope.
| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | / | public | Published events. |
| GET | /:slug | public (optional tickets:read) | Event detail; includes per-user blocked flags when signed in. |
| POST | /:slug/purchase | tickets:write | Buy a ticket. |
| GET | /tickets/me | tickets:read | The caller's tickets. |
| GET | /tickets/:id | tickets:read | One ticket (poll after returning from Stripe). |
| POST | /tickets/:id/pay | tickets:write | Resume payment of a pending ticket. |
| POST | /check-in | staff | Body { code }. Check a ticket in. |
| GET | /admin/membership-options | staff | Selectable memberships from the provider. |
| GET, POST | /admin/events | staff | List events; create one (accepts the whole nested catalogue). |
| GET, PATCH, DELETE | /admin/events/:id | staff | One event with all ticket types whatever its status; update; delete. |
| POST | /admin/events/:id/clone | staff | Copy into a new draft (<slug>-copy, -copy-2, ...). Tickets are not copied; dates stay. |
| GET | /admin/events/:id/tickets | staff | Attendees. |
| POST | /admin/events/:id/ticket-types | staff | Add a ticket type. |
| PATCH, DELETE | /admin/ticket-types/:id | staff | Update or delete a ticket type. |
| POST | /admin/ticket-types/:id/addon-groups | staff | Add an addon group. |
| PATCH, DELETE | /admin/addon-groups/:id | staff | Update or delete an addon group. |
| POST | /admin/addon-groups/:id/addons | staff | Add an addon. |
| PATCH, DELETE | /admin/addons/:id | staff | Update or delete an addon. |
| POST | /admin/tickets/:id/cancel | staff | Cancel a ticket. |
| POST | /admin/tickets/:id/refund | staff | Start a full refund (only paid tickets with a payment). |
"staff" means the events:manage scope plus an allowing events:authorize hook.
Database
| Table | Purpose |
|---|---|
events | slug, translated name / description / body, image, location, locationUrl, startsAt / endsAt, status (draft, published, archived), event-level requiresMembership. |
event_ticket_types | Per event: key (unique per event), name, description, price (0 = free), currency (default DKK), hidePrice, capacity, saleStartsAt / saleEndsAt, saleSchedule, maxPerUser (default 1), requiresMembership, excludesMembership, isDefault, sortOrder. |
event_addon_groups | Per ticket type, e.g. "Food": required (pick at least one), multiple (pick several), sortOrder. |
event_addons | Choices in a group: label, price, optional capacity, sortOrder. |
event_tickets | Purchased or reserved tickets: status (pending, paid, cancelled, refunded, expired), addons, amount and currency snapshot, paymentId, unique QR code, locale, checkedInAt, checkedInBy. |
Translated texts are a string or a { da, en } map (Translated).
Translations
da and en locale files are registered with Module Kit under events.*.
Key groups: upcoming.*, ongoing.*, past.*, detail.* (including the purchase flow), tickets.*
(including tickets.status.*), cart.*, menu.*, admin.* (including admin.tabs.* and admin.statuses.*), plus top-level messages such as
title, empty, loading, ticketReady, paymentReceived and purchaseFailed. Override any string in your app's
i18n/locales/<code>.json (see
overriding module strings):
{ "events": { "upcoming": { "title": "What's on" } } }