Compare commits

..

No commits in common. "main" and "v1.6.1-aio.1" have entirely different histories.

15 changed files with 120 additions and 954 deletions

View file

@ -46,38 +46,6 @@ def events_start():
task1 = create_permanent_unique_task("ext_events", wait_for_paid_invoices)
scheduled_tasks.append(task1)
# Register nostr-transport RPCs. Swallow ImportError on older LNbits
# versions that pre-date the transport (the events extension still
# works fine via HTTP without it).
try:
from lnbits.core.services.nostr_transport.dispatcher import (
AUTH_WALLET,
register_rpc,
)
from .transport_rpcs import (
handle_events_list_event_tickets,
handle_events_ticket_register,
)
register_rpc(
"events_ticket_register", handle_events_ticket_register, AUTH_WALLET
)
register_rpc(
"events_list_event_tickets",
handle_events_list_event_tickets,
AUTH_WALLET,
)
logger.info(
"[EVENTS] Registered nostr-transport RPCs: "
"events_ticket_register, events_list_event_tickets"
)
except ImportError:
logger.info(
"[EVENTS] nostr_transport not available on this LNbits — "
"ticket scanner over Nostr disabled, HTTP endpoint still works"
)
async def _start_nostr_client():
global nostr_client
await asyncio.sleep(10) # Wait for nostrclient to be ready

View file

