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:
commit
9048248353
2 changed files with 101 additions and 34 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue