Compare commits

..

2 commits

Author SHA1 Message Date
edf1493e0c feat: publish ticket counts in NIP-52 tags + republish on sale
Some checks failed
lint.yml / feat: publish ticket counts in NIP-52 tags + republish on sale (pull_request) Failing after 0s
Inventory sync over Nostr, mirroring how nostrmarket republishes
kind 30018 product events when stock changes. Connected webapp /
other-client subscriptions pick up the new state via their existing
relay subscription — no REST polling needed.

build_nip52_event grows four AIO custom tags on every published
kind 31922/31923 event:
- tickets_available — current remaining (omitted when amount_tickets
  is 0, the schema's "unlimited" sentinel, so clients can tell the
  difference between unlimited and sold-out)
- tickets_sold — running count, always emitted (clients derive
  original_capacity = available + sold for progress bars)
- tickets_price — price_per_ticket (0 means free)
- tickets_currency — the currency string

Tags are AIO additions outside the NIP-52 spec; spec-compliant
clients MUST ignore unknown tags so this stays backwards-compatible.

set_ticket_paid calls publish_or_delete_nostr_event after the
counter update so the new state lands on relays. The whole sequence
(counter update + republish) is wrapped in a per-event-id asyncio
lock to address the existing # todo: lock and to ensure two paid
invoices for the same event can't reorder the published state.

Failures inside the Nostr publish are logged + swallowed by the
existing wrapper, so a relay outage can never break the payment
flow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 20:31:56 +02:00
814581f307 feat: expose GET /tickets/user/{user_id} endpoint
The webapp My Tickets view + the owned-ticket badges in the
activities feed both rely on this endpoint to enumerate a buyer's
tickets across all events. The CRUD function already existed
(`get_tickets_by_user_id`); just expose it.

Auth: Bearer access token (the same shape the webapp already sends
to other LNbits endpoints). The path param must match the token-
bound user.id — users can only enumerate their own tickets, not
anyone else's by ID-guessing.

Returns full `Ticket` rows rather than `PublicTicket` because the
owner needs the payment_hash (for the QR) + the `extra` envelope
(for refund / promo / notification state) in My Tickets.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 20:30:03 +02:00
3 changed files with 80 additions and 12 deletions

View file

@ -44,7 +44,18 @@ 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
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 +92,15 @@ 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])
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)