Merge pull request 'fix(activities): route ticket scanner through HTTP, not nostr-transport RPC' (#87) from fix/scanner-via-http into dev

Reviewed-on: #87
This commit is contained in:
padreug 2026-06-03 16:34:01 +00:00
commit 9048248353
2 changed files with 101 additions and 34 deletions

View file

@ -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<string>) {
const transport = injectService<NostrTransportService>(
SERVICE_TOKENS.NOSTR_TRANSPORT_SERVICE,
)
const ticketApi = injectService<TicketApiService>(SERVICE_TOKENS.TICKET_API)
const { currentUser } = useAuth()
const isProcessing = ref(false)
const lastScan = ref<ScanResult | null>(null)
@ -86,20 +92,15 @@ export function useTicketScanner(activityId: Ref<string>) {
async function refreshStats(): Promise<void> {
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<string>) {
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<Record<string, unknown>>(
'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<string, unknown>,
}
} 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,

View file

@ -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<ActivityTicket> {
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",