Compare commits

..

6 commits

Author SHA1 Message Date
80a934be06 chore: bump to v1.3.0-aio.2
Some checks failed
lint.yml / chore: bump to v1.3.0-aio.2 (push) Failing after 0s
Picks up the upstream-lint pass (mypy/pyright/black/prettier).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:14:33 +02:00
b428b0dca8 chore: satisfy upstream lint (black, mypy, prettier, ruff)
Some checks failed
lint.yml / chore: satisfy upstream lint (black, mypy, prettier, ruff) (push) Failing after 0s
- black/prettier reformatting across new aio code
- type annotations on db.fetchone/fetchall callsites in crud.py
- explicit dict[str, list[str]] for tag_lists in nostr_sync.py
- type:ignore[attr-defined] on Account.prvkey access — the field is
  added by the aio-fork lnbits.core.models.Account; upstream lnbits
  does not yet have it, so consumers without the fork must add a
  prvkey column to accounts before the Nostr publisher can sign.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:04:15 +02:00
42a373bff1 feat: add NIP-52 Nostr publish + sync of calendar events
Some checks failed
lint.yml / feat: add NIP-52 Nostr publish + sync of calendar events (push) Failing after 0s
Approved events are mirrored to Nostr as NIP-52 calendar events (kind
31922) signed by the wallet owner's pubkey, and incoming kind 31922/31923
events from subscribed relays are synced into the local DB so events
created on other LNbits instances or Nostr clients show up locally.

- m009 stores nostr_event_id + nostr_event_created_at on each event
  (used for replaceable updates and NIP-09 deletes); m011 adds location
  + JSON-encoded categories list (NIP-52 location/`t` tags).
- models: Event/PublicEvent/CreateEvent gain location, categories,
  nostr_event_id, nostr_event_created_at; parse_categories validator
  decodes the JSON column on read.
- nostr/{event,nostr_client}.py: Schnorr signing, websocket relay client,
  and a NostrEvent model (publish-only and subscribe variants).
- nostr_publisher.py: build/sign NIP-52 kind 31922 events and NIP-09
  delete events; publish via the relay client.
- nostr_sync.py: subscribe to kinds 31922/31923, dedupe by nostr_event_id
  / d-tag, upsert Events; auto-approves discovered Nostr events since
  they're already public.
- nostr_hooks.py: thin bridge that views_api handlers call to publish
  or delete a NIP-52 event for a given local event. Lives in its own
  module to keep `from . import nostr_client` out of the view layer
  and avoid the views_api -> publisher import cycle.
- views_api: hooks publish_or_delete_nostr_event into create-on-approved,
  update-when-already-published, cancel (delete), delete (delete), and
  approve (publish).
- __init__.py: 3-task lifespan — wait_for_paid_invoices (upstream),
  NostrClient bootstrap, and the NIP-52 sync loop. Module-level
  nostr_client global is set by the bootstrap and read dynamically by
  publish_or_delete_nostr_event so the import order works regardless of
  whether nostrclient is up at startup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:31:06 +02:00
4c8e06a6a9 feat: add event approval workflow with admin UI
Non-admin event submissions now land in a "proposed" queue that LNbits
admins review before the event becomes ticketable and publicly listed.

- m008 adds events.events.status (proposed/approved/rejected); m010 seeds
  an events.settings singleton row with the auto_approve toggle.
- Models: Event/CreateEvent.status, EventsSettings, optional date fields
  with sensible defaults (closing_date defaults to event_end_date which
  defaults to event_start_date), PublicEvent.status surfaces the workflow
  state on the public endpoint.
- crud: get_all/public/pending_events for the admin views; get/update_settings
  for the auto_approve toggle; create_event auto-fills missing date defaults.
- views_api:
  * POST /api/v1/events accepts wallet invoice keys so anyone can submit;
    handler stamps status="proposed" for non-admins when auto_approve is off
  * /public, /all, /pending, /settings (GET+PUT), /{id}/{approve,reject},
    /{id}/tickets endpoints; literal-prefix routes declared before /{event_id}
    so FastAPI matches them correctly
  * Public GET /{event_id} bypasses sold-out / closing-window gates for
    proposed/rejected events and returns the trimmed PublicEvent so the SFC
    can render a "pending approval" banner
  * POST /tickets/{event_id} rejects when event.status != "approved"
- Frontend: index.vue gains an admin Settings card, Pending Approvals list,
  status badge column and approve/reject row actions, plus an All Users'
  Events admin table; index.js gains the data + methods + an isAdmin probe
  via GET /events/all; display.vue shows pending/rejected banners and
  hides the Buy Ticket form unless status === "approved".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:31:06 +02:00
11043ec8a7 feat: support optional user_id ticket identifier
Add an alternative ticket identifier scheme: instead of (name, email),
external integrations can issue tickets bound to an LNbits user_id.

- m007 adds the user_id column on events.ticket
- CreateTicket validator enforces exactly one identifier scheme per ticket
- Ticket / PublicTicket: name, email, user_id all Optional
- _parse_ticket_row reverses the empty-string sentinel used to keep the
  NOT NULL name/email columns satisfied when user_id is the identifier
- POST /tickets/{event_id} dispatches to _create_user_id_ticket vs
  _create_named_ticket based on the supplied identifier
