feat: tickets-by-user endpoint + Nostr-driven inventory sync #15
3 changed files with 88 additions and 12 deletions
|
|
@ -39,12 +39,25 @@ def build_nip52_event(event: Event, pubkey: str) -> NostrEvent:
|
||||||
|
|
||||||
Time-based (kind 31923) if event_start_date carries an HH:MM, otherwise
|
Time-based (kind 31923) if event_start_date carries an HH:MM, otherwise
|
||||||
date-based (kind 31922). Tags:
|
date-based (kind 31922). Tags:
|
||||||
d - event.id
|
d - event.id
|
||||||
title - event.name
|
title - event.name
|
||||||
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
|
||||||
|
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
|
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 +94,21 @@ 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])
|
||||||
|
# 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(
|
nostr_event = NostrEvent(
|
||||||
pubkey=pubkey,
|
pubkey=pubkey,
|
||||||
created_at=int(time.time()),
|
created_at=int(time.time()),
|
||||||
|
|
|
||||||
39
services.py
39
services.py
|
|
@ -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,19 +30,42 @@ 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
|
||||||
|
|
||||||
ticket.paid = True
|
async with _event_paid_lock(ticket.event):
|
||||||
await update_ticket(ticket)
|
ticket.paid = True
|
||||||
|
await update_ticket(ticket)
|
||||||
|
|
||||||
event = await get_event(ticket.event)
|
event = await get_event(ticket.event)
|
||||||
assert event, "Couldn't get event from ticket being paid"
|
assert event, "Couldn't get event from ticket being paid"
|
||||||
event.sold += 1
|
event.sold += 1
|
||||||
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
|
||||||
|
|
||||||
|
|
|
||||||
25
views_api.py
25
views_api.py
|
|
@ -14,11 +14,12 @@ from fastapi import (
|
||||||
)
|
)
|
||||||
from lnbits.core.crud import get_user
|
from lnbits.core.crud import get_user
|
||||||
from lnbits.core.crud.wallets import get_wallet
|
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.models.payments import CreateInvoice
|
||||||
from lnbits.core.services import create_payment_request
|
from lnbits.core.services import create_payment_request
|
||||||
from lnbits.decorators import (
|
from lnbits.decorators import (
|
||||||
check_admin,
|
check_admin,
|
||||||
|
check_user_exists,
|
||||||
require_admin_key,
|
require_admin_key,
|
||||||
require_invoice_key,
|
require_invoice_key,
|
||||||
)
|
)
|
||||||
|
|
@ -45,6 +46,7 @@ from .crud import (
|
||||||
get_settings,
|
get_settings,
|
||||||
get_ticket,
|
get_ticket,
|
||||||
get_tickets,
|
get_tickets,
|
||||||
|
get_tickets_by_user_id,
|
||||||
purge_unpaid_tickets,
|
purge_unpaid_tickets,
|
||||||
update_event,
|
update_event,
|
||||||
update_settings,
|
update_settings,
|
||||||
|
|
@ -399,6 +401,27 @@ async def api_tickets(
|
||||||
return await get_tickets(wallet_ids)
|
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)
|
@tickets_api_router.get("/{ticket_id}", response_model=PublicTicket)
|
||||||
async def api_get_ticket(ticket_id: str) -> Ticket:
|
async def api_get_ticket(ticket_id: str) -> Ticket:
|
||||||
ticket = await get_ticket(ticket_id)
|
ticket = await get_ticket(ticket_id)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue