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
2 changed files with 56 additions and 11 deletions
Showing only changes of commit edf1493e0c - Show all commits

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>
Padreug 2026-05-23 20:31:56 +02:00

View file

@ -44,7 +44,18 @@ def build_nip52_event(event: Event, pubkey: str) -> NostrEvent:
start - unix timestamp (31923) or YYYY-MM-DD (31922) start - unix timestamp (31923) or YYYY-MM-DD (31922)
end - same encoding (optional) end - same encoding (optional)
image, location, t (categories) - 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 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) time_based = _has_time(event.event_start_date)
kind = 31923 if time_based else 31922 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 []: for cat in event.categories or []:
tags.append(["t", cat]) 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( nostr_event = NostrEvent(
pubkey=pubkey, pubkey=pubkey,
created_at=int(time.time()), created_at=int(time.time()),

View file

@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import asyncio
from asyncio.tasks import create_task from asyncio.tasks import create_task
from lnbits.core.models.users import UserNotifications from lnbits.core.models.users import UserNotifications
@ -21,6 +22,7 @@ from .crud import (
update_ticket, update_ticket,
) )
from .models import Event, Ticket from .models import Event, Ticket
from .nostr_hooks import publish_or_delete_nostr_event
DEFAULT_NOSTR_RELAYS = [ DEFAULT_NOSTR_RELAYS = [
"wss://relay.damus.io", "wss://relay.damus.io",
@ -28,11 +30,27 @@ DEFAULT_NOSTR_RELAYS = [
"wss://relay.nostr.band", "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: async def set_ticket_paid(ticket: Ticket) -> Ticket:
if ticket.paid: if ticket.paid:
return ticket return ticket
async with _event_paid_lock(ticket.event):
ticket.paid = True ticket.paid = True
await update_ticket(ticket) await update_ticket(ticket)
@ -42,6 +60,13 @@ async def set_ticket_paid(ticket: Ticket) -> Ticket:
event.amount_tickets -= 1 event.amount_tickets -= 1
await update_event(event) 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 return ticket