- New GET /tickets/user/{user_id} returns tickets for a given user

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:31:06 +02:00
708d15629c chore: bump to v1.3.0-aio.1 and make m006 idempotent
Sets the rebase foundation: version metadata, point repo at the aio fork,
and a duplicate-column-tolerant m006 so AIO installs that already ran our
pre-rebase m007_add_extra_fields don't fail when LNbits replays upstream's
m006 under its new name.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:31:06 +02:00
21 changed files with 357 additions and 1831 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.3.0-aio.2",
"name": "Events",
"repo": "https://git.atitlan.io/aiolabs/events",
"short_description": "Sell and register event tickets",
@ -36,7 +36,7 @@
{
"name": "padreug",
"uri": "https://git.atitlan.io/padreug",
"role": "Developer (aio fork: approval workflow + NIP-52 Nostr sync + edit gating)"
"role": "Developer (aio fork: approval workflow + NIP-52 Nostr sync)"
}
],
"images": [

51
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,23 +93,8 @@ 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(
row: dict | None = await db.fetchone(
"SELECT * FROM events.ticket WHERE id = :id",
{"id": payment_hash},
)
@ -135,22 +107,15 @@ async def get_tickets(wallet_ids: str | list[str]) -> list[Ticket]:
if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids]
q = ",".join([f"'{wallet_id}'" for wallet_id in wallet_ids])
rows = await db.fetchall(f"SELECT * FROM events.ticket WHERE wallet IN ({q})")
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},
rows: list[dict] = await db.fetchall(
f"SELECT * FROM events.ticket WHERE wallet IN ({q})"
)
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(
rows: list[dict] = await db.fetchall(
"SELECT * FROM events.ticket WHERE user_id = :user_id ORDER BY time DESC",
{"user_id": user_id},
)
@ -243,7 +208,7 @@ async def get_pending_events() -> list[Event]:
async def get_settings() -> EventsSettings:
"""Singleton settings row, seeded by m010."""
row = await db.fetchone("SELECT * FROM events.settings WHERE id = 1")
row: dict | None = await db.fetchone("SELECT * FROM events.settings WHERE id = 1")
if row:
return EventsSettings(**dict(row))
return EventsSettings()
@ -262,7 +227,7 @@ async def delete_event(event_id: str) -> None:
async def get_event_tickets(event_id: str) -> list[Ticket]:
rows = await db.fetchall(
rows: list[dict] = await db.fetchall(
"SELECT * FROM events.ticket WHERE event = :event",
{"event": event_id},
)

View file

@ -162,36 +162,96 @@ async def m005_add_image_banner(db):
await db.execute("ALTER TABLE events.events ADD COLUMN banner TEXT;")
async def _alter_add_column_safe(db, sql: str) -> None:
"""ALTER TABLE ADD COLUMN that swallows duplicate-column errors.
Earlier aiolabs/events forks added some of these columns under different
migration names (e.g. our former m007). Skipping the error keeps the
migration log monotonic for both fresh installs and pre-rebase upgrades.
"""
try:
await db.execute(sql)
except Exception as exc:
msg = str(exc).lower()
if "duplicate column" in msg or "already exists" in msg:
return
raise
async def m006_add_extra_fields(db):
"""
Add a canceled and 'extra' column to events and ticket tables
to support promo codes and ticket metadata.
"""
# Add canceled and 'extra' columns to events table
await db.execute(
"ALTER TABLE events.events ADD COLUMN canceled BOOLEAN NOT NULL DEFAULT FALSE;"
await _alter_add_column_safe(
db,
"ALTER TABLE events.events ADD COLUMN canceled BOOLEAN NOT NULL DEFAULT FALSE",
)
await db.execute("ALTER TABLE events.events ADD COLUMN extra TEXT;")
# Add 'extra' column to ticket table
await db.execute("ALTER TABLE events.ticket ADD COLUMN extra TEXT;")
await _alter_add_column_safe(db, "ALTER TABLE events.events ADD COLUMN extra TEXT")
await _alter_add_column_safe(db, "ALTER TABLE events.ticket ADD COLUMN extra TEXT")
async def m007_add_allow_fiat(db):
async def m007_add_user_id_support(db):
"""
Add an allow_fiat column so event owners can explicitly enable fiat checkout.
Add user_id column to ticket table so a ticket can reference an LNbits
user id instead of (name, email). Application logic enforces that exactly
one identifier scheme is used per ticket.
"""
await _alter_add_column_safe(
db, "ALTER TABLE events.ticket ADD COLUMN user_id TEXT"
)
async def m008_add_event_status(db):
"""
Add status column to events table for the proposal/approval workflow.
Values: 'proposed', 'approved', 'rejected'. Existing rows default to
'approved' so they stay visible after upgrade.
"""
await _alter_add_column_safe(
db,
"ALTER TABLE events.events ADD COLUMN status TEXT NOT NULL DEFAULT 'approved'",
)
async def m009_add_nostr_columns(db):
"""
Track the most recent NIP-52 calendar event we published for this event
(used for replaceable updates and NIP-09 deletes).
"""
await _alter_add_column_safe(
db, "ALTER TABLE events.events ADD COLUMN nostr_event_id TEXT"
)
await _alter_add_column_safe(
db, "ALTER TABLE events.events ADD COLUMN nostr_event_created_at INTEGER"
)
async def m010_add_events_settings(db):
"""
Create the extension settings singleton row used by the admin UI to
toggle e.g. auto_approve.
"""
await db.execute("""
ALTER TABLE events.events
ADD COLUMN allow_fiat BOOLEAN NOT NULL DEFAULT FALSE;
CREATE TABLE IF NOT EXISTS events.settings (
id INTEGER PRIMARY KEY DEFAULT 1,
auto_approve BOOLEAN NOT NULL DEFAULT FALSE
)
""")
await db.execute(
"INSERT INTO events.settings (id, auto_approve) "
"SELECT 1, FALSE WHERE NOT EXISTS "
"(SELECT 1 FROM events.settings WHERE id = 1)"
)
async def m008_add_fiat_currency(db):
async def m011_add_location_and_categories(db):
"""
Add a fiat_currency column for sat-denominated events using fiat checkout.
Add NIP-52 calendar metadata (location and a JSON-encoded category list).
"""
await db.execute("""
ALTER TABLE events.events
ADD COLUMN fiat_currency TEXT NOT NULL DEFAULT 'GBP';
""")
await _alter_add_column_safe(
db, "ALTER TABLE events.events ADD COLUMN location TEXT"
)
await _alter_add_column_safe(
db, "ALTER TABLE events.events ADD COLUMN categories TEXT"
)

View file

@ -1,130 +0,0 @@
"""
Fork-specific database migrations for the aiolabs events extension.
These migrations are tracked separately under `events_fork` in the
`dbversions` table (loaded by `lnbits/core/helpers.py:migrate_extension_database`),
so they do not collide with upstream's `m{NNN}_*` numbering in
`migrations.py`. Keeping the upstream-tracked file untouched means
`git pull upstream` stays rebase-clean for schema changes.
Conventions:
- Sequential numbering starting from m001.
- Each migration is `async def m{NNN}_<description>(db)`.
- DDL must be idempotent: a fresh install runs every migration; an
install that previously ran the OLD versions of these as
`m007-m011` in `migrations.py` has the columns/tables already.
Use `_alter_add_column_safe` / `_create_table_safe` so re-runs are
no-ops instead of crashes.
History compressed into m001 (was m007-m011 in migrations.py pre-v1.6
rebase):
- m007 add_user_id_support (ticket.user_id column)
- m008 add_event_status (events.status column)
- m009 add_nostr_columns (events.nostr_event_id + created_at)
- m010 add_events_settings (events.settings singleton table)
- m011 add_location_and_categories (events.location + categories)
"""
async def _alter_add_column_safe(db, sql: str) -> None:
"""ALTER TABLE ADD COLUMN that swallows duplicate-column errors.
Re-running the squashed migration on a database that already has
these columns (from the pre-squash `m007-m011` in migrations.py)
must be a silent no-op. Same swallow we used in the old migrations.
"""
try:
await db.execute(sql)
except Exception as exc:
msg = str(exc).lower()
if "duplicate column" in msg or "already exists" in msg:
return
raise
async def m001_aio_event_schema(db):
"""
Apply every aiolabs schema delta on top of upstream events v1.3.0.
This is the squashed equivalent of the pre-v1.6 sequence
m007 m011. Order matters for the settings table seed insert
but the individual column adds are independent and idempotent.
"""
# --- ticket.user_id ----------------------------------------------
# Lets a ticket reference an LNbits user id instead of (name, email).
# Application logic enforces that exactly one identifier scheme is
# used per ticket.
await _alter_add_column_safe(
db, "ALTER TABLE events.ticket ADD COLUMN user_id TEXT"
)
# --- events.status -----------------------------------------------
# Proposal / approval workflow. Existing rows default to 'approved'
# so they stay visible after upgrade.
await _alter_add_column_safe(
db,
"ALTER TABLE events.events ADD COLUMN status TEXT NOT NULL DEFAULT 'approved'",
)
# --- events.nostr_event_id, nostr_event_created_at ---------------
# Track the most recent NIP-52 calendar event we published, so
# subsequent edits can issue replaceable updates and NIP-09 deletes
# against the right addressable coordinate.
await _alter_add_column_safe(
db, "ALTER TABLE events.events ADD COLUMN nostr_event_id TEXT"
)
await _alter_add_column_safe(
db, "ALTER TABLE events.events ADD COLUMN nostr_event_created_at INTEGER"
)
# --- events.settings ---------------------------------------------
# Singleton settings row used by the admin UI to toggle e.g.
# auto_approve. CREATE TABLE IF NOT EXISTS + a guarded seed keeps
# this idempotent.
await db.execute("""
CREATE TABLE IF NOT EXISTS events.settings (
id INTEGER PRIMARY KEY DEFAULT 1,
auto_approve BOOLEAN NOT NULL DEFAULT FALSE
)
""")
await db.execute(
"INSERT INTO events.settings (id, auto_approve) "
"SELECT 1, FALSE WHERE NOT EXISTS "
"(SELECT 1 FROM events.settings WHERE id = 1)"
)
# --- events.location, events.categories --------------------------
# NIP-52 calendar metadata. `categories` carries a JSON-encoded
# list of hashtags (the NIP-52 `t` tags).
await _alter_add_column_safe(
db, "ALTER TABLE events.events ADD COLUMN location TEXT"
)
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

@ -24,24 +24,16 @@ class EventExtra(BaseModel):
promo_codes: list[PromoCode] = Field(default_factory=list)
conditional: bool = False
min_tickets: int = 1
email_notifications: bool = False
nostr_notifications: bool = False
notification_subject: str = ""
notification_body: str = ""
class CreateEvent(BaseModel):
wallet: str | None = None # filled from caller's wallet if absent
name: str # title (required)
info: str = "" # description (optional)
closing_date: str | None = None # date-only YYYY-MM-DD; defaults to event_end_date
# ISO 8601: date-only ("2026-05-19") or datetime ("2026-05-19T18:30").
# Presence of a "T" toggles NIP-52 kind (31922 date / 31923 time).
event_start_date: str
event_end_date: str | None = None # same format as event_start_date
closing_date: str | None = None # defaults to event_end_date
event_start_date: str # required
event_end_date: str | None = None # defaults to event_start_date
currency: str = "sat"
allow_fiat: bool = False
fiat_currency: str = "GBP"
amount_tickets: int = 0 # 0 = unlimited / not ticketed
price_per_ticket: float = 0 # 0 = free
banner: str | None = None
@ -61,8 +53,6 @@ class Event(BaseModel):
event_start_date: str
event_end_date: str | None = None
currency: str = "sat"
allow_fiat: bool = False
fiat_currency: str = "GBP"
amount_tickets: int = 0
price_per_ticket: float = 0
time: datetime
@ -90,14 +80,9 @@ class PublicEvent(BaseModel):
canceled: bool
event_start_date: str
event_end_date: str | None = None
currency: str
allow_fiat: bool = False
fiat_currency: str = "GBP"
price_per_ticket: float
banner: str | None
location: str | None = None
categories: list[str] = Field(default_factory=list)
extra: EventExtra = Field(default_factory=EventExtra)
status: str = "approved" # surfaces "proposed"/"rejected" so SFC can render banner
@validator("categories", pre=True)
@ -117,10 +102,6 @@ class TicketExtra(BaseModel):
applied_promo_code: str | None = None
sats_paid: int | None = None
refund_address: str | None = None
nostr_identifier: str | None = None
ticket_base_url: str | None = None
email_notification_sent: bool = False
nostr_notification_sent: bool = False
refunded: bool = False
@ -130,12 +111,6 @@ class CreateTicket(BaseModel):
user_id: str | None = None # LNbits user id (alternative to name+email)
promo_code: str | None = None
refund_address: str | None = None
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 +136,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):
@ -179,16 +149,4 @@ class PublicTicket(BaseModel):
class TicketPaymentRequest(BaseModel):
payment_hash: str
payment_request: str | None = None
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)
payment_request: str

View file

@ -15,30 +15,29 @@ 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: # type: ignore[attr-defined]
return
nostr_event = await publish_event_to_nostr(
nostr_client, event, signer, delete=delete
nostr_client,
event,
account.pubkey,
account.prvkey, # type: ignore[attr-defined]
delete=delete,
)
if nostr_event and not delete:
event.nostr_event_id = nostr_event.id

View file

@ -1,94 +1,42 @@
"""
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.
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.
Builds kind 31922 (date-based) calendar events from the Event model,
signs them with the event creator's Account keypair, and publishes
via the NostrClient to nostrclient relays.
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:
"""ISO 8601 datetime strings contain a 'T' between date and time."""
return value is not None and "T" in value
def _to_unix(value: str) -> int:
"""Parse ISO 8601 datetime (assume UTC if naive) to unix seconds."""
dt = datetime.fromisoformat(value)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return int(dt.timestamp())
def build_nip52_event(event: Event, pubkey: str) -> NostrEvent:
"""
Convert an Event model to a NIP-52 calendar event.
Convert an Event model to a NIP-52 kind 31922 (date-based) calendar event.
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)
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.
Tags:
d - event.id (addressable identifier)
title - event.name
start - event.event_start_date (ISO date string)
end - event.event_end_date (optional)
image - event.banner (optional)
Content: event.info (description)
"""
time_based = _has_time(event.event_start_date)
kind = 31923 if time_based else 31922
start_value = (
str(_to_unix(event.event_start_date)) if time_based else event.event_start_date
)
tags = [
["d", event.id],
["title", event.name],
["start", start_value],
["start", event.event_start_date],
]
end_unix: int | None = None
if event.event_end_date:
end_value = (
str(_to_unix(event.event_end_date)) if time_based else event.event_end_date
)
tags.append(["end", end_value])
if time_based:
end_unix = _to_unix(event.event_end_date)
if time_based:
start_unix = _to_unix(event.event_start_date)
start_day = start_unix // 86400
end_day = (end_unix // 86400) if end_unix is not None else start_day
for day in range(start_day, end_day + 1):
tags.append(["D", str(day)])
tags.append(["end", event.event_end_date])
if event.banner:
tags.append(["image", event.banner])
if event.location:
@ -96,31 +44,10 @@ 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),
kind=kind,
created_at=int(time.time()),
kind=31922,
tags=tags,
content=event.info or "",
)
@ -132,17 +59,15 @@ def build_nip52_delete_event(event: Event, pubkey: str) -> NostrEvent:
"""
Build a kind 5 delete event for a published NIP-52 calendar event.
Uses an 'a' tag to reference the parameterized replaceable event per
NIP-09. The referenced kind must match what we published 31923 for
time-based events, 31922 for date-only.
Uses an 'a' tag to reference the parameterized replaceable event
(kind 31922) per NIP-09.
"""
referenced_kind = 31923 if _has_time(event.event_start_date) else 31922
nostr_event = NostrEvent(
pubkey=pubkey,
created_at=int(time.time()),
kind=5,
tags=[
["a", f"{referenced_kind}:{pubkey}:{event.id}"],
["a", f"31922:{pubkey}:{event.id}"],
],
content="Event canceled",
)
@ -150,20 +75,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 +100,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

@ -50,7 +50,7 @@ async def _handle_calendar_event(nostr_client: NostrClient, event_data: dict):
return
tags = {t[0]: t[1] for t in event_data.get("tags", []) if len(t) >= 2}
tag_lists = {}
tag_lists: dict[str, list[str]] = {}
for t in event_data.get("tags", []):
if len(t) >= 2:
tag_lists.setdefault(t[0], []).append(t[1])

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,16 +1,3 @@
from __future__ import annotations
import asyncio
from asyncio.tasks import create_task
from lnbits.core.models.users import UserNotifications
from lnbits.core.services.nostr import send_nostr_dm
from lnbits.core.services.notifications import (
send_email_notification,
send_user_notification,
)
from lnbits.settings import settings
from lnbits.utils.nostr import normalize_private_key, normalize_public_key
from lnurl import execute
from loguru import logger
@ -21,146 +8,25 @@ from .crud import (
update_event,
update_ticket,
)
from .models import Event, Ticket
from .nostr_hooks import publish_or_delete_nostr_event
DEFAULT_NOSTR_RELAYS = [
"wss://relay.damus.io",
"wss://relay.primal.net",
"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
from .models import Ticket
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
def send_ticket_notification_in_background(ticket: Ticket) -> None:
create_task(_send_ticket_notification(ticket))
async def _send_ticket_notification(ticket: Ticket) -> None:
event = await get_event(ticket.event)
if not event:
logger.warning(f"Event {ticket.event} not found for ticket notification.")
return
subject, message = _ticket_notification_message(ticket, event)
updated = False
if (
event.extra.email_notifications
and settings.lnbits_email_notifications_enabled
and ticket.email
):
try:
await send_email_notification([ticket.email], message, subject)
ticket.extra.email_notification_sent = True
updated = True
except Exception as exc:
logger.warning(f"Failed to email ticket {ticket.id}: {exc}")
if (
event.extra.nostr_notifications
and settings.is_nostr_notifications_configured()
and ticket.extra.nostr_identifier
):
try:
await _send_nostr_ticket_notification(
ticket.extra.nostr_identifier, message
)
ticket.extra.nostr_notification_sent = True
updated = True
except Exception as exc:
logger.warning(f"Failed to send nostr DM for ticket {ticket.id}: {exc}")
if updated:
await update_ticket(ticket)
async def resend_ticket_email_notification(ticket: Ticket) -> Ticket:
event = await get_event(ticket.event)
if not event:
raise ValueError("Event does not exist.")
if not settings.lnbits_email_notifications_enabled:
raise ValueError("Email notifications are not enabled.")
if not ticket.email:
raise ValueError("Ticket does not have an email address.")
subject, message = _ticket_notification_message(ticket, event)
await send_email_notification([ticket.email], message, subject)
ticket.extra.email_notification_sent = True
return await update_ticket(ticket)
def _ticket_notification_message(ticket: Ticket, event: Event) -> tuple[str, str]:
ticket_url = _ticket_url(ticket)
subject = (
event.extra.notification_subject.strip()
or f"Your ticket for '{event.name}' is ready"
)
body = (
event.extra.notification_body.strip()
or f"Your ticket for '{event.name}' is ready."
)
return subject, f"{body}\n\nOpen it here: {ticket_url}"
async def _send_nostr_ticket_notification(identifier: str, message: str) -> None:
if "@" in identifier:
await send_user_notification(
UserNotifications(nostr_identifier=identifier),
message,
"text_message",
)
return
private_key = normalize_private_key(settings.lnbits_nostr_notifications_private_key)
public_key = normalize_public_key(identifier)
await send_nostr_dm(private_key, public_key, message, DEFAULT_NOSTR_RELAYS)
def _ticket_url(ticket: Ticket) -> str:
base_url = (ticket.extra.ticket_base_url or settings.lnbits_baseurl).rstrip("/")
return f"{base_url}/events/ticket/{ticket.id}"
async def refund_tickets(event_id: str):
"""
Refund tickets for an event that has not met the minimum ticket requirement.

