feat: tickets-by-user endpoint + Nostr-driven inventory sync #15

Merged
padreug merged 3 commits from tickets-nostr-sync into main 2026-05-23 18:50:53 +00:00
3 changed files with 88 additions and 12 deletions

View file

@ -44,7 +44,20 @@ def build_nip52_event(event: Event, pubkey: str) -> NostrEvent:
start - unix timestamp (31923) or YYYY-MM-DD (31922)
end - same encoding (optional)
image, location, t (categories) - optional
tickets_available - current remaining capacity (omitted when unlimited)
tickets_sold - running paid-count (always emitted; clients can
derive original_capacity = available + sold)
tickets_price - price_per_ticket (always emitted; 0 means free)
tickets_currency - the currency string
tickets_allow_fiat - "true" when fiat checkout is enabled (omitted otherwise)
tickets_fiat_currency - the fiat settle currency (only when allow_fiat)
Content: event.info
The four ticket_* tags are AIO custom additions outside the NIP-52
spec; spec-compliant clients ignore unknown tags so this stays
backwards-compatible. They let connected clients render the
"X tickets remaining" badge and the Buy CTA without an extra REST hop,
and pick up live inventory updates via the same relay subscription.
"""
time_based = _has_time(event.event_start_date)
kind = 31923 if time_based else 31922
@ -81,6 +94,21 @@ def build_nip52_event(event: Event, pubkey: str) -> NostrEvent:
for cat in event.categories or []:
tags.append(["t", cat])
# `amount_tickets == 0` means unlimited capacity in this extension's
# schema. Omitting the tag is how clients distinguish unlimited from
# "0 left" (sold out).
if event.amount_tickets > 0:
tags.append(["tickets_available", str(event.amount_tickets)])
tags.append(["tickets_sold", str(event.sold)])
tags.append(["tickets_price", str(event.price_per_ticket)])
tags.append(["tickets_currency", event.currency])
# Fiat-checkout config — only emitted when allow_fiat is on so
# clients can branch the buy UI without re-reading the schema.
if event.allow_fiat:
tags.append(["tickets_allow_fiat", "true"])
if event.fiat_currency:
tags.append(["tickets_fiat_currency", event.fiat_currency])
nostr_event = NostrEvent(
pubkey=pubkey,
created_at=int(time.time()),

View file

@ -1,5 +1,6 @@
from __future__ import annotations
import asyncio
from asyncio.tasks import create_task
from lnbits.core.models.users import UserNotifications
@ -21,6 +22,7 @@ from .crud import (
update_ticket,
)
from .models import Event, Ticket
from .nostr_hooks import publish_or_delete_nostr_event
DEFAULT_NOSTR_RELAYS = [
"wss://relay.damus.io",
@ -28,11 +30,27 @@ DEFAULT_NOSTR_RELAYS = [
"wss://relay.nostr.band",
]
# Per-event lock: serializes the counter-update + Nostr republish for a
# single event_id so two paid invoices landing on the listener queue back-
# to-back can't reorder the published state. Lazy-populated; entries are
# left in memory for the lifetime of the process (cheap — one asyncio.Lock
# object per event ever sold).
_event_paid_locks: dict[str, asyncio.Lock] = {}
def _event_paid_lock(event_id: str) -> asyncio.Lock:
lock = _event_paid_locks.get(event_id)
if lock is None:
lock = asyncio.Lock()
_event_paid_locks[event_id] = lock
return lock
async def set_ticket_paid(ticket: Ticket) -> Ticket:
if ticket.paid:
return ticket
async with _event_paid_lock(ticket.event):
ticket.paid = True
await update_ticket(ticket)
@ -42,6 +60,13 @@ async def set_ticket_paid(ticket: Ticket) -> Ticket:
event.amount_tickets -= 1
await update_event(event)
# Republish the NIP-52 calendar event so connected clients see
# the new tickets_available / tickets_sold counters via their
# existing relay subscription. Failures are logged + swallowed
# inside publish_or_delete_nostr_event so a Nostr outage doesn't
# break the payment flow.
await publish_or_delete_nostr_event(event)
return ticket

View file

@ -14,11 +14,12 @@ from fastapi import (
)
from lnbits.core.crud import get_user
from lnbits.core.crud.wallets import get_wallet
from lnbits.core.models import Account, WalletTypeInfo
from lnbits.core.models import Account, User, WalletTypeInfo
from lnbits.core.models.payments import CreateInvoice
from lnbits.core.services import create_payment_request
from lnbits.decorators import (
check_admin,
check_user_exists,
require_admin_key,
require_invoice_key,
)
@ -45,6 +46,7 @@ from .crud import (
get_settings,
get_ticket,
get_tickets,
get_tickets_by_user_id,
purge_unpaid_tickets,
update_event,
update_settings,
@ -399,6 +401,27 @@ async def api_tickets(
return await get_tickets(wallet_ids)
@tickets_api_router.get("/user/{user_id}")
async def api_tickets_by_user(
user_id: str,
user: User = Depends(check_user_exists),
) -> list[Ticket]:
"""All tickets for the authenticated user.
The `user_id` path param must match the token-bound user so a
Bearer-authenticated session can only enumerate its own tickets.
Returns full `Ticket` rows (not `PublicTicket`) since the owner
needs the payment_hash to render the QR + the `extra` envelope
to surface payment/refund state in My Tickets.
"""
if user_id != user.id:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Can only fetch your own tickets.",
)
return await get_tickets_by_user_id(user_id)
@tickets_api_router.get("/{ticket_id}", response_model=PublicTicket)
async def api_get_ticket(ticket_id: str) -> Ticket:
ticket = await get_ticket(ticket_id)