From f85848a50b03423783cef56a11ba59bc4df59041 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 6 Sep 2026 19:30:47 +0200 Subject: [PATCH] feat(events): guest identity, frontend_url and public ticket read in the ticket API Prepare the webapp for account-less ticket purchases against events ext v1.6.1-aio.8: - requestTicket takes an options object: user_id (+ optional email) for logged-in buyers, name + email for guests, Authorization only when a token exists, and frontend_url so the backend builds the Stripe return URLs and the emailed ticket link under this app's root. - getTicket (anonymous PublicTicket) and getPublicEvent for the return dialog and the public ticket page. - resendTicketEmail maps the structured TicketResendResult (legacy bare ticket tolerated). - NIP-52 tickets_payment_methods tag parsed into ticketInfo.paymentMethods; effectivePaymentMethods() mirrors the backend rule. - renderTicketQr: level-H ticket:// QR with the brand logo centred, shared by every ticket surface; useTicketPurchase accepts a guest contact. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo --- .../events/composables/useTicketPurchase.ts | 44 ++++-- src/modules/events/lib/frontendRoot.spec.ts | 16 +++ src/modules/events/lib/frontendRoot.ts | 20 +++ src/modules/events/lib/paymentMethods.spec.ts | 21 +++ src/modules/events/lib/paymentMethods.ts | 22 +++ src/modules/events/lib/ticketQr.ts | 61 ++++++++ .../events/services/TicketApiService.spec.ts | 92 ++++++++++++ .../events/services/TicketApiService.ts | 133 +++++++++++++----- src/modules/events/types/event.ts | 3 + src/modules/events/types/nip52.spec.ts | 35 +++++ src/modules/events/types/nip52.ts | 8 ++ src/modules/events/types/ticket.ts | 51 ++++++- 12 files changed, 450 insertions(+), 56 deletions(-) create mode 100644 src/modules/events/lib/frontendRoot.spec.ts create mode 100644 src/modules/events/lib/frontendRoot.ts create mode 100644 src/modules/events/lib/paymentMethods.spec.ts create mode 100644 src/modules/events/lib/paymentMethods.ts create mode 100644 src/modules/events/lib/ticketQr.ts create mode 100644 src/modules/events/services/TicketApiService.spec.ts create mode 100644 src/modules/events/types/nip52.spec.ts diff --git a/src/modules/events/composables/useTicketPurchase.ts b/src/modules/events/composables/useTicketPurchase.ts index 3accb7b..ea763d1 100644 --- a/src/modules/events/composables/useTicketPurchase.ts +++ b/src/modules/events/composables/useTicketPurchase.ts @@ -6,6 +6,22 @@ import { injectService, SERVICE_TOKENS } from '@/core/di-container' import type { PaymentService } from '@/core/services/PaymentService' import type { TicketApiService } from '../services/TicketApiService' import { useOwnedTickets } from './useOwnedTickets' +import { frontendRoot } from '../lib/frontendRoot' +import { renderTicketQr } from '../lib/ticketQr' + +/** Guest identity for a purchase without an LNbits account. */ +export interface PurchaseContact { + name: string + email: string +} + +export interface PurchaseTicketOptions { + quantity?: number + /** Guest buyer (no account). Required when not authenticated. */ + contact?: PurchaseContact + /** Delivery email for a logged-in buyer (guests carry it in `contact`). */ + email?: string +} export function useTicketPurchase() { const { isAuthenticated, currentUser } = useAuth() @@ -64,11 +80,7 @@ export function useTicketPurchase() { async function generateTicketQRCode(ticketId: string) { try { - const ticketUrl = `ticket://${ticketId}` - const dataUrl = await paymentService.generateCustomQRCode(ticketUrl, { - width: 128, - margin: 2 - }) + const dataUrl = await renderTicketQr(ticketId, { width: 160 }) ticketQRCode.value = dataUrl return dataUrl } catch (error) { @@ -128,9 +140,13 @@ export function useTicketPurchase() { async function purchaseTicketForEvent( eventId: string, - options: { quantity?: number } = {}, + options: PurchaseTicketOptions = {}, ) { - if (!canPurchase.value || !currentUser.value) { + // Guests are identified by name + email; logged-in buyers by user id. + // Either is enough for a paid ticket — free tickets stay login-gated + // in the dialog (events#29: no anonymous free issuance). + const isGuest = !canPurchase.value || !currentUser.value + if (isGuest && !options.contact) { throw new Error('User must be authenticated to purchase tickets') } @@ -150,12 +166,14 @@ export function useTicketPurchase() { const lnbitsAPI = injectService(SERVICE_TOKENS.LNBITS_API) as any const accessToken = lnbitsAPI?.getAccessToken?.() || '' - const invoice = await ticketApi.requestTicket( - eventId, - currentUser.value!.id, - accessToken, - { quantity: options.quantity }, - ) + const invoice = await ticketApi.requestTicket(eventId, { + userId: isGuest ? undefined : currentUser.value!.id, + name: isGuest ? options.contact?.name : undefined, + email: isGuest ? options.contact?.email : options.email, + accessToken: isGuest ? undefined : accessToken, + frontendUrl: frontendRoot(), + quantity: options.quantity, + }) // Free tickets (price 0 / 100%-off promo): the backend already // issued + marked them paid, so there's no invoice to settle. Skip diff --git a/src/modules/events/lib/frontendRoot.spec.ts b/src/modules/events/lib/frontendRoot.spec.ts new file mode 100644 index 0000000..13f5a68 --- /dev/null +++ b/src/modules/events/lib/frontendRoot.spec.ts @@ -0,0 +1,16 @@ +import { describe, it, expect } from 'vitest' +import { frontendRoot } from './frontendRoot' + +describe('frontendRoot', () => { + it('joins origin and base path without a trailing slash', () => { + expect(frontendRoot('https://demo.aiolabs.dev', '/events/')).toBe('https://demo.aiolabs.dev/events') + }) + + it('collapses a root base to the bare origin', () => { + expect(frontendRoot('http://localhost:5173', '/')).toBe('http://localhost:5173') + }) + + it('tolerates a base without leading slash', () => { + expect(frontendRoot('https://app.ariege.io', 'oyez/')).toBe('https://app.ariege.io/oyez') + }) +}) diff --git a/src/modules/events/lib/frontendRoot.ts b/src/modules/events/lib/frontendRoot.ts new file mode 100644 index 0000000..6232e68 --- /dev/null +++ b/src/modules/events/lib/frontendRoot.ts @@ -0,0 +1,20 @@ +/** + * This app's public root — origin + Vite base path, no trailing slash. + * + * Sent to the events extension as `frontend_url` so the backend can + * compose the Stripe success / cancel URLs and the emailed ticket link + * under the webapp (e.g. `https://demo.aiolabs.dev/events`, + * `https://app.ariege.io/oyez`) instead of the LNbits host. The + * extension appends the same path shapes the LNbits UI uses + * (`/events/{event_id}`, `/events/ticket/{ticket_id}`), which the events + * router serves under that base. + * + * Parameters exist for tests; callers use the defaults. + */ +export function frontendRoot( + origin: string = window.location.origin, + base: string = import.meta.env.BASE_URL ?? '/', +): string { + const path = base.startsWith('/') ? base : `/${base}` + return `${origin}${path}`.replace(/\/+$/, '') +} diff --git a/src/modules/events/lib/paymentMethods.spec.ts b/src/modules/events/lib/paymentMethods.spec.ts new file mode 100644 index 0000000..72c80f2 --- /dev/null +++ b/src/modules/events/lib/paymentMethods.spec.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from 'vitest' +import { effectivePaymentMethods } from './paymentMethods' + +describe('effectivePaymentMethods', () => { + it('falls back to the legacy allowFiat rule when no list is published', () => { + expect(effectivePaymentMethods({ allowFiat: false })).toEqual(['lightning']) + expect(effectivePaymentMethods({ allowFiat: true })).toEqual(['lightning', 'fiat']) + expect(effectivePaymentMethods(undefined)).toEqual(['lightning']) + }) + + it('honours an explicit list, including fiat-only events', () => { + expect(effectivePaymentMethods({ allowFiat: true, paymentMethods: ['fiat'] })).toEqual(['fiat']) + expect(effectivePaymentMethods({ allowFiat: false, paymentMethods: ['lightning', 'fiat'] })) + .toEqual(['lightning', 'fiat']) + }) + + it('drops unknown rails and falls back when nothing known remains', () => { + expect(effectivePaymentMethods({ allowFiat: false, paymentMethods: ['onchain'] })).toEqual(['lightning']) + expect(effectivePaymentMethods({ allowFiat: false, paymentMethods: ['onchain', 'fiat'] })).toEqual(['fiat']) + }) +}) diff --git a/src/modules/events/lib/paymentMethods.ts b/src/modules/events/lib/paymentMethods.ts new file mode 100644 index 0000000..70e08e9 --- /dev/null +++ b/src/modules/events/lib/paymentMethods.ts @@ -0,0 +1,22 @@ +export type PaymentRailId = 'lightning' | 'fiat' + +const KNOWN_RAILS: PaymentRailId[] = ['lightning', 'fiat'] + +/** + * Rails a buyer may use for an event. + * + * Mirrors `effective_payment_methods()` in the events extension: an + * explicit `paymentMethods` list wins; otherwise the legacy rule applies + * — Lightning always, fiat only when the organizer set `allowFiat`. + * Unknown rail ids are dropped so a future backend value can't render + * a button the dialog has no handler for. + */ +export function effectivePaymentMethods( + info: { allowFiat?: boolean; paymentMethods?: string[] } | null | undefined, +): PaymentRailId[] { + const explicit = (info?.paymentMethods ?? []).filter( + (m): m is PaymentRailId => (KNOWN_RAILS as string[]).includes(m), + ) + if (explicit.length > 0) return explicit + return info?.allowFiat ? ['lightning', 'fiat'] : ['lightning'] +} diff --git a/src/modules/events/lib/ticketQr.ts b/src/modules/events/lib/ticketQr.ts new file mode 100644 index 0000000..51790fa --- /dev/null +++ b/src/modules/events/lib/ticketQr.ts @@ -0,0 +1,61 @@ +import brandLogo from '@brand/logo.png' + +/** + * Render the scannable `ticket://` QR every ticket surface uses + * (purchase success, Stripe return dialog, ticket page, My Tickets). + * + * Error-correction level H (30 % recoverable) leaves room for the brand + * logo the events extension also composites into its emailed PNG, so + * the in-app and emailed images look alike. The overlay is best-effort: + * if canvas or the logo asset is unavailable the plain QR is returned. + */ +export async function renderTicketQr( + ticketId: string, + options: { width?: number; logo?: boolean } = {}, +): Promise { + const width = options.width ?? 200 + const qrcode = await import('qrcode') + const plain = await qrcode.toDataURL(`ticket://${ticketId}`, { + width, + margin: 2, + errorCorrectionLevel: 'H', + color: { dark: '#000000', light: '#FFFFFF' }, + }) + if (options.logo === false) return plain + try { + return await overlayLogo(plain, width) + } catch { + return plain + } +} + +function loadImage(src: string): Promise { + return new Promise((resolve, reject) => { + const img = new Image() + img.onload = () => resolve(img) + img.onerror = () => reject(new Error(`Could not load ${src}`)) + img.src = src + }) +} + +async function overlayLogo(qrDataUrl: string, width: number): Promise { + if (typeof document === 'undefined') return qrDataUrl + const canvas = document.createElement('canvas') + canvas.width = width + canvas.height = width + const ctx = canvas.getContext('2d') + if (!ctx) return qrDataUrl + + const [qr, logo] = await Promise.all([loadImage(qrDataUrl), loadImage(brandLogo)]) + ctx.drawImage(qr, 0, 0, width, width) + + // Logo ≤ 20 % of the QR width on a white pad ≈ 26 % — well inside the + // 30 % damage budget of level H, matching the backend's PNG endpoint. + const logoSize = Math.round(width * 0.2) + const padSize = Math.round(width * 0.26) + const center = width / 2 + ctx.fillStyle = '#FFFFFF' + ctx.fillRect(center - padSize / 2, center - padSize / 2, padSize, padSize) + ctx.drawImage(logo, center - logoSize / 2, center - logoSize / 2, logoSize, logoSize) + return canvas.toDataURL('image/png') +} diff --git a/src/modules/events/services/TicketApiService.spec.ts b/src/modules/events/services/TicketApiService.spec.ts new file mode 100644 index 0000000..999ac80 --- /dev/null +++ b/src/modules/events/services/TicketApiService.spec.ts @@ -0,0 +1,92 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { TicketApiService } from './TicketApiService' + +const BASE = 'https://lnbits.test' + +function okJson(body: unknown) { + return { ok: true, json: async () => body } as Response +} + +describe('TicketApiService.requestTicket', () => { + const fetchMock = vi.fn() + let api: TicketApiService + + beforeEach(() => { + fetchMock.mockReset() + vi.stubGlobal('fetch', fetchMock) + api = new TicketApiService({ baseUrl: BASE, apiKey: 'inst-key' }) + }) + afterEach(() => vi.unstubAllGlobals()) + + function lastCall() { + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit] + return { url, init, body: JSON.parse(init.body as string), headers: init.headers as Record } + } + + it('sends name + email and no user_id / Authorization for guests', async () => { + fetchMock.mockResolvedValue(okJson({ payment_hash: 'h', fiat_payment_request: 'https://checkout', is_fiat: true, ticket_ids: ['a', 'b'] })) + const res = await api.requestTicket('evt', { + name: 'Guest', email: 'g@example.com', frontendUrl: 'https://demo.aiolabs.dev/events', + paymentMethod: 'fiat', quantity: 2, + }) + const { url, body, headers } = lastCall() + expect(url).toBe(`${BASE}/events/api/v1/tickets/evt`) + expect(body).toEqual({ + name: 'Guest', email: 'g@example.com', frontend_url: 'https://demo.aiolabs.dev/events', + payment_method: 'fiat', quantity: 2, + }) + expect(body.user_id).toBeUndefined() + expect(headers.Authorization).toBeUndefined() + expect(headers['X-API-KEY']).toBe('inst-key') + expect(res).toMatchObject({ isFiat: true, fiatPaymentRequest: 'https://checkout', ticketIds: ['a', 'b'] }) + }) + + it('sends user_id + email with a Bearer token for logged-in buyers', async () => { + fetchMock.mockResolvedValue(okJson({ payment_hash: 'h', payment_request: 'lnbc1', is_fiat: false })) + await api.requestTicket('evt', { + userId: 'u1', email: 'me@example.com', accessToken: 'tok', frontendUrl: 'http://localhost:5173', + }) + const { body, headers } = lastCall() + expect(body).toEqual({ user_id: 'u1', email: 'me@example.com', frontend_url: 'http://localhost:5173' }) + expect(headers.Authorization).toBe('Bearer tok') + }) + + it('surfaces the backend detail string as the error message', async () => { + fetchMock.mockResolvedValue({ ok: false, json: async () => ({ detail: 'Payment method not enabled for this event.' }) } as Response) + await expect(api.requestTicket('evt', { name: 'G', email: 'g@x.io', paymentMethod: 'fiat' })) + .rejects.toThrow('Payment method not enabled for this event.') + }) +}) + +describe('TicketApiService.getTicket / resendTicketEmail', () => { + const fetchMock = vi.fn() + let api: TicketApiService + + beforeEach(() => { + fetchMock.mockReset() + vi.stubGlobal('fetch', fetchMock) + api = new TicketApiService({ baseUrl: BASE, apiKey: 'k' }) + }) + afterEach(() => vi.unstubAllGlobals()) + + it('maps the anonymous PublicTicket shape', async () => { + fetchMock.mockResolvedValue(okJson({ event: 'evt', name: 'G', registered: false, paid: true, time: 't', reg_timestamp: 'r' })) + const t = await api.getTicket('tid') + expect(fetchMock.mock.calls[0][0]).toBe(`${BASE}/events/api/v1/tickets/tid`) + expect(t).toEqual({ eventId: 'evt', name: 'G', registered: false, paid: true, time: 't', regTimestamp: 'r' }) + }) + + it('maps the structured resend result and tolerates the legacy bare-ticket shape', async () => { + const ticket = { id: 'tid', wallet: 'w', event: 'evt', name: 'G', email: 'g@x.io', user_id: null, registered: false, paid: true, time: 't', reg_timestamp: 'r', extra: {} } + fetchMock.mockResolvedValueOnce(okJson({ ticket, email: { attempted: true, sent: false, error: 'smtp down' }, nostr: { attempted: false, sent: false, error: null } })) + const r = await api.resendTicketEmail('tid', 'admin') + expect(r.ticket.id).toBe('tid') + expect(r.email).toEqual({ attempted: true, sent: false, error: 'smtp down' }) + expect(r.nostr.attempted).toBe(false) + + fetchMock.mockResolvedValueOnce(okJson(ticket)) + const legacy = await api.resendTicketEmail('tid', 'admin') + expect(legacy.ticket.id).toBe('tid') + expect(legacy.email).toEqual({ attempted: false, sent: false, error: null }) + }) +}) diff --git a/src/modules/events/services/TicketApiService.ts b/src/modules/events/services/TicketApiService.ts index ae7476d..9a2da22 100644 --- a/src/modules/events/services/TicketApiService.ts +++ b/src/modules/events/services/TicketApiService.ts @@ -3,8 +3,10 @@ import type { EventTicketExtra, CreateTicketRequest, PaymentMethod, + PublicTicket, TicketPurchaseInvoice, TicketPaymentStatus, + TicketResendResult, TicketedEvent, CreateEventRequest, } from '../types/ticket' @@ -14,6 +16,27 @@ export interface TicketApiConfig { apiKey: string } +/** Options for `TicketApiService.requestTicket`. */ +export interface RequestTicketOptions { + /** LNbits user id — logged-in buyers. */ + userId?: string + /** Guest buyer name (required together with `email` when no `userId`). */ + name?: string + /** Delivery email — required for guests, optional for logged-in buyers. */ + email?: string + /** Bearer token; forwarded only when present. */ + accessToken?: string + /** App root for return URLs + emailed link (see `lib/frontendRoot.ts`). */ + frontendUrl?: string + paymentMethod?: PaymentMethod + fiatProvider?: string + promoCode?: string + refundAddress?: string + nostrIdentifier?: string + /** Number of tickets to buy on this invoice. Backend caps at 10. */ + quantity?: number +} + /** * Database-backed ticketing API service. * Talks to the LNbits events extension for ticket inventory, @@ -57,29 +80,27 @@ export class TicketApiService { * = follow-the-URL string from the configured fiat provider). The * `isFiat` flag is the discriminator. * - * `paymentMethod` defaults to "lightning"; pass "fiat" to opt into - * the fiat path (requires the event to have `allow_fiat=true`). - * `fiatProvider` is optional — backend picks the user's configured - * default when omitted. + * Identity: pass `userId` for a logged-in buyer (plus `email` so the + * ticket can be delivered by mail), or `name` + `email` for a guest. + * The endpoint itself is anonymous; `accessToken` is only forwarded + * when present. * - * Additional ticket metadata (promo code, refund address, nostr - * identifier for DM delivery) can be supplied via `options`. + * `paymentMethod` defaults to "lightning"; pass "fiat" to opt into + * the fiat path (the event must have that rail enabled — the backend + * 400s otherwise). `fiatProvider` is optional — backend picks the + * organizer's configured default when omitted. `frontendUrl` is this + * app's root (see `lib/frontendRoot.ts`) and drives the Stripe + * return URLs + emailed ticket link. */ async requestTicket( eventId: string, - userId: string, - accessToken: string, - options: { - paymentMethod?: PaymentMethod - fiatProvider?: string - promoCode?: string - refundAddress?: string - nostrIdentifier?: string - /** Number of tickets to buy on this invoice. Backend caps at 10. */ - quantity?: number - } = {}, + options: RequestTicketOptions = {}, ): Promise { - const body: CreateTicketRequest = { user_id: userId } + const body: CreateTicketRequest = {} + if (options.userId) body.user_id = options.userId + if (options.name) body.name = options.name + if (options.email) body.email = options.email + if (options.frontendUrl) body.frontend_url = options.frontendUrl if (options.paymentMethod) body.payment_method = options.paymentMethod if (options.fiatProvider) body.fiat_provider = options.fiatProvider if (options.promoCode) body.promo_code = options.promoCode @@ -87,14 +108,14 @@ export class TicketApiService { if (options.nostrIdentifier) body.nostr_identifier = options.nostrIdentifier if (options.quantity && options.quantity > 1) body.quantity = options.quantity + const headers: Record = { 'Content-Type': 'application/json' } + if (options.accessToken) headers['Authorization'] = `Bearer ${options.accessToken}` + const data = await this.request( `/events/api/v1/tickets/${eventId}`, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${accessToken}`, - }, + headers, body: JSON.stringify(body), } ) @@ -112,6 +133,33 @@ export class TicketApiService { } } + /** + * Anonymous read of one ticket — paid / registered state only, no + * PII. Used by the Stripe return dialog to poll settlement and by + * the public ticket page the emailed link opens. + */ + async getTicket(ticketId: string): Promise { + const t = await this.request(`/events/api/v1/tickets/${ticketId}`, { method: 'GET' }) + return { + eventId: t.event, + name: t.name ?? null, + registered: Boolean(t.registered), + paid: Boolean(t.paid), + time: t.time, + regTimestamp: t.reg_timestamp, + } + } + + /** + * Anonymous public event detail (`PublicEvent`). Note the backend + * answers 410 for sold-out / closed events — callers that only need + * a display name should try the Nostr store first and treat a + * failure here as non-fatal. + */ + async getPublicEvent(eventId: string): Promise { + return this.request(`/events/api/v1/events/${eventId}`, { method: 'GET' }) + } + /** * Check whether a ticket payment has been confirmed. */ @@ -226,33 +274,44 @@ export class TicketApiService { /** * Resend the ticket confirmation email for a paid ticket. Requires * the event's wallet admin key (organizer-only). Returns the updated - * Ticket with the `email_notification_sent` flag refreshed. + * Ticket plus per-channel delivery results (events ext >= v1.6.1-aio.8; + * older extensions returned the bare Ticket, which is tolerated). * - * Endpoint added upstream in v1.6.1 (PR #51). + * Endpoint added upstream in v1.6.1 (PR #51); structured result in v1.6.8. */ async resendTicketEmail( ticketId: string, adminKey: string, - ): Promise { - const t = await this.request( + ): Promise { + const r = await this.request( `/events/api/v1/tickets/${ticketId}/resend-email`, { method: 'POST', headers: { 'X-API-KEY': adminKey }, } ) + const t = r.ticket ?? r + const delivery = (d: any) => ({ + attempted: Boolean(d?.attempted), + sent: Boolean(d?.sent), + error: d?.error ?? null, + }) return { - id: t.id, - wallet: t.wallet, - eventId: t.event, - name: t.name, - email: t.email, - userId: t.user_id, - registered: t.registered, - paid: t.paid, - time: t.time, - regTimestamp: t.reg_timestamp, - extra: t.extra as EventTicketExtra | undefined, + ticket: { + id: t.id, + wallet: t.wallet, + eventId: t.event, + name: t.name, + email: t.email, + userId: t.user_id, + registered: t.registered, + paid: t.paid, + time: t.time, + regTimestamp: t.reg_timestamp, + extra: t.extra as EventTicketExtra | undefined, + }, + email: delivery(r.email), + nostr: delivery(r.nostr), } } diff --git a/src/modules/events/types/event.ts b/src/modules/events/types/event.ts index 88762b3..72d7c4f 100644 --- a/src/modules/events/types/event.ts +++ b/src/modules/events/types/event.ts @@ -84,6 +84,8 @@ export interface EventTicketInfo { allowFiat: boolean /** Fiat settle currency when allowFiat is true. */ fiatCurrency?: string + /** Rails the organizer enabled (see TicketTags.paymentMethods). */ + paymentMethods?: string[] } function ticketTagsToInfo(ticket: TicketTags | undefined): EventTicketInfo | undefined { @@ -98,6 +100,7 @@ function ticketTagsToInfo(ticket: TicketTags | undefined): EventTicketInfo | und total: ticket.available !== undefined ? ticket.available + ticket.sold : undefined, allowFiat: ticket.allowFiat, fiatCurrency: ticket.fiatCurrency, + paymentMethods: ticket.paymentMethods, } } diff --git a/src/modules/events/types/nip52.spec.ts b/src/modules/events/types/nip52.spec.ts new file mode 100644 index 0000000..cc95583 --- /dev/null +++ b/src/modules/events/types/nip52.spec.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest' +import { parseCalendarTimeEvent, NIP52_KINDS } from './nip52' +import type { NostrEvent } from 'nostr-tools' + +function makeEvent(extraTags: string[][]): NostrEvent { + return { + kind: NIP52_KINDS.CALENDAR_TIME_EVENT, + id: 'id', + pubkey: 'pk', + created_at: 1_700_000_000, + content: '', + sig: '', + tags: [ + ['d', 'evt1'], + ['title', 'Test'], + ['start', '1700000000'], + ['tickets_currency', 'EUR'], + ['tickets_price', '20'], + ...extraTags, + ], + } as NostrEvent +} + +describe('parseTicketTags — tickets_payment_methods', () => { + it('parses the comma list into paymentMethods', () => { + const parsed = parseCalendarTimeEvent(makeEvent([['tickets_payment_methods', 'lightning, fiat']])) + expect(parsed?.ticket?.paymentMethods).toEqual(['lightning', 'fiat']) + }) + + it('leaves paymentMethods undefined when the tag is absent (legacy events)', () => { + const parsed = parseCalendarTimeEvent(makeEvent([['tickets_allow_fiat', 'true']])) + expect(parsed?.ticket?.allowFiat).toBe(true) + expect(parsed?.ticket?.paymentMethods).toBeUndefined() + }) +}) diff --git a/src/modules/events/types/nip52.ts b/src/modules/events/types/nip52.ts index b87db35..de2d8c1 100644 --- a/src/modules/events/types/nip52.ts +++ b/src/modules/events/types/nip52.ts @@ -36,6 +36,10 @@ export interface TicketTags { allowFiat: boolean /** Fiat settle currency when allowFiat is true. */ fiatCurrency?: string + /** Rails the organizer enabled, from `tickets_payment_methods` + * ("lightning,fiat"). Undefined on events published before the + * tag existed — callers fall back to the allowFiat rule. */ + paymentMethods?: string[] } /** @@ -137,6 +141,7 @@ function parseTicketTags(tags: string[][]): TicketTags | undefined { const soldStr = getTagValue(tags, 'tickets_sold') const priceStr = getTagValue(tags, 'tickets_price') const allowFiatStr = getTagValue(tags, 'tickets_allow_fiat') + const paymentMethodsStr = getTagValue(tags, 'tickets_payment_methods') return { available: availableStr != null ? Number(availableStr) : undefined, @@ -145,6 +150,9 @@ function parseTicketTags(tags: string[][]): TicketTags | undefined { currency, allowFiat: allowFiatStr === 'true', fiatCurrency: getTagValue(tags, 'tickets_fiat_currency'), + paymentMethods: paymentMethodsStr != null + ? paymentMethodsStr.split(',').map((m) => m.trim()).filter(Boolean) + : undefined, } } diff --git a/src/modules/events/types/ticket.ts b/src/modules/events/types/ticket.ts index 3c0842e..d42b3c2c 100644 --- a/src/modules/events/types/ticket.ts +++ b/src/modules/events/types/ticket.ts @@ -26,6 +26,10 @@ export interface EventExtra { nostr_notifications: boolean notification_subject: string notification_body: string + /** Rails the organizer enabled (`lightning`, `fiat`). Empty / absent + * means the legacy rule: Lightning always, fiat iff `allow_fiat`. + * Same shape as upstream v2's `extra.payment_methods`. */ + payment_methods?: string[] } export interface EventTicketExtra { @@ -165,17 +169,22 @@ export interface CreateEventRequest { } /** - * Body for `POST /tickets/{event_id}`. Either `user_id` OR the - * `name`+`email` pair is required (backend root_validator enforces - * mutual exclusion). `nostr_identifier` opts the buyer into Nostr DM - * delivery when the event has nostr_notifications enabled. The - * `payment_method` + `fiat_provider` pair selects between Lightning - * and fiat checkout. + * Body for `POST /tickets/{event_id}`. Identity is `user_id` (logged-in + * buyer, optionally with `email` for ticket delivery) OR the + * `name`+`email` pair (guest buyer). `nostr_identifier` opts the buyer + * into Nostr DM delivery when the event has nostr_notifications + * enabled. The `payment_method` + `fiat_provider` pair selects between + * Lightning and fiat checkout. `frontend_url` is this app's root so the + * backend can build the Stripe return URLs and the emailed ticket link + * under the webapp instead of the LNbits host. */ export interface CreateTicketRequest { name?: string email?: string user_id?: string + /** App root (origin + base path, no trailing slash). Origin must be + * on the LNbits instance's CORS allowlist or the backend 400s. */ + frontend_url?: string promo_code?: string refund_address?: string nostr_identifier?: string @@ -184,3 +193,33 @@ export interface CreateTicketRequest { /** Number of tickets on this invoice (backend bounds 1..10). */ quantity?: number } + +/** + * Anonymous view of a ticket from `GET /tickets/{ticket_id}`. The + * backend deliberately omits id / email / user_id; the id is whatever + * the caller asked for. + */ +export interface PublicTicket { + eventId: string + name: string | null + registered: boolean + paid: boolean + time: string + regTimestamp: string +} + +export interface NotificationDeliveryResult { + attempted: boolean + sent: boolean + error: string | null +} + +/** + * Structured response of `POST /tickets/{ticket_id}/resend-email` + * (events ext >= v1.6.1-aio.8, mirrors upstream v1.6.8). + */ +export interface TicketResendResult { + ticket: EventTicket + email: NotificationDeliveryResult + nostr: NotificationDeliveryResult +}