From 814581f3079fddf4963186432e2d9fdf89c8f73e Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 23 May 2026 20:30:03 +0200 Subject: [PATCH 1/3] feat: expose GET /tickets/user/{user_id} endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- views_api.py | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/views_api.py b/views_api.py index 08e94ab..7fd3aa0 100644 --- a/views_api.py +++ b/views_api.py @@ -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) -- 2.53.0 From edf1493e0cb9028bcae707938c31338ecf6f7077 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 23 May 2026 20:31:56 +0200 Subject: [PATCH 2/3] feat: publish ticket counts in NIP-52 tags + republish on sale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- nostr_publisher.py | 28 ++++++++++++++++++++++++---- services.py | 39 ++++++++++++++++++++++++++++++++------- 2 files changed, 56 insertions(+), 11 deletions(-) diff --git a/nostr_publisher.py b/nostr_publisher.py index a6d487b..240b406 100644 --- a/nostr_publisher.py +++ b/nostr_publisher.py @@ -39,12 +39,23 @@ def build_nip52_event(event: Event, pubkey: str) -> NostrEvent: Time-based (kind 31923) if event_start_date carries an HH:MM, otherwise date-based (kind 31922). Tags: - d - event.id - title - event.name - start - unix timestamp (31923) or YYYY-MM-DD (31922) - end - same encoding (optional) + d - event.id + title - event.name + 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()), diff --git a/services.py b/services.py index 159bbdc..0a2de28 100644 --- a/services.py +++ b/services.py @@ -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,19 +30,42 @@ 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 - ticket.paid = True - await update_ticket(ticket) + async with _event_paid_lock(ticket.event): + ticket.paid = True + await update_ticket(ticket) - event = await get_event(ticket.event) - assert event, "Couldn't get event from ticket being paid" - event.sold += 1 - event.amount_tickets -= 1 - await update_event(event) + event = await get_event(ticket.event) + assert event, "Couldn't get event from ticket being paid" + event.sold += 1 + 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 -- 2.53.0 From b0d089d3c9c8a318984247716d49dc2ddd57098c Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 23 May 2026 20:37:19 +0200 Subject: [PATCH 3/3] feat: also publish allow_fiat + fiat_currency in NIP-52 tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The buyer-side webapp Purchase button needs allow_fiat to know whether to surface the fiat method, and fiat_currency for the conversion-preview label. Without these in the published Nostr event, the buyer would either have to REST-fetch the LNbits event again (defeats the inventory-sync goal) or guess. Same backwards-compat reasoning as the four counter tags — tags are AIO additions outside the NIP-52 spec; unknown tags are ignored by spec-compliant clients. - tickets_allow_fiat: "true" when the organizer enabled the fiat toggle. Omitted otherwise so the on-the-wire payload stays small for the common Lightning-only case. - tickets_fiat_currency: only emitted when allow_fiat is on (otherwise it'd be ambiguous what the value represents). Co-Authored-By: Claude Opus 4.7 (1M context) --- nostr_publisher.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/nostr_publisher.py b/nostr_publisher.py index 240b406..6867041 100644 --- a/nostr_publisher.py +++ b/nostr_publisher.py @@ -44,11 +44,13 @@ 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_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 @@ -100,6 +102,12 @@ def build_nip52_event(event: Event, pubkey: str) -> NostrEvent: 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, -- 2.53.0