@ -1,6 +1,6 @@
{
"id": "events",
"version": "1.6.1-aio.7",
"version": "1.6.1-aio.1",
"name": "Events",
"repo": "https://git.atitlan.io/aiolabs/events",
"short_description": "Sell and register event tickets",

41
crud.py
View file

@ -41,19 +41,8 @@ async def create_ticket(
email: str | None = None,
user_id: str | None = None,
extra: dict | None = None,
ticket_id: str | None = None,
) -> Ticket:
"""Persist one ticket row.
`payment_hash` is the LNbits invoice hash shared across all rows
of a multi-ticket purchase. `ticket_id` is the row primary key /
scannable id; defaults to `payment_hash` for single-ticket
purchases so the legacy id == payment_hash invariant holds.
Multi-ticket callers pass a unique uuid here so each attendee
gets a distinct scannable QR.
"""
now = datetime.now(timezone.utc)
row_id = ticket_id or payment_hash
# name/email columns are NOT NULL in the schema, so we store "" when only
# user_id is supplied. _parse_ticket_row reverses this on read.
@ -65,7 +54,7 @@ async def create_ticket(
db_email = email or ""
db_ticket = Ticket(
id=row_id,
id=payment_hash,
wallet=wallet,
event=event,
name=db_name,
@ -76,12 +65,11 @@ async def create_ticket(
reg_timestamp=now,
time=now,
extra=TicketExtra(**extra) if extra else TicketExtra(),
payment_hash=payment_hash,
)
await db.insert("events.ticket", db_ticket)
return Ticket(
id=row_id,
id=payment_hash,
wallet=wallet,
event=event,
name=name,
@ -92,7 +80,6 @@ async def create_ticket(
reg_timestamp=now,
time=now,
extra=TicketExtra(**extra) if extra else TicketExtra(),
payment_hash=payment_hash,
)
@ -106,21 +93,6 @@ async def update_ticket(ticket: Ticket) -> Ticket:
return ticket
async def get_tickets_by_payment_hash(payment_hash: str) -> list[Ticket]:
"""All ticket rows sharing the given LNbits invoice payment_hash.
For a single-ticket purchase returns one row (legacy invariant
`id == payment_hash` still holds). For a multi-ticket purchase
returns the N rows created with shared `payment_hash` but
distinct `id`s each attendee's scannable QR.
"""
rows = await db.fetchall(
"SELECT * FROM events.ticket WHERE payment_hash = :ph",
{"ph": payment_hash},
)
return [Ticket(**_parse_ticket_row(row)) for row in rows]
async def get_ticket(payment_hash: str) -> Ticket | None:
row = await db.fetchone(
"SELECT * FROM events.ticket WHERE id = :id",
@ -139,15 +111,6 @@ async def get_tickets(wallet_ids: str | list[str]) -> list[Ticket]:
return [Ticket(**_parse_ticket_row(row)) for row in rows]
async def get_tickets_by_event(event_id: str) -> list[Ticket]:
"""All ticket rows for the given calendar event id."""
rows = await db.fetchall(
"SELECT * FROM events.ticket WHERE event = :event_id",
{"event_id": event_id},
)
return [Ticket(**_parse_ticket_row(row)) for row in rows]
async def get_tickets_by_user_id(user_id: str) -> list[Ticket]:
"""All tickets owned by the given LNbits user_id."""
rows = await db.fetchall(

View file

@ -103,28 +103,3 @@ async def m001_aio_event_schema(db):
await _alter_add_column_safe(
db, "ALTER TABLE events.events ADD COLUMN categories TEXT"
)
async def m002_ticket_payment_hash(db):
"""
Add `ticket.payment_hash` for multi-ticket purchases.
Multi-ticket purchases land as N rows sharing one LNbits invoice
(so each attendee gets a distinct scannable QR but the buyer
pays once). `ticket.id` stays the row primary key for legacy
single-purchase rows it equals payment_hash; for multi-purchase
children it's a uuid generated at create-time. `payment_hash`
is the new join key for invoice lookup.
Backfill existing rows from id so the
GET-tickets-by-payment-hash path keeps working for pre-migration
data (id was the payment_hash by invariant before this column).
"""
await _alter_add_column_safe(
db, "ALTER TABLE events.ticket ADD COLUMN payment_hash TEXT"
)
await db.execute(
"UPDATE events.ticket SET payment_hash = id "
"WHERE payment_hash IS NULL OR payment_hash = ''"
)

View file

@ -133,9 +133,6 @@ class CreateTicket(BaseModel):
nostr_identifier: str | None = None
payment_method: str | None = None
fiat_provider: str | None = None
# Number of tickets to buy on this single invoice. Bounded so a
# bad client can't run away with the organizer's capacity.
quantity: int = Field(default=1, ge=1, le=10)
@root_validator
def validate_identifiers(cls, values):
@ -161,11 +158,6 @@ class Ticket(BaseModel):
time: datetime
reg_timestamp: datetime
extra: TicketExtra = Field(default_factory=TicketExtra)
# Shared LNbits invoice payment_hash. Equals `id` for single-ticket
# purchases (legacy + post-migration default). Multi-ticket
# purchases create N rows sharing one payment_hash so each attendee
# gets a distinct scannable id while the buyer pays once.
payment_hash: str | None = None
class PublicTicket(BaseModel):
@ -183,12 +175,3 @@ class TicketPaymentRequest(BaseModel):
fiat_payment_request: str | None = None
fiat_provider: str | None = None
is_fiat: bool = False
# True when the tickets are already issued + paid with no invoice to
# settle — free events (price 0) or a 100%-off promo. The client skips
# the QR / payment-poll step and goes straight to the ticket QRs.
paid: bool = False
# Row ids created on this invoice — one for single-ticket
# purchases, N for multi-ticket (each independently scannable at
# the door). Buyers fetch these after payment to render N QRs in
# My Tickets.
ticket_ids: list[str] = Field(default_factory=list)

View file

@ -15,30 +15,25 @@ from .nostr_publisher import publish_event_to_nostr
async def publish_or_delete_nostr_event(event: Event, *, delete: bool = False) -> None:
"""Publish or delete the NIP-52 calendar event for `event`.
Resolves a `NostrSigner` for the wallet owner backend-agnostic
(LocalSigner / RemoteBunkerSigner / ClientSideOnlySigner). The
signer abstraction handles the actual key material; this hook
only needs `signer.pubkey` for event construction and
`await signer.sign_event(...)` for signing. Failures are logged
and swallowed so a Nostr outage doesn't break the HTTP flow that
triggered the publish.
Pulls the wallet owner's pubkey/prvkey to sign with the user's identity.
Failures are logged and swallowed so a Nostr outage doesn't break the
HTTP flow that triggered the publish.
"""
try:
from lnbits.core.signers import resolve_for_wallet
from lnbits.core.crud.users import get_account
from lnbits.core.crud.wallets import get_wallet
from . import nostr_client
signer = await resolve_for_wallet(event.wallet)
if signer is None:
# Wallet missing, account missing, unclassified row, or
# ClientSideOnlySigner account (server can't sign for them).
# Soft-fail: skip the publish silently. The user can still
# publish kind-31922/31923 events client-side once we have
# that path.
wallet_obj = await get_wallet(event.wallet)
if not wallet_obj:
return
account = await get_account(wallet_obj.user)
if not account or not account.pubkey or not account.prvkey:
return
nostr_event = await publish_event_to_nostr(
nostr_client, event, signer, delete=delete
nostr_client, event, account.pubkey, account.prvkey, delete=delete
)
if nostr_event and not delete:
event.nostr_event_id = nostr_event.id

View file

@ -1,9 +1,8 @@
"""
NIP-52 calendar event publishing for the events extension.
Builds NIP-52 calendar events from the Event model, signs them via the
core `NostrSigner` abstraction (backend-agnostic: LocalSigner,
RemoteBunkerSigner, etc.), and publishes via the NostrClient.
Builds NIP-52 calendar events from the Event model, signs them with the
creator's Account keypair, and publishes via the NostrClient.
Kind 31922 is used for date-only events; kind 31923 (time-based) is used
when event_start_date / event_end_date include a time component.
@ -14,12 +13,11 @@ Reference: https://github.com/nostr-protocol/nips/blob/master/52.md
import time
from datetime import datetime, timezone
from lnbits.core.signers import NostrSigner
import coincurve
from loguru import logger
from .models import Event
from .nostr.event import NostrEvent
from .nostr_timestamp import monotonic_created_at
def _has_time(value: str | None) -> bool:
@ -41,25 +39,12 @@ 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
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
@ -96,30 +81,9 @@ 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])
# NIP-52 calendar events are replaceable: this d-tag is republished
# whenever inventory changes (a ticket sells). Use a strictly-monotonic
# created_at anchored on the last published value so a same-second
# republish still outranks the prior version and relays push it to open
# subscriptions — a bare int(time.time()) can tie and be silently
# dropped, stalling clients' live "tickets remaining" badge.
nostr_event = NostrEvent(
pubkey=pubkey,
created_at=monotonic_created_at(event.nostr_event_created_at),
created_at=int(time.time()),
kind=kind,
tags=tags,
content=event.info or "",
@ -150,20 +114,23 @@ def build_nip52_delete_event(event: Event, pubkey: str) -> NostrEvent:
return nostr_event
def sign_nostr_event(nostr_event: NostrEvent, private_key_hex: str) -> None:
"""Sign a NostrEvent in-place using Schnorr signature."""
privkey = coincurve.PrivateKey(bytes.fromhex(private_key_hex))
sig = privkey.sign_schnorr(bytes.fromhex(nostr_event.id))
nostr_event.sig = sig.hex()
async def publish_event_to_nostr(
nostr_client,
event: Event,
signer: NostrSigner,
account_pubkey: str,
account_prvkey: str,
delete: bool = False,
) -> NostrEvent | None:
"""
Build, sign, and publish a NIP-52 calendar event (or delete event).
Signing routes through the core `NostrSigner` abstraction
`signer.pubkey` for the event identity, `await signer.sign_event(...)`
for the Schnorr signature. The signer backend (LocalSigner /
RemoteBunkerSigner) is transparent to this function.
Returns the published NostrEvent for metadata storage, or None on failure.
"""
if not nostr_client:
@ -172,25 +139,11 @@ async def publish_event_to_nostr(
try:
if delete:
nostr_event = build_nip52_delete_event(event, signer.pubkey)
nostr_event = build_nip52_delete_event(event, account_pubkey)
else:
nostr_event = build_nip52_event(event, signer.pubkey)
# Hand the unsigned event to the signer — it fills in `id`,
# `pubkey`, and `sig`. The signer's serialization rules match
# NIP-01 (same as the local `event_id` property uses), so the
# returned id matches what we'd have computed locally.
unsigned = {
"kind": nostr_event.kind,
"created_at": nostr_event.created_at,
"tags": nostr_event.tags,
"content": nostr_event.content,
}
signed = await signer.sign_event(unsigned)
nostr_event.id = signed["id"]
nostr_event.pubkey = signed["pubkey"]
nostr_event.sig = signed["sig"]
nostr_event = build_nip52_event(event, account_pubkey)
sign_nostr_event(nostr_event, account_prvkey)
await nostr_client.publish_nostr_event(nostr_event)
logger.info(

View file

@ -1,34 +0,0 @@
"""Monotonic ``created_at`` for replaceable / addressable Nostr events.
Relays only push a replaceable update to OPEN subscriptions when its
``created_at`` is strictly newer than the version they already hold.
``created_at`` is integer seconds, so a publisher that stamps
``int(time.time())`` can emit two versions within the same wall-clock
second (e.g. two ticket sales republishing the NIP-52 calendar event)
the relay treats the second as not-newer and never propagates it to live
subscribers (it only surfaces on a reload / fresh REQ).
Returning ``max(now, last_created_at + 1)`` guarantees a strictly
increasing timestamp across successive publishes of the same replaceable
event. When enough real seconds have elapsed it tracks wall-clock; only
same-second (or clock-skewed) republishes get nudged forward.
Mirrors the webapp's ``monotonicCreatedAt`` (src/lib/nostr/timestamp.ts)
and ``docs/nostr-patterns/replaceable-events.md``.
"""
import time
def monotonic_created_at(last_created_at: int | None, now: int | None = None) -> int:
"""Strictly-newer ``created_at`` for the next publish of a coord.
:param last_created_at: ``created_at`` of the previously published
version (seconds), or ``None`` if none has been published yet.
:param now: Current time in seconds injectable for tests; defaults
to ``int(time.time())``.
"""
base = int(time.time()) if now is None else now
if last_created_at is None:
return base
return max(base, last_created_at + 1)

View file

@ -1,6 +1,5 @@
from __future__ import annotations
import asyncio
from asyncio.tasks import create_task
from lnbits.core.models.users import UserNotifications
@ -22,7 +21,6 @@ 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",
@ -30,42 +28,19 @@ 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)
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)
# 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)
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)
return ticket

View file

@ -9,56 +9,9 @@ window.PageEvents = {
pendingEvents: [],
allUserEvents: [],
isAdmin: false,
republishing: false,
republishingMine: false,
settings: {
auto_approve: false
},
allUsersEventsTable: {
// Shown on the admin All Users' Events card. Includes the
// wallet owner (`wallet_user_id` resolved server-side) so
// cross-tenant rows are attributable to a user.
columns: [
{
name: 'wallet_user_id',
align: 'left',
label: 'Owner',
field: 'wallet_user_id'
},
{name: 'id', align: 'left', label: 'ID', field: 'id'},
{name: 'name', align: 'left', label: 'Name', field: 'name'},
{
name: 'event_start_date',
align: 'left',
label: 'Start date',
field: 'event_start_date'
},
{
name: 'event_end_date',
align: 'left',
label: 'End date',
field: 'event_end_date'
},
{
name: 'closing_date',
align: 'left',
label: 'Ticket close',
field: 'closing_date'
},
{
name: 'canceled',
align: 'left',
label: 'Canceled',
field: row => {
if (row.extra && row.extra.conditional && row.canceled) {
return 'Yes'
}
return 'No'
}
},
{name: 'status', align: 'left', label: 'Status', field: 'status'}
]
},
eventsTable: {
columns: [
{name: 'id', align: 'left', label: 'ID', field: 'id'},
@ -322,63 +275,6 @@ window.PageEvents = {
.catch(LNbits.utils.notifyApiError)
})
},
republishAllEvents() {
LNbits.utils
.confirmDialog(
'Re-emit every approved event to Nostr relays? This is safe ' +
'to run multiple times but generates one event per approved row.'
)
.onOk(() => {
this.republishing = true
LNbits.api
.request('POST', '/events/api/v1/events/republish-all')
.then(response => {
Quasar.Notify.create({
type: 'positive',
message:
'Republished ' +
response.data.republished +
' of ' +
response.data.total +
' events'
})
})
.catch(LNbits.utils.notifyApiError)
.finally(() => {
this.republishing = false
})
})
},
republishMyEvents() {
LNbits.utils
.confirmDialog(
'Re-emit your approved events to Nostr relays?'
)
.onOk(() => {
this.republishingMine = true
LNbits.api
.request(
'POST',
'/events/api/v1/events/republish-mine?all_wallets=true',
this.g.user.wallets[0].adminkey
)
.then(response => {
Quasar.Notify.create({
type: 'positive',
message:
'Republished ' +
response.data.republished +
' of your ' +
response.data.total +
' events'
})
})
.catch(LNbits.utils.notifyApiError)
.finally(() => {
this.republishingMine = false
})
})
},
foldDateTime(day, time) {
// Combine separate date/time inputs into the wire format
// expected by the events extension: "YYYY-MM-DD" or

View file

@ -15,50 +15,14 @@
></q-toggle>
</div>
</div>
<q-separator class="q-my-md"></q-separator>
<div class="row items-center justify-between">
<div class="col">
<span class="text-subtitle2">Republish to Nostr</span>
<div class="text-caption text-grey-7" style="color: #aaa">
Re-emit every approved event so connected clients pick
up the latest tag set. Useful after the extension
publisher changes (e.g. new tickets_* tags) so existing
events don't need a per-event edit.
</div>
</div>
<div class="col-auto">
<q-btn
outline
color="primary"
icon="cloud_upload"
label="Republish all"
:loading="republishing"
@click="republishAllEvents"
></q-btn>
</div>
</div>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<div class="row items-center q-gutter-sm">
<q-btn unelevated color="primary" @click="openEventDialog"
>New Event</q-btn
>
<q-btn
outline
color="primary"
icon="cloud_upload"
label="Republish mine"
:loading="republishingMine"
@click="republishMyEvents"
></q-btn>
</div>
<div class="text-caption q-mt-sm" style="color: #aaa">
Re-emit your approved events to Nostr relays. Useful after
a publisher upgrade or if a relay dropped your events.
</div>
<q-btn unelevated color="primary" @click="openEventDialog"
>New Event</q-btn
>
</q-card-section>
</q-card>
@ -286,6 +250,51 @@
</q-card-section>
</q-card>
<q-card v-if="isAdmin && allUserEvents.length > 0">
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">
All Users' Events
<q-badge
color="blue"
:label="allUserEvents.length"
class="q-ml-sm"
></q-badge>
</h5>
</div>
</div>
<q-table
dense
flat
:rows="allUserEvents"
row-key="id"
:columns="eventsTable.columns"
:pagination="{rowsPerPage: 10}"
>
<template v-slot:header="props">
<q-tr :props="props">
<q-th v-for="col in props.cols" :key="col.name" :props="props">
<span v-text="col.label"></span>
</q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td v-for="col in props.cols" :key="col.name" :props="props">
<q-badge
v-if="col.name === 'status'"
:color="col.value === 'approved' ? 'green' : col.value === 'proposed' ? 'orange' : 'red'"
:label="col.value"
></q-badge>
<span v-else v-text="col.value"></span>
</q-td>
</q-tr>
</template>
</q-table>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
@ -364,51 +373,6 @@
</q-table>
</q-card-section>
</q-card>
<q-card v-if="isAdmin && allUserEvents.length > 0">
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">
All Users' Events
<q-badge
color="blue"
:label="allUserEvents.length"
class="q-ml-sm"
></q-badge>
</h5>
</div>
</div>
<q-table
dense
flat
:rows="allUserEvents"
row-key="id"
:columns="allUsersEventsTable.columns"
:pagination="{rowsPerPage: 10}"
>
<template v-slot:header="props">
<q-tr :props="props">
<q-th v-for="col in props.cols" :key="col.name" :props="props">
<span v-text="col.label"></span>
</q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td v-for="col in props.cols" :key="col.name" :props="props">
<q-badge
v-if="col.name === 'status'"
:color="col.value === 'approved' ? 'green' : col.value === 'proposed' ? 'orange' : 'red'"
:label="col.value"
></q-badge>
<span v-else v-text="col.value"></span>
</q-td>
</q-tr>
</template>
</q-table>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-md-4 col-lg-5 q-gutter-y-md">
<q-card>

View file

@ -4,7 +4,7 @@ from lnbits.core.models import Payment
from lnbits.tasks import register_invoice_listener
from loguru import logger
from .crud import get_ticket, get_tickets_by_payment_hash
from .crud import get_ticket
from .models import Ticket
from .services import send_ticket_notification_in_background, set_ticket_paid
@ -37,32 +37,13 @@ async def on_invoice_paid(payment: Payment) -> None:
if not payment.extra or "events" != payment.extra.get("tag"):
return
# Multi-ticket purchases land as N rows sharing this payment_hash;
# each one needs to be marked paid + counted against capacity, and
# each gets its own buyer notification (mostly a no-op when all
# rows are owned by the same buyer, but cheap and consistent).
tickets = await get_tickets_by_payment_hash(payment.payment_hash)
if not tickets:
# Backstop for any legacy row created before the payment_hash
# column was populated by the migration backfill.
legacy = await get_ticket(payment.payment_hash)
if legacy:
tickets = [legacy]
if not tickets:
logger.warning(f"No tickets for payment {payment.payment_hash}.")
ticket = await get_ticket(payment.payment_hash)
if not ticket:
logger.warning(f"Ticket for payment {payment.payment_hash} not found.")
return
paid_tickets: list[Ticket] = []
for ticket in tickets:
paid_tickets.append(await set_ticket_paid(ticket))
for paid_ticket in paid_tickets:
send_ticket_notification_in_background(paid_ticket)
# Wake up the WebSocket / poll listeners. Forward the first paid
# ticket so the existing single-ticket subscribers still work; the
# webapp re-fetches all ids via the polling endpoint anyway.
ticket = await set_ticket_paid(ticket)
send_ticket_notification_in_background(ticket)
if payment_listeners.get(payment.payment_hash):
for paid_ticket_queue in payment_listeners[payment.payment_hash]:
paid_ticket_queue.put_nowait(paid_tickets[0])
paid_ticket_queue.put_nowait(ticket)

View file

@ -1,32 +0,0 @@
from itertools import pairwise
from ..nostr_timestamp import monotonic_created_at
def test_no_prior_uses_now():
assert monotonic_created_at(None, now=1000) == 1000
def test_same_second_bumps_past_prior():
# now == last: a naive int(time.time()) would tie and the relay would
# drop the update; we must produce a strictly newer stamp.
assert monotonic_created_at(1000, now=1000) == 1001
def test_tracks_wallclock_once_seconds_elapse():
assert monotonic_created_at(1000, now=1005) == 1005
def test_steps_past_future_dated_prior():
# clock skew / rapid bursts left the stored value ahead of now
assert monotonic_created_at(2000, now=1000) == 2001
def test_strictly_increasing_same_second_burst():
last = None
stamps = []
for _ in range(5):
last = monotonic_created_at(last, now=1000) # clock frozen at 1000
stamps.append(last)
assert stamps == [1000, 1001, 1002, 1003, 1004]
assert all(b > a for a, b in pairwise(stamps))

View file

@ -1,120 +0,0 @@
"""
Nostr-transport RPC handlers for the aiolabs/events extension.
Each handler is registered with `lnbits.core.services.nostr_transport.
dispatcher.register_rpc` in `events_start()`. The dispatcher resolves
the caller's Nostr pubkey to an LNbits Account → wallet (`AUTH_WALLET`)
and passes a `WalletTypeInfo` as the first argument; handlers verify
event-level ownership on top.
Errors raise `PermissionError` / `ValueError` so the dispatcher maps
them into `{status: "ERROR", error: <msg>}` responses; any other
exception falls through to a generic "Internal error" reply.
"""
from __future__ import annotations
from datetime import datetime, timezone
from lnbits.core.crud import get_user
from lnbits.core.models import WalletTypeInfo
from lnbits.core.services.nostr_transport.models import NostrRpcRequest
from .crud import get_event, get_ticket, get_tickets_by_event, update_ticket
async def handle_events_ticket_register(
auth: WalletTypeInfo,
request: NostrRpcRequest,
) -> dict:
"""Mark a ticket as registered at the door (organizer flow).
The Nostr-transport dispatcher already verified the caller signed
the kind-21000 RPC event and bound them to `auth.wallet`. This
handler adds the event-level check: the ticket's event must be
owned by one of the caller's wallets.
Idempotence mirrors the HTTP endpoint: scanning the same ticket
twice fails with "Ticket already registered". The buyer-side flow
(notifications etc.) reuses whatever the legacy register endpoint
does we just flip the flag + timestamp.
"""
body = request.body or {}
event_id = body.get("event_id")
ticket_id = body.get("ticket_id")
if not event_id or not ticket_id:
raise ValueError("event_id and ticket_id are required")
ticket = await get_ticket(ticket_id)
if not ticket or ticket.event != event_id:
raise ValueError("Ticket does not exist on this event")
if not ticket.paid:
raise PermissionError("Ticket not paid for")
if ticket.registered:
raise PermissionError("Ticket already registered")
event = await get_event(event_id)
if not event:
raise ValueError("Event does not exist")
user = await get_user(auth.wallet.user)
owned_wallet_ids = user.wallet_ids if user else [auth.wallet.id]
if event.wallet not in owned_wallet_ids:
raise PermissionError("You do not own this event")
ticket.registered = True
ticket.reg_timestamp = datetime.now(timezone.utc)
await update_ticket(ticket)
return ticket.dict()
async def handle_events_list_event_tickets(
auth: WalletTypeInfo,
request: NostrRpcRequest,
) -> dict:
"""Return paid + registered counts plus the per-ticket roster for
one calendar event, organizer-only.
Backs the door scanner's counts strip and "All scanned" tab so the
UI reads authoritative state from the backend instead of relying
on per-device localStorage (which diverges the moment a second
organizer scans, or the operator switches devices).
The roster only includes paid tickets proposed/unpaid rows are
irrelevant at the door.
"""
body = request.body or {}
event_id = body.get("event_id")
if not event_id:
raise ValueError("event_id is required")
event = await get_event(event_id)
if not event:
raise ValueError("Event does not exist")
user = await get_user(auth.wallet.user)
owned_wallet_ids = user.wallet_ids if user else [auth.wallet.id]
if event.wallet not in owned_wallet_ids:
raise PermissionError("You do not own this event")
tickets = await get_tickets_by_event(event_id)
paid_tickets = [t for t in tickets if t.paid]
registered_count = sum(1 for t in paid_tickets if t.registered)
return {
"event_id": event_id,
"sold": len(paid_tickets),
"registered": registered_count,
"remaining": len(paid_tickets) - registered_count,
"tickets": [
{
"id": t.id,
"name": t.name,
"registered": t.registered,
"registered_at": (
t.reg_timestamp.isoformat() if t.reg_timestamp else None
),
}
for t in paid_tickets
],
}

View file

@ -14,13 +14,11 @@ from fastapi import (
)
from lnbits.core.crud import get_user
from lnbits.core.crud.wallets import get_wallet
from lnbits.core.models import Account, User, WalletTypeInfo
from lnbits.core.models import Account, WalletTypeInfo
from lnbits.core.models.payments import CreateInvoice
from lnbits.core.services import create_payment_request
from lnbits.helpers import urlsafe_short_hash
from lnbits.decorators import (
check_admin,
check_user_exists,
require_admin_key,
require_invoice_key,
)
@ -47,9 +45,6 @@ from .crud import (
get_settings,
get_ticket,
get_tickets,
get_tickets_by_event,
get_tickets_by_payment_hash,
get_tickets_by_user_id,
purge_unpaid_tickets,
update_event,
update_settings,
@ -66,12 +61,7 @@ from .models import (
TicketPaymentRequest,
)
from .nostr_hooks import publish_or_delete_nostr_event
from .services import (
refund_tickets,
resend_ticket_email_notification,
send_ticket_notification_in_background,
set_ticket_paid,
)
from .services import refund_tickets, resend_ticket_email_notification
from .tasks import deregister_payment_listener, register_payment_listener
events_api_router = APIRouter(prefix="/api/v1/events")
@ -107,22 +97,9 @@ async def api_events_public() -> list[Event]:
@events_api_router.get("/all")
async def api_events_all(
admin: Account = Depends(check_admin),
) -> list[dict]:
"""All events across all wallets, with each row's wallet owner
resolved to a user_id. LNbits admin only.
Returns dicts (not strict `Event` rows) so the response can carry
the synthetic `wallet_user_id` column the admin UI uses to attribute
each cross-tenant event to a user.
"""
events = await get_all_events()
enriched: list[dict] = []
for event in events:
wallet = await get_wallet(event.wallet)
row = event.dict()
row["wallet_user_id"] = wallet.user if wallet else None
enriched.append(row)
return enriched
) -> list[Event]:
"""All events across all wallets. LNbits admin only."""
return await get_all_events()
@events_api_router.get("/pending")
@ -133,61 +110,6 @@ async def api_events_pending(
return await get_pending_events()
@events_api_router.post("/republish-all")
async def api_republish_all(
admin: Account = Depends(check_admin),
) -> dict:
"""Force-republish every approved event to Nostr relays. Admin only.
Used by the catalog-bump migration that introduced the AIO ticket
tags: existing events on a deployed instance were published before
the publisher learned the new tag set, so they don't carry
tickets_available / tickets_sold / etc. until something triggers
a republish. This endpoint walks the approved list and re-emits
each calendar event so connected clients see the new metadata
without waiting for a per-event edit.
Errors are swallowed per-event (logged inside the publisher) so
one bad event doesn't block the rest. Returns a count summary.
"""
events = await get_all_events()
approved = [e for e in events if e.status == "approved" and not e.canceled]
for event in approved:
await publish_or_delete_nostr_event(event)
return {"republished": len(approved), "total": len(events)}
@events_api_router.post("/republish-mine")
async def api_republish_mine(
all_wallets: bool = Query(False),
key_info: WalletTypeInfo = Depends(require_admin_key),
) -> dict:
"""Force-republish the caller's own approved events to Nostr relays.
Same shape as /republish-all but scoped to events owned by the
authenticated wallet (or all wallets belonging to the wallet's
user when `?all_wallets=true`). Lets the organizer trigger the
same migration the admin uses, without needing instance-admin
rights useful when the AIO publisher gains a new tag set and
an organizer wants their published events to carry it.
Only events with `status == "approved"` are republished; pending
and rejected rows aren't on relays in the first place, so a
republish for them would be a no-op (or worse, surface a
proposed-but-not-approved row to subscribers).
"""
wallet_ids: list[str] = [key_info.wallet.id]
if all_wallets:
user = await get_user(key_info.wallet.user)
wallet_ids = user.wallet_ids if user else []
events = await get_events(wallet_ids)
approved = [e for e in events if e.status == "approved" and not e.canceled]
for event in approved:
await publish_or_delete_nostr_event(event)
return {"republished": len(approved), "total": len(events)}
@events_api_router.get("/settings")
async def api_get_settings(
admin: Account = Depends(check_admin),
@ -477,27 +399,6 @@ 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)
@ -513,62 +414,6 @@ async def api_get_ticket(ticket_id: str) -> Ticket:
return ticket
async def _issue_free_tickets(
*,
event: Event,
quantity: int,
name: str | None,
email: str | None,
user_id: str | None,
promo_code: str | None,
nostr_identifier: str | None,
request: Request,
) -> TicketPaymentRequest:
"""Issue `quantity` free tickets without minting an invoice.
Each row is created then run through `set_ticket_paid` the exact path
`on_invoice_paid` drives for a settled payment: it flips `paid`, bumps
the sold / available counters under the per-event lock, and republishes
the NIP-52 calendar event so connected clients see the new counts.
Notifications fire the same way. No invoice exists, so `sats_paid` is 0
and these tickets are naturally skipped by `refund_tickets`.
All rows in the batch share one synthetic `payment_hash` the join key
the poll / WebSocket / My-Tickets lookups use mirroring how the paid
multi-ticket path shares the real invoice hash.
"""
payment_hash = urlsafe_short_hash()
ticket_ids: list[str] = []
for _ in range(quantity):
row_id = urlsafe_short_hash()
ticket = await create_ticket(
payment_hash=payment_hash,
wallet=event.wallet,
event=event.id,
name=name,
email=email,
user_id=user_id,
ticket_id=row_id,
extra={
"applied_promo_code": promo_code,
"nostr_identifier": nostr_identifier,
"ticket_base_url": str(request.base_url).rstrip("/"),
"sats_paid": 0,
},
)
await set_ticket_paid(ticket)
send_ticket_notification_in_background(ticket)
ticket_ids.append(row_id)
return TicketPaymentRequest(
payment_hash=payment_hash,
payment_request=None,
is_fiat=False,
paid=True,
ticket_ids=ticket_ids,
)
@tickets_api_router.post("/{event_id}")
async def api_ticket_create(
event_id: str, data: CreateTicket, request: Request
@ -585,20 +430,11 @@ async def api_ticket_create(
)
if event.canceled:
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is canceled.")
quantity = data.quantity
if event.amount_tickets > 0:
if event.sold >= event.amount_tickets:
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is sold out.")
remaining = event.amount_tickets - event.sold
if quantity > remaining:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Only {remaining} ticket(s) remaining for this event.",
)
if event.amount_tickets > 0 and event.sold >= event.amount_tickets:
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is sold out.")
name = data.name
email = data.email
user_id = data.user_id
promo_code = data.promo_code.upper() if data.promo_code else None
refund_address = data.refund_address
nostr_identifier = data.nostr_identifier.strip() if data.nostr_identifier else None
@ -616,7 +452,7 @@ async def api_ticket_create(
status_code=HTTPStatus.BAD_REQUEST,
detail="Invalid Nostr identifier.",
) from exc
unit_price = event.price_per_ticket
price = event.price_per_ticket
extra: dict[str, Any] = {"tag": "events", "name": name, "email": email}
if promo_code:
@ -628,25 +464,7 @@ async def api_ticket_create(
# get the promocode
promo = next(pc for pc in event.extra.promo_codes if pc.code == promo_code)
extra["promo_code"] = promo.code
unit_price = event.price_per_ticket * (1 - promo.discount_percent / 100)
# Scale by quantity AFTER the promo applies. One invoice, N tickets.
price = unit_price * quantity
# Free tickets (final charge 0 — a free event or a 100%-off promo).
# Short-circuit before any invoice / fiat-provider logic: no Lightning
# invoice can settle for 0, so we issue the rows and mark them paid
# directly. payment_method is irrelevant here (nothing is charged).
if price <= 0:
return await _issue_free_tickets(
event=event,
quantity=quantity,
name=name,
email=email,
user_id=user_id,
promo_code=promo_code,
nostr_identifier=nostr_identifier,
request=request,
)
price = event.price_per_ticket * (1 - promo.discount_percent / 100)
if payment_method == "fiat" and not event.allow_fiat:
raise HTTPException(
@ -703,32 +521,20 @@ async def api_ticket_create(
extra=extra,
),
)
# Each row gets a fresh urlsafe_short_hash id so single- and
# multi-ticket purchases stay shape-consistent — every scannable
# ticket id is a short hash, never the long bolt11 payment_hash.
# The shared `payment_hash` column is the join key for invoice
# lookup (poll endpoint, ws notifier, set_ticket_paid loop).
ticket_ids: list[str] = []
sats_per_ticket = payment.sat // quantity if quantity else payment.sat
for _ in range(quantity):
row_id = urlsafe_short_hash()
await create_ticket(
payment_hash=payment.payment_hash,
wallet=event.wallet,
event=event.id,
name=name,
email=email,
user_id=user_id,
ticket_id=row_id,
extra={
"applied_promo_code": promo_code,
"refund_address": refund_address,
"nostr_identifier": nostr_identifier,
"ticket_base_url": str(request.base_url).rstrip("/"),
"sats_paid": sats_per_ticket,
},
)
ticket_ids.append(row_id)
await create_ticket(
payment_hash=payment.payment_hash,
wallet=event.wallet,
event=event.id,
name=name,
email=email,
extra={
"applied_promo_code": promo_code,
"refund_address": refund_address,
"nostr_identifier": nostr_identifier,
"ticket_base_url": str(request.base_url).rstrip("/"),
"sats_paid": payment.sat,
},
)
return TicketPaymentRequest(
payment_hash=payment.payment_hash,
@ -736,36 +542,9 @@ async def api_ticket_create(
fiat_payment_request=getattr(payment, "extra", {}).get("fiat_payment_request"),
fiat_provider=getattr(payment, "fiat_provider", None) or fiat_provider,
is_fiat=bool(getattr(payment, "fiat_provider", None) or fiat_provider),
ticket_ids=ticket_ids,
)
@tickets_api_router.post("/{event_id}/{payment_hash}")
async def api_ticket_payment_status(event_id: str, payment_hash: str) -> dict:
"""Poll-style payment confirmation for a pending ticket purchase.
The webapp polls this every 2s after presenting the invoice until
`paid: true` comes back, then advances to the success state. The
companion WebSocket at `/tickets/ws/{payment_hash}` is more
efficient for pushes this endpoint is the fallback.
Returns `{paid, ticket_ids: [...]}` so multi-ticket buyers get
every scannable id back in one response (one for single-ticket
purchases). A missing / cross-event purchase returns
`paid: false` rather than 404 so the poll doesn't have to
special-case the not-yet-created race.
"""
tickets = await get_tickets_by_payment_hash(payment_hash)
relevant = [t for t in tickets if t.event == event_id]
if not relevant:
return {"paid": False}
return {
"paid": all(t.paid for t in relevant),
"ticket_id": relevant[0].id, # back-compat with single-ticket clients
"ticket_ids": [t.id for t in relevant],
}
@tickets_api_router.websocket("/ws/{payment_hash}")
async def websocket_endpoint(payment_hash: str, websocket: WebSocket) -> None:
await websocket.accept()
@ -857,24 +636,7 @@ async def api_ticket_resend_email(
@tickets_api_router.put("/register/{ticket_id}")
async def api_event_register_ticket(
ticket_id: str,
key_info: WalletTypeInfo = Depends(require_admin_key),
) -> Ticket:
"""Mark a ticket as registered at the door.
Auth: wallet admin_key. Caller must own the event the ticket
belongs to we check `event.wallet` against the user's full
wallet set so an organizer with multiple wallets can scan
regardless of which wallet's key they're using.
Until v1.6.1-aio.3 this endpoint had no auth, which meant any
caller who knew a ticket id could register it. The
Nostr-transport flow at `events_ticket_register` is now the
preferred call site for the webapp; this HTTP path stays for
the legacy LNbits Quasar register page which already sends
the wallet admin_key through `LNbits.api.request`.
"""
async def api_event_register_ticket(ticket_id) -> Ticket:
ticket = await get_ticket(ticket_id)
if not ticket:
@ -882,20 +644,6 @@ async def api_event_register_ticket(
status_code=HTTPStatus.NOT_FOUND, detail="Ticket does not exist."
)
event = await get_event(ticket.event)
if not event:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
)
user = await get_user(key_info.wallet.user)
owned_wallet_ids = user.wallet_ids if user else [key_info.wallet.id]
if event.wallet not in owned_wallet_ids:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="You do not own this event.",
)
if not ticket.paid:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN, detail="Ticket not paid for."
@ -910,52 +658,3 @@ async def api_event_register_ticket(
ticket.reg_timestamp = datetime.now(timezone.utc)
ticket = await update_ticket(ticket)
return ticket
@tickets_api_router.get("/event/{event_id}/stats")
async def api_event_ticket_stats(
event_id: str,
key_info: WalletTypeInfo = Depends(require_admin_key),
) -> dict:
"""Door-scanner roster + counts for one event, organizer-only.
Mirrors the `events_list_event_tickets` nostr-transport RPC for
callers that don't hold a raw user prvkey (the webapp post-#9, in
particular). Auth: wallet admin_key + the event's wallet must be
in the caller's wallet set.
"""
event = await get_event(event_id)
if not event:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
)
user = await get_user(key_info.wallet.user)
owned_wallet_ids = user.wallet_ids if user else [key_info.wallet.id]
if event.wallet not in owned_wallet_ids:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="You do not own this event.",
)
tickets = await get_tickets_by_event(event_id)
paid_tickets = [t for t in tickets if t.paid]
registered_count = sum(1 for t in paid_tickets if t.registered)
return {
"event_id": event_id,
"sold": len(paid_tickets),
"registered": registered_count,
"remaining": len(paid_tickets) - registered_count,
"tickets": [
{
"id": t.id,
"name": t.name,
"registered": t.registered,
"registered_at": (
t.reg_timestamp.isoformat() if t.reg_timestamp else None
),
}
for t in paid_tickets
],
}