diff --git a/src/modules/activities/composables/useTicketScanner.ts b/src/modules/activities/composables/useTicketScanner.ts index 7053f5e..4538d3e 100644 --- a/src/modules/activities/composables/useTicketScanner.ts +++ b/src/modules/activities/composables/useTicketScanner.ts @@ -1,7 +1,8 @@ import { ref, onMounted, type Ref } from 'vue' import { useLocalStorage } from '@vueuse/core' import { injectService, SERVICE_TOKENS } from '@/core/di-container' -import type { NostrTransportService } from '@/modules/base/services/NostrTransportService' +import { useAuth } from '@/composables/useAuthService' +import type { TicketApiService } from '../services/TicketApiService' export type ScanStatus = 'ok' | 'duplicate-session' | 'error' @@ -39,21 +40,26 @@ export interface EventStats { /** * Stateful scanner driver. Owns the camera lifecycle (delegated to - * useQRScanner upstream), the QR decode, the - * `events_ticket_register` RPC call, an authoritative event roster - * fetched from `events_list_event_tickets`, and a session-local - * dedup cache. + * useQRScanner upstream), the QR decode, the register-ticket call, + * an authoritative event roster fetch, and a session-local dedup + * cache. * * Counts + the displayed scanned list come from the backend so the * UI agrees with reality even when a second organizer is scanning * on another device. The localStorage cache is kept as a silent - * dedup so the 5-fps decode loop doesn't re-fire the RPC on a QR - * the camera held in frame for multiple ticks. + * dedup so the 5-fps decode loop doesn't re-fire the request on a + * QR the camera held in frame for multiple ticks. + * + * Auth: the organizer's wallet admin_key. The events extension's + * `GET /tickets/event/{id}/stats` + `PUT /tickets/register/{id}` + * endpoints both check the event's wallet is in the caller's + * wallet set, so admin_key alone is sufficient. We deliberately + * route via HTTP rather than the kind-21000 nostr-transport RPC + * because post-#9 the webapp no longer holds a raw user prvkey. */ export function useTicketScanner(activityId: Ref) { - const transport = injectService( - SERVICE_TOKENS.NOSTR_TRANSPORT_SERVICE, - ) + const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) + const { currentUser } = useAuth() const isProcessing = ref(false) const lastScan = ref(null) @@ -86,20 +92,15 @@ export function useTicketScanner(activityId: Ref) { async function refreshStats(): Promise { if (!activityId.value) return + const adminKey = currentUser.value?.wallets?.[0]?.adminkey + if (!adminKey) { + statsError.value = 'No wallet admin key available' + return + } statsLoading.value = true statsError.value = null try { - const data = await transport.call<{ - sold: number - registered: number - remaining: number - tickets: Array<{ - id: string - name?: string | null - registered: boolean - registered_at: string | null - }> - }>('events_list_event_tickets', { event_id: activityId.value }) + const data = await ticketApi.getEventStats(activityId.value, adminKey) eventStats.value = { sold: data.sold, registered: data.registered, @@ -132,26 +133,38 @@ export function useTicketScanner(activityId: Ref) { return } + const adminKey = currentUser.value?.wallets?.[0]?.adminkey + if (!adminKey) { + lastScan.value = { + status: 'error', + ticketId, + message: 'No wallet admin key available', + } + isPaused.value = true + return + } + isProcessing.value = true try { - const ticket = await transport.call>( - 'events_ticket_register', - { - event_id: activityId.value, - ticket_id: ticketId, - }, - ) - const name = (ticket?.name as string | null | undefined) ?? null + const ticket = await ticketApi.registerTicket(ticketId, adminKey) scanned.value = [ - { ticketId, name, registeredAt: new Date().toISOString() }, + { + ticketId, + name: ticket.name ?? null, + registeredAt: new Date().toISOString(), + }, ...scanned.value, ] - lastScan.value = { status: 'ok', ticketId, ticket } + lastScan.value = { + status: 'ok', + ticketId, + ticket: ticket as unknown as Record, + } } catch (e) { - // Backend RPC errors arrive as NostrRpcError with the - // string in `.message`: "Ticket not paid for", "Ticket - // already registered", "Ticket does not exist on this - // event", "You do not own this event", etc. + // Backend errors surface via TicketApiService.request as the + // HTTP `detail` string: "Ticket not paid for", "Ticket + // already registered", "Ticket does not exist.", "You do not + // own this event.", etc. lastScan.value = { status: 'error', ticketId, diff --git a/src/modules/activities/services/TicketApiService.ts b/src/modules/activities/services/TicketApiService.ts index f127d5e..43c2308 100644 --- a/src/modules/activities/services/TicketApiService.ts +++ b/src/modules/activities/services/TicketApiService.ts @@ -252,6 +252,60 @@ export class TicketApiService { } } + /** + * Door-scanner roster + counts for one event. Organizer-only — + * requires the event-owning wallet's admin_key. Returns the same + * shape the `events_list_event_tickets` nostr-transport RPC does; + * we route via HTTP because post-#9 the webapp no longer holds a + * raw user prvkey to sign kind-21000 envelopes with. + */ + async getEventStats( + eventId: string, + adminKey: string, + ): Promise<{ + event_id: string + sold: number + registered: number + remaining: number + tickets: Array<{ + id: string + name?: string | null + registered: boolean + registered_at: string | null + }> + }> { + return this.request(`/events/api/v1/tickets/event/${eventId}/stats`, { + method: 'GET', + headers: { 'X-API-KEY': adminKey }, + }) + } + + /** + * Mark a paid ticket as registered at the door. Organizer-only — + * requires the event-owning wallet's admin_key. Backend rejects + * unpaid / already-registered / not-owned cases with HTTP errors + * whose `detail` becomes the thrown Error message. + */ + async registerTicket(ticketId: string, adminKey: string): Promise { + const t = await this.request(`/events/api/v1/tickets/register/${ticketId}`, { + method: 'PUT', + headers: { 'X-API-KEY': adminKey }, + }) + return { + id: t.id, + wallet: t.wallet, + activityId: 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 ActivityTicketExtra | undefined, + } + } + /** * Probe whether the current user has LNbits admin privileges. The * `/all` endpoint is `check_admin`-gated, so a 200 means "admin",