diff --git a/config.json b/config.json index 9a73073..57a7f75 100644 --- a/config.json +++ b/config.json @@ -1,6 +1,6 @@ { "id": "events", - "version": "1.3.0-aio.6", + "version": "1.6.1-aio.1", "name": "Events", "repo": "https://git.atitlan.io/aiolabs/events", "short_description": "Sell and register event tickets", @@ -36,7 +36,7 @@ { "name": "padreug", "uri": "https://git.atitlan.io/padreug", - "role": "Developer (aio fork: approval workflow + NIP-52 Nostr sync)" + "role": "Developer (aio fork: approval workflow + NIP-52 Nostr sync + edit gating)" } ], "images": [ diff --git a/crud.py b/crud.py index 0a51727..004fa7f 100644 --- a/crud.py +++ b/crud.py @@ -94,7 +94,7 @@ async def update_ticket(ticket: Ticket) -> Ticket: async def get_ticket(payment_hash: str) -> Ticket | None: - row: dict | None = await db.fetchone( + row = await db.fetchone( "SELECT * FROM events.ticket WHERE id = :id", {"id": payment_hash}, ) @@ -107,15 +107,13 @@ async def get_tickets(wallet_ids: str | list[str]) -> list[Ticket]: if isinstance(wallet_ids, str): wallet_ids = [wallet_ids] q = ",".join([f"'{wallet_id}'" for wallet_id in wallet_ids]) - rows: list[dict] = await db.fetchall( - f"SELECT * FROM events.ticket WHERE wallet IN ({q})" - ) + 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_user_id(user_id: str) -> list[Ticket]: """All tickets owned by the given LNbits user_id.""" - rows: list[dict] = await db.fetchall( + rows = await db.fetchall( "SELECT * FROM events.ticket WHERE user_id = :user_id ORDER BY time DESC", {"user_id": user_id}, ) @@ -208,7 +206,7 @@ async def get_pending_events() -> list[Event]: async def get_settings() -> EventsSettings: """Singleton settings row, seeded by m010.""" - row: dict | None = await db.fetchone("SELECT * FROM events.settings WHERE id = 1") + row = await db.fetchone("SELECT * FROM events.settings WHERE id = 1") if row: return EventsSettings(**dict(row)) return EventsSettings() @@ -227,7 +225,7 @@ async def delete_event(event_id: str) -> None: async def get_event_tickets(event_id: str) -> list[Ticket]: - rows: list[dict] = await db.fetchall( + rows = await db.fetchall( "SELECT * FROM events.ticket WHERE event = :event", {"event": event_id}, ) diff --git a/migrations.py b/migrations.py index 6f8e838..512540d 100644 --- a/migrations.py +++ b/migrations.py @@ -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'; + """) diff --git a/models.py b/models.py index a617a13..d3f43d3 100644 --- a/models.py +++ b/models.py @@ -24,6 +24,10 @@ 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): @@ -36,6 +40,8 @@ class CreateEvent(BaseModel): event_start_date: str event_end_date: str | None = None # same format as event_start_date currency: str = "sat" + allow_fiat: bool = False + fiat_currency: str = "GBP" amount_tickets: int = 0 # 0 = unlimited / not ticketed price_per_ticket: float = 0 # 0 = free banner: str | None = None @@ -55,6 +61,8 @@ class Event(BaseModel): event_start_date: str event_end_date: str | None = None currency: str = "sat" + allow_fiat: bool = False + fiat_currency: str = "GBP" amount_tickets: int = 0 price_per_ticket: float = 0 time: datetime @@ -82,9 +90,14 @@ class PublicEvent(BaseModel): canceled: bool event_start_date: str event_end_date: str | None = None + currency: str + allow_fiat: bool = False + fiat_currency: str = "GBP" + price_per_ticket: float banner: str | None location: str | None = None categories: list[str] = Field(default_factory=list) + extra: EventExtra = Field(default_factory=EventExtra) status: str = "approved" # surfaces "proposed"/"rejected" so SFC can render banner @validator("categories", pre=True) @@ -104,6 +117,10 @@ 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 @@ -113,6 +130,9 @@ class CreateTicket(BaseModel): user_id: str | None = None # LNbits user id (alternative to name+email) promo_code: str | None = None refund_address: str | None = None + nostr_identifier: str | None = None + payment_method: str | None = None + fiat_provider: str | None = None @root_validator def validate_identifiers(cls, values): @@ -151,4 +171,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/nostr_hooks.py b/nostr_hooks.py index daf1fc0..3211b24 100644 --- a/nostr_hooks.py +++ b/nostr_hooks.py @@ -29,15 +29,11 @@ async def publish_or_delete_nostr_event(event: Event, *, delete: bool = False) - if not wallet_obj: return account = await get_account(wallet_obj.user) - if not account or not account.pubkey or not account.prvkey: # type: ignore[attr-defined] + if not account or not account.pubkey or not account.prvkey: return nostr_event = await publish_event_to_nostr( - nostr_client, - event, - account.pubkey, - account.prvkey, # type: ignore[attr-defined] - delete=delete, + nostr_client, event, account.pubkey, account.prvkey, delete=delete ) if nostr_event and not delete: event.nostr_event_id = nostr_event.id diff --git a/nostr_sync.py b/nostr_sync.py index 11869cd..1dc52bc 100644 --- a/nostr_sync.py +++ b/nostr_sync.py @@ -50,7 +50,7 @@ async def _handle_calendar_event(nostr_client: NostrClient, event_data: dict): return tags = {t[0]: t[1] for t in event_data.get("tags", []) if len(t) >= 2} - tag_lists: dict[str, list[str]] = {} + tag_lists = {} for t in event_data.get("tags", []): if len(t) >= 2: tag_lists.setdefault(t[0], []).append(t[1]) diff --git a/services.py b/services.py index 9099ef0..159bbdc 100644 --- a/services.py +++ b/services.py @@ -1,3 +1,15 @@ +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_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,7 +20,13 @@ from .crud import ( update_event, update_ticket, ) -from .models import Ticket +from .models import Event, Ticket + +DEFAULT_NOSTR_RELAYS = [ + "wss://relay.damus.io", + "wss://relay.primal.net", + "wss://relay.nostr.band", +] async def set_ticket_paid(ticket: Ticket) -> Ticket: @@ -27,6 +45,97 @@ 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 + + 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. diff --git a/static/js/display.js b/static/js/display.js index a652ba5..d8be8e9 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,25 @@ window.PageEventsDisplay = { }, 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: { @@ -56,6 +77,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 +110,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 +121,8 @@ window.PageEventsDisplay = { this.receive = { show: false, status: 'complete', - paymentReq: null + paymentReq: null, + isFiat: false } this.ticketLink = { show: true, @@ -103,9 +130,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 +142,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 +160,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 58fec04..9b27783 100644 --- a/static/js/display.vue +++ b/static/js/display.vue @@ -41,41 +41,77 @@
Buy Ticket
+
+
+ +
+
+ +
+
+ +
+
- - - +
+
+ +
+
+ +
+
Link to your ticket! -

-

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

@@ -119,6 +153,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 @@ -105,14 +106,21 @@ window.PageEvents = { 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( @@ -145,6 +153,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) }, @@ -187,7 +224,12 @@ window.PageEvents = { }, saveSettings() { LNbits.api - .request('PUT', '/events/api/v1/events/settings', null, this.settings) + .request( + 'PUT', + '/events/api/v1/events/settings', + null, + this.settings + ) .then(() => { Quasar.Notify.create({type: 'positive', message: 'Settings saved'}) }) @@ -266,10 +308,18 @@ window.PageEvents = { delete data.event_end_day delete data.event_end_time - 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)) { + if (!data.allow_fiat) { + data.fiat_currency = 'GBP' + } } if (data.id) { @@ -293,6 +343,8 @@ window.PageEvents = { } else { this.formDialog.data = { currency: 'sats', + allow_fiat: false, + fiat_currency: 'GBP', event_start_day: '', event_start_time: '', event_end_day: '', @@ -300,7 +352,11 @@ window.PageEvents = { extra: { conditional: false, min_tickets: 1, - promo_codes: [] + email_notifications: false, + nostr_notifications: false, + promo_codes: [], + notification_subject: '', + notification_body: '' } } } @@ -309,8 +365,15 @@ window.PageEvents = { 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: '' } } }, diff --git a/static/js/index.vue b/static/js/index.vue index df3a990..4117f47 100644 --- a/static/js/index.vue +++ b/static/js/index.vue @@ -192,13 +192,7 @@ @@ -290,13 +284,7 @@ @@ -329,10 +317,12 @@ >