From 2740d73678994a626781c51f1e64d81db84aa135 Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Fri, 24 Apr 2026 02:55:18 -0400 Subject: [PATCH 01/39] feat: add user_id ticket support and public events endpoint - Tickets can be created with user_id instead of name/email - name/email default to empty string in DB (not NULL-safe) - New endpoints: GET /api/v1/events/public, GET /api/v1/tickets/user/{user_id} - POST /api/v1/tickets/{event_id} accepts user_id in body - Migration m007 adds user_id column to tickets table - CreateTicket validates: either user_id or (name + email) required Co-Authored-By: Claude Opus 4.6 (1M context) --- crud.py | 27 +++++++++++++++++++++++- migrations.py | 8 ++++++++ models.py | 22 +++++++++++++++----- views_api.py | 57 ++++++++++++++++++++++++++++++++++++++++++++++++--- 4 files changed, 105 insertions(+), 9 deletions(-) diff --git a/crud.py b/crud.py index 3046f0e..6460f7e 100644 --- a/crud.py +++ b/crud.py @@ -1,4 +1,5 @@ from datetime import datetime, timedelta, timezone +from typing import Optional from lnbits.db import Database from lnbits.helpers import urlsafe_short_hash @@ -9,7 +10,13 @@ db = Database("ext_events") 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 = "", + email: str = "", + user_id: Optional[str] = None, + extra: Optional[dict] = None, ) -> Ticket: now = datetime.now(timezone.utc) ticket = Ticket( @@ -18,6 +25,7 @@ async def create_ticket( event=event, name=name, email=email, + user_id=user_id, registered=False, paid=False, reg_timestamp=now, @@ -102,6 +110,23 @@ async def get_events(wallet_ids: str | list[str]) -> list[Event]: ) +async def get_all_events() -> list[Event]: + """Get all events without wallet filtering (public endpoint).""" + return await db.fetchall( + "SELECT * FROM events.events ORDER BY time DESC", + model=Event, + ) + + +async def get_tickets_by_user_id(user_id: str) -> list[Ticket]: + """Get all tickets for a specific user by their user_id.""" + return await db.fetchall( + "SELECT * FROM events.ticket WHERE user_id = :user_id ORDER BY time DESC", + {"user_id": user_id}, + model=Ticket, + ) + + async def delete_event(event_id: str) -> None: await db.execute("DELETE FROM events.events WHERE id = :id", {"id": event_id}) diff --git a/migrations.py b/migrations.py index 6f8e838..1d9cfe4 100644 --- a/migrations.py +++ b/migrations.py @@ -175,3 +175,11 @@ 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_user_id(db): + """ + Add user_id column to tickets table. + Allows ticket purchase via LNbits user-id without name/email. + """ + await db.execute("ALTER TABLE events.ticket ADD COLUMN user_id TEXT;") diff --git a/models.py b/models.py index f82890e..1073806 100644 --- a/models.py +++ b/models.py @@ -1,7 +1,8 @@ from datetime import datetime +from typing import Optional from fastapi import Query -from pydantic import BaseModel, EmailStr, Field, validator +from pydantic import BaseModel, EmailStr, Field, root_validator, validator class PromoCode(BaseModel): @@ -66,18 +67,29 @@ class TicketExtra(BaseModel): class CreateTicket(BaseModel): - name: str - email: EmailStr + name: Optional[str] = None + email: Optional[str] = None + user_id: Optional[str] = None promo_code: str | None = None refund_address: str | None = None + @root_validator + def validate_identifiers(cls, values): + user_id = values.get("user_id") + name = values.get("name") + email = values.get("email") + if not user_id and not (name and email): + raise ValueError("Either user_id or both name and email must be provided") + return values + class Ticket(BaseModel): id: str wallet: str event: str - name: str - email: str + name: str = "" + email: str = "" + user_id: Optional[str] = None registered: bool paid: bool time: datetime diff --git a/views_api.py b/views_api.py index 58c9bca..e06bfde 100644 --- a/views_api.py +++ b/views_api.py @@ -21,11 +21,13 @@ from .crud import ( delete_event, delete_event_tickets, delete_ticket, + get_all_events, get_event, get_event_tickets, get_events, get_ticket, get_tickets, + get_tickets_by_user_id, update_event, update_ticket, ) @@ -49,6 +51,12 @@ async def api_events( return [event.dict() for event in await get_events(wallet_ids)] +@events_api_router.get("/api/v1/events/public") +async def api_events_public(): + """Retrieve all events (read-only, no auth required).""" + return [event.dict() for event in await get_all_events()] + + @events_api_router.post("/api/v1/events") @events_api_router.put("/api/v1/events/{event_id}") async def api_event_create( @@ -131,14 +139,20 @@ async def api_tickets( return await get_tickets(wallet_ids) +@events_api_router.get("/api/v1/tickets/user/{user_id}") +async def api_tickets_by_user_id(user_id: str) -> list[Ticket]: + """Get all tickets for a specific user by their user_id.""" + return await get_tickets_by_user_id(user_id) + + @events_api_router.post("/api/v1/tickets/{event_id}") async def api_ticket_create(event_id: str, data: CreateTicket): - name = data.name - email = data.email + if data.user_id: + return await api_ticket_make_ticket_with_user_id(event_id, data.user_id) promo_code = data.promo_code.upper() if data.promo_code else None refund_address = data.refund_address return await api_ticket_make_ticket( - event_id, name, email, promo_code, refund_address + event_id, data.name, data.email, promo_code, refund_address ) @@ -198,6 +212,43 @@ async def api_ticket_make_ticket(event_id, name, email, promo_code, refund_addre return {"payment_hash": payment.payment_hash, "payment_request": payment.bolt11} +async def api_ticket_make_ticket_with_user_id(event_id: str, user_id: str): + event = await get_event(event_id) + if not event: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist." + ) + + price = event.price_per_ticket + extra = {"tag": "events", "user_id": user_id} + + if event.currency != "sats": + extra["fiat"] = True + extra["currency"] = event.currency + extra["fiatAmount"] = event.price_per_ticket + extra["rate"] = await get_fiat_rate_satoshis(event.currency) + price = await fiat_amount_as_satoshis(event.price_per_ticket, event.currency) + + try: + payment = await create_invoice( + wallet_id=event.wallet, + amount=price, + memo=f"{event_id}", + extra=extra, + ) + await create_ticket( + payment_hash=payment.payment_hash, + wallet=event.wallet, + event=event.id, + user_id=user_id, + ) + except Exception as exc: + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(exc) + ) from exc + return {"payment_hash": payment.payment_hash, "payment_request": payment.bolt11} + + @events_api_router.post("/api/v1/tickets/{event_id}/{payment_hash}") async def api_ticket_send_ticket(event_id, payment_hash): event = await get_event(event_id) From 9e477ac959555c06bfcc771265e26757da4e6192 Mon Sep 17 00:00:00 2001 From: Tiago Vasconcelos Date: Mon, 4 May 2026 16:01:53 +0100 Subject: [PATCH 02/39] feat: make events dynamic (#43) --------- Co-authored-by: dni --- __init__.py | 3 +- config.json | 6 +- models.py | 25 ++ static/js/display.js | 187 +++++++----- static/js/display.vue | 125 ++++++++ static/js/index.js | 51 ++-- static/js/index.vue | 511 ++++++++++++++++++++++++++++++++ static/js/register.js | 36 +-- static/js/register.vue | 64 ++++ static/js/ticket.js | 26 ++ static/js/ticket.vue | 27 ++ static/routes.json | 26 ++ tasks.py | 25 +- templates/events/_api_docs.html | 25 -- templates/events/display.html | 116 -------- templates/events/error.html | 31 -- templates/events/index.html | 464 ----------------------------- templates/events/register.html | 84 ------ templates/events/ticket.html | 39 --- views.py | 151 ++-------- views_api.py | 293 ++++++++++-------- 21 files changed, 1168 insertions(+), 1147 deletions(-) create mode 100644 static/js/display.vue create mode 100644 static/js/index.vue create mode 100644 static/js/register.vue create mode 100644 static/js/ticket.js create mode 100644 static/js/ticket.vue create mode 100644 static/routes.json delete mode 100644 templates/events/_api_docs.html delete mode 100644 templates/events/display.html delete mode 100644 templates/events/error.html delete mode 100644 templates/events/index.html delete mode 100644 templates/events/register.html delete mode 100644 templates/events/ticket.html diff --git a/__init__.py b/__init__.py index 14c1590..ef37528 100644 --- a/__init__.py +++ b/__init__.py @@ -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 = [ { diff --git a/config.json b/config.json index 8a9a61c..11bd0b1 100644 --- a/config.json +++ b/config.json @@ -1,12 +1,12 @@ { "id": "events", - "version": "1.2.1", + "version": "1.3.0", "name": "Events", "repo": "https://github.com/lnbits/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" }, diff --git a/models.py b/models.py index f82890e..14547d1 100644 --- a/models.py +++ b/models.py @@ -58,6 +58,17 @@ class Event(BaseModel): extra: EventExtra = Field(default_factory=EventExtra) +class PublicEvent(BaseModel): + id: str + name: str + info: str + closing_date: str + canceled: bool + event_start_date: str + event_end_date: str + banner: str | None + + class TicketExtra(BaseModel): applied_promo_code: str | None = None sats_paid: int | None = None @@ -83,3 +94,17 @@ class Ticket(BaseModel): time: datetime reg_timestamp: datetime extra: TicketExtra = Field(default_factory=TicketExtra) + + +class PublicTicket(BaseModel): + event: str + name: str + registered: bool + paid: bool + time: datetime + reg_timestamp: datetime + + +class TicketPaymentRequest(BaseModel): + payment_hash: str + payment_request: str diff --git a/static/js/display.js b/static/js/display.js index 6098e5a..a652ba5 100644 --- a/static/js/display.js +++ b/static/js/display.js @@ -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: { @@ -23,15 +24,14 @@ window.app = Vue.createApp({ show: false, status: 'pending', paymentReq: null - } + }, + 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() { @@ -39,6 +39,18 @@ window.app = Vue.createApp({ } }, 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 = '' @@ -47,10 +59,14 @@ window.app = Vue.createApp({ }, 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 +79,93 @@ 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 = '' + 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/${paymentHash}` + } + } + setTimeout(() => { + window.location.href = `/events/ticket/${paymentHash}` + }, 5000) + }, + 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 } - 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 = '' + ) + this.paymentReq = 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 + } + this.websocketListener(this.paymentHash) + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + websocketListener(paymentHash) { + if (this.paymentWebsocket) { + this.paymentWebsocket.close() + } + + const url = new URL(window.location) + url.protocol = url.protocol === 'https:' ? 'wss' : 'ws' + url.pathname = `/events/api/v1/tickets/ws/${paymentHash}` + url.search = '' + url.hash = '' + + const ws = new WebSocket(url) + this.paymentWebsocket = ws + + ws.onmessage = event => { + const data = JSON.parse(event.data) + if (data.paid) { + this.paymentSuccess(paymentHash) + ws.close() + } + } + ws.onerror = error => { + console.error('WebSocket error:', error) + } + ws.onclose = () => { + if (this.paymentWebsocket === ws) { + this.paymentWebsocket = null + } + } } } -}) +} diff --git a/static/js/display.vue b/static/js/display.vue new file mode 100644 index 0000000..3f80180 --- /dev/null +++ b/static/js/display.vue @@ -0,0 +1,125 @@ + diff --git a/static/js/index.js b/static/js/index.js index d26133c..ca34383 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -1,13 +1,5 @@ -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: [], @@ -105,6 +97,7 @@ window.app = Vue.createApp({ formDialog: { show: false, data: { + currency: 'sats', extra: { promo_codes: [] } @@ -118,18 +111,15 @@ window.app = Vue.createApp({ .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 +128,14 @@ 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) }) }, exportticketsCSV() { @@ -161,9 +149,7 @@ 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() }) }, @@ -190,6 +176,7 @@ window.app = Vue.createApp({ this.formDialog.data = {...data} } else { this.formDialog.data = { + currency: 'sats', extra: { conditional: false, min_tickets: 1, @@ -212,7 +199,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 +220,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 +242,7 @@ window.app = Vue.createApp({ return obj.id == eventsId }) }) - .catch(LNbits.utils.notifyApiError(error)) + .catch(LNbits.utils.notifyApiError) }) }, exporteventsCSV() { @@ -279,9 +266,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 +275,11 @@ window.app = Vue.createApp({ if (this.g.user.wallets.length) { this.getTickets() this.getEvents() - this.currencies = await LNbits.api.getCurrencies() + if (this.g.allowedCurrencies && this.g.allowedCurrencies.length > 0) { + this.currencies = ['sats', ...this.g.allowedCurrencies] + } else { + this.currencies = ['sats', ...this.g.currencies] + } } } -}) +} diff --git a/static/js/index.vue b/static/js/index.vue new file mode 100644 index 0000000..174f0c1 --- /dev/null +++ b/static/js/index.vue @@ -0,0 +1,511 @@ + diff --git a/static/js/register.js b/static/js/register.js index a7ab92f..76ccbcb 100644 --- a/static/js/register.js +++ b/static/js/register.js @@ -1,28 +1,22 @@ -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: 'registered', align: 'left', label: 'Registered', field: 'registered' + }, + { + name: 'paid', + align: 'left', + label: 'Paid', + field: 'paid' } ], pagination: { @@ -49,30 +43,26 @@ window.app = Vue.createApp({ this.sendCamera.show = false const value = res[0].rawValue.split('//')[1] LNbits.api - .request('GET', `/events/api/v1/register/ticket/${value}`) + .request('PUT', `/events/api/v1/tickets/register/${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('GET', `/events/api/v1/events/${this.eventId}/tickets`) .then(response => { - this.tickets = response.data.map(obj => { - return mapEvents(obj) - }) + this.tickets = response.data }) .catch(LNbits.utils.notifyApiError) } }, created() { + this.eventId = this.$route.params.id this.getEventTickets() } -}) +} diff --git a/static/js/register.vue b/static/js/register.vue new file mode 100644 index 0000000..9b40537 --- /dev/null +++ b/static/js/register.vue @@ -0,0 +1,64 @@ + diff --git a/static/js/ticket.js b/static/js/ticket.js new file mode 100644 index 0000000..3085f47 --- /dev/null +++ b/static/js/ticket.js @@ -0,0 +1,26 @@ +window.PageEventsTicket = { + template: '#page-events-ticket', + data() { + return { + ticketId: null, + ticketName: 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.ticketName = data.ticket_name + } catch (error) { + LNbits.utils.notifyApiError(error) + } + } +} diff --git a/static/js/ticket.vue b/static/js/ticket.vue new file mode 100644 index 0000000..7fdecce --- /dev/null +++ b/static/js/ticket.vue @@ -0,0 +1,27 @@ + diff --git a/static/routes.json b/static/routes.json new file mode 100644 index 0000000..ae46a9a --- /dev/null +++ b/static/routes.json @@ -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" + } +] diff --git a/tasks.py b/tasks.py index f7300bb..651994e 100644 --- a/tasks.py +++ b/tasks.py @@ -5,8 +5,24 @@ from lnbits.tasks import register_invoice_listener from loguru import logger from .crud import get_ticket +from .models import Ticket from .services import 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(): invoice_queue = asyncio.Queue() @@ -21,13 +37,12 @@ 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.") - return - ticket = await get_ticket(payment.payment_hash) if not ticket: logger.warning(f"Ticket for payment {payment.payment_hash} not found.") return - await set_ticket_paid(ticket) + ticket = await set_ticket_paid(ticket) + if payment_listeners.get(payment.payment_hash): + for paid_ticket_queue in payment_listeners[payment.payment_hash]: + paid_ticket_queue.put_nowait(ticket) diff --git a/templates/events/_api_docs.html b/templates/events/_api_docs.html deleted file mode 100644 index dbf0131..0000000 --- a/templates/events/_api_docs.html +++ /dev/null @@ -1,25 +0,0 @@ - - - -
- Events: Sell and register ticket waves for an event -
-

- 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.
- - Created by, - Ben Arc - -

-
-
- -
diff --git a/templates/events/display.html b/templates/events/display.html deleted file mode 100644 index 73d279d..0000000 --- a/templates/events/display.html +++ /dev/null @@ -1,116 +0,0 @@ -{% extends "public.html" %} {% block page %} -
-
- - - -

{{ event_name }}

-
-
-
-
-
- - -
Buy Ticket
- - - - - -
- Submit - Cancel -
-
-
-
- - -
- Link to your ticket! -

-

You'll be redirected in a few moments...

-
-
-
- - - - - -
- -
-
- Copy invoice - Close -
-
-
-
- -{% endblock %} {% block scripts %} - - -{% endblock %} diff --git a/templates/events/error.html b/templates/events/error.html deleted file mode 100644 index 3993db5..0000000 --- a/templates/events/error.html +++ /dev/null @@ -1,31 +0,0 @@ -{% extends "public.html" %} {% block page %} -
-
- - -
-

{{ event_name }} error

-
- - -
{{ event_error }}
-
-
-
-
-
-
-{% endblock %} {% block scripts %} - - - -{% endblock %} diff --git a/templates/events/index.html b/templates/events/index.html deleted file mode 100644 index 62752d1..0000000 --- a/templates/events/index.html +++ /dev/null @@ -1,464 +0,0 @@ -{% extends "base.html" %} {% from "macros.jinja" import window_vars with context -%} {% block page %} -
-
- - - New Event - - - - - -
-
-
Events
-
-
- Export to CSV -
-
- - - - -
-
- - - -
-
-
Tickets
-
-
- Export to CSV -
-
- - - - -
-
-
-
- - -
- {{SITE_TITLE}} Events extension -
-
- - - {% include "events/_api_docs.html" %} - -
-
- - - - -
-
- -
-
- - -
-
- - - -
-
Ticket closing date
-
- -
-
-
-
Event begins
-
- -
-
- -
-
Event ends
-
- -
-
-
-
- -
-
- -
-
- -
-
- -
-
Conditional Events
-
- Make this event conditional if - minimum tickets are sold. User will be asked to - provide a Lightning Address or LNURL pay for refunds. -
-
- -
-
- -
-
- -
Promo Codes
-
- Allow users to enter a promo code for discounts. -
- -
- - - - - - -
-
- Add Promo Code -
-
- -
- Update Event - Create Event - Cancel -
-
-
-
-
-{% endblock %} {% block scripts %} {{ window_vars(user) }} - - -{% endblock %} diff --git a/templates/events/register.html b/templates/events/register.html deleted file mode 100644 index 92589a3..0000000 --- a/templates/events/register.html +++ /dev/null @@ -1,84 +0,0 @@ -{% extends "public.html" %} {% block page %} - -
-
- - -
-

{{ event_name }} Registration

-
- -
- - Scan ticket -
-
-
- - - - - - - - - -
- - - -
- -
-
- Cancel -
-
-
-
-{% endblock %} {% block scripts %} - - -{% endblock %} diff --git a/templates/events/ticket.html b/templates/events/ticket.html deleted file mode 100644 index bcf7e82..0000000 --- a/templates/events/ticket.html +++ /dev/null @@ -1,39 +0,0 @@ -{% extends "public.html" %} {% block page %} -
-
- - -
-

{{ ticket_name }} Ticket

-
-
- Bookmark, print or screenshot this page,
- and present it for registration! -
-
- -
- - Print -
-
-
-
-
-{% endblock %} {% block scripts %} - -{% endblock %} diff --git a/views.py b/views.py index 0680dcc..4a3f142 100644 --- a/views.py +++ b/views.py @@ -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 +) diff --git a/views_api.py b/views_api.py index 58c9bca..d9789ec 100644 --- a/views_api.py +++ b/views_api.py @@ -1,8 +1,17 @@ +import asyncio from datetime import datetime, timezone from http import HTTPStatus +from typing import Any -from fastapi import APIRouter, Depends, Query -from lnbits.core.crud import get_standalone_payment, get_user +from fastapi import ( + APIRouter, + Depends, + HTTPException, + Query, + WebSocket, + WebSocketDisconnect, +) +from lnbits.core.crud import get_user from lnbits.core.models import WalletTypeInfo from lnbits.core.services import create_invoice from lnbits.decorators import ( @@ -13,7 +22,6 @@ from lnbits.utils.exchange_rates import ( fiat_amount_as_satoshis, get_fiat_rate_satoshis, ) -from starlette.exceptions import HTTPException from .crud import ( create_event, @@ -26,36 +34,79 @@ from .crud import ( get_events, get_ticket, get_tickets, + purge_unpaid_tickets, update_event, update_ticket, ) -from .models import CreateEvent, CreateTicket, Ticket -from .services import refund_tickets, set_ticket_paid +from .models import ( + CreateEvent, + CreateTicket, + Event, + PublicEvent, + PublicTicket, + Ticket, + TicketPaymentRequest, +) +from .services import refund_tickets +from .tasks import deregister_payment_listener, register_payment_listener -events_api_router = APIRouter() +events_api_router = APIRouter(prefix="/api/v1/events") +tickets_api_router = APIRouter(prefix="/api/v1/tickets") -@events_api_router.get("/api/v1/events") +@events_api_router.get("") async def api_events( all_wallets: bool = Query(False), wallet: WalletTypeInfo = Depends(require_invoice_key), -): +) -> list[Event]: wallet_ids = [wallet.wallet.id] if all_wallets: user = await get_user(wallet.wallet.user) wallet_ids = user.wallet_ids if user else [] - return [event.dict() for event in await get_events(wallet_ids)] + return await get_events(wallet_ids) -@events_api_router.post("/api/v1/events") -@events_api_router.put("/api/v1/events/{event_id}") +@events_api_router.get("/{event_id}", response_model=PublicEvent) +async def api_get_event(event_id: str) -> Event: + 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 = datetime.now(timezone.utc) < datetime.strptime( + event.closing_date, "%Y-%m-%d" + ).replace(tzinfo=timezone.utc) + is_min_tickets_met = ( + event.sold >= event.extra.min_tickets if event.extra.conditional else True + ) + if event.amount_tickets < 1: + raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is 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) + + raise HTTPException(status_code=HTTPStatus.GONE, detail="Event canceled.") + + if not is_window_open: + raise HTTPException( + status_code=HTTPStatus.GONE, detail="Ticket closing date has passed." + ) + + return event + + +@events_api_router.post("") +@events_api_router.put("/{event_id}") async def api_event_create( data: CreateEvent, wallet: WalletTypeInfo = Depends(require_admin_key), event_id: str | None = None, -): +) -> Event: if event_id: event = await get_event(event_id) if not event: @@ -73,14 +124,14 @@ async def api_event_create( else: event = await create_event(data) - return event.dict() + return event -@events_api_router.put("/api/v1/events/{event_id}/cancel") +@events_api_router.put("/{event_id}/cancel") async def api_event_cancel( event_id: str, wallet: WalletTypeInfo = Depends(require_admin_key), -): +) -> Event: event = await get_event(event_id) if not event: raise HTTPException( @@ -93,13 +144,13 @@ async def api_event_cancel( event = await update_event(event) await refund_tickets(event.id) - return event.dict() + return event -@events_api_router.delete("/api/v1/events/{event_id}") +@events_api_router.delete("/{event_id}") async def api_form_delete( event_id: str, wallet: WalletTypeInfo = Depends(require_admin_key) -): +) -> None: event = await get_event(event_id) if not event: raise HTTPException( @@ -111,47 +162,65 @@ async def api_form_delete( await delete_event(event_id) await delete_event_tickets(event_id) - return "", HTTPStatus.NO_CONTENT -#########Tickets########## +@events_api_router.get( + "/{event_id}/tickets", + response_model=list[PublicTicket], +) +async def api_event_tickets(event_id: str) -> list[Ticket]: + return await get_event_tickets(event_id) -@events_api_router.get("/api/v1/tickets") +@tickets_api_router.get("") async def api_tickets( all_wallets: bool = Query(False), - wallet: WalletTypeInfo = Depends(require_invoice_key), + key_info: WalletTypeInfo = Depends(require_admin_key), ) -> list[Ticket]: - wallet_ids = [wallet.wallet.id] + wallet_ids = [key_info.wallet.id] if all_wallets: - user = await get_user(wallet.wallet.user) + user = await get_user(key_info.wallet.user) wallet_ids = user.wallet_ids if user else [] return await get_tickets(wallet_ids) -@events_api_router.post("/api/v1/tickets/{event_id}") -async def api_ticket_create(event_id: str, data: CreateTicket): - name = data.name - email = data.email - promo_code = data.promo_code.upper() if data.promo_code else None - refund_address = data.refund_address - return await api_ticket_make_ticket( - event_id, name, email, promo_code, refund_address - ) +@tickets_api_router.get("/{ticket_id}", response_model=PublicTicket) +async def api_get_ticket(ticket_id: str) -> Ticket: + 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 ticket -@events_api_router.get("/api/v1/tickets/{event_id}/{name}/{email}") -async def api_ticket_make_ticket(event_id, name, email, promo_code, refund_address): +@tickets_api_router.post("/{event_id}") +async def api_ticket_create(event_id: str, data: CreateTicket) -> TicketPaymentRequest: event = await get_event(event_id) if not event: raise HTTPException( status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist." ) + if event.canceled: + raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is canceled.") + + if event.amount_tickets > 0 and event.sold >= event.amount_tickets: + raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is sold out.") + + name = data.name + email = data.email + promo_code = data.promo_code.upper() if data.promo_code else None + refund_address = data.refund_address price = event.price_per_ticket - extra = {"tag": "events", "name": name, "email": email} + extra: dict[str, Any] = {"tag": "events", "name": name, "email": email} if promo_code: # check if promo_code exists in event.extra.promo_codes @@ -172,84 +241,76 @@ async def api_ticket_make_ticket(event_id, name, email, promo_code, refund_addre price = await fiat_amount_as_satoshis(price, event.currency) - try: - payment = await create_invoice( - wallet_id=event.wallet, - amount=price, - memo=f"{event_id}", - extra=extra, - ) - await create_ticket( - payment_hash=payment.payment_hash, - wallet=event.wallet, - event=event.id, - name=name, - email=email, - extra={ - "applied_promo_code": promo_code, - "refund_address": refund_address, - "sats_paid": int(price), - }, - ) - except Exception as exc: - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(exc) - ) from exc - return {"payment_hash": payment.payment_hash, "payment_request": payment.bolt11} - - -@events_api_router.post("/api/v1/tickets/{event_id}/{payment_hash}") -async def api_ticket_send_ticket(event_id, payment_hash): - event = await get_event(event_id) - if not event: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, - detail="Event could not be fetched.", - ) - - ticket = await get_ticket(payment_hash) - if not ticket: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, - detail="Ticket could not be fetched.", - ) - payment = await get_standalone_payment(payment_hash, incoming=True) - assert payment - - if ticket.extra.applied_promo_code: - promo = next( - ( - pc - for pc in event.extra.promo_codes - if pc.code == ticket.extra.applied_promo_code - ), - None, - ) - if promo: - event.price_per_ticket *= 1 - promo.discount_percent / 100 - - price = ( - event.price_per_ticket * 1000 - if event.currency == "sats" - else await fiat_amount_as_satoshis(event.price_per_ticket, event.currency) - * 1000 + payment = await create_invoice( + wallet_id=event.wallet, + amount=price, + memo=f"{event_id}", + extra=extra, + ) + await create_ticket( + payment_hash=payment.payment_hash, + wallet=event.wallet, + event=event.id, + name=name, + email=email, + extra={ + "applied_promo_code": promo_code, + "refund_address": refund_address, + "sats_paid": int(price), + }, ) - # check if price is equal to payment.amount - lower_bound = price * 0.99 # 1% decrease - - if not payment.pending and abs(payment.amount) >= lower_bound: # allow 1% error - ticket.extra.sats_paid = int(payment.amount / 1000) - await set_ticket_paid(ticket) - return {"paid": True, "ticket_id": ticket.id} - - return {"paid": False} + return TicketPaymentRequest( + payment_hash=payment.payment_hash, payment_request=payment.bolt11 + ) -@events_api_router.delete("/api/v1/tickets/{ticket_id}") +@tickets_api_router.websocket("/ws/{payment_hash}") +async def websocket_endpoint(payment_hash: str, websocket: WebSocket) -> None: + await websocket.accept() + queue: asyncio.Queue[Ticket] = asyncio.Queue() + register_payment_listener(payment_hash, queue) + disconnect_task: asyncio.Task | None = None + payment_task: asyncio.Task | None = None + + try: + ticket = await get_ticket(payment_hash) + if ticket and ticket.paid: + await websocket.send_json({"paid": True}) + return + + while True: + disconnect_task = asyncio.create_task(websocket.receive_text()) + payment_task = asyncio.create_task(queue.get()) + done, pending = await asyncio.wait( + {disconnect_task, payment_task}, return_when=asyncio.FIRST_COMPLETED + ) + + for task in pending: + task.cancel() + + if disconnect_task in done: + try: + disconnect_task.result() + except WebSocketDisconnect: + pass + break + + ticket = payment_task.result() + await websocket.send_json({"paid": ticket.paid}) + if ticket.paid: + break + finally: + for pending_task in (disconnect_task, payment_task): + if pending_task and not pending_task.done(): + pending_task.cancel() + deregister_payment_listener(payment_hash, queue) + + +@tickets_api_router.delete("/{ticket_id}") async def api_ticket_delete( - ticket_id: str, wallet: WalletTypeInfo = Depends(require_invoice_key) -): + ticket_id: str, wallet: WalletTypeInfo = Depends(require_admin_key) +) -> None: ticket = await get_ticket(ticket_id) if not ticket: raise HTTPException( @@ -262,14 +323,8 @@ async def api_ticket_delete( await delete_ticket(ticket_id) -@events_api_router.get("/api/v1/eventtickets/{event_id}") -async def api_event_tickets(event_id: str) -> list[Ticket]: - return await get_event_tickets(event_id) - - -# TODO: PUT, updates db! @tal -@events_api_router.get("/api/v1/register/ticket/{ticket_id}") -async def api_event_register_ticket(ticket_id) -> list[Ticket]: +@tickets_api_router.put("/register/{ticket_id}", response_model=PublicTicket) +async def api_event_register_ticket(ticket_id) -> Ticket: ticket = await get_ticket(ticket_id) if not ticket: @@ -289,5 +344,5 @@ async def api_event_register_ticket(ticket_id) -> list[Ticket]: ticket.registered = True ticket.reg_timestamp = datetime.now(timezone.utc) - await update_ticket(ticket) - return await get_event_tickets(ticket.event) + ticket = await update_ticket(ticket) + return ticket From 4afc78d44d36650878728b7a6290504f1ac51fcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?dni=20=E2=9A=A1?= Date: Tue, 5 May 2026 10:45:14 +0200 Subject: [PATCH 03/39] 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 --- static/js/register.js | 58 +++++++++++++++++++++++------------------- static/js/register.vue | 22 ++++++++++++++++ views_api.py | 11 +------- 3 files changed, 55 insertions(+), 36 deletions(-) diff --git a/static/js/register.js b/static/js/register.js index 76ccbcb..f642493 100644 --- a/static/js/register.js +++ b/static/js/register.js @@ -6,17 +6,13 @@ window.PageEventsRegister = { ticketsTable: { columns: [ {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' - }, - { - name: 'paid', - align: 'left', - label: 'Paid', - field: 'paid' + label: 'ID', + field: 'id', + format: val => this.shortId(val) } ], pagination: { @@ -26,12 +22,20 @@ window.PageEventsRegister = { 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 @@ -39,30 +43,32 @@ window.PageEventsRegister = { 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('PUT', `/events/api/v1/tickets/register/${value}`) - .then(() => { - Quasar.Notify.create({ - type: 'positive', - message: 'Registered!' - }) - }) - .catch(LNbits.utils.notifyApiError) - }, - getEventTickets() { - LNbits.api - .request('GET', `/events/api/v1/events/${this.eventId}/tickets`) .then(response => { - this.tickets = response.data + 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.eventId = this.$route.params.id - this.getEventTickets() + this.loadScannedTickets() } } diff --git a/static/js/register.vue b/static/js/register.vue index 9b40537..8055348 100644 --- a/static/js/register.vue +++ b/static/js/register.vue @@ -16,6 +16,28 @@ + + +
+
Registered
+
Name: {{ lastScan.ticket.name }}
+
Email: {{ lastScan.ticket.email }}
+
Paid: {{ lastScan.ticket.paid }}
+
ID: {{ shortId(lastScan.ticket.id) }}
+
+
+
Failed
+
+ Ticket ID: {{ shortId(lastScan.ticketId) }} +
+
Error: {{ lastScan.error }}
+
+
+
+ list[Ticket]: - return await get_event_tickets(event_id) - - @tickets_api_router.get("") async def api_tickets( all_wallets: bool = Query(False), @@ -323,7 +314,7 @@ async def api_ticket_delete( await delete_ticket(ticket_id) -@tickets_api_router.put("/register/{ticket_id}", response_model=PublicTicket) +@tickets_api_router.put("/register/{ticket_id}") async def api_event_register_ticket(ticket_id) -> Ticket: ticket = await get_ticket(ticket_id) From 680b035ec95ae51d0c03ad90861055414190c669 Mon Sep 17 00:00:00 2001 From: Arc <33088785+arcbtc@users.noreply.github.com> Date: Thu, 7 May 2026 12:31:32 +0100 Subject: [PATCH 04/39] feat: add fiat checkout and nostr + email notification (#50) * feat: fiat and email/nostr notifications * make n bake --- migrations.py | 10 ++++ models.py | 20 ++++++- services.py | 86 +++++++++++++++++++++++++++++ static/js/display.js | 58 ++++++++++++++------ static/js/display.vue | 123 ++++++++++++++++++++++++++++++++---------- static/js/index.js | 25 +++++++-- static/js/index.vue | 27 ++++++++++ tasks.py | 3 +- views_api.py | 88 ++++++++++++++++++++++++++---- 9 files changed, 379 insertions(+), 61 deletions(-) diff --git a/migrations.py b/migrations.py index 6f8e838..a7e3e86 100644 --- a/migrations.py +++ b/migrations.py @@ -175,3 +175,13 @@ 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; + """) diff --git a/models.py b/models.py index 14547d1..7886600 100644 --- a/models.py +++ b/models.py @@ -24,6 +24,8 @@ 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 class CreateEvent(BaseModel): @@ -34,6 +36,7 @@ class CreateEvent(BaseModel): event_start_date: str event_end_date: str currency: str = "sat" + allow_fiat: bool = False amount_tickets: int = Query(..., ge=0) price_per_ticket: float = Query(..., ge=0) banner: str | None = None @@ -50,6 +53,7 @@ class Event(BaseModel): event_start_date: str event_end_date: str currency: str + allow_fiat: bool = False amount_tickets: int price_per_ticket: float time: datetime @@ -66,13 +70,21 @@ class PublicEvent(BaseModel): canceled: bool event_start_date: str event_end_date: str + currency: str + allow_fiat: bool = False + price_per_ticket: float banner: str | None + extra: EventExtra = Field(default_factory=EventExtra) 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 @@ -81,6 +93,9 @@ class CreateTicket(BaseModel): email: EmailStr 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 class Ticket(BaseModel): @@ -107,4 +122,7 @@ class PublicTicket(BaseModel): class TicketPaymentRequest(BaseModel): payment_hash: str - payment_request: str + payment_request: str | None = None + fiat_payment_request: str | None = None + fiat_provider: str | None = None + is_fiat: bool = False diff --git a/services.py b/services.py index 9099ef0..60626fd 100644 --- a/services.py +++ b/services.py @@ -1,3 +1,12 @@ +from __future__ import annotations + +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_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 @@ -10,6 +19,12 @@ from .crud import ( ) from .models import Ticket +DEFAULT_NOSTR_RELAYS = [ + "wss://relay.damus.io", + "wss://relay.primal.net", + "wss://relay.nostr.band", +] + async def set_ticket_paid(ticket: Ticket) -> Ticket: if ticket.paid: @@ -27,6 +42,77 @@ async def set_ticket_paid(ticket: Ticket) -> Ticket: return ticket +def send_ticket_notification_in_background(ticket: Ticket) -> None: + create_task(_send_ticket_notification(ticket)) + + +async def _send_ticket_notification(ticket: Ticket) -> None: + event = await get_event(ticket.event) + if not event: + logger.warning(f"Event {ticket.event} not found for ticket notification.") + return + + ticket_url = _ticket_url(ticket) + message = ( + f"{settings.lnbits_site_title}\n" + f"Your ticket for '{event.name}' is ready.\n" + f"Open it here: {ticket_url}" + ) + updated = False + + if ( + event.extra.email_notifications + and settings.lnbits_email_notifications_enabled + and ticket.email + ): + try: + await send_user_notification( + UserNotifications(email_address=ticket.email), + message, + "text_message", + ) + 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 _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. diff --git a/static/js/display.js b/static/js/display.js index a652ba5..a927b09 100644 --- a/static/js/display.js +++ b/static/js/display.js @@ -11,7 +11,9 @@ window.PageEventsDisplay = { data: { name: '', email: '', - refund: '' + refund: '', + nostr_identifier: '', + payment_method: 'lightning' } }, ticketLink: { @@ -23,7 +25,8 @@ window.PageEventsDisplay = { receive: { show: false, status: 'pending', - paymentReq: null + paymentReq: null, + isFiat: false }, paymentDismissMsg: null, paymentWebsocket: null @@ -35,7 +38,17 @@ window.PageEventsDisplay = { }, computed: { formatDescription() { - return LNbits.utils.convertMarkdown(this.info) + return LNbits.utils.convertMarkdown(this.event?.info || '') + }, + allowFiatCheckout() { + const currency = (this.event?.currency || '').toLowerCase() + return this.event?.allow_fiat && !['sat', 'sats'].includes(currency) + }, + allowEmailNotifications() { + return Boolean(this.event?.extra?.email_notifications) + }, + allowNostrNotifications() { + return Boolean(this.event?.extra?.nostr_notifications) } }, methods: { @@ -56,6 +69,8 @@ window.PageEventsDisplay = { this.formDialog.data.name = '' this.formDialog.data.email = '' this.formDialog.data.refund = '' + this.formDialog.data.nostr_identifier = '' + this.formDialog.data.payment_method = 'lightning' }, closeReceiveDialog() { @@ -87,6 +102,9 @@ window.PageEventsDisplay = { this.paymentReq = null this.formDialog.data.name = '' this.formDialog.data.email = '' + this.formDialog.data.refund = '' + this.formDialog.data.nostr_identifier = '' + this.formDialog.data.payment_method = 'lightning' Quasar.Notify.create({ type: 'positive', message: 'Sent, thank you!', @@ -95,7 +113,8 @@ window.PageEventsDisplay = { this.receive = { show: false, status: 'complete', - paymentReq: null + paymentReq: null, + isFiat: false } this.ticketLink = { show: true, @@ -103,9 +122,7 @@ window.PageEventsDisplay = { link: `/events/ticket/${paymentHash}` } } - setTimeout(() => { - window.location.href = `/events/ticket/${paymentHash}` - }, 5000) + window.open(`/events/ticket/${paymentHash}`, '_blank', 'noopener') }, async createInvoice() { try { @@ -117,10 +134,15 @@ window.PageEventsDisplay = { name: this.formDialog.data.name, email: this.formDialog.data.email, promo_code: this.formDialog.data.promo_code || null, - refund_address: this.formDialog.data.refund || null + refund_address: this.formDialog.data.refund || null, + nostr_identifier: this.formDialog.data.nostr_identifier || null, + payment_method: this.formDialog.data.payment_method } ) - this.paymentReq = data.payment_request + const isFiat = Boolean(data.is_fiat) + this.paymentReq = isFiat + ? data.fiat_payment_request || null + : data.payment_request this.paymentHash = data.payment_hash this.paymentDismissMsg = Quasar.Notify.create({ @@ -130,30 +152,34 @@ window.PageEventsDisplay = { this.receive = { show: true, status: 'pending', - paymentReq: this.paymentReq + paymentReq: this.paymentReq, + isFiat } - this.websocketListener(this.paymentHash) + if (isFiat && this.paymentReq) { + window.open(this.paymentReq, '_blank', 'noopener') + } + this.paymentWatcher(this.paymentHash) } catch (error) { LNbits.utils.notifyApiError(error) } }, - websocketListener(paymentHash) { + paymentWatcher(paymentHash) { if (this.paymentWebsocket) { this.paymentWebsocket.close() } const url = new URL(window.location) - url.protocol = url.protocol === 'https:' ? 'wss' : 'ws' - url.pathname = `/events/api/v1/tickets/ws/${paymentHash}` + url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' + url.pathname = `/api/v1/ws/${paymentHash}` url.search = '' url.hash = '' - const ws = new WebSocket(url) + const ws = new WebSocket(url.toString()) this.paymentWebsocket = ws ws.onmessage = event => { const data = JSON.parse(event.data) - if (data.paid) { + if (data.pending === false) { this.paymentSuccess(paymentHash) ws.close() } diff --git a/static/js/display.vue b/static/js/display.vue index 3f80180..e37b844 100644 --- a/static/js/display.vue +++ b/static/js/display.vue @@ -16,41 +16,77 @@
Buy Ticket
+
+
+ +
+
+ +
+
+ +
+
- - - +
+
+ +
+
+ +
+
Link to your ticket! -

-

You'll be redirected in a few moments...

@@ -94,6 +128,37 @@ class="q-pa-lg q-pt-xl lnbits__dialog-card" > + +
+
Continue to checkout
+
+ Your fiat checkout opened in a new tab. If it did not, use the + button below. +
+ + Go to checkout + +
+
+ Copy payment link + Close +
+
{ - if (row.currency != 'sats') { + if (this.isFiatCurrency(row.currency)) { return LNbits.utils.formatCurrency( row.price_per_ticket.toFixed(2), row.currency @@ -98,6 +98,7 @@ window.PageEvents = { show: false, data: { currency: 'sats', + allow_fiat: false, extra: { promo_codes: [] } @@ -106,6 +107,9 @@ window.PageEvents = { } }, methods: { + isFiatCurrency(currency) { + return !['sat', 'sats'].includes((currency || '').toLowerCase()) + }, getTickets() { LNbits.api .request( @@ -158,10 +162,16 @@ window.PageEvents = { id: this.formDialog.data.wallet }) const data = this.formDialog.data - if (data.extra && !data.extra.promo_codes) { + 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)) { + data.allow_fiat = false } if (data.id) { @@ -177,9 +187,12 @@ window.PageEvents = { } else { this.formDialog.data = { currency: 'sats', + allow_fiat: false, extra: { conditional: false, min_tickets: 1, + email_notifications: false, + nostr_notifications: false, promo_codes: [] } } @@ -189,7 +202,11 @@ window.PageEvents = { resetEventDialog() { this.formDialog.show = false this.formDialog.data = { + currency: 'sats', + allow_fiat: false, extra: { + email_notifications: false, + nostr_notifications: false, promo_codes: [] } } diff --git a/static/js/index.vue b/static/js/index.vue index 174f0c1..75af661 100644 --- a/static/js/index.vue +++ b/static/js/index.vue @@ -371,6 +371,18 @@ >
+ Add Promo Code + +
Ticket Delivery
+
+ Send the paid ticket link automatically by email or Nostr DM. +
+ +
diff --git a/tasks.py b/tasks.py index 651994e..1d30dce 100644 --- a/tasks.py +++ b/tasks.py @@ -6,7 +6,7 @@ from loguru import logger from .crud import get_ticket from .models import Ticket -from .services import set_ticket_paid +from .services import send_ticket_notification_in_background, set_ticket_paid payment_listeners: dict[str, list[asyncio.Queue[Ticket]]] = {} @@ -43,6 +43,7 @@ async def on_invoice_paid(payment: Payment) -> None: return ticket = await set_ticket_paid(ticket) + send_ticket_notification_in_background(ticket) if payment_listeners.get(payment.payment_hash): for paid_ticket_queue in payment_listeners[payment.payment_hash]: paid_ticket_queue.put_nowait(ticket) diff --git a/views_api.py b/views_api.py index 73cecd3..84dd9b0 100644 --- a/views_api.py +++ b/views_api.py @@ -8,20 +8,25 @@ from fastapi import ( Depends, HTTPException, Query, + Request, WebSocket, WebSocketDisconnect, ) from lnbits.core.crud import get_user +from lnbits.core.crud.wallets import get_wallet from lnbits.core.models import WalletTypeInfo -from lnbits.core.services import create_invoice +from lnbits.core.models.payments import CreateInvoice +from lnbits.core.services import create_payment_request from lnbits.decorators import ( require_admin_key, require_invoice_key, ) +from lnbits.settings import settings from lnbits.utils.exchange_rates import ( fiat_amount_as_satoshis, get_fiat_rate_satoshis, ) +from lnbits.utils.nostr import normalize_public_key from .crud import ( create_event, @@ -53,6 +58,10 @@ events_api_router = APIRouter(prefix="/api/v1/events") tickets_api_router = APIRouter(prefix="/api/v1/tickets") +def _is_fiat_currency(currency: str | None) -> bool: + return str(currency or "").lower() not in {"sat", "sats"} + + @events_api_router.get("") async def api_events( all_wallets: bool = Query(False), @@ -193,7 +202,9 @@ async def api_get_ticket(ticket_id: str) -> Ticket: @tickets_api_router.post("/{event_id}") -async def api_ticket_create(event_id: str, data: CreateTicket) -> TicketPaymentRequest: +async def api_ticket_create( + event_id: str, data: CreateTicket, request: Request +) -> TicketPaymentRequest: event = await get_event(event_id) if not event: raise HTTPException( @@ -210,6 +221,21 @@ async def api_ticket_create(event_id: str, data: CreateTicket) -> TicketPaymentR email = data.email promo_code = data.promo_code.upper() if data.promo_code else None refund_address = data.refund_address + nostr_identifier = data.nostr_identifier.strip() if data.nostr_identifier else None + payment_method = (data.payment_method or "lightning").lower() + if payment_method not in {"lightning", "fiat"}: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Unsupported payment method.", + ) + if nostr_identifier and "@" not in nostr_identifier: + try: + nostr_identifier = normalize_public_key(nostr_identifier) + except Exception as exc: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Invalid Nostr identifier.", + ) from exc price = event.price_per_ticket extra: dict[str, Any] = {"tag": "events", "name": name, "email": email} @@ -224,19 +250,55 @@ async def api_ticket_create(event_id: str, data: CreateTicket) -> TicketPaymentR extra["promo_code"] = promo.code price = event.price_per_ticket * (1 - promo.discount_percent / 100) - if event.currency != "sats": + if payment_method == "fiat" and not event.allow_fiat: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Fiat payments are not enabled for this event.", + ) + + if _is_fiat_currency(event.currency): extra["fiat"] = True extra["currency"] = event.currency extra["fiatAmount"] = price extra["rate"] = await get_fiat_rate_satoshis(event.currency) - price = await fiat_amount_as_satoshis(price, event.currency) + if payment_method != "fiat": + price = await fiat_amount_as_satoshis(price, event.currency) - payment = await create_invoice( + invoice_unit = event.currency + fiat_provider = None + if payment_method == "fiat": + if not _is_fiat_currency(event.currency): + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Fiat checkout requires a fiat-denominated ticket price.", + ) + wallet = await get_wallet(event.wallet) + if not wallet: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="Event wallet does not exist.", + ) + providers = settings.get_fiat_providers_for_user(wallet.user) + fiat_provider = data.fiat_provider or (providers[0] if providers else None) + if not fiat_provider: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="No fiat payment provider configured for this event.", + ) + else: + invoice_unit = "sat" + + payment = await create_payment_request( wallet_id=event.wallet, - amount=price, - memo=f"{event_id}", - extra=extra, + invoice_data=CreateInvoice( + out=False, + amount=price, + unit=invoice_unit, + fiat_provider=fiat_provider, + memo=f"{event_id}", + extra=extra, + ), ) await create_ticket( payment_hash=payment.payment_hash, @@ -247,12 +309,18 @@ async def api_ticket_create(event_id: str, data: CreateTicket) -> TicketPaymentR extra={ "applied_promo_code": promo_code, "refund_address": refund_address, - "sats_paid": int(price), + "nostr_identifier": nostr_identifier, + "ticket_base_url": str(request.base_url).rstrip("/"), + "sats_paid": payment.sat, }, ) return TicketPaymentRequest( - payment_hash=payment.payment_hash, payment_request=payment.bolt11 + payment_hash=payment.payment_hash, + payment_request=getattr(payment, "bolt11", None), + fiat_payment_request=getattr(payment, "extra", {}).get("fiat_payment_request"), + fiat_provider=getattr(payment, "fiat_provider", None) or fiat_provider, + is_fiat=bool(getattr(payment, "fiat_provider", None) or fiat_provider), ) From 32c230957e5bfe3eaffbcc78fe737e1353ac2a7b Mon Sep 17 00:00:00 2001 From: Arc Date: Thu, 7 May 2026 14:34:22 +0100 Subject: [PATCH 05/39] fix: if sats and fiat checkout conversion currency --- migrations.py | 10 ++++++++++ models.py | 3 +++ static/js/display.js | 12 ++++++++++-- static/js/display.vue | 2 +- static/js/index.js | 7 ++++++- static/js/index.vue | 23 +++++++++++++++++------ views_api.py | 18 ++++++++++++------ 7 files changed, 59 insertions(+), 16 deletions(-) diff --git a/migrations.py b/migrations.py index a7e3e86..512540d 100644 --- a/migrations.py +++ b/migrations.py @@ -185,3 +185,13 @@ async def m007_add_allow_fiat(db): 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'; + """) diff --git a/models.py b/models.py index 7886600..20037ec 100644 --- a/models.py +++ b/models.py @@ -37,6 +37,7 @@ class CreateEvent(BaseModel): event_end_date: str currency: str = "sat" allow_fiat: bool = False + fiat_currency: str = "GBP" amount_tickets: int = Query(..., ge=0) price_per_ticket: float = Query(..., ge=0) banner: str | None = None @@ -54,6 +55,7 @@ class Event(BaseModel): event_end_date: str currency: str allow_fiat: bool = False + fiat_currency: str = "GBP" amount_tickets: int price_per_ticket: float time: datetime @@ -72,6 +74,7 @@ class PublicEvent(BaseModel): event_end_date: str currency: str allow_fiat: bool = False + fiat_currency: str = "GBP" price_per_ticket: float banner: str | None extra: EventExtra = Field(default_factory=EventExtra) diff --git a/static/js/display.js b/static/js/display.js index a927b09..d8be8e9 100644 --- a/static/js/display.js +++ b/static/js/display.js @@ -41,8 +41,16 @@ window.PageEventsDisplay = { return LNbits.utils.convertMarkdown(this.event?.info || '') }, allowFiatCheckout() { - const currency = (this.event?.currency || '').toLowerCase() - return this.event?.allow_fiat && !['sat', 'sats'].includes(currency) + 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) diff --git a/static/js/display.vue b/static/js/display.vue index e37b844..f5ac5fb 100644 --- a/static/js/display.vue +++ b/static/js/display.vue @@ -72,7 +72,7 @@ :options="[ {label: 'Lightning', value: 'lightning'}, { - label: `Fiat (${event.currency.toUpperCase()})`, + label: fiatCheckoutLabel, value: 'fiat' } ]" diff --git a/static/js/index.js b/static/js/index.js index e770db9..07e1b46 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -99,6 +99,7 @@ window.PageEvents = { data: { currency: 'sats', allow_fiat: false, + fiat_currency: 'GBP', extra: { promo_codes: [] } @@ -171,7 +172,9 @@ window.PageEvents = { })) } if (!this.isFiatCurrency(data.currency)) { - data.allow_fiat = false + if (!data.allow_fiat) { + data.fiat_currency = 'GBP' + } } if (data.id) { @@ -188,6 +191,7 @@ window.PageEvents = { this.formDialog.data = { currency: 'sats', allow_fiat: false, + fiat_currency: 'GBP', extra: { conditional: false, min_tickets: 1, @@ -204,6 +208,7 @@ window.PageEvents = { this.formDialog.data = { currency: 'sats', allow_fiat: false, + fiat_currency: 'GBP', extra: { email_notifications: false, nostr_notifications: false, diff --git a/static/js/index.vue b/static/js/index.vue index 75af661..06729f3 100644 --- a/static/js/index.vue +++ b/static/js/index.vue @@ -373,16 +373,27 @@
+ Date: Thu, 7 May 2026 17:06:38 +0200 Subject: [PATCH 06/39] feat: add paid/registered badge to ticket page (#49) some visual verification on the ticket page that it is paid / checked in. --- static/js/ticket.js | 4 ++-- static/js/ticket.vue | 20 ++++++++++++++++---- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/static/js/ticket.js b/static/js/ticket.js index 3085f47..82fbd6d 100644 --- a/static/js/ticket.js +++ b/static/js/ticket.js @@ -3,7 +3,7 @@ window.PageEventsTicket = { data() { return { ticketId: null, - ticketName: null + ticket: null } }, methods: { @@ -18,7 +18,7 @@ window.PageEventsTicket = { 'GET', `/events/api/v1/tickets/${this.ticketId}` ) - this.ticketName = data.ticket_name + this.ticket = data } catch (error) { LNbits.utils.notifyApiError(error) } diff --git a/static/js/ticket.vue b/static/js/ticket.vue index 7fdecce..3c932e1 100644 --- a/static/js/ticket.vue +++ b/static/js/ticket.vue @@ -5,20 +5,32 @@

Ticket

+

Bookmark, print or screenshot this page,
and present it for registration!
-
+
+ + +

- - Print + + Print +
From 6768b78c6fd5cb329ffc126e55998c9d7167b043 Mon Sep 17 00:00:00 2001 From: Arc Date: Fri, 8 May 2026 19:14:07 +0100 Subject: [PATCH 07/39] Custom subject and body --- models.py | 2 ++ services.py | 23 +++++++++++++---------- static/js/index.js | 12 +++++++++--- static/js/index.vue | 18 ++++++++++++++++++ 4 files changed, 42 insertions(+), 13 deletions(-) diff --git a/models.py b/models.py index 20037ec..fa1569f 100644 --- a/models.py +++ b/models.py @@ -26,6 +26,8 @@ class EventExtra(BaseModel): min_tickets: int = 1 email_notifications: bool = False nostr_notifications: bool = False + notification_subject: str = "" + notification_body: str = "" class CreateEvent(BaseModel): diff --git a/services.py b/services.py index 60626fd..479aaa1 100644 --- a/services.py +++ b/services.py @@ -4,7 +4,10 @@ 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_user_notification +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 @@ -53,11 +56,15 @@ async def _send_ticket_notification(ticket: Ticket) -> None: return ticket_url = _ticket_url(ticket) - message = ( - f"{settings.lnbits_site_title}\n" - f"Your ticket for '{event.name}' is ready.\n" - f"Open it here: {ticket_url}" + 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." + ) + message = f"{body}\n\nOpen it here: {ticket_url}" updated = False if ( @@ -66,11 +73,7 @@ async def _send_ticket_notification(ticket: Ticket) -> None: and ticket.email ): try: - await send_user_notification( - UserNotifications(email_address=ticket.email), - message, - "text_message", - ) + await send_email_notification([ticket.email], message, subject) ticket.extra.email_notification_sent = True updated = True except Exception as exc: diff --git a/static/js/index.js b/static/js/index.js index 07e1b46..d53ed90 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -101,7 +101,9 @@ window.PageEvents = { allow_fiat: false, fiat_currency: 'GBP', extra: { - promo_codes: [] + promo_codes: [], + notification_subject: '', + notification_body: '' } } } @@ -197,7 +199,9 @@ window.PageEvents = { min_tickets: 1, email_notifications: false, nostr_notifications: false, - promo_codes: [] + promo_codes: [], + notification_subject: '', + notification_body: '' } } } @@ -212,7 +216,9 @@ window.PageEvents = { extra: { email_notifications: false, nostr_notifications: false, - promo_codes: [] + promo_codes: [], + notification_subject: '', + notification_body: '' } } }, diff --git a/static/js/index.vue b/static/js/index.vue index 06729f3..0c21058 100644 --- a/static/js/index.vue +++ b/static/js/index.vue @@ -513,6 +513,24 @@ > + + + +
Date: Wed, 13 May 2026 11:30:14 +0200 Subject: [PATCH 08/39] feat: add resend email button to ticket list (#51) - resending only possible when ticket is paid. --- config.json | 2 +- services.py | 42 +++++++++++++++++++++++++++++++----------- static/js/index.js | 30 ++++++++++++++++++++++++++++++ static/js/index.vue | 16 ++++++++++++++++ views_api.py | 34 +++++++++++++++++++++++++++++++++- 5 files changed, 111 insertions(+), 13 deletions(-) diff --git a/config.json b/config.json index 11bd0b1..a945b43 100644 --- a/config.json +++ b/config.json @@ -1,6 +1,6 @@ { "id": "events", - "version": "1.3.0", + "version": "1.6.1", "name": "Events", "repo": "https://github.com/lnbits/events", "short_description": "Sell and register event tickets", diff --git a/services.py b/services.py index 479aaa1..159bbdc 100644 --- a/services.py +++ b/services.py @@ -20,7 +20,7 @@ from .crud import ( update_event, update_ticket, ) -from .models import Ticket +from .models import Event, Ticket DEFAULT_NOSTR_RELAYS = [ "wss://relay.damus.io", @@ -55,16 +55,7 @@ async def _send_ticket_notification(ticket: Ticket) -> None: logger.warning(f"Event {ticket.event} not found for ticket notification.") return - 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." - ) - message = f"{body}\n\nOpen it here: {ticket_url}" + subject, message = _ticket_notification_message(ticket, event) updated = False if ( @@ -97,6 +88,35 @@ async def _send_ticket_notification(ticket: Ticket) -> None: 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( diff --git a/static/js/index.js b/static/js/index.js index d53ed90..5424a32 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -4,6 +4,7 @@ window.PageEvents = { return { events: [], tickets: [], + resendingTicketEmails: [], currencies: [], eventsTable: { columns: [ @@ -145,6 +146,35 @@ window.PageEvents = { .catch(LNbits.utils.notifyApiError) }) }, + resendTicketEmail(ticket) { + if (!ticket.paid || !ticket.email) return + const wallet = _.findWhere(this.g.user.wallets, {id: ticket.wallet}) + if (!wallet) return + + this.resendingTicketEmails.push(ticket.id) + LNbits.api + .request( + 'POST', + '/events/api/v1/tickets/' + ticket.id + '/resend-email', + wallet.adminkey + ) + .then(response => { + this.tickets = this.tickets.map(obj => + obj.id === ticket.id ? response.data : obj + ) + Quasar.Notify.create({ + type: 'positive', + message: 'Ticket email resent.', + icon: null + }) + }) + .catch(LNbits.utils.notifyApiError) + .finally(() => { + this.resendingTicketEmails = this.resendingTicketEmails.filter( + ticketId => ticketId !== ticket.id + ) + }) + }, exportticketsCSV() { LNbits.utils.exportCSV(this.ticketsTable.columns, this.tickets) }, diff --git a/static/js/index.vue b/static/js/index.vue index 0c21058..fa39a4e 100644 --- a/static/js/index.vue +++ b/static/js/index.vue @@ -171,10 +171,12 @@ >