Compare commits

...

42 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
dni ⚡
4afc78d44d
feat: register public page saves to localstorage (#48)
* feat: register public page saves to localstorage

previsously it fetched all tickets without much information. now it
saves the full scanned ticket after it was scanned, so it can be checked
by some1 without a login

* add last scan

* short id

* prettier
2026-05-05 10:45:14 +02:00
Tiago Vasconcelos
9e477ac959
feat: make events dynamic (#43)
---------

Co-authored-by: dni <office@dnilabs.com>
2026-05-04 17:01:53 +02:00
34 changed files with 3968 additions and 1232 deletions

View file

@ -6,11 +6,12 @@ from loguru import logger
from .crud import db
from .tasks import wait_for_paid_invoices
from .views import events_generic_router
from .views_api import events_api_router
from .views_api import events_api_router, tickets_api_router
events_ext: APIRouter = APIRouter(prefix="/events", tags=["Events"])
events_ext.include_router(events_generic_router)
events_ext.include_router(events_api_router)
events_ext.include_router(tickets_api_router)
events_static_files = [
{
@ -21,6 +22,11 @@ events_static_files = [
scheduled_tasks: list[asyncio.Task] = []
# Module-level NostrClient — None when nostrclient is unavailable. Set by the
# bootstrap task in events_start() and read via dynamic attribute lookup
# from nostr_hooks.publish_or_delete_nostr_event.
nostr_client = None
def events_stop():
for task in scheduled_tasks:
@ -29,12 +35,80 @@ def events_stop():
except Exception as ex:
logger.warning(ex)
global nostr_client
if nostr_client:
asyncio.get_event_loop().create_task(nostr_client.stop())
def events_start():
from lnbits.tasks import create_permanent_unique_task
task = create_permanent_unique_task("ext_events", wait_for_paid_invoices)
scheduled_tasks.append(task)
task1 = create_permanent_unique_task("ext_events", wait_for_paid_invoices)
scheduled_tasks.append(task1)
# Register nostr-transport RPCs. Swallow ImportError on older LNbits
# versions that pre-date the transport (the events extension still
# works fine via HTTP without it).
try:
from lnbits.core.services.nostr_transport.dispatcher import (
AUTH_WALLET,
register_rpc,
)
from .transport_rpcs import (
handle_events_list_event_tickets,
handle_events_ticket_register,
)
register_rpc(
"events_ticket_register", handle_events_ticket_register, AUTH_WALLET
)
register_rpc(
"events_list_event_tickets",
handle_events_list_event_tickets,
AUTH_WALLET,
)
logger.info(
"[EVENTS] Registered nostr-transport RPCs: "
"events_ticket_register, events_list_event_tickets"
)
except ImportError:
logger.info(
"[EVENTS] nostr_transport not available on this LNbits — "
"ticket scanner over Nostr disabled, HTTP endpoint still works"
)
async def _start_nostr_client():
global nostr_client
await asyncio.sleep(10) # Wait for nostrclient to be ready
try:
from .nostr.nostr_client import NostrClient
nostr_client = NostrClient()
logger.info("[EVENTS] Starting NostrClient for NIP-52 sync")
await nostr_client.run_forever()
except Exception as exc:
logger.warning(f"[EVENTS] NostrClient failed to start: {exc}")
logger.info("[EVENTS] Events will work without Nostr sync")
task2 = create_permanent_unique_task("ext_events_nostr", _start_nostr_client)
scheduled_tasks.append(task2)
async def _sync_nostr_events():
global nostr_client
await asyncio.sleep(15) # Wait for NostrClient to connect
if not nostr_client:
logger.info("[EVENTS] No NostrClient, skipping Nostr sync")
return
try:
from .nostr_sync import wait_for_nostr_events
await wait_for_nostr_events(nostr_client)
except Exception as exc:
logger.error(f"[EVENTS] Nostr sync task failed: {exc}")
task3 = create_permanent_unique_task("ext_events_nostr_sync", _sync_nostr_events)
scheduled_tasks.append(task3)
__all__ = ["db", "events_ext", "events_start", "events_static_files", "events_stop"]

View file

@ -1,12 +1,12 @@
{
"id": "events",
"version": "1.2.1",
"version": "1.6.1-aio.7",
"name": "Events",
"repo": "https://github.com/lnbits/events",
"repo": "https://git.atitlan.io/aiolabs/events",
"short_description": "Sell and register event tickets",
"description": "",
"tile": "/events/static/image/events.png",
"min_lnbits_version": "1.3.0",
"min_lnbits_version": "1.4.1",
"contributors": [
{
"name": "talvasconcelos",
@ -14,7 +14,7 @@
"role": "Developer"
},
{
"name": "DNI",
"name": "dni",
"uri": "https://github.com/dni",
"role": "Developer"
},
@ -32,6 +32,11 @@
"name": "motorina0",
"uri": "https://github.com/motorina0",
"role": "Developer"
},
{
"name": "padreug",
"uri": "https://git.atitlan.io/padreug",
"role": "Developer (aio fork: approval workflow + NIP-52 Nostr sync + edit gating)"
}
],
"images": [

187
crud.py
View file

@ -1,54 +1,160 @@
import json
from datetime import datetime, timedelta, timezone
from lnbits.db import Database
from lnbits.helpers import urlsafe_short_hash
from .models import CreateEvent, Event, Ticket, TicketExtra
from .models import CreateEvent, Event, EventsSettings, Ticket, TicketExtra
db = Database("ext_events")
def _parse_ticket_row(row) -> dict:
"""Normalize a ticket row before constructing a Ticket model.
- Empty-string sentinels in name/email (used because the DB columns are
NOT NULL but the Pydantic field is Optional when user_id is set) are
converted back to None.
- The `extra` JSON column may come back as a string when the row is
fetched without a model= argument; parse it so Pydantic can build
TicketExtra from a dict.
"""
ticket_data = dict(row)
if ticket_data.get("name") == "":
ticket_data["name"] = None
if ticket_data.get("email") == "":
ticket_data["email"] = None
extra = ticket_data.get("extra")
if isinstance(extra, str):
ticket_data["extra"] = json.loads(extra) if extra else {}
return ticket_data
async def create_ticket(
payment_hash: str, wallet: str, event: str, name: str, email: str, extra: dict
payment_hash: str,
wallet: str,
event: str,
name: str | None = None,
email: str | None = None,
user_id: str | None = None,
extra: dict | None = None,
ticket_id: str | None = None,
) -> Ticket:
"""Persist one ticket row.
`payment_hash` is the LNbits invoice hash shared across all rows
of a multi-ticket purchase. `ticket_id` is the row primary key /
scannable id; defaults to `payment_hash` for single-ticket
purchases so the legacy id == payment_hash invariant holds.
Multi-ticket callers pass a unique uuid here so each attendee
gets a distinct scannable QR.
"""
now = datetime.now(timezone.utc)
ticket = Ticket(
id=payment_hash,
row_id = ticket_id or payment_hash
# name/email columns are NOT NULL in the schema, so we store "" when only
# user_id is supplied. _parse_ticket_row reverses this on read.
if user_id:
db_name = ""
db_email = ""
else:
db_name = name or ""
db_email = email or ""
db_ticket = Ticket(
id=row_id,
wallet=wallet,
event=event,
name=name,
email=email,
name=db_name,
email=db_email,
user_id=user_id,
registered=False,
paid=False,
reg_timestamp=now,
time=now,
extra=TicketExtra(**extra) if extra else TicketExtra(),
payment_hash=payment_hash,
)
await db.insert("events.ticket", db_ticket)
return Ticket(
id=row_id,
wallet=wallet,
event=event,
name=name,
email=email,
user_id=user_id,
registered=False,
paid=False,
reg_timestamp=now,
time=now,
extra=TicketExtra(**extra) if extra else TicketExtra(),
payment_hash=payment_hash,
)
await db.insert("events.ticket", ticket)
return ticket
async def update_ticket(ticket: Ticket) -> Ticket:
await db.update("events.ticket", ticket)
ticket_dict = ticket.dict()
if ticket_dict.get("name") is None:
ticket_dict["name"] = ""
if ticket_dict.get("email") is None:
ticket_dict["email"] = ""
await db.update("events.ticket", Ticket(**ticket_dict))
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:
return await db.fetchone(
row = await db.fetchone(
"SELECT * FROM events.ticket WHERE id = :id",
{"id": payment_hash},
Ticket,
)
if not row:
return None
return Ticket(**_parse_ticket_row(row))
async def get_tickets(wallet_ids: str | list[str]) -> list[Ticket]:
if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids]
q = ",".join([f"'{wallet_id}'" for wallet_id in wallet_ids])
return await db.fetchall(
f"SELECT * FROM events.ticket WHERE wallet IN ({q})",
model=Ticket,
rows = await db.fetchall(f"SELECT * FROM events.ticket WHERE wallet IN ({q})")
return [Ticket(**_parse_ticket_row(row)) for row in rows]
async def get_tickets_by_event(event_id: str) -> list[Ticket]:
"""All ticket rows for the given calendar event id."""
rows = await db.fetchall(
"SELECT * FROM events.ticket WHERE event = :event_id",
{"event_id": event_id},
)
return [Ticket(**_parse_ticket_row(row)) for row in rows]
async def get_tickets_by_user_id(user_id: str) -> list[Ticket]:
"""All tickets owned by the given LNbits user_id."""
rows = await db.fetchall(
"SELECT * FROM events.ticket WHERE user_id = :user_id ORDER BY time DESC",
{"user_id": user_id},
)
return [Ticket(**_parse_ticket_row(row)) for row in rows]
async def delete_ticket(payment_hash: str) -> None:
@ -74,6 +180,11 @@ async def purge_unpaid_tickets(event_id: str) -> None:
async def create_event(data: CreateEvent) -> Event:
event_id = urlsafe_short_hash()
# Default end_date to start_date and closing_date to end_date when omitted.
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
event = Event(id=event_id, time=datetime.now(timezone.utc), **data.dict())
await db.insert("events.events", event)
return event
@ -102,13 +213,57 @@ async def get_events(wallet_ids: str | list[str]) -> list[Event]:
)
async def get_all_events() -> list[Event]:
"""All events, no wallet filter. Admin-only callers."""
return await db.fetchall(
"SELECT * FROM events.events ORDER BY time DESC",
model=Event,
)
async def get_public_events() -> list[Event]:
"""Approved, non-canceled events for the public listing."""
return await db.fetchall(
"""
SELECT * FROM events.events
WHERE status = 'approved' AND canceled = FALSE
ORDER BY event_start_date ASC
""",
model=Event,
)
async def get_pending_events() -> list[Event]:
"""Proposed events awaiting admin approval."""
return await db.fetchall(
"SELECT * FROM events.events WHERE status = 'proposed' ORDER BY time DESC",
model=Event,
)
async def get_settings() -> EventsSettings:
"""Singleton settings row, seeded by m010."""
row = await db.fetchone("SELECT * FROM events.settings WHERE id = 1")
if row:
return EventsSettings(**dict(row))
return EventsSettings()
async def update_settings(settings: EventsSettings) -> EventsSettings:
await db.execute(
"UPDATE events.settings SET auto_approve = :auto_approve WHERE id = 1",
{"auto_approve": settings.auto_approve},
)
return settings
async def delete_event(event_id: str) -> None:
await db.execute("DELETE FROM events.events WHERE id = :id", {"id": event_id})
async def get_event_tickets(event_id: str) -> list[Ticket]:
return await db.fetchall(
rows = await db.fetchall(
"SELECT * FROM events.ticket WHERE event = :event",
{"event": event_id},
Ticket,
)
return [Ticket(**_parse_ticket_row(row)) for row in rows]

View file

@ -175,3 +175,23 @@ async def m006_add_extra_fields(db):
# Add 'extra' column to ticket table
await db.execute("ALTER TABLE events.ticket ADD COLUMN extra TEXT;")
async def m007_add_allow_fiat(db):
"""
Add an allow_fiat column so event owners can explicitly enable fiat checkout.
"""
await db.execute("""
ALTER TABLE events.events
ADD COLUMN allow_fiat BOOLEAN NOT NULL DEFAULT FALSE;
""")
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 = ''"
)

147
models.py
View file

@ -1,7 +1,7 @@
import json
from datetime import datetime
from fastapi import Query
from pydantic import BaseModel, EmailStr, Field, validator
from pydantic import BaseModel, EmailStr, Field, root_validator, validator
class PromoCode(BaseModel):
@ -24,62 +24,171 @@ class EventExtra(BaseModel):
promo_codes: list[PromoCode] = Field(default_factory=list)
conditional: bool = False
min_tickets: int = 1
email_notifications: bool = False
nostr_notifications: bool = False
notification_subject: str = ""
notification_body: str = ""
class CreateEvent(BaseModel):
wallet: str
name: str
info: str
closing_date: str
wallet: str | None = None # filled from caller's wallet if absent
name: str # title (required)
info: str = "" # description (optional)
closing_date: str | None = None # date-only YYYY-MM-DD; defaults to event_end_date
# ISO 8601: date-only ("2026-05-19") or datetime ("2026-05-19T18:30").
# Presence of a "T" toggles NIP-52 kind (31922 date / 31923 time).
event_start_date: str
event_end_date: str
event_end_date: str | None = None # same format as event_start_date
currency: str = "sat"
amount_tickets: int = Query(..., ge=0)
price_per_ticket: float = Query(..., ge=0)
allow_fiat: bool = False
fiat_currency: str = "GBP"
amount_tickets: int = 0 # 0 = unlimited / not ticketed
price_per_ticket: float = 0 # 0 = free
banner: str | None = None
location: str | None = None # venue/address (NIP-52 'location' tag)
categories: list[str] = Field(default_factory=list) # NIP-52 't' tags
extra: EventExtra = Field(default_factory=EventExtra)
status: str = "approved" # proposed, approved, rejected
class Event(BaseModel):
id: str
wallet: str
name: str
info: str
closing_date: str
info: str = ""
closing_date: str | None = None
canceled: bool = False
event_start_date: str
event_end_date: str
currency: str
amount_tickets: int
price_per_ticket: float
event_end_date: str | None = None
currency: str = "sat"
allow_fiat: bool = False
fiat_currency: str = "GBP"
amount_tickets: int = 0
price_per_ticket: float = 0
time: datetime
sold: int = 0
banner: str | None = None
location: str | None = None
categories: list[str] = Field(default_factory=list)
extra: EventExtra = Field(default_factory=EventExtra)
status: str = "approved"
nostr_event_id: str | None = None
nostr_event_created_at: int | None = None
@validator("categories", pre=True)
def parse_categories(cls, v):
if isinstance(v, str):
return json.loads(v) if v else []
return v or []
class PublicEvent(BaseModel):
id: str
name: str
info: str
closing_date: str | None = None
canceled: bool
event_start_date: str
event_end_date: str | None = None
currency: str
allow_fiat: bool = False
fiat_currency: str = "GBP"
price_per_ticket: float
banner: str | None
location: str | None = None
categories: list[str] = Field(default_factory=list)
extra: EventExtra = Field(default_factory=EventExtra)
status: str = "approved" # surfaces "proposed"/"rejected" so SFC can render banner
@validator("categories", pre=True)
def parse_categories(cls, v):
if isinstance(v, str):
return json.loads(v) if v else []
return v or []
class EventsSettings(BaseModel):
"""Extension-level settings for the events extension."""
auto_approve: bool = False # Skip approval workflow for non-admin users
class TicketExtra(BaseModel):
applied_promo_code: str | None = None
sats_paid: int | None = None
refund_address: str | None = None
nostr_identifier: str | None = None
ticket_base_url: str | None = None
email_notification_sent: bool = False
nostr_notification_sent: bool = False
refunded: bool = False
class CreateTicket(BaseModel):
name: str
email: EmailStr
name: str | None = None
email: EmailStr | None = None
user_id: str | None = None # LNbits user id (alternative to name+email)
promo_code: str | None = None
refund_address: str | None = None
nostr_identifier: str | None = None
payment_method: str | None = None
fiat_provider: str | None = None
# Number of tickets to buy on this single invoice. Bounded so a
# bad client can't run away with the organizer's capacity.
quantity: int = Field(default=1, ge=1, le=10)
@root_validator
def validate_identifiers(cls, values):
name = values.get("name")
email = values.get("email")
user_id = values.get("user_id")
if not user_id and not (name and email):
raise ValueError("Either user_id or both name and email must be provided")
if user_id and (name or email):
raise ValueError("Cannot provide both user_id and name/email")
return values
class Ticket(BaseModel):
id: str
wallet: str
event: str
name: str
email: str
name: str | None = None
email: str | None = None
user_id: str | None = None
registered: bool
paid: bool
time: datetime
reg_timestamp: datetime
extra: TicketExtra = Field(default_factory=TicketExtra)
# Shared LNbits invoice payment_hash. Equals `id` for single-ticket
# purchases (legacy + post-migration default). Multi-ticket
# purchases create N rows sharing one payment_hash so each attendee
# gets a distinct scannable id while the buyer pays once.
payment_hash: str | None = None
class PublicTicket(BaseModel):
event: str
name: str | None = None
registered: bool
paid: bool
time: datetime
reg_timestamp: datetime
class TicketPaymentRequest(BaseModel):
payment_hash: str
payment_request: str | None = None
fiat_payment_request: str | None = None
fiat_provider: str | None = None
is_fiat: bool = False
# True when the tickets are already issued + paid with no invoice to
# settle — free events (price 0) or a 100%-off promo. The client skips
# the QR / payment-poll step and goes straight to the ticket QRs.
paid: bool = False
# Row ids created on this invoice — one for single-ticket
# purchases, N for multi-ticket (each independently scannable at
# the door). Buyers fetch these after payment to render N QRs in
# My Tickets.
ticket_ids: list[str] = Field(default_factory=list)

0
nostr/__init__.py Normal file
View file

26
nostr/event.py Normal file
View file

@ -0,0 +1,26 @@
import hashlib
import json
from pydantic import BaseModel
class NostrEvent(BaseModel):
id: str = ""
pubkey: str
created_at: int
kind: int
tags: list[list[str]] = []
content: str = ""
sig: str | None = None
def serialize(self) -> list:
return [0, self.pubkey, self.created_at, self.kind, self.tags, self.content]
def serialize_json(self) -> str:
e = self.serialize()
return json.dumps(e, separators=(",", ":"), ensure_ascii=False)
@property
def event_id(self) -> str:
data = self.serialize_json()
return hashlib.sha256(data.encode()).hexdigest()

135
nostr/nostr_client.py Normal file
View file

@ -0,0 +1,135 @@
"""
Bidirectional Nostr client for the events extension.
Connects to the nostrclient extension's internal WebSocket to publish
and subscribe to NIP-52 calendar events. Based on nostrmarket's
NostrClient pattern.
"""
import asyncio
import json
from asyncio import Queue
from collections import OrderedDict
from lnbits.helpers import encrypt_internal_message, urlsafe_short_hash
from lnbits.settings import settings
from loguru import logger
from websocket import WebSocketApp
from .event import NostrEvent
MAX_SEEN_EVENTS = 500
class NostrClient:
def __init__(self):
self.receive_event_queue: Queue = Queue()
self.send_req_queue: Queue = Queue()
self.ws: WebSocketApp | None = None
self.subscription_id = "events-" + urlsafe_short_hash()[:32]
self.running = False
self._seen_events: OrderedDict[str, None] = OrderedDict()
@property
def is_websocket_connected(self):
if not self.ws:
return False
return self.ws.keep_running
async def connect(self) -> WebSocketApp:
relay_endpoint = encrypt_internal_message("relay", urlsafe=True)
ws_url = (
f"ws://localhost:{settings.port}" f"/nostrclient/api/v1/{relay_endpoint}"
)
logger.info("[EVENTS] Connecting to nostrclient WebSocket...")
def on_open(_):
logger.info("[EVENTS] Connected to nostrclient WebSocket")
def on_message(_, message):
try:
self.receive_event_queue.put_nowait(message)
except Exception as e:
logger.error(f"[EVENTS] Failed to queue message: {e}")
def on_error(_, error):
logger.warning(f"[EVENTS] WebSocket error: {error}")
def on_close(_, status_code, message):
logger.warning(f"[EVENTS] WebSocket closed: {status_code} {message}")
self.receive_event_queue.put_nowait(ValueError("WebSocket closed"))
ws = WebSocketApp(
ws_url,
on_message=on_message,
on_open=on_open,
on_close=on_close,
on_error=on_error,
)
from threading import Thread
wst = Thread(target=ws.run_forever)
wst.daemon = True
wst.start()
return ws
async def run_forever(self):
self.running = True
while self.running:
try:
if not self.is_websocket_connected:
self.ws = await self.connect()
await asyncio.sleep(5)
req = await self.send_req_queue.get()
assert self.ws
self.ws.send(json.dumps(req))
except Exception as ex:
logger.warning(f"[EVENTS] NostrClient error: {ex}")
await asyncio.sleep(60)
def is_duplicate_event(self, event_id: str) -> bool:
"""Check if an event has been seen recently."""
if event_id in self._seen_events:
return True
self._seen_events[event_id] = None
if len(self._seen_events) > MAX_SEEN_EVENTS:
self._seen_events.popitem(last=False)
return False
async def get_event(self):
"""Get next event from the receive queue."""
value = await self.receive_event_queue.get()
if isinstance(value, ValueError):
raise value
return value
async def publish_nostr_event(self, e: NostrEvent):
await self.send_req_queue.put(["EVENT", e.dict()])
async def subscribe(self, filters: list[dict]):
"""Subscribe to events matching the given filters."""
self.subscription_id = "events-" + urlsafe_short_hash()[:32]
await self.send_req_queue.put(["REQ", self.subscription_id, *filters])
logger.info(
f"[EVENTS] Subscribed to NIP-52 events "
f"(sub: {self.subscription_id[:20]}...)"
)
async def unsubscribe(self):
"""Unsubscribe from current subscription."""
await self.send_req_queue.put(["CLOSE", self.subscription_id])
async def stop(self):
await self.unsubscribe()
self.running = False
await asyncio.sleep(2)
if self.ws:
try:
self.ws.close()
except Exception:
pass
self.ws = None

48
nostr_hooks.py Normal file
View file

@ -0,0 +1,48 @@
"""Helpers that bridge event-mutation handlers to the Nostr publisher.
Lives in its own module so both `events_api_router` and any future router
can call it without importing through `views_api`, which would create an
import cycle (views_api -> nostr_hooks -> nostr_publisher -> models).
"""
from loguru import logger
from .crud import update_event
from .models import Event
from .nostr_publisher import publish_event_to_nostr
async def publish_or_delete_nostr_event(event: Event, *, delete: bool = False) -> None:
"""Publish or delete the NIP-52 calendar event for `event`.
Resolves a `NostrSigner` for the wallet owner backend-agnostic
(LocalSigner / RemoteBunkerSigner / ClientSideOnlySigner). The
signer abstraction handles the actual key material; this hook
only needs `signer.pubkey` for event construction and
`await signer.sign_event(...)` for signing. Failures are logged
and swallowed so a Nostr outage doesn't break the HTTP flow that
triggered the publish.
"""
try:
from lnbits.core.signers import resolve_for_wallet
from . import nostr_client
signer = await resolve_for_wallet(event.wallet)
if signer is None:
# Wallet missing, account missing, unclassified row, or
# ClientSideOnlySigner account (server can't sign for them).
# Soft-fail: skip the publish silently. The user can still
# publish kind-31922/31923 events client-side once we have
# that path.
return
nostr_event = await publish_event_to_nostr(
nostr_client, event, signer, delete=delete
)
if nostr_event and not delete:
event.nostr_event_id = nostr_event.id
event.nostr_event_created_at = nostr_event.created_at
await update_event(event)
except Exception as exc:
logger.warning(f"[EVENTS] Nostr publish failed: {exc}")

204
nostr_publisher.py Normal file
View file

@ -0,0 +1,204 @@
"""
NIP-52 calendar event publishing for the events extension.
Builds NIP-52 calendar events from the Event model, signs them via the
core `NostrSigner` abstraction (backend-agnostic: LocalSigner,
RemoteBunkerSigner, etc.), and publishes via the NostrClient.
Kind 31922 is used for date-only events; kind 31923 (time-based) is used
when event_start_date / event_end_date include a time component.
Reference: https://github.com/nostr-protocol/nips/blob/master/52.md
"""
import time
from datetime import datetime, timezone
from lnbits.core.signers import NostrSigner
from loguru import logger
from .models import Event
from .nostr.event import NostrEvent
from .nostr_timestamp import monotonic_created_at
def _has_time(value: str | None) -> bool:
"""ISO 8601 datetime strings contain a 'T' between date and time."""
return value is not None and "T" in value
def _to_unix(value: str) -> int:
"""Parse ISO 8601 datetime (assume UTC if naive) to unix seconds."""
dt = datetime.fromisoformat(value)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return int(dt.timestamp())
def build_nip52_event(event: Event, pubkey: str) -> NostrEvent:
"""
Convert an Event model to a NIP-52 calendar event.
Time-based (kind 31923) if event_start_date carries an HH:MM, otherwise
date-based (kind 31922). Tags:
d - event.id
title - event.name
start - unix timestamp (31923) or YYYY-MM-DD (31922)
end - same encoding (optional)
image, location, t (categories) - optional
tickets_available - current remaining capacity (omitted when unlimited)
tickets_sold - running paid-count (always emitted; clients can
derive original_capacity = available + sold)
tickets_price - price_per_ticket (always emitted; 0 means free)
tickets_currency - the currency string
tickets_allow_fiat - "true" when fiat checkout is enabled (omitted otherwise)
tickets_fiat_currency - the fiat settle currency (only when allow_fiat)
Content: event.info
The four ticket_* tags are AIO custom additions outside the NIP-52
spec; spec-compliant clients ignore unknown tags so this stays
backwards-compatible. They let connected clients render the
"X tickets remaining" badge and the Buy CTA without an extra REST hop,
and pick up live inventory updates via the same relay subscription.
"""
time_based = _has_time(event.event_start_date)
kind = 31923 if time_based else 31922
start_value = (
str(_to_unix(event.event_start_date)) if time_based else event.event_start_date
)
tags = [
["d", event.id],
["title", event.name],
["start", start_value],
]
end_unix: int | None = None
if event.event_end_date:
end_value = (
str(_to_unix(event.event_end_date)) if time_based else event.event_end_date
)
tags.append(["end", end_value])
if time_based:
end_unix = _to_unix(event.event_end_date)
if time_based:
start_unix = _to_unix(event.event_start_date)
start_day = start_unix // 86400
end_day = (end_unix // 86400) if end_unix is not None else start_day
for day in range(start_day, end_day + 1):
tags.append(["D", str(day)])
if event.banner:
tags.append(["image", event.banner])
if event.location:
tags.append(["location", event.location])
for cat in event.categories or []:
tags.append(["t", cat])
# `amount_tickets == 0` means unlimited capacity in this extension's
# schema. Omitting the tag is how clients distinguish unlimited from
# "0 left" (sold out).
if event.amount_tickets > 0:
tags.append(["tickets_available", str(event.amount_tickets)])
tags.append(["tickets_sold", str(event.sold)])
tags.append(["tickets_price", str(event.price_per_ticket)])
tags.append(["tickets_currency", event.currency])
# Fiat-checkout config — only emitted when allow_fiat is on so
# clients can branch the buy UI without re-reading the schema.
if event.allow_fiat:
tags.append(["tickets_allow_fiat", "true"])
if event.fiat_currency:
tags.append(["tickets_fiat_currency", event.fiat_currency])
# NIP-52 calendar events are replaceable: this d-tag is republished
# whenever inventory changes (a ticket sells). Use a strictly-monotonic
# created_at anchored on the last published value so a same-second
# republish still outranks the prior version and relays push it to open
# subscriptions — a bare int(time.time()) can tie and be silently
# dropped, stalling clients' live "tickets remaining" badge.
nostr_event = NostrEvent(
pubkey=pubkey,
created_at=monotonic_created_at(event.nostr_event_created_at),
kind=kind,
tags=tags,
content=event.info or "",
)
nostr_event.id = nostr_event.event_id
return nostr_event
def build_nip52_delete_event(event: Event, pubkey: str) -> NostrEvent:
"""
Build a kind 5 delete event for a published NIP-52 calendar event.
Uses an 'a' tag to reference the parameterized replaceable event per
NIP-09. The referenced kind must match what we published 31923 for
time-based events, 31922 for date-only.
"""
referenced_kind = 31923 if _has_time(event.event_start_date) else 31922
nostr_event = NostrEvent(
pubkey=pubkey,
created_at=int(time.time()),
kind=5,
tags=[
["a", f"{referenced_kind}:{pubkey}:{event.id}"],
],
content="Event canceled",
)
nostr_event.id = nostr_event.event_id
return nostr_event
async def publish_event_to_nostr(
nostr_client,
event: Event,
signer: NostrSigner,
delete: bool = False,
) -> NostrEvent | None:
"""
Build, sign, and publish a NIP-52 calendar event (or delete event).
Signing routes through the core `NostrSigner` abstraction
`signer.pubkey` for the event identity, `await signer.sign_event(...)`
for the Schnorr signature. The signer backend (LocalSigner /
RemoteBunkerSigner) is transparent to this function.
Returns the published NostrEvent for metadata storage, or None on failure.
"""
if not nostr_client:
logger.debug("[EVENTS] No NostrClient available, skipping publish")
return None
try:
if delete:
nostr_event = build_nip52_delete_event(event, signer.pubkey)
else:
nostr_event = build_nip52_event(event, signer.pubkey)
# Hand the unsigned event to the signer — it fills in `id`,
# `pubkey`, and `sig`. The signer's serialization rules match
# NIP-01 (same as the local `event_id` property uses), so the
# returned id matches what we'd have computed locally.
unsigned = {
"kind": nostr_event.kind,
"created_at": nostr_event.created_at,
"tags": nostr_event.tags,
"content": nostr_event.content,
}
signed = await signer.sign_event(unsigned)
nostr_event.id = signed["id"]
nostr_event.pubkey = signed["pubkey"]
nostr_event.sig = signed["sig"]
await nostr_client.publish_nostr_event(nostr_event)
logger.info(
f"[EVENTS] Published NIP-52 {'delete' if delete else 'calendar'} "
f"event: {nostr_event.id[:16]}... (kind {nostr_event.kind})"
)
return nostr_event
except Exception as e:
logger.warning(f"[EVENTS] Failed to publish to Nostr: {e}")
return None

157
nostr_sync.py Normal file
View file

@ -0,0 +1,157 @@
"""
Bidirectional Nostr sync for the events extension.
Subscribes to NIP-52 calendar events (kind 31922/31923) from relays
and upserts them into the local database. Enables federated event
discovery events published by other LNbits instances or Nostr
clients appear in the local events listing.
"""
import asyncio
import json
from datetime import datetime, timezone
from loguru import logger
from .crud import db, get_event, update_event
from .models import Event
from .nostr.nostr_client import NostrClient
async def process_nostr_message(nostr_client: NostrClient, message: str):
"""Process an incoming Nostr relay message."""
try:
data = json.loads(message)
except json.JSONDecodeError:
return
if not isinstance(data, list) or len(data) < 2:
return
msg_type = data[0]
if msg_type == "EVENT" and len(data) >= 3:
event_data = data[2]
await _handle_calendar_event(nostr_client, event_data)
elif msg_type == "EOSE":
logger.debug("[EVENTS] End of stored events from relay")
elif msg_type == "NOTICE":
logger.info(f"[EVENTS] Relay notice: {data[1]}")
async def _handle_calendar_event(nostr_client: NostrClient, event_data: dict):
"""Handle an incoming NIP-52 calendar event (kind 31922 or 31923)."""
kind = event_data.get("kind")
if kind not in (31922, 31923):
return
event_id = event_data.get("id", "")
if nostr_client.is_duplicate_event(event_id):
return
tags = {t[0]: t[1] for t in event_data.get("tags", []) if len(t) >= 2}
tag_lists = {}
for t in event_data.get("tags", []):
if len(t) >= 2:
tag_lists.setdefault(t[0], []).append(t[1])
d_tag = tags.get("d")
if not d_tag:
return
title = tags.get("title", "Untitled Event")
start = tags.get("start")
if not start:
return
end = tags.get("end")
description = event_data.get("content", "")
image = tags.get("image")
location = tags.get("location")
categories = tag_lists.get("t", [])
# Check if we already have this event (by d-tag as our event ID
# or by nostr_event_id)
existing = await get_event(d_tag)
if not existing:
# Check by nostr_event_id
existing = await db.fetchone(
"SELECT * FROM events.events WHERE nostr_event_id = :nid",
{"nid": event_id},
Event,
)
if existing:
# Update if the incoming event is newer
incoming_created_at = event_data.get("created_at", 0)
if (
existing.nostr_event_created_at
and incoming_created_at <= existing.nostr_event_created_at
):
return # We already have a newer version
existing.name = title
existing.info = description
existing.event_start_date = start
existing.event_end_date = end
existing.banner = image
existing.location = location
existing.categories = categories
existing.nostr_event_id = event_id
existing.nostr_event_created_at = incoming_created_at
await update_event(existing)
logger.info(f"[EVENTS] Updated event from Nostr: {title}")
else:
# Create new event from Nostr — discovered events are auto-approved
# (they're already public on relays). Use the d-tag as the event ID
# for replaceable-event correlation.
new_event = Event(
id=d_tag,
wallet="",
name=title,
info=description,
event_start_date=start,
event_end_date=end,
banner=image,
location=location,
categories=categories,
status="approved",
time=datetime.now(timezone.utc),
nostr_event_id=event_id,
nostr_event_created_at=event_data.get("created_at", 0),
)
try:
await db.insert("events.events", new_event)
logger.info(f"[EVENTS] Discovered event from Nostr: {title}")
except Exception as e:
# Likely duplicate key — skip
logger.debug(f"[EVENTS] Skipped duplicate event: {e}")
async def wait_for_nostr_events(nostr_client: NostrClient):
"""
Background task: subscribe to NIP-52 events and process them.
"""
logger.info("[EVENTS] Starting Nostr event sync...")
while True:
try:
# Subscribe to NIP-52 calendar events
await nostr_client.subscribe(
[
{"kinds": [31922, 31923]},
]
)
# Process incoming events
while True:
message = await nostr_client.get_event()
await process_nostr_message(nostr_client, message)
except ValueError:
# WebSocket closed — will reconnect
logger.warning("[EVENTS] Nostr connection lost, resubscribing...")
await asyncio.sleep(10)
except Exception as e:
logger.error(f"[EVENTS] Nostr sync error: {e}")
await asyncio.sleep(30)

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 loguru import logger
@ -8,25 +21,146 @@ from .crud import (
update_event,
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:
if ticket.paid:
return ticket
ticket.paid = True
await update_ticket(ticket)
async with _event_paid_lock(ticket.event):
ticket.paid = True
await update_ticket(ticket)
event = await get_event(ticket.event)
assert event, "Couldn't get event from ticket being paid"
event.sold += 1
event.amount_tickets -= 1
await update_event(event)
event = await get_event(ticket.event)
assert event, "Couldn't get event from ticket being paid"
event.sold += 1
event.amount_tickets -= 1
await update_event(event)
# Republish the NIP-52 calendar event so connected clients see
# the new tickets_available / tickets_sold counters via their
# existing relay subscription. Failures are logged + swallowed
# inside publish_or_delete_nostr_event so a Nostr outage doesn't
# break the payment flow.
await publish_or_delete_nostr_event(event)
return ticket
def send_ticket_notification_in_background(ticket: Ticket) -> None:
create_task(_send_ticket_notification(ticket))
async def _send_ticket_notification(ticket: Ticket) -> None:
event = await get_event(ticket.event)
if not event:
logger.warning(f"Event {ticket.event} not found for ticket notification.")
return
subject, message = _ticket_notification_message(ticket, event)
updated = False
if (
event.extra.email_notifications
and settings.lnbits_email_notifications_enabled
and ticket.email
):
try:
await send_email_notification([ticket.email], message, subject)
ticket.extra.email_notification_sent = True
updated = True
except Exception as exc:
logger.warning(f"Failed to email ticket {ticket.id}: {exc}")
if (
event.extra.nostr_notifications
and settings.is_nostr_notifications_configured()
and ticket.extra.nostr_identifier
):
try:
await _send_nostr_ticket_notification(
ticket.extra.nostr_identifier, message
)
ticket.extra.nostr_notification_sent = True
updated = True
except Exception as exc:
logger.warning(f"Failed to send nostr DM for ticket {ticket.id}: {exc}")
if updated:
await update_ticket(ticket)
async def resend_ticket_email_notification(ticket: Ticket) -> Ticket:
event = await get_event(ticket.event)
if not event:
raise ValueError("Event does not exist.")
if not settings.lnbits_email_notifications_enabled:
raise ValueError("Email notifications are not enabled.")
if not ticket.email:
raise ValueError("Ticket does not have an email address.")
subject, message = _ticket_notification_message(ticket, event)
await send_email_notification([ticket.email], message, subject)
ticket.extra.email_notification_sent = True
return await update_ticket(ticket)
def _ticket_notification_message(ticket: Ticket, event: Event) -> tuple[str, str]:
ticket_url = _ticket_url(ticket)
subject = (
event.extra.notification_subject.strip()
or f"Your ticket for '{event.name}' is ready"
)
body = (
event.extra.notification_body.strip()
or f"Your ticket for '{event.name}' is ready."
)
return subject, f"{body}\n\nOpen it here: {ticket_url}"
async def _send_nostr_ticket_notification(identifier: str, message: str) -> None:
if "@" in identifier:
await send_user_notification(
UserNotifications(nostr_identifier=identifier),
message,
"text_message",
)
return
private_key = normalize_private_key(settings.lnbits_nostr_notifications_private_key)
public_key = normalize_public_key(identifier)
await send_nostr_dm(private_key, public_key, message, DEFAULT_NOSTR_RELAYS)
def _ticket_url(ticket: Ticket) -> str:
base_url = (ticket.extra.ticket_base_url or settings.lnbits_baseurl).rstrip("/")
return f"{base_url}/events/ticket/{ticket.id}"
async def refund_tickets(event_id: str):
"""
Refund tickets for an event that has not met the minimum ticket requirement.

View file

@ -1,8 +1,9 @@
window.app = Vue.createApp({
el: '#vue',
mixins: [windowMixin],
window.PageEventsDisplay = {
template: '#page-events-display',
data() {
return {
eventErrorLabel: '',
event: null,
paymentReq: null,
redirectUrl: null,
formDialog: {
@ -10,7 +11,9 @@ window.app = Vue.createApp({
data: {
name: '',
email: '',
refund: ''
refund: '',
nostr_identifier: '',
payment_method: 'lightning'
}
},
ticketLink: {
@ -22,35 +25,71 @@ window.app = Vue.createApp({
receive: {
show: false,
status: 'pending',
paymentReq: null
}
paymentReq: null,
isFiat: false
},
paymentDismissMsg: null,
paymentWebsocket: null
}
},
async created() {
this.info = event_info
this.info = this.info.substring(1, this.info.length - 1)
this.banner = event_banner
this.extra = event_extra
this.hasPromoCodes = has_promoCodes
this.eventId = this.$route.params.id
this.event = await this.getEvent()
},
computed: {
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: {
async getEvent() {
try {
const {data} = await LNbits.api.request(
'GET',
`/events/api/v1/events/${this.eventId}`
)
return data
} catch (error) {
this.eventErrorLabel = 'Event unavailable.'
LNbits.utils.notifyApiError(error)
}
},
resetForm(e) {
e.preventDefault()
this.formDialog.data.name = ''
this.formDialog.data.email = ''
this.formDialog.data.refund = ''
this.formDialog.data.nostr_identifier = ''
this.formDialog.data.payment_method = 'lightning'
},
closeReceiveDialog() {
const checker = this.receive.paymentChecker
dismissMsg()
clearInterval(paymentChecker)
setTimeout(() => {}, 10000)
if (this.paymentDismissMsg) {
this.paymentDismissMsg()
this.paymentDismissMsg = null
}
if (this.paymentWebsocket) {
this.paymentWebsocket.close()
this.paymentWebsocket = null
}
},
nameValidation(val) {
const regex = /[`!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?~]/g
@ -63,68 +102,104 @@ window.app = Vue.createApp({
const regex = /^[\w\.-]+@[a-zA-Z\d\.-]+\.[a-zA-Z]{2,}$/
return regex.test(val) || 'Please enter valid email.'
},
Invoice() {
axios
.post(`/events/api/v1/tickets/${event_id}`, {
name: this.formDialog.data.name,
email: this.formDialog.data.email,
promo_code: this.formDialog.data.promo_code || null
})
.then(response => {
this.paymentReq = response.data.payment_request
this.paymentCheck = response.data.payment_hash
dismissMsg = Quasar.Notify.create({
timeout: 0,
message: 'Waiting for payment...'
})
this.receive = {
show: true,
status: 'pending',
paymentReq: this.paymentReq
paymentSuccess(paymentHash) {
if (this.paymentDismissMsg) {
this.paymentDismissMsg()
this.paymentDismissMsg = null
}
this.paymentReq = null
this.formDialog.data.name = ''
this.formDialog.data.email = ''
this.formDialog.data.refund = ''
this.formDialog.data.nostr_identifier = ''
this.formDialog.data.payment_method = 'lightning'
Quasar.Notify.create({
type: 'positive',
message: 'Sent, thank you!',
icon: null
})
this.receive = {
show: false,
status: 'complete',
paymentReq: null,
isFiat: false
}
this.ticketLink = {
show: true,
data: {
link: `/events/ticket/${paymentHash}`
}
}
window.open(`/events/ticket/${paymentHash}`, '_blank', 'noopener')
},
async createInvoice() {
try {
const {data} = await LNbits.api.request(
'POST',
`/events/api/v1/tickets/${this.eventId}`,
null,
{
name: this.formDialog.data.name,
email: this.formDialog.data.email,
promo_code: this.formDialog.data.promo_code || null,
refund_address: this.formDialog.data.refund || null,
nostr_identifier: this.formDialog.data.nostr_identifier || null,
payment_method: this.formDialog.data.payment_method
}
paymentChecker = setInterval(() => {
axios
.post(`/events/api/v1/tickets/${event_id}/${this.paymentCheck}`, {
event: event_id,
event_name: event_name,
name: this.formDialog.data.name,
email: this.formDialog.data.email
})
.then(res => {
if (res.data.paid) {
clearInterval(paymentChecker)
dismissMsg()
this.formDialog.data.name = ''
this.formDialog.data.email = ''
)
const isFiat = Boolean(data.is_fiat)
this.paymentReq = isFiat
? data.fiat_payment_request || null
: data.payment_request
this.paymentHash = data.payment_hash
Quasar.Notify.create({
type: 'positive',
message: 'Sent, thank you!',
icon: null
})
this.receive = {
show: false,
status: 'complete',
paymentReq: null
}
this.ticketLink = {
show: true,
data: {
link: `/events/ticket/${res.data.ticket_id}`
}
}
setTimeout(() => {
window.location.href = `/events/ticket/${res.data.ticket_id}`
}, 5000)
}
})
.catch(LNbits.utils.notifyApiError)
}, 2000)
this.paymentDismissMsg = Quasar.Notify.create({
timeout: 0,
message: 'Waiting for payment...'
})
.catch(LNbits.utils.notifyApiError)
this.receive = {
show: true,
status: 'pending',
paymentReq: this.paymentReq,
isFiat
}
if (isFiat && this.paymentReq) {
window.open(this.paymentReq, '_blank', 'noopener')
}
this.paymentWatcher(this.paymentHash)
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
paymentWatcher(paymentHash) {
if (this.paymentWebsocket) {
this.paymentWebsocket.close()
}
const url = new URL(window.location)
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
url.pathname = `/api/v1/ws/${paymentHash}`
url.search = ''
url.hash = ''
const ws = new WebSocket(url.toString())
this.paymentWebsocket = ws
ws.onmessage = event => {
const data = JSON.parse(event.data)
if (data.pending === false) {
this.paymentSuccess(paymentHash)
ws.close()
}
}
ws.onerror = error => {
console.error('WebSocket error:', error)
}
ws.onclose = () => {
if (this.paymentWebsocket === ws) {
this.paymentWebsocket = null
}
}
}
}
})
}

215
static/js/display.vue Normal file
View file

@ -0,0 +1,215 @@
<template id="page-events-display">
<div v-if="event" class="row q-col-gutter-md justify-center">
<div class="col-12 col-md-7 col-lg-6 q-gutter-y-md">
<q-card>
<q-img
v-if="event.banner"
:src="event.banner"
transition="slide-up"
></q-img>
<q-card-section class="q-pa-none">
<h3 class="q-my-none q-pa-lg" v-text="event.name"></h3>
<div v-html="event.info" class="q-pa-lg"></div>
</q-card-section>
</q-card>
<q-banner
v-if="event.status === 'proposed'"
class="bg-orange-2 text-orange-10"
rounded
>
<template v-slot:avatar>
<q-icon name="pending" color="orange-10"></q-icon>
</template>
<span class="text-weight-medium">Pending approval</span> &mdash; this
event is awaiting an admin review and is not yet open for tickets.
</q-banner>
<q-banner
v-else-if="event.status === 'rejected'"
class="bg-red-2 text-red-10"
rounded
>
<template v-slot:avatar>
<q-icon name="block" color="red-10"></q-icon>
</template>
<span class="text-weight-medium">Not approved</span> &mdash; this event
was reviewed and is not being published.
</q-banner>
<q-card v-if="event.status === 'approved'" class="q-pa-lg">
<q-card-section class="q-pa-none">
<h5 class="q-mt-none">Buy Ticket</h5>
<q-form @submit="createInvoice()" class="q-gutter-md">
<div class="row">
<div class="col-12">
<q-input
filled
dense
v-model.trim="formDialog.data.name"
label="Your name "
:rules="[val => nameValidation(val)]"
></q-input>
</div>
<div class="col-12 col-md-6 q-pr-sm">
<q-input
filled
dense
v-model.trim="formDialog.data.email"
type="email"
:label="
allowEmailNotifications
? 'Your email (ticket delivery) '
: 'Your email '
"
:rules="[
val => !!val || '* Required',
val => emailValidation(val)
]"
lazy-rules
></q-input>
</div>
<div v-if="allowNostrNotifications" class="col-12 col-md-6">
<q-input
filled
dense
v-model.trim="formDialog.data.nostr_identifier"
label="(optional) Nostr NIP-05 or npub"
hint="If provided, we'll DM your ticket link after payment."
></q-input>
</div>
</div>
<q-input
v-if="event.extra?.conditional"
filled
dense
v-model.trim="formDialog.data.refund"
label="Refund lnadress or LNURL "
:rules="[val => !!val || '* Required']"
lazy-rules
:hint="`If minimum tickets (${event.extra?.min_tickets}) are not met, refund will be sent.`"
></q-input>
<div class="row q-col-gutter-md q-pt-lg items-center">
<div v-if="allowFiatCheckout" class="col-auto">
<q-option-group
v-model="formDialog.data.payment_method"
inline
:options="[
{label: 'Lightning', value: 'lightning'},
{
label: fiatCheckoutLabel,
value: 'fiat'
}
]"
></q-option-group>
</div>
<div :class="allowFiatCheckout ? 'col-12 col-md-3' : 'col-12'">
<q-input
filled
dense
v-model.trim="formDialog.data.promo_code"
label="(optional) Promo Code "
></q-input>
</div>
</div>
<div class="row q-mt-lg">
<q-btn
unelevated
color="primary"
:disable="
formDialog.data.name == '' ||
formDialog.data.email == '' ||
Boolean(paymentReq)
"
type="submit"
>Submit</q-btn
>
<q-btn @click="resetForm" flat color="grey" class="q-ml-auto"
>Clear</q-btn
>
</div>
</q-form>
</q-card-section>
</q-card>
<q-card v-show="ticketLink.show" class="q-pa-lg">
<div class="text-center q-mb-lg">
<q-btn
unelevated
size="xl"
:href="ticketLink.data.link"
target="_blank"
color="primary"
type="a"
>Link to your ticket!</q-btn
>
</div>
</q-card>
</div>
<q-dialog v-model="receive.show" position="top" @hide="closeReceiveDialog">
<q-card
v-if="!receive.paymentReq"
class="q-pa-lg q-pt-xl lnbits__dialog-card"
>
</q-card>
<q-card
v-else-if="receive.isFiat"
class="q-pa-lg q-pt-xl lnbits__dialog-card"
>
<div class="text-center q-mb-lg">
<div class="text-h6 q-mb-sm">Continue to checkout</div>
<div class="text-body2 text-grey-5 q-mb-lg">
Your fiat checkout opened in a new tab. If it did not, use the
button below.
</div>
<q-btn
unelevated
color="primary"
type="a"
:href="receive.paymentReq"
target="_blank"
rel="noopener"
>
Go to checkout
</q-btn>
</div>
<div class="row q-mt-lg">
<q-btn
outline
color="grey"
@click="utils.copyText(receive.paymentReq)"
>Copy payment link</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div>
</q-card>
<q-card v-else class="q-pa-lg q-pt-xl lnbits__dialog-card">
<div class="text-center q-mb-lg">
<lnbits-qrcode
:href="'lightning:' + receive.paymentReq"
:value="'LIGHTNING:' + receive.paymentReq.toUpperCase()"
></lnbits-qrcode>
</div>
<div class="row q-mt-lg">
<q-btn
outline
color="grey"
@click="utils.copyText(receive.paymentReq)"
>Copy invoice</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div>
</q-card>
</q-dialog>
</div>
<div v-else class="row q-col-gutter-md justify-center">
<div class="col-12 col-md-7 col-lg-6 q-gutter-y-md">
<q-card class="q-pa-lg">
<q-card-section class="q-pa-none">
<h3 class="q-my-none q-pa-lg" v-text="eventErrorLabel"></h3>
</q-card-section>
</q-card>
</div>
</div>
</template>

View file

@ -1,18 +1,64 @@
const mapEvents = function (obj) {
obj.date = LNbits.utils.formatTimestamp(obj.time)
obj.fsat = new Intl.NumberFormat(window.g.locale).format(obj.price_per_ticket)
obj.displayUrl = ['/events/', obj.id].join('')
return obj
}
window.app = Vue.createApp({
el: '#vue',
mixins: [windowMixin],
window.PageEvents = {
template: '#page-events',
data() {
return {
events: [],
tickets: [],
resendingTicketEmails: [],
currencies: [],
pendingEvents: [],
allUserEvents: [],
isAdmin: false,
republishing: false,
republishingMine: false,
settings: {
auto_approve: false
},
allUsersEventsTable: {
// Shown on the admin All Users' Events card. Includes the
// wallet owner (`wallet_user_id` resolved server-side) so
// cross-tenant rows are attributable to a user.
columns: [
{
name: 'wallet_user_id',
align: 'left',
label: 'Owner',
field: 'wallet_user_id'
},
{name: 'id', align: 'left', label: 'ID', field: 'id'},
{name: 'name', align: 'left', label: 'Name', field: 'name'},
{
name: 'event_start_date',
align: 'left',
label: 'Start date',
field: 'event_start_date'
},
{
name: 'event_end_date',
align: 'left',
label: 'End date',
field: 'event_end_date'
},
{
name: 'closing_date',
align: 'left',
label: 'Ticket close',
field: 'closing_date'
},
{
name: 'canceled',
align: 'left',
label: 'Canceled',
field: row => {
if (row.extra && row.extra.conditional && row.canceled) {
return 'Yes'
}
return 'No'
}
},
{name: 'status', align: 'left', label: 'Status', field: 'status'}
]
},
eventsTable: {
columns: [
{name: 'id', align: 'left', label: 'ID', field: 'id'},
@ -51,7 +97,7 @@ window.app = Vue.createApp({
align: 'left',
label: 'Price',
field: row => {
if (row.currency != 'sats') {
if (this.isFiatCurrency(row.currency)) {
return LNbits.utils.formatCurrency(
row.price_per_ticket.toFixed(2),
row.currency
@ -73,7 +119,8 @@ window.app = Vue.createApp({
field: 'sold'
},
{name: 'info', align: 'left', label: 'Info', field: 'info'},
{name: 'banner', align: 'left', label: 'Banner', field: 'banner'}
{name: 'banner', align: 'left', label: 'Banner', field: 'banner'},
{name: 'status', align: 'left', label: 'Status', field: 'status'}
],
pagination: {
rowsPerPage: 10
@ -105,31 +152,36 @@ window.app = Vue.createApp({
formDialog: {
show: false,
data: {
currency: 'sats',
allow_fiat: false,
fiat_currency: 'GBP',
extra: {
promo_codes: []
promo_codes: [],
notification_subject: '',
notification_body: ''
}
}
}
}
},
methods: {
isFiatCurrency(currency) {
return !['sat', 'sats'].includes((currency || '').toLowerCase())
},
getTickets() {
LNbits.api
.request(
'GET',
'/events/api/v1/tickets?all_wallets=true',
this.g.user.wallets[0].inkey
this.g.user.wallets[0].adminkey
)
.then(response => {
this.tickets = response.data
.map(function (obj) {
return mapEvents(obj)
})
.filter(e => e.paid)
this.tickets = response.data.filter(e => e.paid)
})
},
deleteTicket(ticketId) {
const tickets = _.findWhere(this.tickets, {id: ticketId})
const wallet = _.findWhere(this.g.user.wallets, {id: tickets.wallet})
LNbits.utils
.confirmDialog('Are you sure you want to delete this ticket')
@ -138,16 +190,43 @@ window.app = Vue.createApp({
.request(
'DELETE',
'/events/api/v1/tickets/' + ticketId,
_.findWhere(this.g.user.wallets, {id: tickets.wallet}).inkey
wallet.adminkey
)
.then(response => {
this.tickets = _.reject(this.tickets, function (obj) {
return obj.id == ticketId
})
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
.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() {
@ -161,21 +240,190 @@ window.app = Vue.createApp({
this.g.user.wallets[0].inkey
)
.then(response => {
this.events = response.data.map(obj => {
return mapEvents(obj)
})
this.events = response.data
this.checkCanceledEvents()
})
// Admin probe: a 200 from /all means we're an LNbits admin.
LNbits.api
.request('GET', '/events/api/v1/events/all')
.then(response => {
this.isAdmin = true
const ownWalletIds = this.g.user.wallets.map(w => w.id)
this.allUserEvents = response.data.filter(
e => !ownWalletIds.includes(e.wallet)
)
})
.catch(() => {
this.isAdmin = false
this.allUserEvents = []
})
},
getSettings() {
LNbits.api
.request('GET', '/events/api/v1/events/settings')
.then(response => {
this.settings = response.data
})
.catch(() => {
// Not admin or settings unavailable; keep defaults.
})
},
saveSettings() {
LNbits.api
.request(
'PUT',
'/events/api/v1/events/settings',
null,
this.settings
)
.then(() => {
Quasar.Notify.create({type: 'positive', message: 'Settings saved'})
})
.catch(LNbits.utils.notifyApiError)
},
getPendingEvents() {
LNbits.api
.request('GET', '/events/api/v1/events/pending')
.then(response => {
this.pendingEvents = response.data
})
.catch(() => {
this.pendingEvents = []
})
},
approveEvent(eventId) {
LNbits.utils.confirmDialog('Approve this event?').onOk(() => {
LNbits.api
.request('PUT', '/events/api/v1/events/' + eventId + '/approve')
.then(() => {
Quasar.Notify.create({
type: 'positive',
message: 'Event approved'
})
this.getEvents()
this.getPendingEvents()
})
.catch(LNbits.utils.notifyApiError)
})
},
rejectEvent(eventId) {
LNbits.utils.confirmDialog('Reject this event?').onOk(() => {
LNbits.api
.request('PUT', '/events/api/v1/events/' + eventId + '/reject')
.then(() => {
Quasar.Notify.create({
type: 'positive',
message: 'Event rejected'
})
this.getEvents()
this.getPendingEvents()
})
.catch(LNbits.utils.notifyApiError)
})
},
republishAllEvents() {
LNbits.utils
.confirmDialog(
'Re-emit every approved event to Nostr relays? This is safe ' +
'to run multiple times but generates one event per approved row.'
)
.onOk(() => {
this.republishing = true
LNbits.api
.request('POST', '/events/api/v1/events/republish-all')
.then(response => {
Quasar.Notify.create({
type: 'positive',
message:
'Republished ' +
response.data.republished +
' of ' +
response.data.total +
' events'
})
})
.catch(LNbits.utils.notifyApiError)
.finally(() => {
this.republishing = false
})
})
},
republishMyEvents() {
LNbits.utils
.confirmDialog(
'Re-emit your approved events to Nostr relays?'
)
.onOk(() => {
this.republishingMine = true
LNbits.api
.request(
'POST',
'/events/api/v1/events/republish-mine?all_wallets=true',
this.g.user.wallets[0].adminkey
)
.then(response => {
Quasar.Notify.create({
type: 'positive',
message:
'Republished ' +
response.data.republished +
' of your ' +
response.data.total +
' events'
})
})
.catch(LNbits.utils.notifyApiError)
.finally(() => {
this.republishingMine = false
})
})
},
foldDateTime(day, time) {
// Combine separate date/time inputs into the wire format
// expected by the events extension: "YYYY-MM-DD" or
// "YYYY-MM-DDTHH:MM" (time is optional).
if (!day) return null
return time ? `${day}T${time}` : day
},
splitDateTime(value) {
// Inverse of foldDateTime: split a stored string back into the
// day/time pieces the form inputs bind to.
if (!value) return {day: '', time: ''}
const [day, time = ''] = value.split('T')
// Time inputs only accept HH:MM, drop any seconds we stored.
return {day, time: time.slice(0, 5)}
},
sendEventData() {
const wallet = _.findWhere(this.g.user.wallets, {
id: this.formDialog.data.wallet
})
const data = this.formDialog.data
if (data.extra && !data.extra.promo_codes) {
const data = {...this.formDialog.data}
data.event_start_date = this.foldDateTime(
data.event_start_day,
data.event_start_time
)
data.event_end_date = this.foldDateTime(
data.event_end_day,
data.event_end_time
)
delete data.event_start_day
delete data.event_start_time
delete data.event_end_day
delete data.event_end_time
if (data.extra?.promo_codes) {
data.extra.promo_codes = data.extra.promo_codes
.filter(code => code.trim() !== '')
.map(code => code.trim().toUpperCase())
.filter(code => code.code?.trim() !== '')
.map(code => ({
...code,
code: code.code.trim().toUpperCase()
}))
}
if (!this.isFiatCurrency(data.currency)) {
if (!data.allow_fiat) {
data.fiat_currency = 'GBP'
}
}
if (data.id) {
@ -187,13 +435,32 @@ window.app = Vue.createApp({
openEventDialog(data = false) {
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 {
this.formDialog.data = {
currency: 'sats',
allow_fiat: false,
fiat_currency: 'GBP',
event_start_day: '',
event_start_time: '',
event_end_day: '',
event_end_time: '',
extra: {
conditional: false,
min_tickets: 1,
promo_codes: []
email_notifications: false,
nostr_notifications: false,
promo_codes: [],
notification_subject: '',
notification_body: ''
}
}
}
@ -202,8 +469,15 @@ window.app = Vue.createApp({
resetEventDialog() {
this.formDialog.show = false
this.formDialog.data = {
currency: 'sats',
allow_fiat: false,
fiat_currency: 'GBP',
extra: {
promo_codes: []
email_notifications: false,
nostr_notifications: false,
promo_codes: [],
notification_subject: '',
notification_body: ''
}
}
},
@ -212,7 +486,7 @@ window.app = Vue.createApp({
LNbits.api
.request('POST', '/events/api/v1/events', wallet.adminkey, data)
.then(response => {
this.events.push(mapEvents(response.data))
this.events.push(response.data)
this.resetEventDialog()
})
.catch(LNbits.utils.notifyApiError)
@ -233,7 +507,7 @@ window.app = Vue.createApp({
this.events = _.reject(this.events, function (obj) {
return obj.id == data.id
})
this.events.push(mapEvents(response.data))
this.events.push(response.data)
this.resetEventDialog()
})
.catch(LNbits.utils.notifyApiError)
@ -255,7 +529,7 @@ window.app = Vue.createApp({
return obj.id == eventsId
})
})
.catch(LNbits.utils.notifyApiError(error))
.catch(LNbits.utils.notifyApiError)
})
},
exporteventsCSV() {
@ -279,9 +553,7 @@ window.app = Vue.createApp({
message: `Event ${ev.name} has been canceled and refunds have been issued.`,
icon: null
})
this.events = this.events.map(e =>
e.id === ev.id ? mapEvents(data) : e
)
this.events = this.events.map(e => (e.id === ev.id ? data : e))
}
})
}
@ -290,7 +562,13 @@ window.app = Vue.createApp({
if (this.g.user.wallets.length) {
this.getTickets()
this.getEvents()
this.currencies = await LNbits.api.getCurrencies()
this.getSettings()
this.getPendingEvents()
if (this.g.allowedCurrencies && this.g.allowedCurrencies.length > 0) {
this.currencies = ['sats', ...this.g.allowedCurrencies]
} else {
this.currencies = ['sats', ...this.g.currencies]
}
}
}
})
}

783
static/js/index.vue Normal file
View file

@ -0,0 +1,783 @@
<template id="page-events">
<div class="row q-col-gutter-md">
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
<q-card v-if="isAdmin">
<q-card-section>
<div class="row items-center justify-between">
<div class="col">
<span class="text-subtitle1">Settings</span>
</div>
<div class="col-auto">
<q-toggle
v-model="settings.auto_approve"
label="Auto-approve events"
@update:model-value="saveSettings"
></q-toggle>
</div>
</div>
<q-separator class="q-my-md"></q-separator>
<div class="row items-center justify-between">
<div class="col">
<span class="text-subtitle2">Republish to Nostr</span>
<div class="text-caption text-grey-7" style="color: #aaa">
Re-emit every approved event so connected clients pick
up the latest tag set. Useful after the extension
publisher changes (e.g. new tickets_* tags) so existing
events don't need a per-event edit.
</div>
</div>
<div class="col-auto">
<q-btn
outline
color="primary"
icon="cloud_upload"
label="Republish all"
:loading="republishing"
@click="republishAllEvents"
></q-btn>
</div>
</div>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<div class="row items-center q-gutter-sm">
<q-btn unelevated color="primary" @click="openEventDialog"
>New Event</q-btn
>
<q-btn
outline
color="primary"
icon="cloud_upload"
label="Republish mine"
:loading="republishingMine"
@click="republishMyEvents"
></q-btn>
</div>
<div class="text-caption q-mt-sm" style="color: #aaa">
Re-emit your approved events to Nostr relays. Useful after
a publisher upgrade or if a relay dropped your events.
</div>
</q-card-section>
</q-card>
<q-card v-if="pendingEvents.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">
<q-icon name="pending" color="orange" class="q-mr-sm"></q-icon>
Pending Approvals
<q-badge
color="orange"
:label="pendingEvents.length"
class="q-ml-sm"
></q-badge>
</h5>
</div>
</div>
<q-list separator>
<q-item v-for="event in pendingEvents" :key="event.id">
<q-item-section>
<q-item-label v-text="event.name"></q-item-label>
<q-item-label caption>
<span v-text="event.event_start_date"></span>
&mdash;
<span v-text="event.info.substring(0, 80)"></span
><span v-if="event.info.length > 80">...</span>
</q-item-label>
<q-item-label caption>
<span v-text="event.amount_tickets"></span> tickets &bull;
<span v-text="event.price_per_ticket"></span>
<span v-text="event.currency"></span>
</q-item-label>
</q-item-section>
<q-item-section side>
<div class="row q-gutter-sm">
<q-btn
dense
color="green"
icon="check_circle"
label="Approve"
size="sm"
@click="approveEvent(event.id)"
></q-btn>
<q-btn
dense
outline
color="red"
icon="block"
label="Reject"
size="sm"
@click="rejectEvent(event.id)"
></q-btn>
</div>
</q-item-section>
</q-item>
</q-list>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Events</h5>
</div>
<div class="col-auto">
<q-btn flat color="grey" @click="exporteventsCSV"
>Export to CSV</q-btn
>
</div>
</div>
<q-table
dense
flat
:rows="events"
row-key="id"
:columns="eventsTable.columns"
v-model:pagination="eventsTable.pagination"
>
<template v-slot:header="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">
<span v-text="col.label"></span>
</q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
size="sm"
color="accent"
round
dense
@click="props.expand = !props.expand"
:icon="props.expand ? 'expand_less' : 'expand_more'"
/>
</q-td>
<q-td auto-width>
<q-btn
unelevated
dense
size="xs"
icon="link"
:color="$q.dark.isActive ? 'grey-7' : 'grey-5'"
type="a"
:href="'/events/' + props.row.id"
target="_blank"
></q-btn>
<q-btn
unelevated
dense
size="xs"
icon="how_to_reg"
:color="$q.dark.isActive ? 'grey-7' : 'grey-5'"
type="a"
:href="'/events/register/' + props.row.id"
target="_blank"
class="q-ml-xs"
></q-btn>
</q-td>
<q-td auto-width>
<q-btn
v-if="isAdmin && props.row.status === 'proposed'"
flat
dense
size="xs"
@click="approveEvent(props.row.id)"
icon="check_circle"
color="green"
>
<q-tooltip>Approve</q-tooltip>
</q-btn>
<q-btn
v-if="isAdmin && props.row.status === 'proposed'"
flat
dense
size="xs"
@click="rejectEvent(props.row.id)"
icon="block"
color="red"
>
<q-tooltip>Reject</q-tooltip>
</q-btn>
<q-btn
flat
dense
size="xs"
@click="updateformDialog(props.row.id)"
icon="edit"
color="light-blue"
></q-btn>
<q-btn
flat
dense
size="xs"
@click="deleteEvent(props.row.id)"
icon="cancel"
color="pink"
class="q-ml-xs"
></q-btn>
</q-td>
<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>
<q-tr v-show="props.expand" :props="props">
<q-td colspan="100%">
<div class="q-pa-md">
<div class="text-subtitle1 q-mb-md">Promo codes</div>
<div class="column">
<div
v-if="props.row.extra.promo_codes.length == 0"
class="text-caption"
>
No promo codes for this event.
</div>
<div
v-for="(code, index) in props.row.extra.promo_codes"
:key="index"
class="row items-center q-col-gutter-sm q-mb-sm"
>
<div class="col-auto">
<q-chip
square
size="md"
clickable
@click="utils.copyText(code.code.toUpperCase())"
>
<q-avatar
icon="bookmark"
:color="code.active ? 'green' : 'grey'"
text-color="white"
></q-avatar>
<span v-text="code.code.toUpperCase()"></span>
</q-chip>
</div>
<div class="col-auto">
Discount:
<span v-text="code.discount_percent"></span>%
</div>
<div class="col-auto">
Status:
<span
:class="code.active ? 'text-green' : 'text-grey'"
v-text="code.active ? 'Active' : 'Inactive'"
></span>
</div>
</div>
</div>
</div>
</q-td>
</q-tr>
</template>
</q-table>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Tickets</h5>
</div>
<div class="col-auto">
<q-btn flat color="grey" @click="exportticketsCSV"
>Export to CSV</q-btn
>
</div>
</div>
<q-table
dense
flat
:rows="tickets"
row-key="id"
:columns="ticketsTable.columns"
v-model:pagination="ticketsTable.pagination"
>
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
<span v-text="col.label"></span>
</q-th>
<q-th auto-width></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
unelevated
dense
size="xs"
icon="local_activity"
:color="$q.dark.isActive ? 'grey-7' : 'grey-5'"
type="a"
:href="'/events/ticket/' + props.row.id"
target="_blank"
></q-btn>
</q-td>
<q-td auto-width>
<q-btn
flat
dense
size="xs"
@click="resendTicketEmail(props.row)"
icon="email"
color="primary"
:disable="!props.row.paid || !props.row.email"
:loading="resendingTicketEmails.includes(props.row.id)"
>
<q-tooltip>Resend ticket email</q-tooltip>
</q-btn>
</q-td>
<q-td v-for="col in props.cols" :key="col.name" :props="props">
<span v-text="col.value"></span>
</q-td>
<q-td auto-width>
<q-btn
flat
dense
size="xs"
@click="deleteTicket(props.row.id)"
icon="cancel"
color="pink"
></q-btn>
</q-td>
</q-tr>
</template>
</q-table>
</q-card-section>
</q-card>
<q-card v-if="isAdmin && allUserEvents.length > 0">
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">
All Users' Events
<q-badge
color="blue"
:label="allUserEvents.length"
class="q-ml-sm"
></q-badge>
</h5>
</div>
</div>
<q-table
dense
flat
:rows="allUserEvents"
row-key="id"
:columns="allUsersEventsTable.columns"
:pagination="{rowsPerPage: 10}"
>
<template v-slot:header="props">
<q-tr :props="props">
<q-th v-for="col in props.cols" :key="col.name" :props="props">
<span v-text="col.label"></span>
</q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td v-for="col in props.cols" :key="col.name" :props="props">
<q-badge
v-if="col.name === 'status'"
:color="col.value === 'approved' ? 'green' : col.value === 'proposed' ? 'orange' : 'red'"
:label="col.value"
></q-badge>
<span v-else v-text="col.value"></span>
</q-td>
</q-tr>
</template>
</q-table>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-md-4 col-lg-5 q-gutter-y-md">
<q-card>
<q-card-section>
<h6 class="text-subtitle1 ellipsis q-my-none">
<span v-text="SITE_TITLE"></span>
Events extension
</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list>
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="Info"
:content-inset-level="0.5"
>
<q-card>
<q-card-section>
<h5 class="text-subtitle1 q-my-none">
Events: Sell and register ticket waves for an event
</h5>
<p>
Events allows you to make a wave of tickets for an event,
each ticket is in the form of a unique QRcode, which the
user presents at registration. Events comes with a shareable
ticket scanner, which can be used to register attendees.<br />
<small>
Created by,
<a class="text-secondary" href="https://github.com/benarc"
>Ben Arc</a
>
</small>
</p>
</q-card-section>
</q-card>
<q-btn
flat
label="Swagger API"
type="a"
href="../docs#/events"
></q-btn>
</q-expansion-item>
</q-list>
</q-card-section>
</q-card>
</div>
<q-dialog v-model="formDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="sendEventData" class="q-gutter-md">
<div class="row">
<div class="col">
<q-input
filled
dense
v-model.trim="formDialog.data.name"
type="name"
label="Title of event "
></q-input>
</div>
<div class="col q-pl-sm">
<q-select
filled
dense
emit-value
v-model="formDialog.data.wallet"
:options="g.user.walletOptions"
label="Wallet *"
>
</q-select>
</div>
</div>
<q-input
filled
dense
v-model.trim="formDialog.data.info"
type="textarea"
label="Info about the event"
hint="Markdown supported"
></q-input>
<q-input
filled
dense
v-model.trim="formDialog.data.banner"
type="url"
label="Image URL"
hint="Optional banner image to display on the event page"
></q-input>
<div class="row q-mt-lg">
<div class="col-4">Ticket closing date</div>
<div class="col-8">
<q-input
filled
dense
v-model.trim="formDialog.data.closing_date"
type="date"
></q-input>
</div>
</div>
<div class="row q-col-gutter-sm">
<div class="col-4">Event begins</div>
<div class="col-5">
<q-input
filled
dense
v-model.trim="formDialog.data.event_start_day"
type="date"
></q-input>
</div>
<div class="col-3">
<q-input
filled
dense
v-model.trim="formDialog.data.event_start_time"
type="time"
hint="Optional"
></q-input>
</div>
</div>
<div class="row q-col-gutter-sm">
<div class="col-4">Event ends</div>
<div class="col-5">
<q-input
filled
dense
v-model.trim="formDialog.data.event_end_day"
type="date"
></q-input>
</div>
<div class="col-3">
<q-input
filled
dense
v-model.trim="formDialog.data.event_end_time"
type="time"
hint="Optional"
></q-input>
</div>
</div>
<div class="row q-col-gutter-sm">
<div class="col">
<q-select
filled
dense
v-model="formDialog.data.currency"
type="text"
label="Unit"
:options="currencies"
></q-select>
</div>
<div class="col">
<q-input
filled
dense
v-model.number="formDialog.data.amount_tickets"
type="number"
label="Amount of tickets "
></q-input>
</div>
<div class="col">
<q-input
filled
dense
v-model.number="formDialog.data.price_per_ticket"
type="number"
:label="'Price (' + formDialog.data.currency + ') *'"
:step="formDialog.data.currency != 'sats' ? '0.01' : '1'"
:mask="formDialog.data.currency != 'sats' ? '#.##' : '#'"
fill-mask="0"
reverse-fill-mask
:disable="formDialog.data.currency == null"
></q-input>
</div>
</div>
<q-toggle
v-model="formDialog.data.allow_fiat"
label="Allow fiat checkout"
left-label
hint="Lets attendees pay through a configured fiat provider using the event currency."
></q-toggle>
<q-select
v-if="
formDialog.data.allow_fiat &&
['sat', 'sats'].includes(
(formDialog.data.currency || '').toLowerCase()
)
"
filled
dense
v-model="formDialog.data.fiat_currency"
label="Fiat checkout currency"
:options="
currencies.filter(
c => !['sat', 'sats'].includes((c || '').toLowerCase())
)
"
></q-select>
<q-expansion-item
group="advanced"
icon="settings"
label="Advanced options"
>
<div class="row q-mt-lg">
<div class="text-subtitle1 q-mb-md">Conditional Events</div>
<div class="text-caption">
Make this event conditional if
<strong>minimum tickets</strong> are sold. User will be asked to
provide a Lightning Address or LNURL pay for refunds.
</div>
<div class="col-8">
<q-toggle
v-model="formDialog.data.extra.conditional"
label="Conditional Event"
left-label
></q-toggle>
</div>
<div class="col-4">
<q-input
filled
dense
v-model.number="formDialog.data.extra.min_tickets"
type="number"
label="Minimum Tickets"
:disable="!formDialog.data.extra.conditional"
></q-input>
</div>
</div>
<q-separator class="q-my-md"></q-separator>
<div class="text-subtitle1 q-mb-md">Promo Codes</div>
<div class="text-caption">
Allow users to enter a promo code for discounts.
</div>
<div
v-for="(code, index) in formDialog.data.extra.promo_codes"
:key="index"
class="row q-col-gutter-sm q-mt-md"
>
<q-input
class="col-8"
filled
dense
v-model.trim="formDialog.data.extra.promo_codes[index].code"
type="text"
label="Promo Code"
>
<template v-slot:before>
<q-checkbox
left-label
v-model="formDialog.data.extra.promo_codes[index].active"
checked-icon="radio_button_checked"
unchecked-icon="radio_button_unchecked"
></q-checkbox>
<q-tooltip>
<span
v-text="
formDialog.data.extra.promo_codes[index].active
? 'Active'
: 'Inactive'
"
></span>
</q-tooltip>
</template>
</q-input>
<q-input
class="col-4"
filled
dense
v-model.number="
formDialog.data.extra.promo_codes[index].discount_percent
"
type="number"
label="Discount (%)"
min="0"
max="100"
>
<template v-slot:after>
<q-btn
round
dense
flat
icon="delete"
@click="formDialog.data.extra.promo_codes.splice(index, 1)"
></q-btn>
</template>
</q-input>
</div>
<div class="col-12 q-mt-md">
<q-btn
@click="
formDialog.data.extra.promo_codes.push({
code: '',
discount_percent: 0,
active: true
})
"
>Add Promo Code</q-btn
>
</div>
<q-separator class="q-my-md"></q-separator>
<div class="text-subtitle1 q-mb-md">Ticket Delivery</div>
<div class="text-caption">
Send the paid ticket link automatically by email or Nostr DM.
</div>
<q-toggle
v-model="formDialog.data.extra.email_notifications"
label="Email notifications"
left-label
></q-toggle>
<q-toggle
v-model="formDialog.data.extra.nostr_notifications"
label="Nostr notifications"
left-label
></q-toggle>
</q-expansion-item>
<q-separator class="q-my-md"></q-separator>
<q-input
filled
dense
v-model.trim="formDialog.data.extra.notification_subject"
type="text"
label="Ticket notification subject"
hint="Used as the email subject when sending paid ticket links."
></q-input>
<q-input
filled
dense
v-model.trim="formDialog.data.extra.notification_body"
type="textarea"
label="Ticket notification body"
hint="Shown before the ticket link in the paid ticket notification."
></q-input>
<div class="row q-mt-lg">
<q-btn
v-if="formDialog.data.id"
unelevated
color="primary"
type="submit"
>Update Event</q-btn
>
<q-btn
v-else
unelevated
color="primary"
:disable="
formDialog.data.wallet == null ||
formDialog.data.name == null ||
formDialog.data.info == null ||
formDialog.data.closing_date == null ||
formDialog.data.event_start_day == null ||
formDialog.data.event_end_day == null ||
formDialog.data.amount_tickets == null ||
formDialog.data.price_per_ticket == null
"
type="submit"
>Create Event</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
</div>
</template>

View file

@ -1,28 +1,18 @@
const mapEvents = function (obj) {
obj.date = Quasar.date.formatDate(
new Date(obj.time * 1000),
'YYYY-MM-DD HH:mm'
)
obj.fsat = new Intl.NumberFormat(LOCALE).format(obj.amount)
obj.displayUrl = ['/events/', obj.id].join('')
return obj
}
window.app = Vue.createApp({
el: '#vue',
mixins: [windowMixin],
window.PageEventsRegister = {
template: '#page-events-register',
data() {
return {
tickets: [],
ticketsTable: {
columns: [
{name: 'id', align: 'left', label: 'ID', field: 'id'},
{name: 'name', align: 'left', label: 'Name', field: 'name'},
{name: 'email', align: 'left', label: 'Email', field: 'email'},
{
name: 'registered',
name: 'id',
align: 'left',
label: 'Registered',
field: 'registered'
label: 'ID',
field: 'id',
format: val => this.shortId(val)
}
],
pagination: {
@ -32,12 +22,20 @@ window.app = Vue.createApp({
sendCamera: {
show: false,
camera: 'auto'
}
},
lastScan: null
}
},
methods: {
hoverEmail(tmp) {
this.tickets.data.emailtemp = tmp
storageKey() {
return `events_scanned_${this.eventId}`
},
loadScannedTickets() {
this.tickets = Quasar.LocalStorage.getItem(this.storageKey()) || []
},
saveScannedTicket(ticket) {
this.tickets.unshift(ticket)
Quasar.LocalStorage.set(this.storageKey(), this.tickets)
},
closeCamera() {
this.sendCamera.show = false
@ -45,34 +43,32 @@ window.app = Vue.createApp({
showCamera() {
this.sendCamera.show = true
},
shortId(id) {
return id ? `${id.slice(0, 6)}...${id.slice(-4)}` : ''
},
decodeQR(res) {
this.sendCamera.show = false
const value = res[0].rawValue.split('//')[1]
LNbits.api
.request('GET', `/events/api/v1/register/ticket/${value}`)
.then(() => {
Quasar.Notify.create({
type: 'positive',
message: 'Registered!'
})
setTimeout(() => {
window.location.reload()
}, 2000)
})
.catch(LNbits.utils.notifyApiError)
},
getEventTickets() {
LNbits.api
.request('GET', `/events/api/v1/eventtickets/${event_id}`)
.request('PUT', `/events/api/v1/tickets/register/${value}`)
.then(response => {
this.tickets = response.data.map(obj => {
return mapEvents(obj)
})
this.saveScannedTicket(response.data)
this.lastScan = {success: true, ticket: response.data}
Quasar.Notify.create({type: 'positive', message: 'Registered!'})
})
.catch(error => {
this.lastScan = {
success: false,
ticketId: value,
error:
error.response?.data?.detail || error.message || 'Unknown error'
}
LNbits.utils.notifyApiError(error)
})
.catch(LNbits.utils.notifyApiError)
}
},
created() {
this.getEventTickets()
this.eventId = this.$route.params.id
this.loadScannedTickets()
}
})
}

86
static/js/register.vue Normal file
View file

@ -0,0 +1,86 @@
<template id="page-events-register">
<div class="row q-col-gutter-md justify-center">
<div class="col-12 col-md-7 col-lg-6 q-gutter-y-md">
<q-card class="q-pa-lg">
<q-card-section class="q-pa-none">
<center>
<h3 class="q-my-none">Registration</h3>
<br />
<br />
<q-btn unelevated color="primary" @click="showCamera" size="xl"
>Scan ticket</q-btn
>
</center>
</q-card-section>
</q-card>
<q-card
v-if="lastScan"
:class="lastScan.success ? 'bg-positive' : 'bg-negative'"
>
<q-card-section class="text-white">
<div v-if="lastScan.success">
<div class="text-h6 q-mb-sm">Registered</div>
<div><strong>Name:</strong> {{ lastScan.ticket.name }}</div>
<div><strong>Email:</strong> {{ lastScan.ticket.email }}</div>
<div><strong>Paid:</strong> {{ lastScan.ticket.paid }}</div>
<div><strong>ID:</strong> {{ shortId(lastScan.ticket.id) }}</div>
</div>
<div v-else>
<div class="text-h6 q-mb-sm">Failed</div>
<div>
<strong>Ticket ID:</strong> {{ shortId(lastScan.ticketId) }}
</div>
<div><strong>Error:</strong> {{ lastScan.error }}</div>
</div>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<q-table
dense
flat
:rows="tickets"
row-key="id"
:columns="ticketsTable.columns"
v-model:pagination="ticketsTable.pagination"
>
<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">
<span v-text="col.value"></span>
</q-td>
</q-tr>
</template>
</q-table>
</q-card-section>
</q-card>
</div>
<q-dialog v-model="sendCamera.show" position="top">
<q-card class="q-pa-lg q-pt-xl">
<div class="text-center q-mb-lg">
<qrcode-stream
@detect="decodeQR"
class="rounded-borders"
></qrcode-stream>
</div>
<div class="row q-mt-lg">
<q-btn @click="closeCamera" flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-card>
</q-dialog>
</div>
</template>

26
static/js/ticket.js Normal file
View file

@ -0,0 +1,26 @@
window.PageEventsTicket = {
template: '#page-events-ticket',
data() {
return {
ticketId: null,
ticket: null
}
},
methods: {
printWindow() {
window.print()
}
},
async created() {
this.ticketId = this.$route.params.id
try {
const {data} = await LNbits.api.request(
'GET',
`/events/api/v1/tickets/${this.ticketId}`
)
this.ticket = data
} catch (error) {
LNbits.utils.notifyApiError(error)
}
}
}

39
static/js/ticket.vue Normal file
View file

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

26
static/routes.json Normal file
View file

@ -0,0 +1,26 @@
[
{
"path": "/events/",
"name": "PageEvents",
"template": "/events/static/js/index.vue",
"component": "/events/static/js/index.js"
},
{
"path": "/events/:id",
"name": "PageEventsDisplay",
"template": "/events/static/js/display.vue",
"component": "/events/static/js/display.js"
},
{
"path": "/events/ticket/:id",
"name": "PageEventsTicket",
"template": "/events/static/js/ticket.vue",
"component": "/events/static/js/ticket.js"
},
{
"path": "/events/register/:id",
"name": "PageEventsRegister",
"template": "/events/static/js/register.vue",
"component": "/events/static/js/register.js"
}
]

View file

@ -4,8 +4,24 @@ from lnbits.core.models import Payment
from lnbits.tasks import register_invoice_listener
from loguru import logger
from .crud import get_ticket
from .services import set_ticket_paid
from .crud import get_ticket, get_tickets_by_payment_hash
from .models import Ticket
from .services import send_ticket_notification_in_background, set_ticket_paid
payment_listeners: dict[str, list[asyncio.Queue[Ticket]]] = {}
def register_payment_listener(payment_hash, queue: asyncio.Queue[Ticket]) -> None:
if payment_hash not in payment_listeners:
payment_listeners[payment_hash] = []
payment_listeners[payment_hash].append(queue)
def deregister_payment_listener(payment_hash, queue: asyncio.Queue[Ticket]) -> None:
if payment_hash in payment_listeners:
payment_listeners[payment_hash].remove(queue)
if not payment_listeners[payment_hash]:
del payment_listeners[payment_hash]
async def wait_for_paid_invoices():
@ -21,13 +37,32 @@ async def on_invoice_paid(payment: Payment) -> None:
if not payment.extra or "events" != payment.extra.get("tag"):
return
if not payment.extra.get("name") or not payment.extra.get("email"):
logger.warning(f"Ticket {payment.payment_hash} missing name or email.")
# Multi-ticket purchases land as N rows sharing this payment_hash;
# each one needs to be marked paid + counted against capacity, and
# each gets its own buyer notification (mostly a no-op when all
# rows are owned by the same buyer, but cheap and consistent).
tickets = await get_tickets_by_payment_hash(payment.payment_hash)
if not tickets:
# Backstop for any legacy row created before the payment_hash
# column was populated by the migration backfill.
legacy = await get_ticket(payment.payment_hash)
if legacy:
tickets = [legacy]
if not tickets:
logger.warning(f"No tickets for payment {payment.payment_hash}.")
return
ticket = await get_ticket(payment.payment_hash)
if not ticket:
logger.warning(f"Ticket for payment {payment.payment_hash} not found.")
return
paid_tickets: list[Ticket] = []
for ticket in tickets:
paid_tickets.append(await set_ticket_paid(ticket))
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):
for paid_ticket_queue in payment_listeners[payment.payment_hash]:
paid_ticket_queue.put_nowait(paid_tickets[0])

View file

@ -1,25 +0,0 @@
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="Info"
:content-inset-level="0.5"
>
<q-card>
<q-card-section>
<h5 class="text-subtitle1 q-my-none">
Events: Sell and register ticket waves for an event
</h5>
<p>
Events allows you to make a wave of tickets for an event, each ticket is
in the form of a unique QRcode, which the user presents at registration.
Events comes with a shareable ticket scanner, which can be used to
register attendees.<br />
<small>
Created by,
<a class="text-secondary" href="https://github.com/benarc">Ben Arc</a>
</small>
</p>
</q-card-section>
</q-card>
<q-btn flat label="Swagger API" type="a" href="../docs#/events"></q-btn>
</q-expansion-item>

View file

@ -1,116 +0,0 @@
{% extends "public.html" %} {% block page %}
<div class="row q-col-gutter-md justify-center">
<div class="col-12 col-md-7 col-lg-6 q-gutter-y-md">
<q-card>
<q-img v-if="banner" :src="banner" transition="slide-up"></q-img>
<q-card-section class="q-pa-none">
<h3 class="q-my-none q-pa-lg">{{ event_name }}</h3>
<br />
<div v-html="formatDescription" class="q-pa-md"></div>
<br />
</q-card-section>
</q-card>
<q-card class="q-pa-lg">
<q-card-section class="q-pa-none">
<h5 class="q-mt-none">Buy Ticket</h5>
<q-form @submit="Invoice()" class="q-gutter-md">
<q-input
filled
dense
v-model.trim="formDialog.data.name"
label="Your name "
:rules="[val => nameValidation(val)]"
></q-input>
<q-input
filled
dense
v-model.trim="formDialog.data.email"
type="email"
label="Your email "
:rules="[val => !!val || '* Required', val => emailValidation(val)]"
lazy-rules
></q-input>
<q-input
v-if="this.extra?.conditional"
filled
dense
v-model.trim="formDialog.data.refund"
label="Refund lnadress or LNURL "
:rules="[val => !!val || '* Required']"
lazy-rules
:hint="`If minimum tickets (${this.extra?.min_tickets}) are not met, refund will be sent.`"
></q-input>
<q-input
v-if="hasPromoCodes"
filled
dense
v-model.trim="formDialog.data.promo_code"
label="Apply Promo Code "
></q-input>
<div class="row q-mt-lg">
<q-btn
unelevated
color="primary"
:disable="formDialog.data.name == '' || formDialog.data.email == '' || Boolean(paymentReq)"
type="submit"
>Submit</q-btn
>
<q-btn @click="resetForm" flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card-section>
</q-card>
<q-card v-show="ticketLink.show" class="q-pa-lg">
<div class="text-center q-mb-lg">
<q-btn
unelevated
size="xl"
:href="ticketLink.data.link"
target="_blank"
color="primary"
type="a"
>Link to your ticket!</q-btn
>
<br /><br />
<p>You'll be redirected in a few moments...</p>
</div>
</q-card>
</div>
<q-dialog v-model="receive.show" position="top" @hide="closeReceiveDialog">
<q-card
v-if="!receive.paymentReq"
class="q-pa-lg q-pt-xl lnbits__dialog-card"
>
</q-card>
<q-card v-else class="q-pa-lg q-pt-xl lnbits__dialog-card">
<div class="text-center q-mb-lg">
<lnbits-qrcode
:href="'lightning:' + receive.paymentReq"
:value="'lightning:' + receive.paymentReq.toUpperCase()"
></lnbits-qrcode>
</div>
<div class="row q-mt-lg">
<q-btn outline color="grey" @click="copyText(receive.paymentReq)"
>Copy invoice</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div>
</q-card>
</q-dialog>
</div>
{% endblock %} {% block scripts %}
<script>
const event_id = '{{ event_id }}'
const event_name = '{{ event_name }}'
const event_info = '{{ event_info | tojson }}'
const event_banner = JSON.parse('{{ event_banner | tojson | safe }}')
const event_extra = JSON.parse('{{ event_extra | safe }}')
const has_promoCodes = {{ has_promo_codes | tojson }}
</script>
<script src="{{ static_url_for('events/static', path='js/display.js') }}"></script>
{% endblock %}

View file

@ -1,31 +0,0 @@
{% extends "public.html" %} {% block page %}
<div class="row q-col-gutter-md justify-center">
<div class="col-12 col-md-7 col-lg-6 q-gutter-y-md">
<q-card class="q-pa-lg">
<q-card-section class="q-pa-none">
<center>
<h3 class="q-my-none">{{ event_name }} error</h3>
<br />
<q-icon
name="warning"
class="text-grey"
style="font-size: 20rem"
></q-icon>
<h5 class="q-my-none">{{ event_error }}</h5>
<br />
</center>
</q-card-section>
</q-card>
</div>
</div>
{% endblock %} {% block scripts %}
<script>
window.app = Vue.createApp({
el: '#vue',
mixins: [windowMixin]
})
</script>
{% endblock %}

View file

@ -1,464 +0,0 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %}
<div class="row q-col-gutter-md">
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
<q-card>
<q-card-section>
<q-btn unelevated color="primary" @click="openEventDialog"
>New Event</q-btn
>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Events</h5>
</div>
<div class="col-auto">
<q-btn flat color="grey" @click="exporteventsCSV"
>Export to CSV</q-btn
>
</div>
</div>
<q-table
dense
flat
:rows="events"
row-key="id"
:columns="eventsTable.columns"
v-model:pagination="eventsTable.pagination"
>
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
<span v-text="col.label"></span>
</q-th>
<q-th auto-width></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
size="sm"
color="accent"
round
dense
@click="props.expand = !props.expand"
:icon="props.expand ? 'expand_less' : 'expand_more'"
/>
</q-td>
<q-td auto-width>
<q-btn
unelevated
dense
size="xs"
icon="link"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
type="a"
:href="props.row.displayUrl"
target="_blank"
></q-btn>
<q-btn
unelevated
dense
size="xs"
icon="how_to_reg"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
type="a"
:href="'/events/register/' + props.row.id"
target="_blank"
></q-btn>
</q-td>
<q-td v-for="col in props.cols" :key="col.name" :props="props">
<span v-text="col.value"></span>
</q-td>
<q-td auto-width>
<q-btn
flat
dense
size="xs"
@click="updateformDialog(props.row.id)"
icon="edit"
color="light-blue"
></q-btn>
</q-td>
<q-td auto-width>
<q-btn
flat
dense
size="xs"
@click="deleteEvent(props.row.id)"
icon="cancel"
color="pink"
></q-btn>
</q-td>
</q-tr>
<q-tr v-show="props.expand" :props="props">
<q-td colspan="100%">
<div class="q-pa-md">
<div class="text-subtitle1 q-mb-md">Promo codes</div>
<div class="column">
<div
v-if="props.row.extra.promo_codes.length == 0"
class="text-caption"
>
No promo codes for this event.
</div>
<div
v-for="(code, index) in props.row.extra.promo_codes"
:key="index"
class="row items-center q-col-gutter-sm q-mb-sm"
>
<div class="col-auto">
<q-chip
square
size="md"
clickable
@click="utils.copyText(code.code.toUpperCase())"
>
<q-avatar
icon="bookmark"
:color="code.active ? 'green' : 'grey'"
text-color="white"
></q-avatar>
<span v-text="code.code.toUpperCase()"></span>
</q-chip>
</div>
<div class="col-auto">
Discount: <span v-text="code.discount_percent"></span>%
</div>
<div class="col-auto">
Status:
<span
:class="code.active ? 'text-green' : 'text-grey'"
v-text="code.active ? 'Active' : 'Inactive'"
></span>
</div>
</div>
</div>
</div>
</q-td>
</q-tr>
</template>
</q-table>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Tickets</h5>
</div>
<div class="col-auto">
<q-btn flat color="grey" @click="exportticketsCSV"
>Export to CSV</q-btn
>
</div>
</div>
<q-table
dense
flat
:rows="tickets"
row-key="id"
:columns="ticketsTable.columns"
v-model:pagination="ticketsTable.pagination"
>
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
<span v-text="col.label"></span>
</q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
unelevated
dense
size="xs"
icon="local_activity"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
type="a"
:href="'/events/ticket/' + props.row.id"
target="_blank"
></q-btn>
</q-td>
<q-td v-for="col in props.cols" :key="col.name" :props="props">
<span v-text="col.value"></span>
</q-td>
<q-td auto-width>
<q-btn
flat
dense
size="xs"
@click="deleteTicket(props.row.id)"
icon="cancel"
color="pink"
></q-btn>
</q-td>
</q-tr>
</template>
</q-table>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-md-4 col-lg-5 q-gutter-y-md">
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-my-none">
{{SITE_TITLE}} Events extension
</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list> {% include "events/_api_docs.html" %} </q-list>
</q-card-section>
</q-card>
</div>
<q-dialog v-model="formDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="sendEventData" class="q-gutter-md">
<div class="row">
<div class="col">
<q-input
filled
dense
v-model.trim="formDialog.data.name"
type="name"
label="Title of event "
></q-input>
</div>
<div class="col q-pl-sm">
<q-select
filled
dense
emit-value
v-model="formDialog.data.wallet"
:options="g.user.walletOptions"
label="Wallet *"
>
</q-select>
</div>
</div>
<q-input
filled
dense
v-model.trim="formDialog.data.info"
type="textarea"
label="Info about the event"
hint="Markdown supported"
></q-input>
<q-input
filled
dense
v-model.trim="formDialog.data.banner"
type="url"
label="Image URL"
hint="Optional banner image to display on the event page"
></q-input>
<div class="row q-mt-lg">
<div class="col-4">Ticket closing date</div>
<div class="col-8">
<q-input
filled
dense
v-model.trim="formDialog.data.closing_date"
type="date"
></q-input>
</div>
</div>
<div class="row">
<div class="col-4">Event begins</div>
<div class="col-8">
<q-input
filled
dense
v-model.trim="formDialog.data.event_start_date"
type="date"
></q-input>
</div>
</div>
<div class="row">
<div class="col-4">Event ends</div>
<div class="col-8">
<q-input
filled
dense
v-model.trim="formDialog.data.event_end_date"
type="date"
></q-input>
</div>
</div>
<div class="row q-col-gutter-sm">
<div class="col">
<q-select
filled
dense
v-model="formDialog.data.currency"
type="text"
label="Unit"
:options="currencies"
></q-select>
</div>
<div class="col">
<q-input
filled
dense
v-model.number="formDialog.data.amount_tickets"
type="number"
label="Amount of tickets "
></q-input>
</div>
<div class="col">
<q-input
filled
dense
v-model.number="formDialog.data.price_per_ticket"
type="number"
:label="'Price (' + formDialog.data.currency + ') *'"
:step="formDialog.data.currency != 'sats' ? '0.01' : '1'"
:mask="formDialog.data.currency != 'sats' ? '#.##' : '#'"
fill-mask="0"
reverse-fill-mask
:disable="formDialog.data.currency == null"
></q-input>
</div>
</div>
<q-expansion-item
group="advanced"
icon="settings"
label="Advanced options"
>
<div class="row q-mt-lg">
<div class="text-subtitle1 q-mb-md">Conditional Events</div>
<div class="text-caption">
Make this event conditional if
<strong>minimum tickets</strong> are sold. User will be asked to
provide a Lightning Address or LNURL pay for refunds.
</div>
<div class="col-8">
<q-toggle
v-model="formDialog.data.extra.conditional"
label="Conditional Event"
left-label
></q-toggle>
</div>
<div class="col-4">
<q-input
filled
dense
v-model.number="formDialog.data.extra.min_tickets"
type="number"
label="Minimum Tickets"
:disable="!formDialog.data.extra.conditional"
></q-input>
</div>
</div>
<q-separator class="q-my-md"></q-separator>
<div class="text-subtitle1 q-mb-md">Promo Codes</div>
<div class="text-caption">
Allow users to enter a promo code for discounts.
</div>
<div
v-for="(code, index) in formDialog.data.extra.promo_codes"
:key="index"
class="row q-col-gutter-sm q-mt-md"
>
<q-input
class="col-8"
filled
dense
v-model.trim="formDialog.data.extra.promo_codes[index].code"
type="text"
label="Promo Code"
>
<template v-slot:before>
<q-checkbox
left-label
v-model="formDialog.data.extra.promo_codes[index].active"
checked-icon="radio_button_checked"
unchecked-icon="radio_button_unchecked"
></q-checkbox>
<q-tooltip>
<span
v-text="formDialog.data.extra.promo_codes[index].active ? 'Active' : 'Inactive'"
></span>
</q-tooltip>
</template>
</q-input>
<q-input
class="col-4"
filled
dense
v-model.number="formDialog.data.extra.promo_codes[index].discount_percent"
type="number"
label="Discount (%)"
min="0"
max="100"
>
<template v-slot:after>
<q-btn
round
dense
flat
icon="delete"
@click="formDialog.data.extra.promo_codes.splice(index, 1)"
></q-btn>
</template>
</q-input>
</div>
<div class="col-12 q-mt-md">
<q-btn
@click="formDialog.data.extra.promo_codes.push({code: '', discount_percent: 0, active: true})"
>Add Promo Code</q-btn
>
</div>
</q-expansion-item>
<div class="row q-mt-lg">
<q-btn
v-if="formDialog.data.id"
unelevated
color="primary"
type="submit"
>Update Event</q-btn
>
<q-btn
v-else
unelevated
color="primary"
:disable="formDialog.data.wallet == null || formDialog.data.name == null || formDialog.data.info == null || formDialog.data.closing_date == null || formDialog.data.event_start_date == null || formDialog.data.event_end_date == null || formDialog.data.amount_tickets == null || formDialog.data.price_per_ticket == null"
type="submit"
>Create Event</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<style>
.q-field__native span {
overflow-x: hidden;
}
</style>
<script src="{{ static_url_for('events/static', path='js/index.js') }}"></script>
{% endblock %}

View file

@ -1,84 +0,0 @@
{% extends "public.html" %} {% block page %}
<div class="row q-col-gutter-md justify-center">
<div class="col-12 col-md-7 col-lg-6 q-gutter-y-md">
<q-card class="q-pa-lg">
<q-card-section class="q-pa-none">
<center>
<h3 class="q-my-none">{{ event_name }} Registration</h3>
<br />
<br />
<q-btn unelevated color="primary" @click="showCamera" size="xl"
>Scan ticket</q-btn
>
</center>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<q-table
dense
flat
:rows="tickets"
row-key="id"
:columns="ticketsTable.columns"
v-model:pagination="ticketsTable.pagination"
>
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
<span v-text="col.label"></span>
</q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
unelevated
dense
size="xs"
icon="local_activity"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
type="a"
:href="'/events/ticket/' + props.row.id"
target="_blank"
></q-btn>
</q-td>
<q-td v-for="col in props.cols" :key="col.name" :props="props">
<span v-text="col.value"></span>
</q-td>
</q-tr>
</template>
</q-table>
</q-card-section>
</q-card>
</div>
<q-dialog v-model="sendCamera.show" position="top">
<q-card class="q-pa-lg q-pt-xl">
<div class="text-center q-mb-lg">
<qrcode-stream
@detect="decodeQR"
class="rounded-borders"
></qrcode-stream>
</div>
<div class="row q-mt-lg">
<q-btn @click="closeCamera" flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-card>
</q-dialog>
</div>
{% endblock %} {% block scripts %}
<script>
const event_id = '{{ event_id }}'
</script>
<script src="{{ static_url_for('events/static', path='js/register.js') }}"></script>
{% endblock %}

View file

@ -1,39 +0,0 @@
{% extends "public.html" %} {% block page %}
<div class="row q-col-gutter-md justify-center">
<div class="col-12 col-md-7 col-lg-6 q-gutter-y-md">
<q-card class="q-pa-lg">
<q-card-section class="q-pa-none">
<center>
<h3 class="q-my-none">{{ ticket_name }} Ticket</h3>
<br />
<h5 class="q-my-none">
Bookmark, print or screenshot this page,<br />
and present it for registration!
</h5>
<br />
<lnbits-qrcode
:value="'ticket://{{ ticket_id }}'"
:options="{width: 500}"
></lnbits-qrcode>
<br />
<q-btn @click="printWindow" color="grey" class="q-ml-auto">
<q-icon left size="3em" name="print"></q-icon> Print</q-btn
>
</center>
</q-card-section>
</q-card>
</div>
</div>
{% endblock %} {% block scripts %}
<script>
window.app = Vue.createApp({
el: '#vue',
mixins: [windowMixin],
methods: {
printWindow() {
window.print()
}
}
})
</script>
{% endblock %}

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
],
}

151
views.py
View file

@ -1,139 +1,24 @@
from datetime import date, datetime
from http import HTTPStatus
from fastapi import APIRouter, Depends, Request
from lnbits.core.models import User
from lnbits.decorators import check_user_exists
from lnbits.helpers import template_renderer
from starlette.exceptions import HTTPException
from starlette.responses import HTMLResponse
from .crud import get_event, get_ticket, purge_unpaid_tickets, update_event
from .services import refund_tickets
from fastapi import APIRouter, Depends
from lnbits.core.views.generic import index, index_public
from lnbits.decorators import check_account_id_exists
events_generic_router = APIRouter()
events_generic_router.add_api_route(
"/",
methods=["GET"],
endpoint=index,
dependencies=[Depends(check_account_id_exists)],
)
def events_renderer():
return template_renderer(["events/templates"])
events_generic_router.add_api_route(
"/{event_id}", methods=["GET"], endpoint=index_public
)
events_generic_router.add_api_route(
"/ticket/{ticket_id}", methods=["GET"], endpoint=index_public
)
@events_generic_router.get("/", response_class=HTMLResponse)
async def index(request: Request, user: User = Depends(check_user_exists)):
return events_renderer().TemplateResponse(
"events/index.html", {"request": request, "user": user.json()}
)
@events_generic_router.get("/{event_id}", response_class=HTMLResponse)
async def display(request: Request, event_id):
event = await get_event(event_id)
if not event:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
)
await purge_unpaid_tickets(event_id)
is_window_open = (
date.today() < datetime.strptime(event.closing_date, "%Y-%m-%d").date()
)
is_min_tickets_met = (
event.sold >= event.extra.min_tickets if event.extra.conditional else True
)
if event.amount_tickets < 1:
return events_renderer().TemplateResponse(
"events/error.html",
{
"request": request,
"event_name": event.name,
"event_error": "Sorry, tickets are sold out :(",
},
)
if event.extra.conditional and not is_min_tickets_met and not is_window_open:
event.canceled = True
await update_event(event)
await refund_tickets(event_id)
return events_renderer().TemplateResponse(
"events/error.html",
{
"request": request,
"event_name": event.name,
"event_error": "Sorry, event was cancelled.",
},
)
if not is_window_open:
return events_renderer().TemplateResponse(
"events/error.html",
{
"request": request,
"event_name": event.name,
"event_error": "Sorry, ticket closing date has passed :(",
},
)
if len(event.extra.promo_codes) > 0:
has_promo_codes = True
else:
has_promo_codes = False
event.extra.promo_codes = []
return events_renderer().TemplateResponse(
"events/display.html",
{
"request": request,
"event_id": event_id,
"event_name": event.name,
"event_info": event.info,
"event_price": event.price_per_ticket,
"event_banner": event.banner,
"event_extra": event.extra.json(),
"has_promo_codes": has_promo_codes,
},
)
@events_generic_router.get("/ticket/{ticket_id}", response_class=HTMLResponse)
async def ticket(request: Request, ticket_id):
ticket = await get_ticket(ticket_id)
if not ticket:
raise HTTPException(
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."
)
return events_renderer().TemplateResponse(
"events/ticket.html",
{
"request": request,
"ticket_id": ticket_id,
"ticket_name": event.name,
"ticket_info": event.info,
},
)
@events_generic_router.get("/register/{event_id}", response_class=HTMLResponse)
async def register(request: Request, event_id):
event = await get_event(event_id)
if not event:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
)
return events_renderer().TemplateResponse(
"events/register.html",
{
"request": request,
"event_id": event_id,
"event_name": event.name,
"wallet_id": event.wallet,
},
)
events_generic_router.add_api_route(
"/register/{event_id}", methods=["GET"], endpoint=index_public
)

File diff suppressed because it is too large Load diff