Compare commits

..

40 commits

Author SHA1 Message Date
fe9f005b53 Merge pull request 'feat: issue free tickets without minting an invoice' (#31) from feat/free-tickets into main
Some checks failed
lint.yml / Merge pull request 'feat: issue free tickets without minting an invoice' (#31) from feat/free-tickets into main (push) Failing after 0s
Reviewed-on: #31
2026-06-20 09:51:18 +00:00
2093e63020 chore: bump config.json version to 1.6.1-aio.7
Some checks failed
lint.yml / chore: bump config.json version to 1.6.1-aio.7 (pull_request) Failing after 0s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 09:04:02 +02:00
9d7efd7662 feat: issue free tickets without minting an invoice
Free events (price_per_ticket == 0) tried to mint a 0-amount Lightning
invoice via create_payment_request — an invoice that can't settle, and
which the invoice listener would never mark paid, so the ticket never
became scannable.

api_ticket_create now short-circuits when the final charge is 0 (a free
event or a 100%-off promo, computed after promo + quantity) before any
invoice / fiat-provider logic: _issue_free_tickets creates the N rows and
runs each through the existing set_ticket_paid — the same path
on_invoice_paid drives for a settled payment (flip paid, bump
sold/available under the per-event lock, republish the NIP-52 event) —
plus the ticket notification. The response carries a new
TicketPaymentRequest.paid=True with no payment_request so the client
skips the QR / payment-poll and goes straight to the ticket QRs.

No invoice means sats_paid=0, so free tickets are naturally skipped by
refund_tickets. All rows in a batch share one synthetic payment_hash —
the join key the poll / WebSocket / My-Tickets lookups use — mirroring
the paid multi-ticket path.

Self-service forfeit (#28), abuse/identity limits (#29) and
pay-what-you-want/donation tickets (#30) are tracked as follow-ups.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 09:03:44 +02:00
f8059516f8 Merge pull request 'fix: publish NIP-52 events with monotonic created_at (#26)' (#27) from fix/monotonic-created-at into main
Some checks failed
lint.yml / Merge pull request 'fix: publish NIP-52 events with monotonic created_at (#26)' (#27) from fix/monotonic-created-at into main (push) Failing after 0s
Reviewed-on: #27
2026-06-18 12:18:55 +00:00
cfc2e38a5e chore: bump config.json version to 1.6.1-aio.6
Some checks failed
lint.yml / chore: bump config.json version to 1.6.1-aio.6 (pull_request) Failing after 0s
Marks the monotonic created_at fix (#26). aio semver stays ahead of the
upstream 1.6.1 tag per fork versioning rules.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 14:13:11 +02:00
b5c87c60b4 fix: publish NIP-52 events with monotonic created_at (#26)
NIP-52 calendar events (31922/31923) are replaceable and republished
whenever inventory changes (a ticket sells). build_nip52_event stamped
created_at=int(time.time()); relays only push a replacement to OPEN
subscriptions when created_at is strictly newer, so two republishes in
the same wall-clock second tie and the second is silently dropped for
live subscribers — clients' "tickets remaining" badge stalls until a
reload. Same root cause as the webapp fix (aiolabs/webapp#122).

- Add monotonic_created_at() in nostr_timestamp.py = max(now, last+1),
  mirroring the webapp helper + docs/nostr-patterns/replaceable-events.md.
- Anchor it on the already-persisted Event.nostr_event_created_at
  (set after each publish in nostr_hooks.py). The kind-5 delete event is
  not replaceable, so it keeps plain int(time.time()).
- Unit tests mirror the webapp's timestamp suite.

Concurrent same-second sales reading the same stored anchor can still
collide; full hardening (row-level lock) is noted as follow-up in #26.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 14:13:10 +02:00
fd12476b90 Merge pull request 'feat(signer): nostr publish via resolve_for_wallet + door-scanner stats endpoint' (#24) from signer-abstraction into main
Some checks failed
lint.yml / Merge pull request 'feat(signer): nostr publish via resolve_for_wallet + door-scanner stats endpoint' (#24) from signer-abstraction into main (push) Failing after 0s
Reviewed-on: #24
2026-06-07 17:11:43 +00:00
1fb96bfe3c chore: bump config.json version to 1.6.1-aio.5
Some checks failed
lint.yml / chore: bump config.json version to 1.6.1-aio.5 (pull_request) Failing after 0s
Releases the door-scanner stats endpoint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 19:48:18 +02:00
4238b41f10 feat: GET /tickets/event/{event_id}/stats for door-scanner roster
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 —
useTicketScanner.refreshStats now has a working HTTP path). Auth:
wallet admin_key + the event's wallet must be in the caller's wallet
set, matching the register endpoint's owner check.

Without this endpoint the activities scanner page loaded its initial
counts (via no-op fallbacks) but every post-scan refreshStats returned
404, leaving the Scanned counter stuck at 0 even though registrations
landed correctly. Surfaced by aio-demo manual test on 2026-06-03.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 19:47:49 +02:00
66076d6ca7 feat(signer): migrate Nostr publishing off account.prvkey → resolve_for_wallet (#23)
Closes aiolabs/events#23. Pre-cascade prerequisite for aiolabs/lnbits#17
(signer abstraction phase 1), which lands an m002 startup job that
NULLs the legacy `accounts.prvkey` column. After this migration, the
events extension reads no plaintext nsec and works with any
NostrSigner backend (LocalSigner / RemoteBunkerSigner / ClientSideOnlySigner).

## What changed

### nostr_hooks.py — publish_or_delete_nostr_event

Was: pulled `(account.pubkey, account.prvkey)` from the wallet owner,
passed both to `publish_event_to_nostr`. Hard-skipped publish when
`account.prvkey` was None.

Now: calls `await resolve_for_wallet(event.wallet)` (the DRY helper
from aiolabs/lnbits#23 — wallet → account → signer → can_sign-check
in one call, returns None on any soft-fail). Passes the resolved
`NostrSigner` to the publisher. Soft-skip on None (wallet missing,
account unclassified, or ClientSideOnlySigner where the server has
no signing authority) — matching previous "no prvkey" behavior.

### nostr_publisher.py — publish_event_to_nostr

Was: accepted `(account_pubkey, account_prvkey)` and signed via a
local `sign_nostr_event` helper that called `coincurve.PrivateKey
.sign_schnorr` directly on the plaintext nsec.

Now: accepts `signer: NostrSigner`. Builds the unsigned event dict
(`kind`/`created_at`/`tags`/`content`), hands it to
`await signer.sign_event(...)`, reconstructs the local `NostrEvent`
model from the signed dict (`id`/`pubkey`/`sig` fields). The signer
backend (LocalSigner / RemoteBunkerSigner) is transparent.

Removed the `sign_nostr_event` helper entirely — the signer abstraction
handles all signing now.

Dropped the `coincurve` import; no direct crypto in this extension.

## Acceptance

- [x] keypair helper replaced (nostr_hooks no longer touches account.prvkey)
- [x] publish_event_to_nostr accepts NostrSigner instead of (pubkey, prvkey)
- [x] extension-local Schnorr code removed (sign_nostr_event gone)
- [x] re-grep `events/`: zero `account.prvkey` references
- [x] version bumped: 1.6.1-aio.3 → 1.6.1-aio.4

Manual smoke testing + tag + catalog entry follow the migration
landing; will run against the regtest stack with lnbits on
`issue-18-phase-2.3` (which validates both LocalSigner and
RemoteBunkerSigner signing paths end-to-end).

## Cross-references

- aiolabs/events#23 — issue this commit closes
- aiolabs/lnbits#17 — the cascading signer-abstraction PR
- aiolabs/lnbits#23 — the resolve_for_wallet helper this uses
- aiolabs/lnbits#26 — phase 2.3 (sign_event over bunker, validated against
  aiolabs/nsecbunkerd@fb1c239)
- aiolabs/lnbits#21 — umbrella audit identifying 5 affected extensions

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 21:55:56 +02:00
37fad05c1f chore: bump config.json version to 1.6.1-aio.3
Some checks failed
lint.yml / chore: bump config.json version to 1.6.1-aio.3 (push) Failing after 0s
2026-05-24 18:56:07 +02:00
26b1be8ff0 Merge pull request 'feat: organizer ticket scanning over nostr-transport + secure legacy HTTP register endpoint' (#19) from ticket-scanner-nostr into main
Some checks failed
lint.yml / Merge pull request 'feat: organizer ticket scanning over nostr-transport + secure legacy HTTP register endpoint' (#19) from ticket-scanner-nostr into main (push) Failing after 0s
Reviewed-on: #19
2026-05-24 16:54:00 +00:00
3606fd9a0a feat(admin): Owner column on All Users' Events card
Some checks failed
lint.yml / feat(admin): Owner column on All Users' Events card (pull_request) Failing after 0s
Adds the event's wallet owner (user_id) as the first column of the
admin-only All Users' Events table so cross-tenant rows are
attributable at a glance. Server-side join: GET /events/all now
resolves each event.wallet -> wallet.user and stamps the result on
the response as wallet_user_id. Frontend gets a dedicated
allUsersEventsTable.columns definition so the user's own-events
table stays unchanged.

Follow-up #22 covers letting the admin actually edit those events
once attributed.
2026-05-24 18:51:51 +02:00
66d263ef14 ui(admin): Tickets card above All Users' Events on the admin index
Some checks failed
lint.yml / ui(admin): Tickets card above All Users' Events on the admin index (pull_request) Failing after 0s
The Tickets table is what an organiser actually scans during day-of
operations — it deserves the top slot. All Users' Events stays one
section down for the cross-tenant audit view (admin-only anyway).
2026-05-24 18:46:18 +02:00
02071e6541 feat: events_list_event_tickets RPC for organizer ticket roster
Second nostr-transport handler on this branch. Returns paid + registered
counts plus the per-ticket roster (id, name, registered status, timestamp)
for one calendar event, organizer-only.

Backs the door scanner's counts strip and "scanned" list with backend
truth so a second organizer scanning on another device, an operator
switching from mobile to laptop mid-event, or a refresh in incognito
all see the same numbers instead of diverging from a per-device
localStorage cache.

Same authorisation posture as events_ticket_register: dispatcher
binds caller pubkey to wallet via AUTH_WALLET, handler verifies the
event's wallet is in the caller's wallet set. Only paid tickets land
in the response — proposed/unpaid rows are irrelevant at the door.

Webapp consumes this in aiolabs/webapp#73.
2026-05-24 18:45:48 +02:00
1d8dacbaa3 fix: require admin_key + owner check on PUT /tickets/register
Some checks failed
lint.yml / fix: require admin_key + owner check on PUT /tickets/register (pull_request) Failing after 0s
The legacy register endpoint had no auth decorator and no
event-ownership check — any caller who knew a ticket id could
mark it registered. Add require_admin_key (matches the rest of
the wallet-bound endpoints in this file) and verify the caller's
user owns the event the ticket belongs to.

Breaking change for any external integration that hit this
endpoint unauthed; the in-tree Quasar register page
(static/js/register.js) already sends the session admin_key via
LNbits.api.request so it keeps working.

The Nostr-transport flow at events_ticket_register (previous
commit) is the preferred call site for new callers; this HTTP
path stays for the legacy LNbits admin UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 16:32:29 +02:00
2b3d9df11d feat: events_ticket_register RPC over nostr transport
Organizer-side ticket scanning over LNbits's freshly-merged
nostr-transport (kind 21000, NIP-44 v2). The organizer signs the
RPC event with their Nostr key; the transport dispatcher resolves
pubkey → Account → wallet (AUTH_WALLET) and the handler verifies
event-level ownership (event.wallet ∈ caller_user.wallet_ids)
before flipping `registered = True`.

Idempotence + state transitions mirror the legacy HTTP endpoint:
"Ticket not paid for" / "Ticket already registered" / "Ticket
does not exist on this event" / "You do not own this event" come
back as ERROR responses. Registration in events_start() is
guarded with try/except ImportError so the extension still loads
on older LNbits versions that pre-date the transport (HTTP path
stays the fallback there).

Webapp uses this as the new primary scan call site instead of
the legacy HTTP endpoint — see companion webapp PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 16:32:18 +02:00
7b761a1aef fix: every ticket row gets a fresh short-hash id (no payment_hash reuse)
Some checks failed
lint.yml / fix: every ticket row gets a fresh short-hash id (no payment_hash reuse) (pull_request) Failing after 0s
lint.yml / fix: every ticket row gets a fresh short-hash id (no payment_hash reuse) (push) Failing after 0s
Previous commit reused the LNbits invoice payment_hash as the
first row's id, so a 3-ticket purchase ended up with one 64-hex
id and two short-hash ids — inconsistent and noisy in My Tickets.

Switch every row to urlsafe_short_hash. The shared payment_hash
column is the join key for invoice lookups (poll endpoint, ws
notifier, on_invoice_paid); rows never need to BE the payment
hash, they only need to point at it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 22:57:00 +02:00
59068fe09d feat: multi-ticket purchases as N rows sharing one payment_hash
Replaces the previous "one row, N seats via extra.quantity" model
with proper one-row-per-attendee semantics. Each attendee gets a
unique scannable id; the door PUT /register/{ticket_id} marks
them registered independently — so a buyer can purchase 3 tickets,
hand 2 QRs to friends arriving separately, and each attendee can
enter on their own schedule.

Schema (migrations_fork.py m002):
- ticket.payment_hash: new TEXT column shared across all rows of
  a multi-ticket purchase. Backfilled `payment_hash = id` for
  pre-migration rows (id WAS the payment_hash by invariant).

Wire:
- TicketPaymentRequest grows `ticket_ids: list[str]` so the
  webapp gets every scannable id back in the create response.
- POST /tickets/{event_id}/{payment_hash} polling endpoint now
  reports `ticket_ids` (every row) + keeps `ticket_id` for
  back-compat.
- api_ticket_create loops quantity times; the first row reuses
  payment_hash as id (preserves legacy `id == payment_hash`
  invariant for single-ticket purchases), the rest get
  urlsafe_short_hash() uuids.

Payment flow:
- on_invoice_paid fetches all rows by payment_hash and marks each
  paid via set_ticket_paid, which now increments event.sold by 1
  per row (was N per row via extra.quantity — simpler now). The
  per-event asyncio lock still serializes counter + republish so
  concurrent multi-ticket purchases for the same event don't
  reorder the published Nostr state.
- Each paid row triggers its own send_ticket_notification_in_
  background call — no-op for buyers without nostr_identifier /
  email, useful when the buyer set those on the row.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 22:57:00 +02:00
36568d3eee fix: propagate CreateTicket.user_id to the persisted ticket row
api_ticket_create accepted user_id in the CreateTicket request body
(its root_validator even requires user_id XOR name+email), but
dropped it on the way to crud.create_ticket — tickets ended up
with user_id = NULL and the new GET /tickets/user/{id} endpoint
returned an empty list for every webapp buyer.

Pull data.user_id alongside name/email and forward it to
create_ticket. Backfilling existing rows is left to the operator
(deployment-specific data fix); fresh purchases starting from this
commit are correctly attributed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 21:10:33 +02:00
902bafe7f2 feat: POST /tickets/{event_id}/{payment_hash} polling endpoint
The webapp's useTicketPurchase polls this every 2s after firing
Pay with Wallet (or after presenting the QR) to confirm payment
before advancing to the ticket-QR success state. Without this
endpoint the post-payment poll loop returns 404 indefinitely and
the buyer never sees their ticket land — even though set_ticket_paid
fired on the invoice listener and the row is correctly marked paid
in the DB.

Returns {paid: bool, ticket_id?: str}. A missing or cross-event
ticket returns paid: false rather than 404 so the poll loop doesn't
need to special-case the not-yet-created race.

The WebSocket at /tickets/ws/{payment_hash} is more efficient for
push notifications — this POST is the fallback for clients that
can't open a relay-side socket.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 21:06:03 +02:00
ced6ca2b2b feat: organizer-side "Republish mine" button + scoped endpoint
The admin /republish-all hits every approved event regardless of
owner — useful for the catalog migration, but heavy. Organizers
who want to re-emit just THEIR own events (e.g. after the AIO
publisher gained the tickets_* tags and an organizer's events
should pick them up) need a lighter knob.

Backend: new POST /republish-mine wallet-scoped via require_admin_key,
mirrors api_tickets's `all_wallets=true` shape so the page can
re-emit across every wallet the user owns. Filters to approved +
non-canceled rows.

UI: "Republish mine" button alongside "New Event" so every
logged-in user sees it (no isAdmin gate). Loading state +
confirm dialog + success count notification.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 21:02:36 +02:00
fa2a6e40f0 feat(ui): "Republish all" button on the admin Settings card
Surfaces the POST /republish-all endpoint added in the previous
commit. Lives in the existing admin-gated Settings card on the
events extension landing page, so the LNbits operator can trigger
the migration without curl + access tokens.

Confirm dialog before firing (the endpoint emits one Nostr event
per approved row, fine to retry but worth a click of friction).
Notification shows the republished/total count on success.

Self-closing tags expanded per the LNbits UMD rule
(webapp CLAUDE.md > LNbits + Quasar UMD gotchas) — q-separator
and q-btn would silently nest wrong otherwise.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 21:00:30 +02:00
05593c9c3c feat: POST /republish-all admin endpoint
Loops over approved events and re-emits each NIP-52 calendar event.
Useful as a one-shot migration after the publisher's tag set
changes (e.g. the tickets_* tag rollout introduced in this PR) so
existing events on a deployed instance pick up the new metadata
without each organizer having to edit and save.

Gated by check_admin (LNbits instance admin), errors swallowed
per-event inside the publisher so one bad row doesn't block the
rest. Returns a count summary.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 20:58:20 +02:00
b0d089d3c9 feat: also publish allow_fiat + fiat_currency in NIP-52 tags
Some checks failed
lint.yml / feat: also publish allow_fiat + fiat_currency in NIP-52 tags (pull_request) Failing after 0s
lint.yml / feat: also publish allow_fiat + fiat_currency in NIP-52 tags (push) Failing after 0s
The buyer-side webapp Purchase button needs allow_fiat to know
whether to surface the fiat method, and fiat_currency for the
conversion-preview label. Without these in the published Nostr
event, the buyer would either have to REST-fetch the LNbits event
again (defeats the inventory-sync goal) or guess.

Same backwards-compat reasoning as the four counter tags — tags
are AIO additions outside the NIP-52 spec; unknown tags are
ignored by spec-compliant clients.

- tickets_allow_fiat: "true" when the organizer enabled the fiat
  toggle. Omitted otherwise so the on-the-wire payload stays
  small for the common Lightning-only case.
- tickets_fiat_currency: only emitted when allow_fiat is on
  (otherwise it'd be ambiguous what the value represents).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 20:37:19 +02:00
edf1493e0c feat: publish ticket counts in NIP-52 tags + republish on sale
Some checks failed
lint.yml / feat: publish ticket counts in NIP-52 tags + republish on sale (pull_request) Failing after 0s
Inventory sync over Nostr, mirroring how nostrmarket republishes
kind 30018 product events when stock changes. Connected webapp /
other-client subscriptions pick up the new state via their existing
relay subscription — no REST polling needed.

build_nip52_event grows four AIO custom tags on every published
kind 31922/31923 event:
- tickets_available — current remaining (omitted when amount_tickets
  is 0, the schema's "unlimited" sentinel, so clients can tell the
  difference between unlimited and sold-out)
- tickets_sold — running count, always emitted (clients derive
  original_capacity = available + sold for progress bars)
- tickets_price — price_per_ticket (0 means free)
- tickets_currency — the currency string

Tags are AIO additions outside the NIP-52 spec; spec-compliant
clients MUST ignore unknown tags so this stays backwards-compatible.

set_ticket_paid calls publish_or_delete_nostr_event after the
counter update so the new state lands on relays. The whole sequence
(counter update + republish) is wrapped in a per-event-id asyncio
lock to address the existing # todo: lock and to ensure two paid
invoices for the same event can't reorder the published state.

Failures inside the Nostr publish are logged + swallowed by the
existing wrapper, so a relay outage can never break the payment
flow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 20:31:56 +02:00
814581f307 feat: expose GET /tickets/user/{user_id} endpoint
The webapp My Tickets view + the owned-ticket badges in the
activities feed both rely on this endpoint to enumerate a buyer's
tickets across all events. The CRUD function already existed
(`get_tickets_by_user_id`); just expose it.

Auth: Bearer access token (the same shape the webapp already sends
to other LNbits endpoints). The path param must match the token-
bound user.id — users can only enumerate their own tickets, not
anyone else's by ID-guessing.

Returns full `Ticket` rows rather than `PublicTicket` because the
owner needs the payment_hash (for the QR) + the `extra` envelope
(for refund / promo / notification state) in My Tickets.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 20:30:03 +02:00
27cc8d2f1c chore: rebase onto upstream v1.6.1 + bump to v1.6.1-aio.1
Some checks failed
lint.yml / chore: rebase onto upstream v1.6.1 + bump to v1.6.1-aio.1 (push) Failing after 0s
Rebases the aio fork onto upstream v1.6.1 (4bf867e), pulling in:
- fiat checkout + email/Nostr DM ticket notifications (PR #50)
- currency-conversion fix (v1.5.0)
- custom notification subject/body (v1.6.0)
- resend-email button on the ticket list (PR #51)

Notable merges:
- views_api.api_event_update keeps the explicit-field-list gating from
  the aio.4 security fix, with allow_fiat + fiat_currency added so an
  owner editing a fiat-enabled event keeps the fiat config.
- models.PublicEvent now exposes both upstream's fiat fields and our
  location / categories / status fields.
- migrations.py reverts to byte-identical to upstream v1.6.1 (no aio
  entries); fork schema lives in migrations_fork.py (per aiolabs/lnbits#8).
- Lint reformatted with black + ruff to match upstream style.

Contributors entry adds `padreug` (aio fork maintainer).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 09:24:35 +02:00
b576a490d2 refactor: move fork-only migrations to migrations_fork.py
`migrations.py` now matches upstream v1.3.0 exactly. Every aio-only
schema delta (the old m007-m011: user_id, status, nostr_event_id +
created_at, settings table, location + categories) moves into a
single `m001_aio_event_schema` function in `migrations_fork.py`,
tracked under `events_fork` in `dbversions` by the loader added in
aiolabs/lnbits@ae997181.

Idempotency guards on every ADD COLUMN / CREATE TABLE let the
squashed migration no-op cleanly on dev DBs that already ran the
old m007-m011 — schema lands identical from either path.

Why now: aiolabs/lnbits#8. We're about to rebase events onto
upstream v1.6.1 which adds its own m007_add_allow_fiat. With this
move done first, migrations.py stays a fast-forward on rebase and
our fork-only schema lives in a separate file that never collides.

Requires aiolabs/lnbits @ ae997181 or later for the extension_fork
loader. Running on an upstream lnbits without the loader patch
will NOT apply the fork schema — but the aiolabs deploy fleet
already pulls from aiolabs/lnbits, so this is the only host we
ship to.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 09:22:46 +02:00
16eb68d080 feat: public auto_approve probe + bump to v1.3.0-aio.5
Add GET /events/api/v1/events/settings/public — invoice-key-gated
(anyone with a wallet) — returning just `{ auto_approve }`. The webapp
needs this to render accurate edit-flow copy without forcing every
event creator to also be an LNbits admin.

The admin-only GET /settings stays the source of truth for the full
EventsSettings payload.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 09:22:34 +02:00
0dc2dcc35f fix: gate event edits through the approval workflow
The PUT /events/{id} endpoint blindly copied every field from the
request body onto the existing event, including `status`. A non-admin
owner with auto_approve=false could PUT {"status": "approved", ...}
and self-approve, bypassing review entirely.

Replace the blanket setattr loop with an explicit field list (status
omitted) and derive the new status from the same admin / auto_approve
gate that api_event_create uses. Reconcile Nostr 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

Also apply the same end/closing-date defaulting as create_event so an
edit that omits those fields doesn't wipe them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 09:22:16 +02:00
df4775126f feat: support optional start/end time on events
event_start_date / event_end_date now accept either YYYY-MM-DD (date-only)
or YYYY-MM-DDTHH:MM (ISO datetime). The NIP-52 publisher switches kind
on the "T" delimiter: kind 31922 (date-based, YYYY-MM-DD start/end) when
absent, kind 31923 (time-based, unix-timestamp start/end + day-granularity
D tags) when present. Delete events match the original publish kind.

Closing-date parsing accepts both formats. The LNbits admin form gains
optional HH:MM inputs alongside each date picker; they fold into the
wire-format string on submit and split back on edit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 09:21:30 +02:00
6aa280680e feat: add NIP-52 Nostr publish + sync of calendar events
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-22 09:20:00 +02:00
c7e95c5452 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-22 09:19:21 +02:00
dfabcb8f54 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-22 09:17:04 +02:00
dni ⚡
4bf867eef0
feat: add resend email button to ticket list (#51)
- resending only possible when ticket is paid.
2026-05-13 11:30:14 +02:00
Arc
6768b78c6f Custom subject and body 2026-05-08 19:14:07 +01:00
dni ⚡
0824b1120b
feat: add paid/registered badge to ticket page (#49)
some visual verification on the ticket page that it is paid / checked
in.
2026-05-07 17:06:38 +02:00
Arc
32c230957e fix: if sats and fiat checkout conversion currency 2026-05-07 14:34:22 +01:00
Arc
680b035ec9
feat: add fiat checkout and nostr + email notification (#50)
* feat: fiat and email/nostr notifications

* make n bake
2026-05-07 12:31:32 +01:00
22 changed files with 1834 additions and 369 deletions

View file

@ -46,6 +46,38 @@ def events_start():
task1 = create_permanent_unique_task("ext_events", wait_for_paid_invoices) task1 = create_permanent_unique_task("ext_events", wait_for_paid_invoices)
scheduled_tasks.append(task1) 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(): async def _start_nostr_client():
global nostr_client global nostr_client
await asyncio.sleep(10) # Wait for nostrclient to be ready await asyncio.sleep(10) # Wait for nostrclient to be ready
@ -75,9 +107,7 @@ def events_start():
except Exception as exc: except Exception as exc:
logger.error(f"[EVENTS] Nostr sync task failed: {exc}") logger.error(f"[EVENTS] Nostr sync task failed: {exc}")
task3 = create_permanent_unique_task( task3 = create_permanent_unique_task("ext_events_nostr_sync", _sync_nostr_events)
"ext_events_nostr_sync", _sync_nostr_events
)
scheduled_tasks.append(task3) scheduled_tasks.append(task3)

View file

@ -1,6 +1,6 @@
{ {
"id": "events", "id": "events",
"version": "1.3.0-aio.1", "version": "1.6.1-aio.7",
"name": "Events", "name": "Events",
"repo": "https://git.atitlan.io/aiolabs/events", "repo": "https://git.atitlan.io/aiolabs/events",
"short_description": "Sell and register event tickets", "short_description": "Sell and register event tickets",
@ -36,7 +36,7 @@
{ {
"name": "padreug", "name": "padreug",
"uri": "https://git.atitlan.io/padreug", "uri": "https://git.atitlan.io/padreug",
"role": "Developer (aio fork: approval workflow + NIP-52 Nostr sync)" "role": "Developer (aio fork: approval workflow + NIP-52 Nostr sync + edit gating)"
} }
], ],
"images": [ "images": [

41
crud.py
View file

@ -41,8 +41,19 @@ async def create_ticket(
email: str | None = None, email: str | None = None,
user_id: str | None = None, user_id: str | None = None,
extra: dict | None = None, extra: dict | None = None,
ticket_id: str | None = None,
) -> Ticket: ) -> 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) 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 # 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. # user_id is supplied. _parse_ticket_row reverses this on read.
@ -54,7 +65,7 @@ async def create_ticket(
db_email = email or "" db_email = email or ""
db_ticket = Ticket( db_ticket = Ticket(
id=payment_hash, id=row_id,
wallet=wallet, wallet=wallet,
event=event, event=event,
name=db_name, name=db_name,
@ -65,11 +76,12 @@ async def create_ticket(
reg_timestamp=now, reg_timestamp=now,
time=now, time=now,
extra=TicketExtra(**extra) if extra else TicketExtra(), extra=TicketExtra(**extra) if extra else TicketExtra(),
payment_hash=payment_hash,
) )
await db.insert("events.ticket", db_ticket) await db.insert("events.ticket", db_ticket)
return Ticket( return Ticket(
id=payment_hash, id=row_id,
wallet=wallet, wallet=wallet,
event=event, event=event,
name=name, name=name,
@ -80,6 +92,7 @@ async def create_ticket(
reg_timestamp=now, reg_timestamp=now,
time=now, time=now,
extra=TicketExtra(**extra) if extra else TicketExtra(), extra=TicketExtra(**extra) if extra else TicketExtra(),
payment_hash=payment_hash,
) )
@ -93,6 +106,21 @@ async def update_ticket(ticket: Ticket) -> Ticket:
return 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: async def get_ticket(payment_hash: str) -> Ticket | None:
row = await db.fetchone( row = await db.fetchone(
"SELECT * FROM events.ticket WHERE id = :id", "SELECT * FROM events.ticket WHERE id = :id",
@ -111,6 +139,15 @@ async def get_tickets(wallet_ids: str | list[str]) -> list[Ticket]:
return [Ticket(**_parse_ticket_row(row)) for row in rows] return [Ticket(**_parse_ticket_row(row)) for row in rows]
async def get_tickets_by_event(event_id: str) -> list[Ticket]:
"""All ticket rows for the given calendar event id."""
rows = await db.fetchall(
"SELECT * FROM events.ticket WHERE event = :event_id",
{"event_id": event_id},
)
return [Ticket(**_parse_ticket_row(row)) for row in rows]
async def get_tickets_by_user_id(user_id: str) -> list[Ticket]: async def get_tickets_by_user_id(user_id: str) -> list[Ticket]:
"""All tickets owned by the given LNbits user_id.""" """All tickets owned by the given LNbits user_id."""
rows = await db.fetchall( rows = await db.fetchall(

View file

@ -162,98 +162,36 @@ async def m005_add_image_banner(db):
await db.execute("ALTER TABLE events.events ADD COLUMN banner TEXT;") 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): async def m006_add_extra_fields(db):
""" """
Add a canceled and 'extra' column to events and ticket tables Add a canceled and 'extra' column to events and ticket tables
to support promo codes and ticket metadata. to support promo codes and ticket metadata.
""" """
await _alter_add_column_safe( # Add canceled and 'extra' columns to events table
db,
"ALTER TABLE events.events ADD COLUMN canceled BOOLEAN NOT NULL DEFAULT FALSE",
)
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_user_id_support(db):
"""
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( await db.execute(
""" "ALTER TABLE events.events ADD COLUMN canceled 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)"
) )
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;")
async def m011_add_location_and_categories(db): async def m007_add_allow_fiat(db):
""" """
Add NIP-52 calendar metadata (location and a JSON-encoded category list). Add an allow_fiat column so event owners can explicitly enable fiat checkout.
""" """
await _alter_add_column_safe( await db.execute("""
db, "ALTER TABLE events.events ADD COLUMN location TEXT" ALTER TABLE events.events
) ADD COLUMN allow_fiat BOOLEAN NOT NULL DEFAULT FALSE;
await _alter_add_column_safe( """)
db, "ALTER TABLE events.events ADD COLUMN categories TEXT"
)
async def m008_add_fiat_currency(db):
"""
Add a fiat_currency column for sat-denominated events using fiat checkout.
"""
await db.execute("""
ALTER TABLE events.events
ADD COLUMN fiat_currency TEXT NOT NULL DEFAULT 'GBP';
""")

130
migrations_fork.py Normal file
View file

@ -0,0 +1,130 @@
"""
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,16 +24,24 @@ class EventExtra(BaseModel):
promo_codes: list[PromoCode] = Field(default_factory=list) promo_codes: list[PromoCode] = Field(default_factory=list)
conditional: bool = False conditional: bool = False
min_tickets: int = 1 min_tickets: int = 1
email_notifications: bool = False
nostr_notifications: bool = False
notification_subject: str = ""
notification_body: str = ""
class CreateEvent(BaseModel): class CreateEvent(BaseModel):
wallet: str | None = None # filled from caller's wallet if absent wallet: str | None = None # filled from caller's wallet if absent
name: str # title (required) name: str # title (required)
info: str = "" # description (optional) info: str = "" # description (optional)
closing_date: str | None = None # defaults to event_end_date closing_date: str | None = None # date-only YYYY-MM-DD; defaults to event_end_date
event_start_date: str # required # ISO 8601: date-only ("2026-05-19") or datetime ("2026-05-19T18:30").
event_end_date: str | None = None # defaults to event_start_date # 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
currency: str = "sat" currency: str = "sat"
allow_fiat: bool = False
fiat_currency: str = "GBP"
amount_tickets: int = 0 # 0 = unlimited / not ticketed amount_tickets: int = 0 # 0 = unlimited / not ticketed
price_per_ticket: float = 0 # 0 = free price_per_ticket: float = 0 # 0 = free
banner: str | None = None banner: str | None = None
@ -53,6 +61,8 @@ class Event(BaseModel):
event_start_date: str event_start_date: str
event_end_date: str | None = None event_end_date: str | None = None
currency: str = "sat" currency: str = "sat"
allow_fiat: bool = False
fiat_currency: str = "GBP"
amount_tickets: int = 0 amount_tickets: int = 0
price_per_ticket: float = 0 price_per_ticket: float = 0
time: datetime time: datetime
@ -80,9 +90,14 @@ class PublicEvent(BaseModel):
canceled: bool canceled: bool
event_start_date: str event_start_date: str
event_end_date: str | None = None event_end_date: str | None = None
currency: str
allow_fiat: bool = False
fiat_currency: str = "GBP"
price_per_ticket: float
banner: str | None banner: str | None
location: str | None = None location: str | None = None
categories: list[str] = Field(default_factory=list) categories: list[str] = Field(default_factory=list)
extra: EventExtra = Field(default_factory=EventExtra)
status: str = "approved" # surfaces "proposed"/"rejected" so SFC can render banner status: str = "approved" # surfaces "proposed"/"rejected" so SFC can render banner
@validator("categories", pre=True) @validator("categories", pre=True)
@ -102,6 +117,10 @@ class TicketExtra(BaseModel):
applied_promo_code: str | None = None applied_promo_code: str | None = None
sats_paid: int | None = None sats_paid: int | None = None
refund_address: str | 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 refunded: bool = False
@ -111,6 +130,12 @@ class CreateTicket(BaseModel):
user_id: str | None = None # LNbits user id (alternative to name+email) user_id: str | None = None # LNbits user id (alternative to name+email)
promo_code: str | None = None promo_code: str | None = None
refund_address: 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 @root_validator
def validate_identifiers(cls, values): def validate_identifiers(cls, values):
@ -118,9 +143,7 @@ class CreateTicket(BaseModel):
email = values.get("email") email = values.get("email")
user_id = values.get("user_id") user_id = values.get("user_id")
if not user_id and not (name and email): if not user_id and not (name and email):
raise ValueError( raise ValueError("Either user_id or both name and email must be provided")
"Either user_id or both name and email must be provided"
)
if user_id and (name or email): if user_id and (name or email):
raise ValueError("Cannot provide both user_id and name/email") raise ValueError("Cannot provide both user_id and name/email")
return values return values
@ -138,6 +161,11 @@ class Ticket(BaseModel):
time: datetime time: datetime
reg_timestamp: datetime reg_timestamp: datetime
extra: TicketExtra = Field(default_factory=TicketExtra) 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): class PublicTicket(BaseModel):
@ -151,4 +179,16 @@ class PublicTicket(BaseModel):
class TicketPaymentRequest(BaseModel): class TicketPaymentRequest(BaseModel):
payment_hash: str payment_hash: str
payment_request: 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)

View file

@ -39,8 +39,7 @@ class NostrClient:
async def connect(self) -> WebSocketApp: async def connect(self) -> WebSocketApp:
relay_endpoint = encrypt_internal_message("relay", urlsafe=True) relay_endpoint = encrypt_internal_message("relay", urlsafe=True)
ws_url = ( ws_url = (
f"ws://localhost:{settings.port}" f"ws://localhost:{settings.port}" f"/nostrclient/api/v1/{relay_endpoint}"
f"/nostrclient/api/v1/{relay_endpoint}"
) )
logger.info("[EVENTS] Connecting to nostrclient WebSocket...") logger.info("[EVENTS] Connecting to nostrclient WebSocket...")
@ -58,12 +57,8 @@ class NostrClient:
logger.warning(f"[EVENTS] WebSocket error: {error}") logger.warning(f"[EVENTS] WebSocket error: {error}")
def on_close(_, status_code, message): def on_close(_, status_code, message):
logger.warning( logger.warning(f"[EVENTS] WebSocket closed: {status_code} {message}")
f"[EVENTS] WebSocket closed: {status_code} {message}" self.receive_event_queue.put_nowait(ValueError("WebSocket closed"))
)
self.receive_event_queue.put_nowait(
ValueError("WebSocket closed")
)
ws = WebSocketApp( ws = WebSocketApp(
ws_url, ws_url,
@ -118,9 +113,7 @@ class NostrClient:
async def subscribe(self, filters: list[dict]): async def subscribe(self, filters: list[dict]):
"""Subscribe to events matching the given filters.""" """Subscribe to events matching the given filters."""
self.subscription_id = "events-" + urlsafe_short_hash()[:32] self.subscription_id = "events-" + urlsafe_short_hash()[:32]
await self.send_req_queue.put( await self.send_req_queue.put(["REQ", self.subscription_id, *filters])
["REQ", self.subscription_id, *filters]
)
logger.info( logger.info(
f"[EVENTS] Subscribed to NIP-52 events " f"[EVENTS] Subscribed to NIP-52 events "
f"(sub: {self.subscription_id[:20]}...)" f"(sub: {self.subscription_id[:20]}...)"

View file

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

View file

@ -1,53 +1,126 @@
""" """
NIP-52 calendar event publishing for the events extension. NIP-52 calendar event publishing for the events extension.
Builds kind 31922 (date-based) calendar events from the Event model, Builds NIP-52 calendar events from the Event model, signs them via the
signs them with the event creator's Account keypair, and publishes core `NostrSigner` abstraction (backend-agnostic: LocalSigner,
via the NostrClient to nostrclient relays. 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.
Reference: https://github.com/nostr-protocol/nips/blob/master/52.md Reference: https://github.com/nostr-protocol/nips/blob/master/52.md
""" """
import time import time
from datetime import datetime, timezone
import coincurve from lnbits.core.signers import NostrSigner
from loguru import logger from loguru import logger
from .models import Event from .models import Event
from .nostr.event import NostrEvent 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: def build_nip52_event(event: Event, pubkey: str) -> NostrEvent:
""" """
Convert an Event model to a NIP-52 kind 31922 (date-based) calendar event. Convert an Event model to a NIP-52 calendar event.
Tags: Time-based (kind 31923) if event_start_date carries an HH:MM, otherwise
d - event.id (addressable identifier) date-based (kind 31922). Tags:
title - event.name d - event.id
start - event.event_start_date (ISO date string) title - event.name
end - event.event_end_date (optional) start - unix timestamp (31923) or YYYY-MM-DD (31922)
image - event.banner (optional) end - same encoding (optional)
Content: event.info (description) image, location, t (categories) - optional
tickets_available - current remaining capacity (omitted when unlimited)
tickets_sold - running paid-count (always emitted; clients can
derive original_capacity = available + sold)
tickets_price - price_per_ticket (always emitted; 0 means free)
tickets_currency - the currency string
tickets_allow_fiat - "true" when fiat checkout is enabled (omitted otherwise)
tickets_fiat_currency - the fiat settle currency (only when allow_fiat)
Content: event.info
The four ticket_* tags are AIO custom additions outside the NIP-52
spec; spec-compliant clients ignore unknown tags so this stays
backwards-compatible. They let connected clients render the
"X tickets remaining" badge and the Buy CTA without an extra REST hop,
and pick up live inventory updates via the same relay subscription.
""" """
time_based = _has_time(event.event_start_date)
kind = 31923 if time_based else 31922
start_value = (
str(_to_unix(event.event_start_date)) if time_based else event.event_start_date
)
tags = [ tags = [
["d", event.id], ["d", event.id],
["title", event.name], ["title", event.name],
["start", event.event_start_date], ["start", start_value],
] ]
end_unix: int | None = None
if event.event_end_date: if event.event_end_date:
tags.append(["end", 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)])
if event.banner: if event.banner:
tags.append(["image", event.banner]) tags.append(["image", event.banner])
if event.location: if event.location:
tags.append(["location", event.location]) tags.append(["location", event.location])
for cat in (event.categories or []): for cat in event.categories or []:
tags.append(["t", cat]) tags.append(["t", cat])
# `amount_tickets == 0` means unlimited capacity in this extension's
# schema. Omitting the tag is how clients distinguish unlimited from
# "0 left" (sold out).
if event.amount_tickets > 0:
tags.append(["tickets_available", str(event.amount_tickets)])
tags.append(["tickets_sold", str(event.sold)])
tags.append(["tickets_price", str(event.price_per_ticket)])
tags.append(["tickets_currency", event.currency])
# Fiat-checkout config — only emitted when allow_fiat is on so
# clients can branch the buy UI without re-reading the schema.
if event.allow_fiat:
tags.append(["tickets_allow_fiat", "true"])
if event.fiat_currency:
tags.append(["tickets_fiat_currency", event.fiat_currency])
# 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( nostr_event = NostrEvent(
pubkey=pubkey, pubkey=pubkey,
created_at=int(time.time()), created_at=monotonic_created_at(event.nostr_event_created_at),
kind=31922, kind=kind,
tags=tags, tags=tags,
content=event.info or "", content=event.info or "",
) )
@ -59,15 +132,17 @@ def build_nip52_delete_event(event: Event, pubkey: str) -> NostrEvent:
""" """
Build a kind 5 delete event for a published NIP-52 calendar event. Build a kind 5 delete event for a published NIP-52 calendar event.
Uses an 'a' tag to reference the parameterized replaceable event Uses an 'a' tag to reference the parameterized replaceable event per
(kind 31922) per NIP-09. NIP-09. The referenced kind must match what we published 31923 for
time-based events, 31922 for date-only.
""" """
referenced_kind = 31923 if _has_time(event.event_start_date) else 31922
nostr_event = NostrEvent( nostr_event = NostrEvent(
pubkey=pubkey, pubkey=pubkey,
created_at=int(time.time()), created_at=int(time.time()),
kind=5, kind=5,
tags=[ tags=[
["a", f"31922:{pubkey}:{event.id}"], ["a", f"{referenced_kind}:{pubkey}:{event.id}"],
], ],
content="Event canceled", content="Event canceled",
) )
@ -75,23 +150,20 @@ def build_nip52_delete_event(event: Event, pubkey: str) -> NostrEvent:
return nostr_event 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( async def publish_event_to_nostr(
nostr_client, nostr_client,
event: Event, event: Event,
account_pubkey: str, signer: NostrSigner,
account_prvkey: str,
delete: bool = False, delete: bool = False,
) -> NostrEvent | None: ) -> NostrEvent | None:
""" """
Build, sign, and publish a NIP-52 calendar event (or delete event). 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. Returns the published NostrEvent for metadata storage, or None on failure.
""" """
if not nostr_client: if not nostr_client:
@ -100,11 +172,25 @@ async def publish_event_to_nostr(
try: try:
if delete: if delete:
nostr_event = build_nip52_delete_event(event, account_pubkey) nostr_event = build_nip52_delete_event(event, signer.pubkey)
else: else:
nostr_event = build_nip52_event(event, account_pubkey) 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"]
sign_nostr_event(nostr_event, account_prvkey)
await nostr_client.publish_nostr_event(nostr_event) await nostr_client.publish_nostr_event(nostr_event)
logger.info( logger.info(

View file

@ -137,9 +137,11 @@ async def wait_for_nostr_events(nostr_client: NostrClient):
while True: while True:
try: try:
# Subscribe to NIP-52 calendar events # Subscribe to NIP-52 calendar events
await nostr_client.subscribe([ await nostr_client.subscribe(
{"kinds": [31922, 31923]}, [
]) {"kinds": [31922, 31923]},
]
)
# Process incoming events # Process incoming events
while True: while True:

34
nostr_timestamp.py Normal file
View file

@ -0,0 +1,34 @@
"""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,3 +1,16 @@
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 lnurl import execute
from loguru import logger from loguru import logger
@ -8,25 +21,146 @@ from .crud import (
update_event, update_event,
update_ticket, update_ticket,
) )
from .models import 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
async def set_ticket_paid(ticket: Ticket) -> Ticket: async def set_ticket_paid(ticket: Ticket) -> Ticket:
if ticket.paid: if ticket.paid:
return ticket return ticket
ticket.paid = True async with _event_paid_lock(ticket.event):
await update_ticket(ticket) ticket.paid = True
await update_ticket(ticket)
event = await get_event(ticket.event) event = await get_event(ticket.event)
assert event, "Couldn't get event from ticket being paid" assert event, "Couldn't get event from ticket being paid"
event.sold += 1 event.sold += 1
event.amount_tickets -= 1 event.amount_tickets -= 1
await update_event(event) await update_event(event)
# Republish the NIP-52 calendar event so connected clients see
# the new tickets_available / tickets_sold counters via their
# existing relay subscription. Failures are logged + swallowed
# inside publish_or_delete_nostr_event so a Nostr outage doesn't
# break the payment flow.
await publish_or_delete_nostr_event(event)
return ticket return ticket
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): async def refund_tickets(event_id: str):
""" """
Refund tickets for an event that has not met the minimum ticket requirement. Refund tickets for an event that has not met the minimum ticket requirement.

View file

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

View file

@ -41,41 +41,77 @@
<q-card-section class="q-pa-none"> <q-card-section class="q-pa-none">
<h5 class="q-mt-none">Buy Ticket</h5> <h5 class="q-mt-none">Buy Ticket</h5>
<q-form @submit="createInvoice()" class="q-gutter-md"> <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 <q-input
filled v-if="event.extra?.conditional"
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 filled
dense dense
v-model.trim="formDialog.data.refund" v-model.trim="formDialog.data.refund"
label="Refund lnadress or LNURL " label="Refund lnadress or LNURL "
:rules="[val => !!val || '* Required']" :rules="[val => !!val || '* Required']"
lazy-rules lazy-rules
:hint="`If minimum tickets (${this.extra?.min_tickets}) are not met, refund will be sent.`" :hint="`If minimum tickets (${event.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> ></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"> <div class="row q-mt-lg">
<q-btn <q-btn
unelevated unelevated
@ -107,8 +143,6 @@
type="a" type="a"
>Link to your ticket!</q-btn >Link to your ticket!</q-btn
> >
<br /><br />
<p>You'll be redirected in a few moments...</p>
</div> </div>
</q-card> </q-card>
</div> </div>
@ -119,6 +153,37 @@
class="q-pa-lg q-pt-xl lnbits__dialog-card" class="q-pa-lg q-pt-xl lnbits__dialog-card"
> >
</q-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"> <q-card v-else class="q-pa-lg q-pt-xl lnbits__dialog-card">
<div class="text-center q-mb-lg"> <div class="text-center q-mb-lg">
<lnbits-qrcode <lnbits-qrcode

View file

@ -4,13 +4,61 @@ window.PageEvents = {
return { return {
events: [], events: [],
tickets: [], tickets: [],
resendingTicketEmails: [],
currencies: [], currencies: [],
pendingEvents: [], pendingEvents: [],
allUserEvents: [], allUserEvents: [],
isAdmin: false, isAdmin: false,
republishing: false,
republishingMine: false,
settings: { settings: {
auto_approve: false 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: { eventsTable: {
columns: [ columns: [
{name: 'id', align: 'left', label: 'ID', field: 'id'}, {name: 'id', align: 'left', label: 'ID', field: 'id'},
@ -49,7 +97,7 @@ window.PageEvents = {
align: 'left', align: 'left',
label: 'Price', label: 'Price',
field: row => { field: row => {
if (row.currency != 'sats') { if (this.isFiatCurrency(row.currency)) {
return LNbits.utils.formatCurrency( return LNbits.utils.formatCurrency(
row.price_per_ticket.toFixed(2), row.price_per_ticket.toFixed(2),
row.currency row.currency
@ -105,14 +153,21 @@ window.PageEvents = {
show: false, show: false,
data: { data: {
currency: 'sats', currency: 'sats',
allow_fiat: false,
fiat_currency: 'GBP',
extra: { extra: {
promo_codes: [] promo_codes: [],
notification_subject: '',
notification_body: ''
} }
} }
} }
} }
}, },
methods: { methods: {
isFiatCurrency(currency) {
return !['sat', 'sats'].includes((currency || '').toLowerCase())
},
getTickets() { getTickets() {
LNbits.api LNbits.api
.request( .request(
@ -145,6 +200,35 @@ window.PageEvents = {
.catch(LNbits.utils.notifyApiError) .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() { exportticketsCSV() {
LNbits.utils.exportCSV(this.ticketsTable.columns, this.tickets) LNbits.utils.exportCSV(this.ticketsTable.columns, this.tickets)
}, },
@ -238,15 +322,108 @@ window.PageEvents = {
.catch(LNbits.utils.notifyApiError) .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() { sendEventData() {
const wallet = _.findWhere(this.g.user.wallets, { const wallet = _.findWhere(this.g.user.wallets, {
id: this.formDialog.data.wallet id: this.formDialog.data.wallet
}) })
const data = this.formDialog.data const data = {...this.formDialog.data}
if (data.extra && !data.extra.promo_codes) { 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) {
data.extra.promo_codes = data.extra.promo_codes data.extra.promo_codes = data.extra.promo_codes
.filter(code => code.trim() !== '') .filter(code => code.code?.trim() !== '')
.map(code => code.trim().toUpperCase()) .map(code => ({
...code,
code: code.code.trim().toUpperCase()
}))
}
if (!this.isFiatCurrency(data.currency)) {
if (!data.allow_fiat) {
data.fiat_currency = 'GBP'
}
} }
if (data.id) { if (data.id) {
@ -258,14 +435,32 @@ window.PageEvents = {
openEventDialog(data = false) { openEventDialog(data = false) {
if (data && data.id) { if (data && data.id) {
this.formDialog.data = {...data} 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
}
} else { } else {
this.formDialog.data = { this.formDialog.data = {
currency: 'sats', currency: 'sats',
allow_fiat: false,
fiat_currency: 'GBP',
event_start_day: '',
event_start_time: '',
event_end_day: '',
event_end_time: '',
extra: { extra: {
conditional: false, conditional: false,
min_tickets: 1, min_tickets: 1,
promo_codes: [] email_notifications: false,
nostr_notifications: false,
promo_codes: [],
notification_subject: '',
notification_body: ''
} }
} }
} }
@ -274,8 +469,15 @@ window.PageEvents = {
resetEventDialog() { resetEventDialog() {
this.formDialog.show = false this.formDialog.show = false
this.formDialog.data = { this.formDialog.data = {
currency: 'sats',
allow_fiat: false,
fiat_currency: 'GBP',
extra: { extra: {
promo_codes: [] email_notifications: false,
nostr_notifications: false,
promo_codes: [],
notification_subject: '',
notification_body: ''
} }
} }
}, },

View file

@ -15,14 +15,50 @@
></q-toggle> ></q-toggle>
</div> </div>
</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-section>
</q-card> </q-card>
<q-card> <q-card>
<q-card-section> <q-card-section>
<q-btn unelevated color="primary" @click="openEventDialog" <div class="row items-center q-gutter-sm">
>New Event</q-btn <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-card-section> </q-card-section>
</q-card> </q-card>
@ -250,51 +286,6 @@
</q-card-section> </q-card-section>
</q-card> </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>
<q-card-section> <q-card-section>
<div class="row items-center no-wrap q-mb-md"> <div class="row items-center no-wrap q-mb-md">
@ -317,10 +308,12 @@
> >
<template v-slot:header="props"> <template v-slot:header="props">
<q-tr :props="props"> <q-tr :props="props">
<q-th auto-width></q-th>
<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"> <q-th v-for="col in props.cols" :key="col.name" :props="props">
<span v-text="col.label"></span> <span v-text="col.label"></span>
</q-th> </q-th>
<q-th auto-width></q-th>
</q-tr> </q-tr>
</template> </template>
<template v-slot:body="props"> <template v-slot:body="props">
@ -337,6 +330,20 @@
target="_blank" target="_blank"
></q-btn> ></q-btn>
</q-td> </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"> <q-td v-for="col in props.cols" :key="col.name" :props="props">
<span v-text="col.value"></span> <span v-text="col.value"></span>
@ -357,6 +364,51 @@
</q-table> </q-table>
</q-card-section> </q-card-section>
</q-card> </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>
<div class="col-12 col-md-4 col-lg-5 q-gutter-y-md"> <div class="col-12 col-md-4 col-lg-5 q-gutter-y-md">
<q-card> <q-card>
@ -459,28 +511,46 @@
></q-input> ></q-input>
</div> </div>
</div> </div>
<div class="row"> <div class="row q-col-gutter-sm">
<div class="col-4">Event begins</div> <div class="col-4">Event begins</div>
<div class="col-8"> <div class="col-5">
<q-input <q-input
filled filled
dense dense
v-model.trim="formDialog.data.event_start_date" v-model.trim="formDialog.data.event_start_day"
type="date" type="date"
></q-input> ></q-input>
</div> </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>
<div class="row"> <div class="row q-col-gutter-sm">
<div class="col-4">Event ends</div> <div class="col-4">Event ends</div>
<div class="col-8"> <div class="col-5">
<q-input <q-input
filled filled
dense dense
v-model.trim="formDialog.data.event_end_date" v-model.trim="formDialog.data.event_end_day"
type="date" type="date"
></q-input> ></q-input>
</div> </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>
<div class="row q-col-gutter-sm"> <div class="row q-col-gutter-sm">
<div class="col"> <div class="col">
@ -517,6 +587,29 @@
></q-input> ></q-input>
</div> </div>
</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 <q-expansion-item
group="advanced" group="advanced"
icon="settings" icon="settings"
@ -619,8 +712,41 @@
>Add Promo Code</q-btn >Add Promo Code</q-btn
> >
</div> </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-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"> <div class="row q-mt-lg">
<q-btn <q-btn
v-if="formDialog.data.id" v-if="formDialog.data.id"
@ -638,8 +764,8 @@
formDialog.data.name == null || formDialog.data.name == null ||
formDialog.data.info == null || formDialog.data.info == null ||
formDialog.data.closing_date == null || formDialog.data.closing_date == null ||
formDialog.data.event_start_date == null || formDialog.data.event_start_day == null ||
formDialog.data.event_end_date == null || formDialog.data.event_end_day == null ||
formDialog.data.amount_tickets == null || formDialog.data.amount_tickets == null ||
formDialog.data.price_per_ticket == null formDialog.data.price_per_ticket == null
" "

View file

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

View file

@ -5,20 +5,32 @@
<q-card-section class="q-pa-none"> <q-card-section class="q-pa-none">
<center> <center>
<h3 class="q-my-none">Ticket</h3> <h3 class="q-my-none">Ticket</h3>
<h5 v-if="ticket" v-text="ticket.name" class="q-my-none"></h5>
<br /> <br />
<h5 class="q-my-none"> <h5 class="q-my-none">
Bookmark, print or screenshot this page,<br /> Bookmark, print or screenshot this page,<br />
and present it for registration! and present it for registration!
</h5> </h5>
<br /> <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>
<lnbits-qrcode <lnbits-qrcode
:value="`ticket://${ticketId}`" :value="`ticket://${ticketId}`"
:options="{width: 500}" :options="{width: 500}"
></lnbits-qrcode> ></lnbits-qrcode>
<br /> <br />
<q-btn @click="printWindow" color="grey" class="q-ml-auto"> <q-btn @click="printWindow" color="grey">
<q-icon left size="3em" name="print"></q-icon> Print</q-btn <q-icon left size="3em" name="print"></q-icon> Print
> </q-btn>
</center> </center>
</q-card-section> </q-card-section>
</q-card> </q-card>

View file

@ -4,9 +4,9 @@ from lnbits.core.models import Payment
from lnbits.tasks import register_invoice_listener from lnbits.tasks import register_invoice_listener
from loguru import logger from loguru import logger
from .crud import get_ticket from .crud import get_ticket, get_tickets_by_payment_hash
from .models import Ticket from .models import Ticket
from .services import set_ticket_paid from .services import send_ticket_notification_in_background, set_ticket_paid
payment_listeners: dict[str, list[asyncio.Queue[Ticket]]] = {} payment_listeners: dict[str, list[asyncio.Queue[Ticket]]] = {}
@ -37,12 +37,32 @@ async def on_invoice_paid(payment: Payment) -> None:
if not payment.extra or "events" != payment.extra.get("tag"): if not payment.extra or "events" != payment.extra.get("tag"):
return return
ticket = await get_ticket(payment.payment_hash) # Multi-ticket purchases land as N rows sharing this payment_hash;
if not ticket: # each one needs to be marked paid + counted against capacity, and
logger.warning(f"Ticket for payment {payment.payment_hash} not found.") # 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}.")
return return
ticket = await set_ticket_paid(ticket) 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.
if payment_listeners.get(payment.payment_hash): if payment_listeners.get(payment.payment_hash):
for paid_ticket_queue in payment_listeners[payment.payment_hash]: for paid_ticket_queue in payment_listeners[payment.payment_hash]:
paid_ticket_queue.put_nowait(ticket) paid_ticket_queue.put_nowait(paid_tickets[0])

View file

@ -0,0 +1,32 @@
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))

120
transport_rpcs.py Normal file
View file

@ -0,0 +1,120 @@
"""
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,21 +8,29 @@ from fastapi import (
Depends, Depends,
HTTPException, HTTPException,
Query, Query,
Request,
WebSocket, WebSocket,
WebSocketDisconnect, WebSocketDisconnect,
) )
from lnbits.core.crud import get_user from lnbits.core.crud import get_user
from lnbits.core.models import Account, WalletTypeInfo from lnbits.core.crud.wallets import get_wallet
from lnbits.core.services import create_invoice 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.decorators import ( from lnbits.decorators import (
check_admin, check_admin,
check_user_exists,
require_admin_key, require_admin_key,
require_invoice_key, require_invoice_key,
) )
from lnbits.settings import settings
from lnbits.utils.exchange_rates import ( from lnbits.utils.exchange_rates import (
fiat_amount_as_satoshis, fiat_amount_as_satoshis,
get_fiat_rate_satoshis, get_fiat_rate_satoshis,
satoshis_amount_as_fiat,
) )
from lnbits.utils.nostr import normalize_public_key
from .crud import ( from .crud import (
create_event, create_event,
@ -39,6 +47,8 @@ from .crud import (
get_settings, get_settings,
get_ticket, get_ticket,
get_tickets, get_tickets,
get_tickets_by_event,
get_tickets_by_payment_hash,
get_tickets_by_user_id, get_tickets_by_user_id,
purge_unpaid_tickets, purge_unpaid_tickets,
update_event, update_event,
@ -56,13 +66,22 @@ from .models import (
TicketPaymentRequest, TicketPaymentRequest,
) )
from .nostr_hooks import publish_or_delete_nostr_event from .nostr_hooks import publish_or_delete_nostr_event
from .services import refund_tickets from .services import (
refund_tickets,
resend_ticket_email_notification,
send_ticket_notification_in_background,
set_ticket_paid,
)
from .tasks import deregister_payment_listener, register_payment_listener from .tasks import deregister_payment_listener, register_payment_listener
events_api_router = APIRouter(prefix="/api/v1/events") events_api_router = APIRouter(prefix="/api/v1/events")
tickets_api_router = APIRouter(prefix="/api/v1/tickets") 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 # Literal-prefix routes (/public, /all, /pending, /settings) MUST be declared
# before any "/{event_id}" route or FastAPI matches them as a path parameter. # before any "/{event_id}" route or FastAPI matches them as a path parameter.
@ -88,9 +107,22 @@ async def api_events_public() -> list[Event]:
@events_api_router.get("/all") @events_api_router.get("/all")
async def api_events_all( async def api_events_all(
admin: Account = Depends(check_admin), admin: Account = Depends(check_admin),
) -> list[Event]: ) -> list[dict]:
"""All events across all wallets. LNbits admin only.""" """All events across all wallets, with each row's wallet owner
return await get_all_events() 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
@events_api_router.get("/pending") @events_api_router.get("/pending")
@ -101,6 +133,61 @@ async def api_events_pending(
return await get_pending_events() 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") @events_api_router.get("/settings")
async def api_get_settings( async def api_get_settings(
admin: Account = Depends(check_admin), admin: Account = Depends(check_admin),
@ -116,6 +203,19 @@ async def api_update_settings(
return await update_settings(data) 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) @events_api_router.get("/{event_id}", response_model=PublicEvent)
async def api_get_event(event_id: str) -> Event: async def api_get_event(event_id: str) -> Event:
"""Public event detail used by display.vue. """Public event detail used by display.vue.
@ -139,12 +239,17 @@ async def api_get_event(event_id: str) -> Event:
# closing_date is filled in by create_event (defaults to end_date or # 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. # start_date) but the field is typed Optional, so guard for the typechecker.
closing_date = ( closing_date = event.closing_date or event.event_end_date or event.event_start_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
is_window_open = datetime.now(timezone.utc) < datetime.strptime( # feature).
closing_date, "%Y-%m-%d" try:
).replace(tzinfo=timezone.utc) 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_min_tickets_met = ( is_min_tickets_met = (
event.sold >= event.extra.min_tickets if event.extra.conditional else True event.sold >= event.extra.min_tickets if event.extra.conditional else True
) )
@ -181,10 +286,7 @@ async def api_event_create(
ext_settings = await get_settings() ext_settings = await get_settings()
user_id = wallet.wallet.user user_id = wallet.wallet.user
is_admin = ( is_admin = user_id == settings.super_user or user_id in settings.lnbits_admin_users
user_id == settings.super_user
or user_id in settings.lnbits_admin_users
)
if not is_admin and not ext_settings.auto_approve: if not is_admin and not ext_settings.auto_approve:
data.status = "proposed" data.status = "proposed"
@ -202,22 +304,72 @@ async def api_event_update(
data: CreateEvent, data: CreateEvent,
wallet: WalletTypeInfo = Depends(require_admin_key), wallet: WalletTypeInfo = Depends(require_admin_key),
) -> Event: ) -> 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) event = await get_event(event_id)
if not event: if not event:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist." status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
) )
if event.wallet != wallet.wallet.id: if event.wallet != wallet.wallet.id:
raise HTTPException( raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your event.")
status_code=HTTPStatus.FORBIDDEN, detail="Not your event."
) from lnbits.settings import settings
for k, v in data.dict().items():
setattr(event, k, v) 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"
event = await update_event(event) event = await update_event(event)
# Re-publish the replaceable NIP-52 event if we already announced it. if event.status == "approved":
if event.status == "approved" and event.nostr_event_id:
await publish_or_delete_nostr_event(event) 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 return event
@ -326,12 +478,23 @@ async def api_tickets(
@tickets_api_router.get("/user/{user_id}") @tickets_api_router.get("/user/{user_id}")
async def api_tickets_by_user_id(user_id: str) -> list[Ticket]: async def api_tickets_by_user(
"""Tickets bound to an LNbits user_id (used by external integrations). user_id: str,
user: User = Depends(check_user_exists),
) -> list[Ticket]:
"""All tickets for the authenticated user.
Declared before /{ticket_id} so FastAPI matches the literal `/user/` The `user_id` path param must match the token-bound user so a
prefix instead of treating "user" as a ticket id. Bearer-authenticated session can only enumerate its own tickets.
Returns full `Ticket` rows (not `PublicTicket`) since the owner
needs the payment_hash to render the QR + the `extra` envelope
to surface payment/refund state in My Tickets.
""" """
if user_id != user.id:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Can only fetch your own tickets.",
)
return await get_tickets_by_user_id(user_id) return await get_tickets_by_user_id(user_id)
@ -350,9 +513,65 @@ async def api_get_ticket(ticket_id: str) -> Ticket:
return 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}") @tickets_api_router.post("/{event_id}")
async def api_ticket_create( async def api_ticket_create(
event_id: str, data: CreateTicket event_id: str, data: CreateTicket, request: Request
) -> TicketPaymentRequest: ) -> TicketPaymentRequest:
event = await get_event(event_id) event = await get_event(event_id)
if not event: if not event:
@ -366,93 +585,187 @@ async def api_ticket_create(
) )
if event.canceled: if event.canceled:
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is canceled.") raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is canceled.")
if event.amount_tickets > 0 and event.sold >= event.amount_tickets: quantity = data.quantity
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is sold out.") 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 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 name = data.name
email = data.email email = data.email
user_id = data.user_id
promo_code = data.promo_code.upper() if data.promo_code else None promo_code = data.promo_code.upper() if data.promo_code else None
refund_address = data.refund_address refund_address = data.refund_address
price = event.price_per_ticket 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
extra: dict[str, Any] = {"tag": "events", "name": name, "email": email} extra: dict[str, Any] = {"tag": "events", "name": name, "email": email}
if promo_code: 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]: if promo_code not in [pc.code for pc in event.extra.promo_codes]:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="Invalid promo code." 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) promo = next(pc for pc in event.extra.promo_codes if pc.code == promo_code)
extra["promo_code"] = promo.code extra["promo_code"] = promo.code
price = event.price_per_ticket * (1 - promo.discount_percent / 100) 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
if event.currency != "sats": # 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):
extra["fiat"] = True extra["fiat"] = True
extra["currency"] = event.currency extra["currency"] = event.currency
extra["fiatAmount"] = price extra["fiatAmount"] = price
extra["rate"] = await get_fiat_rate_satoshis(event.currency) extra["rate"] = await get_fiat_rate_satoshis(event.currency)
price = await fiat_amount_as_satoshis(price, event.currency)
payment = await create_invoice( 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(
wallet_id=event.wallet, wallet_id=event.wallet,
amount=price, invoice_data=CreateInvoice(
memo=f"{event.id}", out=False,
extra=extra, amount=fiat_amount if payment_method == "fiat" else price,
) unit=invoice_unit,
await create_ticket( fiat_provider=fiat_provider,
payment_hash=payment.payment_hash, memo=f"{event_id}",
wallet=event.wallet, extra=extra,
event=event.id, ),
name=name,
email=email,
extra={
"applied_promo_code": promo_code,
"refund_address": refund_address,
"sats_paid": int(price),
},
) )
# 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( return TicketPaymentRequest(
payment_hash=payment.payment_hash, payment_request=payment.bolt11
)
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}
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)
payment = await create_invoice(
wallet_id=event.wallet,
amount=price,
memo=f"{event.id}",
extra=extra,
)
await create_ticket(
payment_hash=payment.payment_hash, payment_hash=payment.payment_hash,
wallet=event.wallet, payment_request=getattr(payment, "bolt11", None),
event=event.id, fiat_payment_request=getattr(payment, "extra", {}).get("fiat_payment_request"),
user_id=user_id, fiat_provider=getattr(payment, "fiat_provider", None) or fiat_provider,
) is_fiat=bool(getattr(payment, "fiat_provider", None) or fiat_provider),
return TicketPaymentRequest( ticket_ids=ticket_ids,
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.
The webapp polls this every 2s after presenting the invoice until
`paid: true` comes back, then advances to the success state. The
companion WebSocket at `/tickets/ws/{payment_hash}` is more
efficient for pushes this endpoint is the fallback.
Returns `{paid, ticket_ids: [...]}` so multi-ticket buyers get
every scannable id back in one response (one for single-ticket
purchases). A missing / cross-event purchase returns
`paid: false` rather than 404 so the poll doesn't have to
special-case the not-yet-created race.
"""
tickets = await get_tickets_by_payment_hash(payment_hash)
relevant = [t for t in tickets if t.event == event_id]
if not relevant:
return {"paid": False}
return {
"paid": all(t.paid for t in relevant),
"ticket_id": relevant[0].id, # back-compat with single-ticket clients
"ticket_ids": [t.id for t in relevant],
}
@tickets_api_router.websocket("/ws/{payment_hash}") @tickets_api_router.websocket("/ws/{payment_hash}")
async def websocket_endpoint(payment_hash: str, websocket: WebSocket) -> None: async def websocket_endpoint(payment_hash: str, websocket: WebSocket) -> None:
await websocket.accept() await websocket.accept()
@ -511,8 +824,57 @@ async def api_ticket_delete(
await delete_ticket(ticket_id) 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}") @tickets_api_router.put("/register/{ticket_id}")
async def api_event_register_ticket(ticket_id) -> Ticket: 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`.
"""
ticket = await get_ticket(ticket_id) ticket = await get_ticket(ticket_id)
if not ticket: if not ticket:
@ -520,6 +882,20 @@ async def api_event_register_ticket(ticket_id) -> Ticket:
status_code=HTTPStatus.NOT_FOUND, detail="Ticket does not exist." 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: if not ticket.paid:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.FORBIDDEN, detail="Ticket not paid for." status_code=HTTPStatus.FORBIDDEN, detail="Ticket not paid for."
@ -534,3 +910,52 @@ async def api_event_register_ticket(ticket_id) -> Ticket:
ticket.reg_timestamp = datetime.now(timezone.utc) ticket.reg_timestamp = datetime.now(timezone.utc)
ticket = await update_ticket(ticket) ticket = await update_ticket(ticket)
return 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
],
}