View file

@ -11,9 +11,7 @@ window.PageEventsDisplay = {
data: {
name: '',
email: '',
refund: '',
nostr_identifier: '',
payment_method: 'lightning'
refund: ''
}
},
ticketLink: {
@ -25,8 +23,7 @@ window.PageEventsDisplay = {
receive: {
show: false,
status: 'pending',
paymentReq: null,
isFiat: false
paymentReq: null
},
paymentDismissMsg: null,
paymentWebsocket: null
@ -38,25 +35,7 @@ window.PageEventsDisplay = {
},
computed: {
formatDescription() {
return LNbits.utils.convertMarkdown(this.event?.info || '')
},
allowFiatCheckout() {
return Boolean(this.event?.allow_fiat)
},
fiatCheckoutLabel() {
if (!this.allowFiatCheckout) return 'Fiat'
const unit = ['sat', 'sats'].includes(
(this.event?.currency || '').toLowerCase()
)
? this.event?.fiat_currency
: this.event?.currency
return `Fiat (${(unit || 'GBP').toUpperCase()})`
},
allowEmailNotifications() {
return Boolean(this.event?.extra?.email_notifications)
},
allowNostrNotifications() {
return Boolean(this.event?.extra?.nostr_notifications)
return LNbits.utils.convertMarkdown(this.info)
}
},
methods: {
@ -77,8 +56,6 @@ window.PageEventsDisplay = {
this.formDialog.data.name = ''
this.formDialog.data.email = ''
this.formDialog.data.refund = ''
this.formDialog.data.nostr_identifier = ''
this.formDialog.data.payment_method = 'lightning'
},
closeReceiveDialog() {
@ -110,9 +87,6 @@ window.PageEventsDisplay = {
this.paymentReq = null
this.formDialog.data.name = ''
this.formDialog.data.email = ''
this.formDialog.data.refund = ''
this.formDialog.data.nostr_identifier = ''
this.formDialog.data.payment_method = 'lightning'
Quasar.Notify.create({
type: 'positive',
message: 'Sent, thank you!',
@ -121,8 +95,7 @@ window.PageEventsDisplay = {
this.receive = {
show: false,
status: 'complete',
paymentReq: null,
isFiat: false
paymentReq: null
}
this.ticketLink = {
show: true,
@ -130,7 +103,9 @@ window.PageEventsDisplay = {
link: `/events/ticket/${paymentHash}`
}
}
window.open(`/events/ticket/${paymentHash}`, '_blank', 'noopener')
setTimeout(() => {
window.location.href = `/events/ticket/${paymentHash}`
}, 5000)
},
async createInvoice() {
try {
@ -142,15 +117,10 @@ window.PageEventsDisplay = {
name: this.formDialog.data.name,
email: this.formDialog.data.email,
promo_code: this.formDialog.data.promo_code || null,
refund_address: this.formDialog.data.refund || null,
nostr_identifier: this.formDialog.data.nostr_identifier || null,
payment_method: this.formDialog.data.payment_method
refund_address: this.formDialog.data.refund || null
}
)
const isFiat = Boolean(data.is_fiat)
this.paymentReq = isFiat
? data.fiat_payment_request || null
: data.payment_request
this.paymentReq = data.payment_request
this.paymentHash = data.payment_hash
this.paymentDismissMsg = Quasar.Notify.create({
@ -160,34 +130,30 @@ window.PageEventsDisplay = {
this.receive = {
show: true,
status: 'pending',
paymentReq: this.paymentReq,
isFiat
paymentReq: this.paymentReq
}
if (isFiat && this.paymentReq) {
window.open(this.paymentReq, '_blank', 'noopener')
}
this.paymentWatcher(this.paymentHash)
this.websocketListener(this.paymentHash)
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
paymentWatcher(paymentHash) {
websocketListener(paymentHash) {
if (this.paymentWebsocket) {
this.paymentWebsocket.close()
}
const url = new URL(window.location)
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
url.pathname = `/api/v1/ws/${paymentHash}`
url.protocol = url.protocol === 'https:' ? 'wss' : 'ws'
url.pathname = `/events/api/v1/tickets/ws/${paymentHash}`
url.search = ''
url.hash = ''
const ws = new WebSocket(url.toString())
const ws = new WebSocket(url)
this.paymentWebsocket = ws
ws.onmessage = event => {
const data = JSON.parse(event.data)
if (data.pending === false) {
if (data.paid) {
this.paymentSuccess(paymentHash)
ws.close()
}

View file

@ -41,77 +41,41 @@
<q-card-section class="q-pa-none">
<h5 class="q-mt-none">Buy Ticket</h5>
<q-form @submit="createInvoice()" class="q-gutter-md">
<div class="row">
<div class="col-12">
<q-input
filled
dense
v-model.trim="formDialog.data.name"
label="Your name "
:rules="[val => nameValidation(val)]"
></q-input>
</div>
<div class="col-12 col-md-6 q-pr-sm">
<q-input
filled
dense
v-model.trim="formDialog.data.email"
type="email"
:label="
allowEmailNotifications
? 'Your email (ticket delivery) '
: 'Your email '
"
:rules="[
val => !!val || '* Required',
val => emailValidation(val)
]"
lazy-rules
></q-input>
</div>
<div v-if="allowNostrNotifications" class="col-12 col-md-6">
<q-input
filled
dense
v-model.trim="formDialog.data.nostr_identifier"
label="(optional) Nostr NIP-05 or npub"
hint="If provided, we'll DM your ticket link after payment."
></q-input>
</div>
</div>
<q-input
v-if="event.extra?.conditional"
filled
dense
v-model.trim="formDialog.data.name"
label="Your name "
:rules="[val => nameValidation(val)]"
></q-input>
<q-input
filled
dense
v-model.trim="formDialog.data.email"
type="email"
label="Your email "
:rules="[
val => !!val || '* Required',
val => emailValidation(val)
]"
lazy-rules
></q-input>
<q-input
v-if="this.extra?.conditional"
filled
dense
v-model.trim="formDialog.data.refund"
label="Refund lnadress or LNURL "
:rules="[val => !!val || '* Required']"
lazy-rules
:hint="`If minimum tickets (${event.extra?.min_tickets}) are not met, refund will be sent.`"
:hint="`If minimum tickets (${this.extra?.min_tickets}) are not met, refund will be sent.`"
></q-input>
<q-input
filled
dense
v-model.trim="formDialog.data.promo_code"
label="(optional) Promo Code "
></q-input>
<div class="row q-col-gutter-md q-pt-lg items-center">
<div v-if="allowFiatCheckout" class="col-auto">
<q-option-group
v-model="formDialog.data.payment_method"
inline
:options="[
{label: 'Lightning', value: 'lightning'},
{
label: fiatCheckoutLabel,
value: 'fiat'
}
]"
></q-option-group>
</div>
<div :class="allowFiatCheckout ? 'col-12 col-md-3' : 'col-12'">
<q-input
filled
dense
v-model.trim="formDialog.data.promo_code"
label="(optional) Promo Code "
></q-input>
</div>
</div>
<div class="row q-mt-lg">
<q-btn
unelevated
@ -143,6 +107,8 @@
type="a"
>Link to your ticket!</q-btn
>
<br /><br />
<p>You'll be redirected in a few moments...</p>
</div>
</q-card>
</div>
@ -153,37 +119,6 @@
class="q-pa-lg q-pt-xl lnbits__dialog-card"
>
</q-card>
<q-card
v-else-if="receive.isFiat"
class="q-pa-lg q-pt-xl lnbits__dialog-card"
>
<div class="text-center q-mb-lg">
<div class="text-h6 q-mb-sm">Continue to checkout</div>
<div class="text-body2 text-grey-5 q-mb-lg">
Your fiat checkout opened in a new tab. If it did not, use the
button below.
</div>
<q-btn
unelevated
color="primary"
type="a"
:href="receive.paymentReq"
target="_blank"
rel="noopener"
>
Go to checkout
</q-btn>
</div>
<div class="row q-mt-lg">
<q-btn
outline
color="grey"
@click="utils.copyText(receive.paymentReq)"
>Copy payment link</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div>
</q-card>
<q-card v-else class="q-pa-lg q-pt-xl lnbits__dialog-card">
<div class="text-center q-mb-lg">
<lnbits-qrcode

View file

@ -4,61 +4,13 @@ window.PageEvents = {
return {
events: [],
tickets: [],
resendingTicketEmails: [],
currencies: [],
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'},
@ -97,7 +49,7 @@ window.PageEvents = {
align: 'left',
label: 'Price',
field: row => {
if (this.isFiatCurrency(row.currency)) {
if (row.currency != 'sats') {
return LNbits.utils.formatCurrency(
row.price_per_ticket.toFixed(2),
row.currency
@ -153,21 +105,14 @@ window.PageEvents = {
show: false,
data: {
currency: 'sats',
allow_fiat: false,
fiat_currency: 'GBP',
extra: {
promo_codes: [],
notification_subject: '',
notification_body: ''
promo_codes: []
}
}
}
}
},
methods: {
isFiatCurrency(currency) {
return !['sat', 'sats'].includes((currency || '').toLowerCase())
},
getTickets() {
LNbits.api
.request(
@ -200,35 +145,6 @@ window.PageEvents = {
.catch(LNbits.utils.notifyApiError)
})
},
resendTicketEmail(ticket) {
if (!ticket.paid || !ticket.email) return
const wallet = _.findWhere(this.g.user.wallets, {id: ticket.wallet})
if (!wallet) return
this.resendingTicketEmails.push(ticket.id)
LNbits.api
.request(
'POST',
'/events/api/v1/tickets/' + ticket.id + '/resend-email',
wallet.adminkey
)
.then(response => {
this.tickets = this.tickets.map(obj =>
obj.id === ticket.id ? response.data : obj
)
Quasar.Notify.create({
type: 'positive',
message: 'Ticket email resent.',
icon: null
})
})
.catch(LNbits.utils.notifyApiError)
.finally(() => {
this.resendingTicketEmails = this.resendingTicketEmails.filter(
ticketId => ticketId !== ticket.id
)
})
},
exportticketsCSV() {
LNbits.utils.exportCSV(this.ticketsTable.columns, this.tickets)
},
@ -271,12 +187,7 @@ window.PageEvents = {
},
saveSettings() {
LNbits.api
.request(
'PUT',
'/events/api/v1/events/settings',
null,
this.settings
)
.request('PUT', '/events/api/v1/events/settings', null, this.settings)
.then(() => {
Quasar.Notify.create({type: 'positive', message: 'Settings saved'})
})
@ -322,108 +233,15 @@ 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
// "YYYY-MM-DDTHH:MM" (time is optional).
if (!day) return null
return time ? `${day}T${time}` : day
},
splitDateTime(value) {
// Inverse of foldDateTime: split a stored string back into the
// day/time pieces the form inputs bind to.
if (!value) return {day: '', time: ''}
const [day, time = ''] = value.split('T')
// Time inputs only accept HH:MM, drop any seconds we stored.
return {day, time: time.slice(0, 5)}
},
sendEventData() {
const wallet = _.findWhere(this.g.user.wallets, {
id: this.formDialog.data.wallet
})
const data = {...this.formDialog.data}
data.event_start_date = this.foldDateTime(
data.event_start_day,
data.event_start_time
)
data.event_end_date = this.foldDateTime(
data.event_end_day,
data.event_end_time
)
delete data.event_start_day
delete data.event_start_time
delete data.event_end_day
delete data.event_end_time
if (data.extra?.promo_codes) {
const data = this.formDialog.data
if (data.extra && !data.extra.promo_codes) {
data.extra.promo_codes = data.extra.promo_codes
.filter(code => code.code?.trim() !== '')
.map(code => ({
...code,
code: code.code.trim().toUpperCase()
}))
}
if (!this.isFiatCurrency(data.currency)) {
if (!data.allow_fiat) {
data.fiat_currency = 'GBP'
}
.filter(code => code.trim() !== '')
.map(code => code.trim().toUpperCase())
}
if (data.id) {
@ -435,32 +253,14 @@ window.PageEvents = {
openEventDialog(data = false) {
if (data && data.id) {
const start = this.splitDateTime(data.event_start_date)
const end = this.splitDateTime(data.event_end_date)
this.formDialog.data = {
...data,
event_start_day: start.day,
event_start_time: start.time,
event_end_day: end.day,
event_end_time: end.time
}
this.formDialog.data = {...data}
} else {
this.formDialog.data = {
currency: 'sats',
allow_fiat: false,
fiat_currency: 'GBP',
event_start_day: '',
event_start_time: '',
event_end_day: '',
event_end_time: '',
extra: {
conditional: false,
min_tickets: 1,
email_notifications: false,
nostr_notifications: false,
promo_codes: [],
notification_subject: '',
notification_body: ''
promo_codes: []
}
}
}
@ -469,15 +269,8 @@ window.PageEvents = {
resetEventDialog() {
this.formDialog.show = false
this.formDialog.data = {
currency: 'sats',
allow_fiat: false,
fiat_currency: 'GBP',
extra: {
email_notifications: false,
nostr_notifications: false,
promo_codes: [],
notification_subject: '',
notification_body: ''
promo_codes: []
}
}
},

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>
@ -228,7 +192,13 @@
<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'"
:color="
col.value === 'approved'
? 'green'
: col.value === 'proposed'
? 'orange'
: 'red'
"
:label="col.value"
></q-badge>
<span v-else v-text="col.value"></span>
@ -286,6 +256,57 @@
</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">
@ -308,12 +329,10 @@
>
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
<span v-text="col.label"></span>
</q-th>
<q-th auto-width></q-th>
</q-tr>
</template>
<template v-slot:body="props">
@ -330,20 +349,6 @@
target="_blank"
></q-btn>
</q-td>
<q-td auto-width>
<q-btn
flat
dense
size="xs"
@click="resendTicketEmail(props.row)"
icon="email"
color="primary"
:disable="!props.row.paid || !props.row.email"
:loading="resendingTicketEmails.includes(props.row.id)"
>
<q-tooltip>Resend ticket email</q-tooltip>
</q-btn>
</q-td>
<q-td v-for="col in props.cols" :key="col.name" :props="props">
<span v-text="col.value"></span>
@ -364,51 +369,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>
@ -511,46 +471,28 @@
></q-input>
</div>
</div>
<div class="row q-col-gutter-sm">
<div class="row">
<div class="col-4">Event begins</div>
<div class="col-5">
<div class="col-8">
<q-input
filled
dense
v-model.trim="formDialog.data.event_start_day"
v-model.trim="formDialog.data.event_start_date"
type="date"
></q-input>
</div>
<div class="col-3">
<q-input
filled
dense
v-model.trim="formDialog.data.event_start_time"
type="time"
hint="Optional"
></q-input>
</div>
</div>
<div class="row q-col-gutter-sm">
<div class="row">
<div class="col-4">Event ends</div>
<div class="col-5">
<div class="col-8">
<q-input
filled
dense
v-model.trim="formDialog.data.event_end_day"
v-model.trim="formDialog.data.event_end_date"
type="date"
></q-input>
</div>
<div class="col-3">
<q-input
filled
dense
v-model.trim="formDialog.data.event_end_time"
type="time"
hint="Optional"
></q-input>
</div>
</div>
<div class="row q-col-gutter-sm">
<div class="col">
@ -587,29 +529,6 @@
></q-input>
</div>
</div>
<q-toggle
v-model="formDialog.data.allow_fiat"
label="Allow fiat checkout"
left-label
hint="Lets attendees pay through a configured fiat provider using the event currency."
></q-toggle>
<q-select
v-if="
formDialog.data.allow_fiat &&
['sat', 'sats'].includes(
(formDialog.data.currency || '').toLowerCase()
)
"
filled
dense
v-model="formDialog.data.fiat_currency"
label="Fiat checkout currency"
:options="
currencies.filter(
c => !['sat', 'sats'].includes((c || '').toLowerCase())
)
"
></q-select>
<q-expansion-item
group="advanced"
icon="settings"
@ -712,41 +631,8 @@
>Add Promo Code</q-btn
>
</div>
<q-separator class="q-my-md"></q-separator>
<div class="text-subtitle1 q-mb-md">Ticket Delivery</div>
<div class="text-caption">
Send the paid ticket link automatically by email or Nostr DM.
</div>
<q-toggle
v-model="formDialog.data.extra.email_notifications"
label="Email notifications"
left-label
></q-toggle>
<q-toggle
v-model="formDialog.data.extra.nostr_notifications"
label="Nostr notifications"
left-label
></q-toggle>
</q-expansion-item>
<q-separator class="q-my-md"></q-separator>
<q-input
filled
dense
v-model.trim="formDialog.data.extra.notification_subject"
type="text"
label="Ticket notification subject"
hint="Used as the email subject when sending paid ticket links."
></q-input>
<q-input
filled
dense
v-model.trim="formDialog.data.extra.notification_body"
type="textarea"
label="Ticket notification body"
hint="Shown before the ticket link in the paid ticket notification."
></q-input>
<div class="row q-mt-lg">
<q-btn
v-if="formDialog.data.id"
@ -764,8 +650,8 @@
formDialog.data.name == null ||
formDialog.data.info == null ||
formDialog.data.closing_date == null ||
formDialog.data.event_start_day == null ||
formDialog.data.event_end_day == null ||
formDialog.data.event_start_date == null ||
formDialog.data.event_end_date == null ||
formDialog.data.amount_tickets == null ||
formDialog.data.price_per_ticket == null
"

View file

@ -3,7 +3,7 @@ window.PageEventsTicket = {
data() {
return {
ticketId: null,
ticket: null
ticketName: null
}
},
methods: {
@ -18,7 +18,7 @@ window.PageEventsTicket = {
'GET',
`/events/api/v1/tickets/${this.ticketId}`
)
this.ticket = data
this.ticketName = data.ticket_name
} catch (error) {
LNbits.utils.notifyApiError(error)
}

View file

@ -5,32 +5,20 @@
<q-card-section class="q-pa-none">
<center>
<h3 class="q-my-none">Ticket</h3>
<h5 v-if="ticket" v-text="ticket.name" class="q-my-none"></h5>
<br />
<h5 class="q-my-none">
Bookmark, print or screenshot this page,<br />
and present it for registration!
</h5>
<div v-if="ticket" class="row justify-center q-gutter-sm q-mb-md">
<q-btn
unelevated
:color="ticket.paid ? 'positive' : 'negative'"
:label="ticket.paid ? 'Paid' : 'Not Paid'"
></q-btn>
<q-btn
unelevated
:color="ticket.registered ? 'positive' : 'warning'"
:label="ticket.registered ? 'Checked In' : 'Not Checked In'"
></q-btn>
</div>
<br />
<lnbits-qrcode
:value="`ticket://${ticketId}`"
:options="{width: 500}"
></lnbits-qrcode>
<br />
<q-btn @click="printWindow" color="grey">
<q-icon left size="3em" name="print"></q-icon> Print
</q-btn>
<q-btn @click="printWindow" color="grey" class="q-ml-auto">
<q-icon left size="3em" name="print"></q-icon> Print</q-btn
>
</center>
</q-card-section>
</q-card>

View file

@ -4,9 +4,9 @@ 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
from .services import set_ticket_paid
payment_listeners: dict[str, list[asyncio.Queue[Ticket]]] = {}
@ -37,32 +37,12 @@ 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)
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

@ -8,29 +8,21 @@ from fastapi import (
Depends,
HTTPException,
Query,
Request,
WebSocket,
WebSocketDisconnect,
)
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.payments import CreateInvoice
from lnbits.core.services import create_payment_request
from lnbits.helpers import urlsafe_short_hash
from lnbits.core.models import Account, WalletTypeInfo
from lnbits.core.services import create_invoice
from lnbits.decorators import (
check_admin,
check_user_exists,
require_admin_key,
require_invoice_key,
)
from lnbits.settings import settings
from lnbits.utils.exchange_rates import (
fiat_amount_as_satoshis,
get_fiat_rate_satoshis,
satoshis_amount_as_fiat,
)
from lnbits.utils.nostr import normalize_public_key
from .crud import (
create_event,
@ -47,8 +39,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,
@ -66,22 +56,13 @@ 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
from .tasks import deregister_payment_listener, register_payment_listener
events_api_router = APIRouter(prefix="/api/v1/events")
tickets_api_router = APIRouter(prefix="/api/v1/tickets")
def _is_fiat_currency(currency: str | None) -> bool:
return str(currency or "").lower() not in {"sat", "sats"}
# Literal-prefix routes (/public, /all, /pending, /settings) MUST be declared
# before any "/{event_id}" route or FastAPI matches them as a path parameter.
@ -107,22 +88,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 +101,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),
@ -203,19 +116,6 @@ async def api_update_settings(
return await update_settings(data)
@events_api_router.get("/settings/public")
async def api_get_settings_public(
wallet: WalletTypeInfo = Depends(require_invoice_key),
) -> dict:
"""Subset of EventsSettings safe to expose to any authenticated
caller. The webapp needs `auto_approve` to render accurate edit-flow
copy ("your edit will go back to pending" vs "edit stays approved")
without forcing every event-creator to also be an LNbits admin.
"""
settings = await get_settings()
return {"auto_approve": settings.auto_approve}
@events_api_router.get("/{event_id}", response_model=PublicEvent)
async def api_get_event(event_id: str) -> Event:
"""Public event detail used by display.vue.
@ -240,16 +140,9 @@ async def api_get_event(event_id: str) -> Event:
# closing_date is filled in by create_event (defaults to end_date or
# start_date) but the field is typed Optional, so guard for the typechecker.
closing_date = event.closing_date or event.event_end_date or event.event_start_date
# Accept either YYYY-MM-DD or full ISO 8601 datetime (event_end_date
# may carry a time component since v1.3.0-aio.3 / our start-end-time
# feature).
try:
closing_dt = datetime.fromisoformat(closing_date)
except ValueError:
closing_dt = datetime.strptime(closing_date[:10], "%Y-%m-%d")
if closing_dt.tzinfo is None:
closing_dt = closing_dt.replace(tzinfo=timezone.utc)
is_window_open = datetime.now(timezone.utc) < closing_dt
is_window_open = datetime.now(timezone.utc) < datetime.strptime(
closing_date, "%Y-%m-%d"
).replace(tzinfo=timezone.utc)
is_min_tickets_met = (
event.sold >= event.extra.min_tickets if event.extra.conditional else True
)
@ -304,18 +197,6 @@ async def api_event_update(
data: CreateEvent,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> Event:
"""Update an event. The owner can edit any mutable field; the status
is derived (admin / `auto_approve` approved, otherwise proposed)
and is NEVER taken from the request body that would let owners
self-approve.
Nostr is reconciled against the status transition:
approved approved : re-publish the replaceable NIP-52 event
proposed approved : fresh publish
approved proposed : NIP-09 delete so the public feed drops it
until the edit is re-approved
proposed proposed : no-op
"""
event = await get_event(event_id)
if not event:
raise HTTPException(
@ -323,53 +204,13 @@ async def api_event_update(
)
if event.wallet != wallet.wallet.id:
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your event.")
from lnbits.settings import settings
ext_settings = await get_settings()
user_id = wallet.wallet.user
is_admin = user_id == settings.super_user or user_id in settings.lnbits_admin_users
previous_status = event.status
# Same defaulting as create_event: optional end/closing dates fall
# back to start_date when omitted, so an edit that doesn't restate
# them doesn't wipe them.
if not data.event_end_date:
data.event_end_date = data.event_start_date
if not data.closing_date:
data.closing_date = data.event_end_date
# Explicit field list — never copy `status` from the request body.
# Includes upstream v1.6.1 fields (allow_fiat, fiat_currency) so an
# owner editing a fiat-enabled event keeps the fiat config.
for field in (
"name",
"info",
"closing_date",
"event_start_date",
"event_end_date",
"currency",
"allow_fiat",
"fiat_currency",
"amount_tickets",
"price_per_ticket",
"banner",
"location",
"categories",
"extra",
):
setattr(event, field, getattr(data, field))
event.status = "approved" if (is_admin or ext_settings.auto_approve) else "proposed"
for k, v in data.dict().items():
setattr(event, k, v)
event = await update_event(event)
if event.status == "approved":
# Re-publish the replaceable NIP-52 event if we already announced it.
if event.status == "approved" and event.nostr_event_id:
await publish_or_delete_nostr_event(event)
elif previous_status == "approved":
# Take it down from the public feed while it waits for re-approval.
await publish_or_delete_nostr_event(event, delete=True)
return event
@ -478,23 +319,12 @@ async def api_tickets(
@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.
async def api_tickets_by_user_id(user_id: str) -> list[Ticket]:
"""Tickets bound to an LNbits user_id (used by external integrations).
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.
Declared before /{ticket_id} so FastAPI matches the literal `/user/`
prefix instead of treating "user" as a ticket id.
"""
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)
@ -513,66 +343,8 @@ 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
) -> TicketPaymentRequest:
async def api_ticket_create(event_id: str, data: CreateTicket) -> TicketPaymentRequest:
event = await get_event(event_id)
if not event:
raise HTTPException(
@ -585,185 +357,89 @@ 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.")
if data.user_id:
return await _create_user_id_ticket(event, data.user_id)
return await _create_named_ticket(event, data)
async def _create_named_ticket(
event: Event, data: CreateTicket
) -> TicketPaymentRequest:
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
payment_method = (data.payment_method or "lightning").lower()
if payment_method not in {"lightning", "fiat"}:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Unsupported payment method.",
)
if nostr_identifier and "@" not in nostr_identifier:
try:
nostr_identifier = normalize_public_key(nostr_identifier)
except Exception as exc:
raise HTTPException(
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:
# check if promo_code exists in event.extra.promo_codes
if promo_code not in [pc.code for pc in event.extra.promo_codes]:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="Invalid promo code."
)
# 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
price = event.price_per_ticket * (1 - promo.discount_percent / 100)
# 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,
)
if payment_method == "fiat" and not event.allow_fiat:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Fiat payments are not enabled for this event.",
)
if _is_fiat_currency(event.currency):
if event.currency != "sats":
extra["fiat"] = True
extra["currency"] = event.currency
extra["fiatAmount"] = price
extra["rate"] = await get_fiat_rate_satoshis(event.currency)
price = await fiat_amount_as_satoshis(price, event.currency)
if payment_method != "fiat":
price = await fiat_amount_as_satoshis(price, event.currency)
invoice_unit = event.currency
fiat_amount = price
fiat_provider = None
if payment_method == "fiat":
if _is_fiat_currency(event.currency):
invoice_unit = event.currency
else:
invoice_unit = event.fiat_currency
fiat_amount = await satoshis_amount_as_fiat(price, invoice_unit)
extra["fiat"] = True
extra["currency"] = invoice_unit
extra["fiatAmount"] = fiat_amount
extra["rate"] = await get_fiat_rate_satoshis(invoice_unit)
wallet = await get_wallet(event.wallet)
if not wallet:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Event wallet does not exist.",
)
providers = settings.get_fiat_providers_for_user(wallet.user)
fiat_provider = data.fiat_provider or (providers[0] if providers else None)
if not fiat_provider:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="No fiat payment provider configured for this event.",
)
else:
invoice_unit = "sat"
payment = await create_payment_request(
payment = await create_invoice(
wallet_id=event.wallet,
invoice_data=CreateInvoice(
out=False,
amount=fiat_amount if payment_method == "fiat" else price,
unit=invoice_unit,
fiat_provider=fiat_provider,
memo=f"{event_id}",
extra=extra,
),
amount=price,
memo=f"{event.id}",
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)
return TicketPaymentRequest(
await create_ticket(
payment_hash=payment.payment_hash,
payment_request=getattr(payment, "bolt11", None),
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,
wallet=event.wallet,
event=event.id,
name=name,
email=email,
extra={
"applied_promo_code": promo_code,
"refund_address": refund_address,
"sats_paid": int(price),
},
)
return TicketPaymentRequest(
payment_hash=payment.payment_hash, payment_request=payment.bolt11
)
@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.
async def _create_user_id_ticket(event: Event, user_id: str) -> TicketPaymentRequest:
price = event.price_per_ticket
extra: dict[str, Any] = {"tag": "events", "user_id": user_id}
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.
if event.currency != "sats":
price = await fiat_amount_as_satoshis(event.price_per_ticket, event.currency)
extra["fiat"] = True
extra["currency"] = event.currency
extra["fiatAmount"] = event.price_per_ticket
extra["rate"] = await get_fiat_rate_satoshis(event.currency)
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],
}
payment = await create_invoice(
wallet_id=event.wallet,
amount=price,
memo=f"{event.id}",
extra=extra,
)
await create_ticket(
payment_hash=payment.payment_hash,
wallet=event.wallet,
event=event.id,
user_id=user_id,
)
return TicketPaymentRequest(
payment_hash=payment.payment_hash, payment_request=payment.bolt11
)
@tickets_api_router.websocket("/ws/{payment_hash}")
@ -824,57 +500,8 @@ async def api_ticket_delete(
await delete_ticket(ticket_id)
@tickets_api_router.post("/{ticket_id}/resend-email")
async def api_ticket_resend_email(
ticket_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)
) -> Ticket:
ticket = await get_ticket(ticket_id)
if not ticket:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Ticket does not exist."
)
if ticket.wallet != wallet.wallet.id:
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your ticket.")
if not ticket.paid:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Only paid tickets can be resent by email.",
)
try:
return await resend_ticket_email_notification(ticket)
except ValueError as exc:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)
) from exc
except Exception as exc:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Failed to resend ticket email.",
) from exc
@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 +509,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 +523,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
],
}