fix(activities): route ticket scanner through HTTP, not nostr-transport RPC #87
2 changed files with 101 additions and 34 deletions
|
|
@ -1,7 +1,8 @@
|
||||||
import { ref, onMounted, type Ref } from 'vue'
|
import { ref, onMounted, type Ref } from 'vue'
|
||||||
import { useLocalStorage } from '@vueuse/core'
|
import { useLocalStorage } from '@vueuse/core'
|
||||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
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'
|
export type ScanStatus = 'ok' | 'duplicate-session' | 'error'
|
||||||
|
|
||||||
|
|
@ -39,21 +40,26 @@ export interface EventStats {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stateful scanner driver. Owns the camera lifecycle (delegated to
|
* Stateful scanner driver. Owns the camera lifecycle (delegated to
|
||||||
* useQRScanner upstream), the QR decode, the
|
* useQRScanner upstream), the QR decode, the register-ticket call,
|
||||||
* `events_ticket_register` RPC call, an authoritative event roster
|
* an authoritative event roster fetch, and a session-local dedup
|
||||||
* fetched from `events_list_event_tickets`, and a session-local
|
* cache.
|
||||||
* dedup cache.
|
|
||||||
*
|
*
|
||||||
* Counts + the displayed scanned list come from the backend so the
|
* Counts + the displayed scanned list come from the backend so the
|
||||||
* UI agrees with reality even when a second organizer is scanning
|
* UI agrees with reality even when a second organizer is scanning
|
||||||
* on another device. The localStorage cache is kept as a silent
|
* 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
|
* dedup so the 5-fps decode loop doesn't re-fire the request on a
|
||||||
* the camera held in frame for multiple ticks.
|
* 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>) {
|
export function useTicketScanner(activityId: Ref<string>) {
|
||||||
const transport = injectService<NostrTransportService>(
|
const ticketApi = injectService<TicketApiService>(SERVICE_TOKENS.TICKET_API)
|
||||||
SERVICE_TOKENS.NOSTR_TRANSPORT_SERVICE,
|
const { currentUser } = useAuth()
|
||||||
)
|
|
||||||
|
|
||||||
const isProcessing = ref(false)
|
const isProcessing = ref(false)
|
||||||
const lastScan = ref<ScanResult | null>(null)
|
const lastScan = ref<ScanResult | null>(null)
|
||||||
|
|
@ -86,20 +92,15 @@ export function useTicketScanner(activityId: Ref<string>) {
|
||||||
|
|
||||||
async function refreshStats(): Promise<void> {
|
async function refreshStats(): Promise<void> {
|
||||||
if (!activityId.value) return
|
if (!activityId.value) return
|
||||||
|
const adminKey = currentUser.value?.wallets?.[0]?.adminkey
|
||||||
|
if (!adminKey) {
|
||||||
|
statsError.value = 'No wallet admin key available'
|
||||||
|
return
|
||||||
|
}
|
||||||
statsLoading.value = true
|
statsLoading.value = true
|
||||||
statsError.value = null
|
statsError.value = null
|
||||||
try {
|
try {
|
||||||
const data = await transport.call<{
|
const data = await ticketApi.getEventStats(activityId.value, adminKey)
|
||||||
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 })
|
|
||||||
eventStats.value = {
|
eventStats.value = {
|
||||||
sold: data.sold,
|
sold: data.sold,
|
||||||
registered: data.registered,
|
registered: data.registered,
|
||||||
|
|
@ -132,26 +133,38 @@ export function useTicketScanner(activityId: Ref<string>) {
|
||||||
return
|
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
|
isProcessing.value = true
|
||||||
try {
|
try {
|
||||||
const ticket = await transport.call<Record<string, unknown>>(
|
const ticket = await ticketApi.registerTicket(ticketId, adminKey)
|
||||||
'events_ticket_register',
|
|
||||||
{
|
|
||||||
event_id: activityId.value,
|
|
||||||
ticket_id: ticketId,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
const name = (ticket?.name as string | null | undefined) ?? null
|
|
||||||
scanned.value = [
|
scanned.value = [
|
||||||
{ ticketId, name, registeredAt: new Date().toISOString() },
|
{
|
||||||
|
ticketId,
|
||||||
|
name: ticket.name ?? null,
|
||||||
|
registeredAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
...scanned.value,
|
...scanned.value,
|
||||||
]
|
]
|
||||||
lastScan.value = { status: 'ok', ticketId, ticket }
|
lastScan.value = {
|
||||||
|
status: 'ok',
|
||||||
|
ticketId,
|
||||||
|
ticket: ticket as unknown as Record<string, unknown>,
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Backend RPC errors arrive as NostrRpcError with the
|
// Backend errors surface via TicketApiService.request as the
|
||||||
// string in `.message`: "Ticket not paid for", "Ticket
|
// HTTP `detail` string: "Ticket not paid for", "Ticket
|
||||||
// already registered", "Ticket does not exist on this
|
// already registered", "Ticket does not exist.", "You do not
|
||||||
// event", "You do not own this event", etc.
|
// own this event.", etc.
|
||||||
lastScan.value = {
|
lastScan.value = {
|
||||||
status: 'error',
|
status: 'error',
|
||||||
ticketId,
|
ticketId,
|
||||||
|
|
|
||||||
|
|
@ -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
|
* Probe whether the current user has LNbits admin privileges. The
|
||||||
* `/all` endpoint is `check_admin`-gated, so a 200 means "admin",
|
* `/all` endpoint is `check_admin`-gated, so a 200 means "admin",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue