diff --git a/__init__.py b/__init__.py index 01b145e..14c1590 100644 --- a/__init__.py +++ b/__init__.py @@ -6,12 +6,11 @@ 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, tickets_api_router +from .views_api import events_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 = [ { @@ -22,11 +21,6 @@ events_static_files = [ scheduled_tasks: list[asyncio.Task] = [] -# Module-level NostrClient — None when nostrclient is unavailable. Set by the -# bootstrap task in events_start() and read via dynamic attribute lookup -# from nostr_hooks.publish_or_delete_nostr_event. -nostr_client = None - def events_stop(): for task in scheduled_tasks: @@ -35,80 +29,12 @@ def events_stop(): except Exception as ex: logger.warning(ex) - global nostr_client - if nostr_client: - asyncio.get_event_loop().create_task(nostr_client.stop()) - def events_start(): from lnbits.tasks import create_permanent_unique_task - task1 = create_permanent_unique_task("ext_events", wait_for_paid_invoices) - scheduled_tasks.append(task1) - - # Register nostr-transport RPCs. Swallow ImportError on older LNbits - # versions that pre-date the transport (the events extension still - # works fine via HTTP without it). - try: - from lnbits.core.services.nostr_transport.dispatcher import ( - AUTH_WALLET, - register_rpc, - ) - - from .transport_rpcs import ( - handle_events_list_event_tickets, - handle_events_ticket_register, - ) - - register_rpc( - "events_ticket_register", handle_events_ticket_register, AUTH_WALLET - ) - register_rpc( - "events_list_event_tickets", - handle_events_list_event_tickets, - AUTH_WALLET, - ) - logger.info( - "[EVENTS] Registered nostr-transport RPCs: " - "events_ticket_register, events_list_event_tickets" - ) - except ImportError: - logger.info( - "[EVENTS] nostr_transport not available on this LNbits — " - "ticket scanner over Nostr disabled, HTTP endpoint still works" - ) - - async def _start_nostr_client(): - global nostr_client - await asyncio.sleep(10) # Wait for nostrclient to be ready - try: - from .nostr.nostr_client import NostrClient - - nostr_client = NostrClient() - logger.info("[EVENTS] Starting NostrClient for NIP-52 sync") - await nostr_client.run_forever() - except Exception as exc: - logger.warning(f"[EVENTS] NostrClient failed to start: {exc}") - logger.info("[EVENTS] Events will work without Nostr sync") - - task2 = create_permanent_unique_task("ext_events_nostr", _start_nostr_client) - scheduled_tasks.append(task2) - - async def _sync_nostr_events(): - global nostr_client - await asyncio.sleep(15) # Wait for NostrClient to connect - if not nostr_client: - logger.info("[EVENTS] No NostrClient, skipping Nostr sync") - return - try: - from .nostr_sync import wait_for_nostr_events - - await wait_for_nostr_events(nostr_client) - except Exception as exc: - logger.error(f"[EVENTS] Nostr sync task failed: {exc}") - - task3 = create_permanent_unique_task("ext_events_nostr_sync", _sync_nostr_events) - scheduled_tasks.append(task3) + task = create_permanent_unique_task("ext_events", wait_for_paid_invoices) + scheduled_tasks.append(task) __all__ = ["db", "events_ext", "events_start", "events_static_files", "events_stop"] diff --git a/config.json b/config.json index 02272e3..8a9a61c 100644 --- a/config.json +++ b/config.json @@ -1,12 +1,12 @@ { "id": "events", - "version": "1.6.1-aio.7", + "version": "1.2.1", "name": "Events", - "repo": "https://git.atitlan.io/aiolabs/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.4.1", + "min_lnbits_version": "1.3.0", "contributors": [ { "name": "talvasconcelos", @@ -14,7 +14,7 @@ "role": "Developer" }, { - "name": "dni", + "name": "DNI", "uri": "https://github.com/dni", "role": "Developer" }, @@ -32,11 +32,6 @@ "name": "motorina0", "uri": "https://github.com/motorina0", "role": "Developer" - }, - { - "name": "padreug", - "uri": "https://git.atitlan.io/padreug", - "role": "Developer (aio fork: approval workflow + NIP-52 Nostr sync + edit gating)" } ], "images": [ diff --git a/crud.py b/crud.py index 551a3bc..6460f7e 100644 --- a/crud.py +++ b/crud.py @@ -1,87 +1,26 @@ -import json from datetime import datetime, timedelta, timezone +from typing import Optional from lnbits.db import Database from lnbits.helpers import urlsafe_short_hash -from .models import CreateEvent, Event, EventsSettings, Ticket, TicketExtra +from .models import CreateEvent, Event, Ticket, TicketExtra db = Database("ext_events") -def _parse_ticket_row(row) -> dict: - """Normalize a ticket row before constructing a Ticket model. - - - Empty-string sentinels in name/email (used because the DB columns are - NOT NULL but the Pydantic field is Optional when user_id is set) are - converted back to None. - - The `extra` JSON column may come back as a string when the row is - fetched without a model= argument; parse it so Pydantic can build - TicketExtra from a dict. - """ - ticket_data = dict(row) - - if ticket_data.get("name") == "": - ticket_data["name"] = None - if ticket_data.get("email") == "": - ticket_data["email"] = None - - extra = ticket_data.get("extra") - if isinstance(extra, str): - ticket_data["extra"] = json.loads(extra) if extra else {} - - return ticket_data - - async def create_ticket( payment_hash: str, wallet: str, event: str, - name: str | None = None, - email: str | None = None, - user_id: str | None = None, - extra: dict | None = None, - ticket_id: str | None = None, + name: str = "", + email: str = "", + user_id: Optional[str] = None, + extra: Optional[dict] = None, ) -> Ticket: - """Persist one ticket row. - - `payment_hash` is the LNbits invoice hash shared across all rows - of a multi-ticket purchase. `ticket_id` is the row primary key / - scannable id; defaults to `payment_hash` for single-ticket - purchases so the legacy id == payment_hash invariant holds. - Multi-ticket callers pass a unique uuid here so each attendee - gets a distinct scannable QR. - """ now = datetime.now(timezone.utc) - row_id = ticket_id or payment_hash - - # name/email columns are NOT NULL in the schema, so we store "" when only - # user_id is supplied. _parse_ticket_row reverses this on read. - if user_id: - db_name = "" - db_email = "" - else: - db_name = name or "" - db_email = email or "" - - db_ticket = Ticket( - id=row_id, - wallet=wallet, - event=event, - name=db_name, - email=db_email, - user_id=user_id, - registered=False, - paid=False, - reg_timestamp=now, - time=now, - extra=TicketExtra(**extra) if extra else TicketExtra(), - payment_hash=payment_hash, - ) - await db.insert("events.ticket", db_ticket) - - return Ticket( - id=row_id, + ticket = Ticket( + id=payment_hash, wallet=wallet, event=event, name=name, @@ -92,69 +31,32 @@ async def create_ticket( reg_timestamp=now, time=now, extra=TicketExtra(**extra) if extra else TicketExtra(), - payment_hash=payment_hash, ) - - -async def update_ticket(ticket: Ticket) -> Ticket: - ticket_dict = ticket.dict() - if ticket_dict.get("name") is None: - ticket_dict["name"] = "" - if ticket_dict.get("email") is None: - ticket_dict["email"] = "" - await db.update("events.ticket", Ticket(**ticket_dict)) + await db.insert("events.ticket", ticket) return ticket -async def get_tickets_by_payment_hash(payment_hash: str) -> list[Ticket]: - """All ticket rows sharing the given LNbits invoice payment_hash. - - For a single-ticket purchase returns one row (legacy invariant - `id == payment_hash` still holds). For a multi-ticket purchase - returns the N rows created with shared `payment_hash` but - distinct `id`s — each attendee's scannable QR. - """ - rows = await db.fetchall( - "SELECT * FROM events.ticket WHERE payment_hash = :ph", - {"ph": payment_hash}, - ) - return [Ticket(**_parse_ticket_row(row)) for row in rows] +async def update_ticket(ticket: Ticket) -> Ticket: + await db.update("events.ticket", ticket) + return ticket async def get_ticket(payment_hash: str) -> Ticket | None: - row = await db.fetchone( + return await db.fetchone( "SELECT * FROM events.ticket WHERE id = :id", {"id": payment_hash}, + Ticket, ) - if not row: - return None - return Ticket(**_parse_ticket_row(row)) async def get_tickets(wallet_ids: str | list[str]) -> list[Ticket]: if isinstance(wallet_ids, str): wallet_ids = [wallet_ids] q = ",".join([f"'{wallet_id}'" for wallet_id in wallet_ids]) - rows = await db.fetchall(f"SELECT * FROM events.ticket WHERE wallet IN ({q})") - return [Ticket(**_parse_ticket_row(row)) for row in rows] - - -async def get_tickets_by_event(event_id: str) -> list[Ticket]: - """All ticket rows for the given calendar event id.""" - rows = await db.fetchall( - "SELECT * FROM events.ticket WHERE event = :event_id", - {"event_id": event_id}, + return await db.fetchall( + f"SELECT * FROM events.ticket WHERE wallet IN ({q})", + model=Ticket, ) - return [Ticket(**_parse_ticket_row(row)) for row in rows] - - -async def get_tickets_by_user_id(user_id: str) -> list[Ticket]: - """All tickets owned by the given LNbits user_id.""" - rows = await db.fetchall( - "SELECT * FROM events.ticket WHERE user_id = :user_id ORDER BY time DESC", - {"user_id": user_id}, - ) - return [Ticket(**_parse_ticket_row(row)) for row in rows] async def delete_ticket(payment_hash: str) -> None: @@ -180,11 +82,6 @@ async def purge_unpaid_tickets(event_id: str) -> None: async def create_event(data: CreateEvent) -> Event: event_id = urlsafe_short_hash() - # Default end_date to start_date and closing_date to end_date when omitted. - if not data.event_end_date: - data.event_end_date = data.event_start_date - if not data.closing_date: - data.closing_date = data.event_end_date event = Event(id=event_id, time=datetime.now(timezone.utc), **data.dict()) await db.insert("events.events", event) return event @@ -214,56 +111,29 @@ async def get_events(wallet_ids: str | list[str]) -> list[Event]: async def get_all_events() -> list[Event]: - """All events, no wallet filter. Admin-only callers.""" + """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_public_events() -> list[Event]: - """Approved, non-canceled events for the public listing.""" +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.events - WHERE status = 'approved' AND canceled = FALSE - ORDER BY event_start_date ASC - """, - model=Event, + "SELECT * FROM events.ticket WHERE user_id = :user_id ORDER BY time DESC", + {"user_id": user_id}, + model=Ticket, ) -async def get_pending_events() -> list[Event]: - """Proposed events awaiting admin approval.""" - return await db.fetchall( - "SELECT * FROM events.events WHERE status = 'proposed' ORDER BY time DESC", - model=Event, - ) - - -async def get_settings() -> EventsSettings: - """Singleton settings row, seeded by m010.""" - row = await db.fetchone("SELECT * FROM events.settings WHERE id = 1") - if row: - return EventsSettings(**dict(row)) - return EventsSettings() - - -async def update_settings(settings: EventsSettings) -> EventsSettings: - await db.execute( - "UPDATE events.settings SET auto_approve = :auto_approve WHERE id = 1", - {"auto_approve": settings.auto_approve}, - ) - return settings - - async def delete_event(event_id: str) -> None: await db.execute("DELETE FROM events.events WHERE id = :id", {"id": event_id}) async def get_event_tickets(event_id: str) -> list[Ticket]: - rows = await db.fetchall( + return await db.fetchall( "SELECT * FROM events.ticket WHERE event = :event", {"event": event_id}, + Ticket, ) - return [Ticket(**_parse_ticket_row(row)) for row in rows] diff --git a/migrations.py b/migrations.py index 512540d..1d9cfe4 100644 --- a/migrations.py +++ b/migrations.py @@ -177,21 +177,9 @@ async def m006_add_extra_fields(db): await db.execute("ALTER TABLE events.ticket ADD COLUMN extra TEXT;") -async def m007_add_allow_fiat(db): +async def m007_add_user_id(db): """ - Add an allow_fiat column so event owners can explicitly enable fiat checkout. + Add user_id column to tickets table. + Allows ticket purchase via LNbits user-id without name/email. """ - 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'; - """) + await db.execute("ALTER TABLE events.ticket ADD COLUMN user_id TEXT;") diff --git a/migrations_fork.py b/migrations_fork.py deleted file mode 100644 index 864cbb8..0000000 --- a/migrations_fork.py +++ /dev/null @@ -1,130 +0,0 @@ -""" -Fork-specific database migrations for the aiolabs events extension. - -These migrations are tracked separately under `events_fork` in the -`dbversions` table (loaded by `lnbits/core/helpers.py:migrate_extension_database`), -so they do not collide with upstream's `m{NNN}_*` numbering in -`migrations.py`. Keeping the upstream-tracked file untouched means -`git pull upstream` stays rebase-clean for schema changes. - -Conventions: - - Sequential numbering starting from m001. - - Each migration is `async def m{NNN}_(db)`. - - DDL must be idempotent: a fresh install runs every migration; an - install that previously ran the OLD versions of these as - `m007-m011` in `migrations.py` has the columns/tables already. - Use `_alter_add_column_safe` / `_create_table_safe` so re-runs are - no-ops instead of crashes. - -History compressed into m001 (was m007-m011 in migrations.py pre-v1.6 -rebase): - - m007 add_user_id_support (ticket.user_id column) - - m008 add_event_status (events.status column) - - m009 add_nostr_columns (events.nostr_event_id + created_at) - - m010 add_events_settings (events.settings singleton table) - - m011 add_location_and_categories (events.location + categories) -""" - - -async def _alter_add_column_safe(db, sql: str) -> None: - """ALTER TABLE ADD COLUMN that swallows duplicate-column errors. - - Re-running the squashed migration on a database that already has - these columns (from the pre-squash `m007-m011` in migrations.py) - must be a silent no-op. Same swallow we used in the old migrations. - """ - try: - await db.execute(sql) - except Exception as exc: - msg = str(exc).lower() - if "duplicate column" in msg or "already exists" in msg: - return - raise - - -async def m001_aio_event_schema(db): - """ - Apply every aiolabs schema delta on top of upstream events v1.3.0. - - This is the squashed equivalent of the pre-v1.6 sequence - m007 → m011. Order matters for the settings table seed insert - but the individual column adds are independent and idempotent. - """ - - # --- ticket.user_id ---------------------------------------------- - # Lets a ticket reference an LNbits user id instead of (name, email). - # Application logic enforces that exactly one identifier scheme is - # used per ticket. - await _alter_add_column_safe( - db, "ALTER TABLE events.ticket ADD COLUMN user_id TEXT" - ) - - # --- events.status ----------------------------------------------- - # Proposal / approval workflow. Existing rows default to 'approved' - # so they stay visible after upgrade. - await _alter_add_column_safe( - db, - "ALTER TABLE events.events ADD COLUMN status TEXT NOT NULL DEFAULT 'approved'", - ) - - # --- events.nostr_event_id, nostr_event_created_at --------------- - # Track the most recent NIP-52 calendar event we published, so - # subsequent edits can issue replaceable updates and NIP-09 deletes - # against the right addressable coordinate. - await _alter_add_column_safe( - db, "ALTER TABLE events.events ADD COLUMN nostr_event_id TEXT" - ) - await _alter_add_column_safe( - db, "ALTER TABLE events.events ADD COLUMN nostr_event_created_at INTEGER" - ) - - # --- events.settings --------------------------------------------- - # Singleton settings row used by the admin UI to toggle e.g. - # auto_approve. CREATE TABLE IF NOT EXISTS + a guarded seed keeps - # this idempotent. - await db.execute(""" - CREATE TABLE IF NOT EXISTS events.settings ( - id INTEGER PRIMARY KEY DEFAULT 1, - auto_approve BOOLEAN NOT NULL DEFAULT FALSE - ) - """) - await db.execute( - "INSERT INTO events.settings (id, auto_approve) " - "SELECT 1, FALSE WHERE NOT EXISTS " - "(SELECT 1 FROM events.settings WHERE id = 1)" - ) - - # --- events.location, events.categories -------------------------- - # NIP-52 calendar metadata. `categories` carries a JSON-encoded - # list of hashtags (the NIP-52 `t` tags). - await _alter_add_column_safe( - db, "ALTER TABLE events.events ADD COLUMN location TEXT" - ) - await _alter_add_column_safe( - db, "ALTER TABLE events.events ADD COLUMN categories TEXT" - ) - - -async def m002_ticket_payment_hash(db): - """ - Add `ticket.payment_hash` for multi-ticket purchases. - - Multi-ticket purchases land as N rows sharing one LNbits invoice - (so each attendee gets a distinct scannable QR but the buyer - pays once). `ticket.id` stays the row primary key — for legacy - single-purchase rows it equals payment_hash; for multi-purchase - children it's a uuid generated at create-time. `payment_hash` - is the new join key for invoice lookup. - - Backfill existing rows from id so the - GET-tickets-by-payment-hash path keeps working for pre-migration - data (id was the payment_hash by invariant before this column). - """ - await _alter_add_column_safe( - db, "ALTER TABLE events.ticket ADD COLUMN payment_hash TEXT" - ) - await db.execute( - "UPDATE events.ticket SET payment_hash = id " - "WHERE payment_hash IS NULL OR payment_hash = ''" - ) - diff --git a/models.py b/models.py index 7f1feac..1073806 100644 --- a/models.py +++ b/models.py @@ -1,6 +1,7 @@ -import json from datetime import datetime +from typing import Optional +from fastapi import Query from pydantic import BaseModel, EmailStr, Field, root_validator, validator @@ -24,128 +25,61 @@ class EventExtra(BaseModel): promo_codes: list[PromoCode] = Field(default_factory=list) conditional: bool = False min_tickets: int = 1 - email_notifications: bool = False - nostr_notifications: bool = False - notification_subject: str = "" - notification_body: str = "" class CreateEvent(BaseModel): - wallet: str | None = None # filled from caller's wallet if absent - name: str # title (required) - info: str = "" # description (optional) - closing_date: str | None = None # date-only YYYY-MM-DD; defaults to event_end_date - # ISO 8601: date-only ("2026-05-19") or datetime ("2026-05-19T18:30"). - # Presence of a "T" toggles NIP-52 kind (31922 date / 31923 time). + wallet: str + name: str + info: str + closing_date: str event_start_date: str - event_end_date: str | None = None # same format as event_start_date + event_end_date: str 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 + amount_tickets: int = Query(..., ge=0) + price_per_ticket: float = Query(..., ge=0) banner: str | None = None - location: str | None = None # venue/address (NIP-52 'location' tag) - categories: list[str] = Field(default_factory=list) # NIP-52 't' tags extra: EventExtra = Field(default_factory=EventExtra) - status: str = "approved" # proposed, approved, rejected class Event(BaseModel): id: str wallet: str name: str - info: str = "" - closing_date: str | None = None + info: str + closing_date: str canceled: bool = False 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 + event_end_date: str + currency: str + amount_tickets: int + price_per_ticket: float time: datetime sold: int = 0 banner: str | None = None - location: str | None = None - categories: list[str] = Field(default_factory=list) extra: EventExtra = Field(default_factory=EventExtra) - status: str = "approved" - nostr_event_id: str | None = None - nostr_event_created_at: int | None = None - - @validator("categories", pre=True) - def parse_categories(cls, v): - if isinstance(v, str): - return json.loads(v) if v else [] - return v or [] - - -class PublicEvent(BaseModel): - id: str - name: str - info: str - closing_date: str | None = None - canceled: bool - event_start_date: str - event_end_date: str | None = None - currency: str - allow_fiat: bool = False - fiat_currency: str = "GBP" - price_per_ticket: float - banner: str | None - location: str | None = None - categories: list[str] = Field(default_factory=list) - extra: EventExtra = Field(default_factory=EventExtra) - status: str = "approved" # surfaces "proposed"/"rejected" so SFC can render banner - - @validator("categories", pre=True) - def parse_categories(cls, v): - if isinstance(v, str): - return json.loads(v) if v else [] - return v or [] - - -class EventsSettings(BaseModel): - """Extension-level settings for the events extension.""" - - auto_approve: bool = False # Skip approval workflow for non-admin users class TicketExtra(BaseModel): applied_promo_code: str | None = None sats_paid: int | None = None refund_address: str | None = None - nostr_identifier: str | None = None - ticket_base_url: str | None = None - email_notification_sent: bool = False - nostr_notification_sent: bool = False refunded: bool = False class CreateTicket(BaseModel): - name: str | None = None - email: EmailStr | None = None - user_id: str | None = None # LNbits user id (alternative to name+email) + name: Optional[str] = None + email: Optional[str] = None + user_id: Optional[str] = None promo_code: str | None = None refund_address: str | None = None - nostr_identifier: str | None = None - payment_method: str | None = None - fiat_provider: str | None = None - # Number of tickets to buy on this single invoice. Bounded so a - # bad client can't run away with the organizer's capacity. - quantity: int = Field(default=1, ge=1, le=10) @root_validator def validate_identifiers(cls, values): + user_id = values.get("user_id") name = values.get("name") email = values.get("email") - user_id = values.get("user_id") if not user_id and not (name and email): raise ValueError("Either user_id or both name and email must be provided") - if user_id and (name or email): - raise ValueError("Cannot provide both user_id and name/email") return values @@ -153,42 +87,11 @@ class Ticket(BaseModel): id: str wallet: str event: str - name: str | None = None - email: str | None = None - user_id: str | None = None + name: str = "" + email: str = "" + user_id: Optional[str] = None registered: bool paid: bool time: datetime reg_timestamp: datetime extra: TicketExtra = Field(default_factory=TicketExtra) - # Shared LNbits invoice payment_hash. Equals `id` for single-ticket - # purchases (legacy + post-migration default). Multi-ticket - # purchases create N rows sharing one payment_hash so each attendee - # gets a distinct scannable id while the buyer pays once. - payment_hash: str | None = None - - -class PublicTicket(BaseModel): - event: str - name: str | None = None - registered: bool - paid: bool - time: datetime - reg_timestamp: datetime - - -class TicketPaymentRequest(BaseModel): - payment_hash: str - payment_request: str | None = None - fiat_payment_request: str | None = None - fiat_provider: str | None = None - is_fiat: bool = False - # True when the tickets are already issued + paid with no invoice to - # settle — free events (price 0) or a 100%-off promo. The client skips - # the QR / payment-poll step and goes straight to the ticket QRs. - paid: bool = False - # Row ids created on this invoice — one for single-ticket - # purchases, N for multi-ticket (each independently scannable at - # the door). Buyers fetch these after payment to render N QRs in - # My Tickets. - ticket_ids: list[str] = Field(default_factory=list) diff --git a/nostr/__init__.py b/nostr/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/nostr/event.py b/nostr/event.py deleted file mode 100644 index b6832b1..0000000 --- a/nostr/event.py +++ /dev/null @@ -1,26 +0,0 @@ -import hashlib -import json - -from pydantic import BaseModel - - -class NostrEvent(BaseModel): - id: str = "" - pubkey: str - created_at: int - kind: int - tags: list[list[str]] = [] - content: str = "" - sig: str | None = None - - def serialize(self) -> list: - return [0, self.pubkey, self.created_at, self.kind, self.tags, self.content] - - def serialize_json(self) -> str: - e = self.serialize() - return json.dumps(e, separators=(",", ":"), ensure_ascii=False) - - @property - def event_id(self) -> str: - data = self.serialize_json() - return hashlib.sha256(data.encode()).hexdigest() diff --git a/nostr/nostr_client.py b/nostr/nostr_client.py deleted file mode 100644 index 4de332f..0000000 --- a/nostr/nostr_client.py +++ /dev/null @@ -1,135 +0,0 @@ -""" -Bidirectional Nostr client for the events extension. - -Connects to the nostrclient extension's internal WebSocket to publish -and subscribe to NIP-52 calendar events. Based on nostrmarket's -NostrClient pattern. -""" - -import asyncio -import json -from asyncio import Queue -from collections import OrderedDict - -from lnbits.helpers import encrypt_internal_message, urlsafe_short_hash -from lnbits.settings import settings -from loguru import logger -from websocket import WebSocketApp - -from .event import NostrEvent - -MAX_SEEN_EVENTS = 500 - - -class NostrClient: - def __init__(self): - self.receive_event_queue: Queue = Queue() - self.send_req_queue: Queue = Queue() - self.ws: WebSocketApp | None = None - self.subscription_id = "events-" + urlsafe_short_hash()[:32] - self.running = False - self._seen_events: OrderedDict[str, None] = OrderedDict() - - @property - def is_websocket_connected(self): - if not self.ws: - return False - return self.ws.keep_running - - async def connect(self) -> WebSocketApp: - relay_endpoint = encrypt_internal_message("relay", urlsafe=True) - ws_url = ( - f"ws://localhost:{settings.port}" f"/nostrclient/api/v1/{relay_endpoint}" - ) - - logger.info("[EVENTS] Connecting to nostrclient WebSocket...") - - def on_open(_): - logger.info("[EVENTS] Connected to nostrclient WebSocket") - - def on_message(_, message): - try: - self.receive_event_queue.put_nowait(message) - except Exception as e: - logger.error(f"[EVENTS] Failed to queue message: {e}") - - def on_error(_, error): - logger.warning(f"[EVENTS] WebSocket error: {error}") - - def on_close(_, status_code, message): - logger.warning(f"[EVENTS] WebSocket closed: {status_code} {message}") - self.receive_event_queue.put_nowait(ValueError("WebSocket closed")) - - ws = WebSocketApp( - ws_url, - on_message=on_message, - on_open=on_open, - on_close=on_close, - on_error=on_error, - ) - - from threading import Thread - - wst = Thread(target=ws.run_forever) - wst.daemon = True - wst.start() - - return ws - - async def run_forever(self): - self.running = True - while self.running: - try: - if not self.is_websocket_connected: - self.ws = await self.connect() - await asyncio.sleep(5) - - req = await self.send_req_queue.get() - assert self.ws - self.ws.send(json.dumps(req)) - except Exception as ex: - logger.warning(f"[EVENTS] NostrClient error: {ex}") - await asyncio.sleep(60) - - def is_duplicate_event(self, event_id: str) -> bool: - """Check if an event has been seen recently.""" - if event_id in self._seen_events: - return True - self._seen_events[event_id] = None - if len(self._seen_events) > MAX_SEEN_EVENTS: - self._seen_events.popitem(last=False) - return False - - async def get_event(self): - """Get next event from the receive queue.""" - value = await self.receive_event_queue.get() - if isinstance(value, ValueError): - raise value - return value - - async def publish_nostr_event(self, e: NostrEvent): - await self.send_req_queue.put(["EVENT", e.dict()]) - - async def subscribe(self, filters: list[dict]): - """Subscribe to events matching the given filters.""" - self.subscription_id = "events-" + urlsafe_short_hash()[:32] - await self.send_req_queue.put(["REQ", self.subscription_id, *filters]) - logger.info( - f"[EVENTS] Subscribed to NIP-52 events " - f"(sub: {self.subscription_id[:20]}...)" - ) - - async def unsubscribe(self): - """Unsubscribe from current subscription.""" - await self.send_req_queue.put(["CLOSE", self.subscription_id]) - - async def stop(self): - await self.unsubscribe() - self.running = False - await asyncio.sleep(2) - if self.ws: - try: - self.ws.close() - except Exception: - pass - self.ws = None diff --git a/nostr_hooks.py b/nostr_hooks.py deleted file mode 100644 index 32ea11c..0000000 --- a/nostr_hooks.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Helpers that bridge event-mutation handlers to the Nostr publisher. - -Lives in its own module so both `events_api_router` and any future router -can call it without importing through `views_api`, which would create an -import cycle (views_api -> nostr_hooks -> nostr_publisher -> models). -""" - -from loguru import logger - -from .crud import update_event -from .models import Event -from .nostr_publisher import publish_event_to_nostr - - -async def publish_or_delete_nostr_event(event: Event, *, delete: bool = False) -> None: - """Publish or delete the NIP-52 calendar event for `event`. - - Resolves a `NostrSigner` for the wallet owner — backend-agnostic - (LocalSigner / RemoteBunkerSigner / ClientSideOnlySigner). The - signer abstraction handles the actual key material; this hook - only needs `signer.pubkey` for event construction and - `await signer.sign_event(...)` for signing. Failures are logged - and swallowed so a Nostr outage doesn't break the HTTP flow that - triggered the publish. - """ - try: - from lnbits.core.signers import resolve_for_wallet - - from . import nostr_client - - signer = await resolve_for_wallet(event.wallet) - if signer is None: - # Wallet missing, account missing, unclassified row, or - # ClientSideOnlySigner account (server can't sign for them). - # Soft-fail: skip the publish silently. The user can still - # publish kind-31922/31923 events client-side once we have - # that path. - return - - nostr_event = await publish_event_to_nostr( - nostr_client, event, signer, delete=delete - ) - if nostr_event and not delete: - event.nostr_event_id = nostr_event.id - event.nostr_event_created_at = nostr_event.created_at - await update_event(event) - except Exception as exc: - logger.warning(f"[EVENTS] Nostr publish failed: {exc}") diff --git a/nostr_publisher.py b/nostr_publisher.py deleted file mode 100644 index 2588fcb..0000000 --- a/nostr_publisher.py +++ /dev/null @@ -1,204 +0,0 @@ -""" -NIP-52 calendar event publishing for the events extension. - -Builds NIP-52 calendar events from the Event model, signs them via the -core `NostrSigner` abstraction (backend-agnostic: LocalSigner, -RemoteBunkerSigner, etc.), and publishes via the NostrClient. - -Kind 31922 is used for date-only events; kind 31923 (time-based) is used -when event_start_date / event_end_date include a time component. - -Reference: https://github.com/nostr-protocol/nips/blob/master/52.md -""" - -import time -from datetime import datetime, timezone - -from lnbits.core.signers import NostrSigner -from loguru import logger - -from .models import Event -from .nostr.event import NostrEvent -from .nostr_timestamp import monotonic_created_at - - -def _has_time(value: str | None) -> bool: - """ISO 8601 datetime strings contain a 'T' between date and time.""" - return value is not None and "T" in value - - -def _to_unix(value: str) -> int: - """Parse ISO 8601 datetime (assume UTC if naive) to unix seconds.""" - dt = datetime.fromisoformat(value) - if dt.tzinfo is None: - dt = dt.replace(tzinfo=timezone.utc) - return int(dt.timestamp()) - - -def build_nip52_event(event: Event, pubkey: str) -> NostrEvent: - """ - Convert an Event model to a NIP-52 calendar event. - - Time-based (kind 31923) if event_start_date carries an HH:MM, otherwise - date-based (kind 31922). Tags: - d - event.id - title - event.name - start - unix timestamp (31923) or YYYY-MM-DD (31922) - end - same encoding (optional) - image, location, t (categories) - optional - tickets_available - current remaining capacity (omitted when unlimited) - tickets_sold - running paid-count (always emitted; clients can - derive original_capacity = available + sold) - tickets_price - price_per_ticket (always emitted; 0 means free) - tickets_currency - the currency string - tickets_allow_fiat - "true" when fiat checkout is enabled (omitted otherwise) - tickets_fiat_currency - the fiat settle currency (only when allow_fiat) - Content: event.info - - The four ticket_* tags are AIO custom additions outside the NIP-52 - spec; spec-compliant clients ignore unknown tags so this stays - backwards-compatible. They let connected clients render the - "X tickets remaining" badge and the Buy CTA without an extra REST hop, - and pick up live inventory updates via the same relay subscription. - """ - time_based = _has_time(event.event_start_date) - kind = 31923 if time_based else 31922 - start_value = ( - str(_to_unix(event.event_start_date)) if time_based else event.event_start_date - ) - - tags = [ - ["d", event.id], - ["title", event.name], - ["start", start_value], - ] - - end_unix: int | None = None - if event.event_end_date: - end_value = ( - str(_to_unix(event.event_end_date)) if time_based else event.event_end_date - ) - tags.append(["end", end_value]) - if time_based: - end_unix = _to_unix(event.event_end_date) - - if time_based: - start_unix = _to_unix(event.event_start_date) - start_day = start_unix // 86400 - end_day = (end_unix // 86400) if end_unix is not None else start_day - for day in range(start_day, end_day + 1): - tags.append(["D", str(day)]) - - if event.banner: - tags.append(["image", event.banner]) - if event.location: - tags.append(["location", event.location]) - for cat in event.categories or []: - tags.append(["t", cat]) - - # `amount_tickets == 0` means unlimited capacity in this extension's - # schema. Omitting the tag is how clients distinguish unlimited from - # "0 left" (sold out). - if event.amount_tickets > 0: - tags.append(["tickets_available", str(event.amount_tickets)]) - tags.append(["tickets_sold", str(event.sold)]) - tags.append(["tickets_price", str(event.price_per_ticket)]) - tags.append(["tickets_currency", event.currency]) - # Fiat-checkout config — only emitted when allow_fiat is on so - # clients can branch the buy UI without re-reading the schema. - if event.allow_fiat: - tags.append(["tickets_allow_fiat", "true"]) - if event.fiat_currency: - tags.append(["tickets_fiat_currency", event.fiat_currency]) - - # NIP-52 calendar events are replaceable: this d-tag is republished - # whenever inventory changes (a ticket sells). Use a strictly-monotonic - # created_at anchored on the last published value so a same-second - # republish still outranks the prior version and relays push it to open - # subscriptions — a bare int(time.time()) can tie and be silently - # dropped, stalling clients' live "tickets remaining" badge. - nostr_event = NostrEvent( - pubkey=pubkey, - created_at=monotonic_created_at(event.nostr_event_created_at), - kind=kind, - tags=tags, - content=event.info or "", - ) - nostr_event.id = nostr_event.event_id - return nostr_event - - -def build_nip52_delete_event(event: Event, pubkey: str) -> NostrEvent: - """ - Build a kind 5 delete event for a published NIP-52 calendar event. - - Uses an 'a' tag to reference the parameterized replaceable event per - NIP-09. The referenced kind must match what we published — 31923 for - time-based events, 31922 for date-only. - """ - referenced_kind = 31923 if _has_time(event.event_start_date) else 31922 - nostr_event = NostrEvent( - pubkey=pubkey, - created_at=int(time.time()), - kind=5, - tags=[ - ["a", f"{referenced_kind}:{pubkey}:{event.id}"], - ], - content="Event canceled", - ) - nostr_event.id = nostr_event.event_id - return nostr_event - - -async def publish_event_to_nostr( - nostr_client, - event: Event, - signer: NostrSigner, - delete: bool = False, -) -> NostrEvent | None: - """ - Build, sign, and publish a NIP-52 calendar event (or delete event). - - Signing routes through the core `NostrSigner` abstraction — - `signer.pubkey` for the event identity, `await signer.sign_event(...)` - for the Schnorr signature. The signer backend (LocalSigner / - RemoteBunkerSigner) is transparent to this function. - - Returns the published NostrEvent for metadata storage, or None on failure. - """ - if not nostr_client: - logger.debug("[EVENTS] No NostrClient available, skipping publish") - return None - - try: - if delete: - nostr_event = build_nip52_delete_event(event, signer.pubkey) - else: - nostr_event = build_nip52_event(event, signer.pubkey) - - # Hand the unsigned event to the signer — it fills in `id`, - # `pubkey`, and `sig`. The signer's serialization rules match - # NIP-01 (same as the local `event_id` property uses), so the - # returned id matches what we'd have computed locally. - unsigned = { - "kind": nostr_event.kind, - "created_at": nostr_event.created_at, - "tags": nostr_event.tags, - "content": nostr_event.content, - } - signed = await signer.sign_event(unsigned) - nostr_event.id = signed["id"] - nostr_event.pubkey = signed["pubkey"] - nostr_event.sig = signed["sig"] - - await nostr_client.publish_nostr_event(nostr_event) - - logger.info( - f"[EVENTS] Published NIP-52 {'delete' if delete else 'calendar'} " - f"event: {nostr_event.id[:16]}... (kind {nostr_event.kind})" - ) - return nostr_event - - except Exception as e: - logger.warning(f"[EVENTS] Failed to publish to Nostr: {e}") - return None diff --git a/nostr_sync.py b/nostr_sync.py deleted file mode 100644 index 1dc52bc..0000000 --- a/nostr_sync.py +++ /dev/null @@ -1,157 +0,0 @@ -""" -Bidirectional Nostr sync for the events extension. - -Subscribes to NIP-52 calendar events (kind 31922/31923) from relays -and upserts them into the local database. Enables federated event -discovery — events published by other LNbits instances or Nostr -clients appear in the local events listing. -""" - -import asyncio -import json -from datetime import datetime, timezone - -from loguru import logger - -from .crud import db, get_event, update_event -from .models import Event -from .nostr.nostr_client import NostrClient - - -async def process_nostr_message(nostr_client: NostrClient, message: str): - """Process an incoming Nostr relay message.""" - try: - data = json.loads(message) - except json.JSONDecodeError: - return - - if not isinstance(data, list) or len(data) < 2: - return - - msg_type = data[0] - - if msg_type == "EVENT" and len(data) >= 3: - event_data = data[2] - await _handle_calendar_event(nostr_client, event_data) - elif msg_type == "EOSE": - logger.debug("[EVENTS] End of stored events from relay") - elif msg_type == "NOTICE": - logger.info(f"[EVENTS] Relay notice: {data[1]}") - - -async def _handle_calendar_event(nostr_client: NostrClient, event_data: dict): - """Handle an incoming NIP-52 calendar event (kind 31922 or 31923).""" - kind = event_data.get("kind") - if kind not in (31922, 31923): - return - - event_id = event_data.get("id", "") - if nostr_client.is_duplicate_event(event_id): - return - - tags = {t[0]: t[1] for t in event_data.get("tags", []) if len(t) >= 2} - tag_lists = {} - for t in event_data.get("tags", []): - if len(t) >= 2: - tag_lists.setdefault(t[0], []).append(t[1]) - - d_tag = tags.get("d") - if not d_tag: - return - - title = tags.get("title", "Untitled Event") - start = tags.get("start") - if not start: - return - - end = tags.get("end") - description = event_data.get("content", "") - image = tags.get("image") - location = tags.get("location") - categories = tag_lists.get("t", []) - - # Check if we already have this event (by d-tag as our event ID - # or by nostr_event_id) - existing = await get_event(d_tag) - if not existing: - # Check by nostr_event_id - existing = await db.fetchone( - "SELECT * FROM events.events WHERE nostr_event_id = :nid", - {"nid": event_id}, - Event, - ) - - if existing: - # Update if the incoming event is newer - incoming_created_at = event_data.get("created_at", 0) - if ( - existing.nostr_event_created_at - and incoming_created_at <= existing.nostr_event_created_at - ): - return # We already have a newer version - - existing.name = title - existing.info = description - existing.event_start_date = start - existing.event_end_date = end - existing.banner = image - existing.location = location - existing.categories = categories - existing.nostr_event_id = event_id - existing.nostr_event_created_at = incoming_created_at - await update_event(existing) - logger.info(f"[EVENTS] Updated event from Nostr: {title}") - else: - # Create new event from Nostr — discovered events are auto-approved - # (they're already public on relays). Use the d-tag as the event ID - # for replaceable-event correlation. - new_event = Event( - id=d_tag, - wallet="", - name=title, - info=description, - event_start_date=start, - event_end_date=end, - banner=image, - location=location, - categories=categories, - status="approved", - time=datetime.now(timezone.utc), - nostr_event_id=event_id, - nostr_event_created_at=event_data.get("created_at", 0), - ) - try: - await db.insert("events.events", new_event) - logger.info(f"[EVENTS] Discovered event from Nostr: {title}") - except Exception as e: - # Likely duplicate key — skip - logger.debug(f"[EVENTS] Skipped duplicate event: {e}") - - -async def wait_for_nostr_events(nostr_client: NostrClient): - """ - Background task: subscribe to NIP-52 events and process them. - """ - logger.info("[EVENTS] Starting Nostr event sync...") - - while True: - try: - # Subscribe to NIP-52 calendar events - await nostr_client.subscribe( - [ - {"kinds": [31922, 31923]}, - ] - ) - - # Process incoming events - while True: - message = await nostr_client.get_event() - await process_nostr_message(nostr_client, message) - - except ValueError: - # WebSocket closed — will reconnect - logger.warning("[EVENTS] Nostr connection lost, resubscribing...") - await asyncio.sleep(10) - except Exception as e: - logger.error(f"[EVENTS] Nostr sync error: {e}") - await asyncio.sleep(30) diff --git a/nostr_timestamp.py b/nostr_timestamp.py deleted file mode 100644 index 625b21c..0000000 --- a/nostr_timestamp.py +++ /dev/null @@ -1,34 +0,0 @@ -"""Monotonic ``created_at`` for replaceable / addressable Nostr events. - -Relays only push a replaceable update to OPEN subscriptions when its -``created_at`` is strictly newer than the version they already hold. -``created_at`` is integer seconds, so a publisher that stamps -``int(time.time())`` can emit two versions within the same wall-clock -second (e.g. two ticket sales republishing the NIP-52 calendar event) — -the relay treats the second as not-newer and never propagates it to live -subscribers (it only surfaces on a reload / fresh REQ). - -Returning ``max(now, last_created_at + 1)`` guarantees a strictly -increasing timestamp across successive publishes of the same replaceable -event. When enough real seconds have elapsed it tracks wall-clock; only -same-second (or clock-skewed) republishes get nudged forward. - -Mirrors the webapp's ``monotonicCreatedAt`` (src/lib/nostr/timestamp.ts) -and ``docs/nostr-patterns/replaceable-events.md``. -""" - -import time - - -def monotonic_created_at(last_created_at: int | None, now: int | None = None) -> int: - """Strictly-newer ``created_at`` for the next publish of a coord. - - :param last_created_at: ``created_at`` of the previously published - version (seconds), or ``None`` if none has been published yet. - :param now: Current time in seconds — injectable for tests; defaults - to ``int(time.time())``. - """ - base = int(time.time()) if now is None else now - if last_created_at is None: - return base - return max(base, last_created_at + 1) diff --git a/services.py b/services.py index 0a2de28..9099ef0 100644 --- a/services.py +++ b/services.py @@ -1,16 +1,3 @@ -from __future__ import annotations - -import asyncio -from asyncio.tasks import create_task - -from lnbits.core.models.users import UserNotifications -from lnbits.core.services.nostr import send_nostr_dm -from lnbits.core.services.notifications import ( - send_email_notification, - send_user_notification, -) -from lnbits.settings import settings -from lnbits.utils.nostr import normalize_private_key, normalize_public_key from lnurl import execute from loguru import logger @@ -21,146 +8,25 @@ from .crud import ( update_event, update_ticket, ) -from .models import Event, Ticket -from .nostr_hooks import publish_or_delete_nostr_event - -DEFAULT_NOSTR_RELAYS = [ - "wss://relay.damus.io", - "wss://relay.primal.net", - "wss://relay.nostr.band", -] - -# Per-event lock: serializes the counter-update + Nostr republish for a -# single event_id so two paid invoices landing on the listener queue back- -# to-back can't reorder the published state. Lazy-populated; entries are -# left in memory for the lifetime of the process (cheap — one asyncio.Lock -# object per event ever sold). -_event_paid_locks: dict[str, asyncio.Lock] = {} - - -def _event_paid_lock(event_id: str) -> asyncio.Lock: - lock = _event_paid_locks.get(event_id) - if lock is None: - lock = asyncio.Lock() - _event_paid_locks[event_id] = lock - return lock +from .models import Ticket async def set_ticket_paid(ticket: Ticket) -> Ticket: if ticket.paid: return ticket - async with _event_paid_lock(ticket.event): - ticket.paid = True - await update_ticket(ticket) + ticket.paid = True + await update_ticket(ticket) - event = await get_event(ticket.event) - assert event, "Couldn't get event from ticket being paid" - event.sold += 1 - event.amount_tickets -= 1 - await update_event(event) - - # Republish the NIP-52 calendar event so connected clients see - # the new tickets_available / tickets_sold counters via their - # existing relay subscription. Failures are logged + swallowed - # inside publish_or_delete_nostr_event so a Nostr outage doesn't - # break the payment flow. - await publish_or_delete_nostr_event(event) + event = await get_event(ticket.event) + assert event, "Couldn't get event from ticket being paid" + event.sold += 1 + event.amount_tickets -= 1 + await update_event(event) return ticket -def send_ticket_notification_in_background(ticket: Ticket) -> None: - create_task(_send_ticket_notification(ticket)) - - -async def _send_ticket_notification(ticket: Ticket) -> None: - event = await get_event(ticket.event) - if not event: - logger.warning(f"Event {ticket.event} not found for ticket notification.") - return - - subject, message = _ticket_notification_message(ticket, event) - updated = False - - if ( - event.extra.email_notifications - and settings.lnbits_email_notifications_enabled - and ticket.email - ): - try: - await send_email_notification([ticket.email], message, subject) - ticket.extra.email_notification_sent = True - updated = True - except Exception as exc: - logger.warning(f"Failed to email ticket {ticket.id}: {exc}") - - if ( - event.extra.nostr_notifications - and settings.is_nostr_notifications_configured() - and ticket.extra.nostr_identifier - ): - try: - await _send_nostr_ticket_notification( - ticket.extra.nostr_identifier, message - ) - ticket.extra.nostr_notification_sent = True - updated = True - except Exception as exc: - logger.warning(f"Failed to send nostr DM for ticket {ticket.id}: {exc}") - - if updated: - await update_ticket(ticket) - - -async def resend_ticket_email_notification(ticket: Ticket) -> Ticket: - event = await get_event(ticket.event) - if not event: - raise ValueError("Event does not exist.") - if not settings.lnbits_email_notifications_enabled: - raise ValueError("Email notifications are not enabled.") - if not ticket.email: - raise ValueError("Ticket does not have an email address.") - - subject, message = _ticket_notification_message(ticket, event) - await send_email_notification([ticket.email], message, subject) - ticket.extra.email_notification_sent = True - return await update_ticket(ticket) - - -def _ticket_notification_message(ticket: Ticket, event: Event) -> tuple[str, str]: - ticket_url = _ticket_url(ticket) - subject = ( - event.extra.notification_subject.strip() - or f"Your ticket for '{event.name}' is ready" - ) - body = ( - event.extra.notification_body.strip() - or f"Your ticket for '{event.name}' is ready." - ) - - return subject, f"{body}\n\nOpen it here: {ticket_url}" - - -async def _send_nostr_ticket_notification(identifier: str, message: str) -> None: - if "@" in identifier: - await send_user_notification( - UserNotifications(nostr_identifier=identifier), - message, - "text_message", - ) - return - - private_key = normalize_private_key(settings.lnbits_nostr_notifications_private_key) - public_key = normalize_public_key(identifier) - await send_nostr_dm(private_key, public_key, message, DEFAULT_NOSTR_RELAYS) - - -def _ticket_url(ticket: Ticket) -> str: - base_url = (ticket.extra.ticket_base_url or settings.lnbits_baseurl).rstrip("/") - return f"{base_url}/events/ticket/{ticket.id}" - - async def refund_tickets(event_id: str): """ Refund tickets for an event that has not met the minimum ticket requirement. diff --git a/static/js/display.js b/static/js/display.js index d8be8e9..6098e5a 100644 --- a/static/js/display.js +++ b/static/js/display.js @@ -1,9 +1,8 @@ -window.PageEventsDisplay = { - template: '#page-events-display', +window.app = Vue.createApp({ + el: '#vue', + mixins: [windowMixin], data() { return { - eventErrorLabel: '', - event: null, paymentReq: null, redirectUrl: null, formDialog: { @@ -11,9 +10,7 @@ window.PageEventsDisplay = { data: { name: '', email: '', - refund: '', - nostr_identifier: '', - payment_method: 'lightning' + refund: '' } }, ticketLink: { @@ -25,71 +22,35 @@ window.PageEventsDisplay = { receive: { show: false, status: 'pending', - paymentReq: null, - isFiat: false - }, - paymentDismissMsg: null, - paymentWebsocket: null + paymentReq: null + } } }, async created() { - this.eventId = this.$route.params.id - this.event = await this.getEvent() + 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 }, computed: { formatDescription() { - return LNbits.utils.convertMarkdown(this.event?.info || '') - }, - allowFiatCheckout() { - return Boolean(this.event?.allow_fiat) - }, - fiatCheckoutLabel() { - if (!this.allowFiatCheckout) return 'Fiat' - const unit = ['sat', 'sats'].includes( - (this.event?.currency || '').toLowerCase() - ) - ? this.event?.fiat_currency - : this.event?.currency - return `Fiat (${(unit || 'GBP').toUpperCase()})` - }, - allowEmailNotifications() { - return Boolean(this.event?.extra?.email_notifications) - }, - allowNostrNotifications() { - return Boolean(this.event?.extra?.nostr_notifications) + return LNbits.utils.convertMarkdown(this.info) } }, methods: { - async getEvent() { - try { - const {data} = await LNbits.api.request( - 'GET', - `/events/api/v1/events/${this.eventId}` - ) - return data - } catch (error) { - this.eventErrorLabel = 'Event unavailable.' - LNbits.utils.notifyApiError(error) - } - }, resetForm(e) { e.preventDefault() this.formDialog.data.name = '' this.formDialog.data.email = '' this.formDialog.data.refund = '' - this.formDialog.data.nostr_identifier = '' - this.formDialog.data.payment_method = 'lightning' }, closeReceiveDialog() { - if (this.paymentDismissMsg) { - this.paymentDismissMsg() - this.paymentDismissMsg = null - } - if (this.paymentWebsocket) { - this.paymentWebsocket.close() - this.paymentWebsocket = null - } + const checker = this.receive.paymentChecker + dismissMsg() + clearInterval(paymentChecker) + setTimeout(() => {}, 10000) }, nameValidation(val) { const regex = /[`!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?~]/g @@ -102,104 +63,68 @@ window.PageEventsDisplay = { const regex = /^[\w\.-]+@[a-zA-Z\d\.-]+\.[a-zA-Z]{2,}$/ return regex.test(val) || 'Please enter valid email.' }, - paymentSuccess(paymentHash) { - if (this.paymentDismissMsg) { - this.paymentDismissMsg() - this.paymentDismissMsg = null - } - this.paymentReq = null - this.formDialog.data.name = '' - this.formDialog.data.email = '' - this.formDialog.data.refund = '' - this.formDialog.data.nostr_identifier = '' - this.formDialog.data.payment_method = 'lightning' - Quasar.Notify.create({ - type: 'positive', - message: 'Sent, thank you!', - icon: null - }) - this.receive = { - show: false, - status: 'complete', - paymentReq: null, - isFiat: false - } - this.ticketLink = { - show: true, - data: { - link: `/events/ticket/${paymentHash}` - } - } - window.open(`/events/ticket/${paymentHash}`, '_blank', 'noopener') - }, - async createInvoice() { - try { - const {data} = await LNbits.api.request( - 'POST', - `/events/api/v1/tickets/${this.eventId}`, - null, - { - name: this.formDialog.data.name, - email: this.formDialog.data.email, - promo_code: this.formDialog.data.promo_code || null, - refund_address: this.formDialog.data.refund || null, - nostr_identifier: this.formDialog.data.nostr_identifier || null, - payment_method: this.formDialog.data.payment_method - } - ) - 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({ - timeout: 0, - message: 'Waiting for payment...' + 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 }) - this.receive = { - show: true, - status: 'pending', - paymentReq: this.paymentReq, - isFiat - } - if (isFiat && this.paymentReq) { - window.open(this.paymentReq, '_blank', 'noopener') - } - this.paymentWatcher(this.paymentHash) - } catch (error) { - LNbits.utils.notifyApiError(error) - } - }, - paymentWatcher(paymentHash) { - if (this.paymentWebsocket) { - this.paymentWebsocket.close() - } + .then(response => { + this.paymentReq = response.data.payment_request + this.paymentCheck = response.data.payment_hash - const url = new URL(window.location) - url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' - url.pathname = `/api/v1/ws/${paymentHash}` - url.search = '' - url.hash = '' + dismissMsg = Quasar.Notify.create({ + timeout: 0, + message: 'Waiting for payment...' + }) - const ws = new WebSocket(url.toString()) - this.paymentWebsocket = ws + this.receive = { + show: true, + status: 'pending', + paymentReq: this.paymentReq + } + 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 = '' - ws.onmessage = event => { - const data = JSON.parse(event.data) - if (data.pending === false) { - this.paymentSuccess(paymentHash) - ws.close() - } - } - ws.onerror = error => { - console.error('WebSocket error:', error) - } - ws.onclose = () => { - if (this.paymentWebsocket === ws) { - this.paymentWebsocket = null - } - } + 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) + }) + .catch(LNbits.utils.notifyApiError) } } -} +}) diff --git a/static/js/display.vue b/static/js/display.vue deleted file mode 100644 index 9b27783..0000000 --- a/static/js/display.vue +++ /dev/null @@ -1,215 +0,0 @@ - diff --git a/static/js/index.js b/static/js/index.js index 2b4bcb9..d26133c 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -1,64 +1,18 @@ -window.PageEvents = { - template: '#page-events', +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], data() { return { events: [], tickets: [], - resendingTicketEmails: [], currencies: [], - pendingEvents: [], - allUserEvents: [], - isAdmin: false, - republishing: false, - republishingMine: false, - settings: { - auto_approve: false - }, - allUsersEventsTable: { - // Shown on the admin All Users' Events card. Includes the - // wallet owner (`wallet_user_id` resolved server-side) so - // cross-tenant rows are attributable to a user. - columns: [ - { - name: 'wallet_user_id', - align: 'left', - label: 'Owner', - field: 'wallet_user_id' - }, - {name: 'id', align: 'left', label: 'ID', field: 'id'}, - {name: 'name', align: 'left', label: 'Name', field: 'name'}, - { - name: 'event_start_date', - align: 'left', - label: 'Start date', - field: 'event_start_date' - }, - { - name: 'event_end_date', - align: 'left', - label: 'End date', - field: 'event_end_date' - }, - { - name: 'closing_date', - align: 'left', - label: 'Ticket close', - field: 'closing_date' - }, - { - name: 'canceled', - align: 'left', - label: 'Canceled', - field: row => { - if (row.extra && row.extra.conditional && row.canceled) { - return 'Yes' - } - return 'No' - } - }, - {name: 'status', align: 'left', label: 'Status', field: 'status'} - ] - }, eventsTable: { columns: [ {name: 'id', align: 'left', label: 'ID', field: 'id'}, @@ -97,7 +51,7 @@ window.PageEvents = { align: 'left', label: 'Price', field: row => { - if (this.isFiatCurrency(row.currency)) { + if (row.currency != 'sats') { return LNbits.utils.formatCurrency( row.price_per_ticket.toFixed(2), row.currency @@ -119,8 +73,7 @@ window.PageEvents = { field: 'sold' }, {name: 'info', align: 'left', label: 'Info', field: 'info'}, - {name: 'banner', align: 'left', label: 'Banner', field: 'banner'}, - {name: 'status', align: 'left', label: 'Status', field: 'status'} + {name: 'banner', align: 'left', label: 'Banner', field: 'banner'} ], pagination: { rowsPerPage: 10 @@ -152,36 +105,31 @@ window.PageEvents = { formDialog: { show: false, data: { - currency: 'sats', - allow_fiat: false, - fiat_currency: 'GBP', extra: { - promo_codes: [], - notification_subject: '', - notification_body: '' + promo_codes: [] } } } } }, methods: { - isFiatCurrency(currency) { - return !['sat', 'sats'].includes((currency || '').toLowerCase()) - }, getTickets() { LNbits.api .request( 'GET', '/events/api/v1/tickets?all_wallets=true', - this.g.user.wallets[0].adminkey + this.g.user.wallets[0].inkey ) .then(response => { - this.tickets = response.data.filter(e => e.paid) + this.tickets = response.data + .map(function (obj) { + return mapEvents(obj) + }) + .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') @@ -190,43 +138,16 @@ window.PageEvents = { .request( 'DELETE', '/events/api/v1/tickets/' + ticketId, - wallet.adminkey + _.findWhere(this.g.user.wallets, {id: tickets.wallet}).inkey ) .then(response => { this.tickets = _.reject(this.tickets, function (obj) { return obj.id == ticketId }) }) - .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 - ) + .catch(function (error) { + LNbits.utils.notifyApiError(error) + }) }) }, exportticketsCSV() { @@ -240,190 +161,21 @@ window.PageEvents = { this.g.user.wallets[0].inkey ) .then(response => { - this.events = response.data + this.events = response.data.map(obj => { + return mapEvents(obj) + }) this.checkCanceledEvents() }) - - // Admin probe: a 200 from /all means we're an LNbits admin. - LNbits.api - .request('GET', '/events/api/v1/events/all') - .then(response => { - this.isAdmin = true - const ownWalletIds = this.g.user.wallets.map(w => w.id) - this.allUserEvents = response.data.filter( - e => !ownWalletIds.includes(e.wallet) - ) - }) - .catch(() => { - this.isAdmin = false - this.allUserEvents = [] - }) - }, - getSettings() { - LNbits.api - .request('GET', '/events/api/v1/events/settings') - .then(response => { - this.settings = response.data - }) - .catch(() => { - // Not admin or settings unavailable; keep defaults. - }) - }, - saveSettings() { - LNbits.api - .request( - 'PUT', - '/events/api/v1/events/settings', - null, - this.settings - ) - .then(() => { - Quasar.Notify.create({type: 'positive', message: 'Settings saved'}) - }) - .catch(LNbits.utils.notifyApiError) - }, - getPendingEvents() { - LNbits.api - .request('GET', '/events/api/v1/events/pending') - .then(response => { - this.pendingEvents = response.data - }) - .catch(() => { - this.pendingEvents = [] - }) - }, - approveEvent(eventId) { - LNbits.utils.confirmDialog('Approve this event?').onOk(() => { - LNbits.api - .request('PUT', '/events/api/v1/events/' + eventId + '/approve') - .then(() => { - Quasar.Notify.create({ - type: 'positive', - message: 'Event approved' - }) - this.getEvents() - this.getPendingEvents() - }) - .catch(LNbits.utils.notifyApiError) - }) - }, - rejectEvent(eventId) { - LNbits.utils.confirmDialog('Reject this event?').onOk(() => { - LNbits.api - .request('PUT', '/events/api/v1/events/' + eventId + '/reject') - .then(() => { - Quasar.Notify.create({ - type: 'positive', - message: 'Event rejected' - }) - this.getEvents() - this.getPendingEvents() - }) - .catch(LNbits.utils.notifyApiError) - }) - }, - republishAllEvents() { - LNbits.utils - .confirmDialog( - 'Re-emit every approved event to Nostr relays? This is safe ' + - 'to run multiple times but generates one event per approved row.' - ) - .onOk(() => { - this.republishing = true - LNbits.api - .request('POST', '/events/api/v1/events/republish-all') - .then(response => { - Quasar.Notify.create({ - type: 'positive', - message: - 'Republished ' + - response.data.republished + - ' of ' + - response.data.total + - ' events' - }) - }) - .catch(LNbits.utils.notifyApiError) - .finally(() => { - this.republishing = false - }) - }) - }, - republishMyEvents() { - LNbits.utils - .confirmDialog( - 'Re-emit your approved events to Nostr relays?' - ) - .onOk(() => { - this.republishingMine = true - LNbits.api - .request( - 'POST', - '/events/api/v1/events/republish-mine?all_wallets=true', - this.g.user.wallets[0].adminkey - ) - .then(response => { - Quasar.Notify.create({ - type: 'positive', - message: - 'Republished ' + - response.data.republished + - ' of your ' + - response.data.total + - ' events' - }) - }) - .catch(LNbits.utils.notifyApiError) - .finally(() => { - this.republishingMine = false - }) - }) - }, - foldDateTime(day, time) { - // Combine separate date/time inputs into the wire format - // expected by the events extension: "YYYY-MM-DD" or - // "YYYY-MM-DDTHH:MM" (time is optional). - if (!day) return null - return time ? `${day}T${time}` : day - }, - splitDateTime(value) { - // Inverse of foldDateTime: split a stored string back into the - // day/time pieces the form inputs bind to. - if (!value) return {day: '', time: ''} - const [day, time = ''] = value.split('T') - // Time inputs only accept HH:MM, drop any seconds we stored. - return {day, time: time.slice(0, 5)} }, sendEventData() { const wallet = _.findWhere(this.g.user.wallets, { id: this.formDialog.data.wallet }) - const data = {...this.formDialog.data} - data.event_start_date = this.foldDateTime( - data.event_start_day, - data.event_start_time - ) - data.event_end_date = this.foldDateTime( - data.event_end_day, - data.event_end_time - ) - delete data.event_start_day - delete data.event_start_time - delete data.event_end_day - delete data.event_end_time - - if (data.extra?.promo_codes) { + const data = this.formDialog.data + if (data.extra && !data.extra.promo_codes) { data.extra.promo_codes = data.extra.promo_codes - .filter(code => code.code?.trim() !== '') - .map(code => ({ - ...code, - code: code.code.trim().toUpperCase() - })) - } - if (!this.isFiatCurrency(data.currency)) { - if (!data.allow_fiat) { - data.fiat_currency = 'GBP' - } + .filter(code => code.trim() !== '') + .map(code => code.trim().toUpperCase()) } if (data.id) { @@ -435,32 +187,13 @@ window.PageEvents = { openEventDialog(data = false) { if (data && data.id) { - const start = this.splitDateTime(data.event_start_date) - const end = this.splitDateTime(data.event_end_date) - this.formDialog.data = { - ...data, - event_start_day: start.day, - event_start_time: start.time, - event_end_day: end.day, - event_end_time: end.time - } + this.formDialog.data = {...data} } else { this.formDialog.data = { - currency: 'sats', - allow_fiat: false, - fiat_currency: 'GBP', - event_start_day: '', - event_start_time: '', - event_end_day: '', - event_end_time: '', extra: { conditional: false, min_tickets: 1, - email_notifications: false, - nostr_notifications: false, - promo_codes: [], - notification_subject: '', - notification_body: '' + promo_codes: [] } } } @@ -469,15 +202,8 @@ window.PageEvents = { resetEventDialog() { this.formDialog.show = false this.formDialog.data = { - currency: 'sats', - allow_fiat: false, - fiat_currency: 'GBP', extra: { - email_notifications: false, - nostr_notifications: false, - promo_codes: [], - notification_subject: '', - notification_body: '' + promo_codes: [] } } }, @@ -486,7 +212,7 @@ window.PageEvents = { LNbits.api .request('POST', '/events/api/v1/events', wallet.adminkey, data) .then(response => { - this.events.push(response.data) + this.events.push(mapEvents(response.data)) this.resetEventDialog() }) .catch(LNbits.utils.notifyApiError) @@ -507,7 +233,7 @@ window.PageEvents = { this.events = _.reject(this.events, function (obj) { return obj.id == data.id }) - this.events.push(response.data) + this.events.push(mapEvents(response.data)) this.resetEventDialog() }) .catch(LNbits.utils.notifyApiError) @@ -529,7 +255,7 @@ window.PageEvents = { return obj.id == eventsId }) }) - .catch(LNbits.utils.notifyApiError) + .catch(LNbits.utils.notifyApiError(error)) }) }, exporteventsCSV() { @@ -553,7 +279,9 @@ window.PageEvents = { message: `Event ${ev.name} has been canceled and refunds have been issued.`, icon: null }) - this.events = this.events.map(e => (e.id === ev.id ? data : e)) + this.events = this.events.map(e => + e.id === ev.id ? mapEvents(data) : e + ) } }) } @@ -562,13 +290,7 @@ window.PageEvents = { if (this.g.user.wallets.length) { this.getTickets() this.getEvents() - this.getSettings() - this.getPendingEvents() - if (this.g.allowedCurrencies && this.g.allowedCurrencies.length > 0) { - this.currencies = ['sats', ...this.g.allowedCurrencies] - } else { - this.currencies = ['sats', ...this.g.currencies] - } + this.currencies = await LNbits.api.getCurrencies() } } -} +}) diff --git a/static/js/index.vue b/static/js/index.vue deleted file mode 100644 index 6e6891f..0000000 --- a/static/js/index.vue +++ /dev/null @@ -1,783 +0,0 @@ - diff --git a/static/js/register.js b/static/js/register.js index f642493..a7ab92f 100644 --- a/static/js/register.js +++ b/static/js/register.js @@ -1,18 +1,28 @@ -window.PageEventsRegister = { - template: '#page-events-register', +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], data() { return { tickets: [], ticketsTable: { columns: [ + {name: 'id', align: 'left', label: 'ID', field: 'id'}, {name: 'name', align: 'left', label: 'Name', field: 'name'}, - {name: 'email', align: 'left', label: 'Email', field: 'email'}, { - name: 'id', + name: 'registered', align: 'left', - label: 'ID', - field: 'id', - format: val => this.shortId(val) + label: 'Registered', + field: 'registered' } ], pagination: { @@ -22,20 +32,12 @@ window.PageEventsRegister = { sendCamera: { show: false, camera: 'auto' - }, - lastScan: null + } } }, methods: { - 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) + hoverEmail(tmp) { + this.tickets.data.emailtemp = tmp }, closeCamera() { this.sendCamera.show = false @@ -43,32 +45,34 @@ 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}`) + .request('GET', `/events/api/v1/register/ticket/${value}`) + .then(() => { + Quasar.Notify.create({ + type: 'positive', + message: 'Registered!' + }) + setTimeout(() => { + window.location.reload() + }, 2000) + }) + .catch(LNbits.utils.notifyApiError) + }, + getEventTickets() { + LNbits.api + .request('GET', `/events/api/v1/eventtickets/${event_id}`) .then(response => { - 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) + this.tickets = response.data.map(obj => { + return mapEvents(obj) + }) }) + .catch(LNbits.utils.notifyApiError) } }, created() { - this.eventId = this.$route.params.id - this.loadScannedTickets() + this.getEventTickets() } -} +}) diff --git a/static/js/register.vue b/static/js/register.vue deleted file mode 100644 index 8055348..0000000 --- a/static/js/register.vue +++ /dev/null @@ -1,86 +0,0 @@ - diff --git a/static/js/ticket.js b/static/js/ticket.js deleted file mode 100644 index 82fbd6d..0000000 --- a/static/js/ticket.js +++ /dev/null @@ -1,26 +0,0 @@ -window.PageEventsTicket = { - template: '#page-events-ticket', - data() { - return { - ticketId: null, - ticket: null - } - }, - methods: { - printWindow() { - window.print() - } - }, - async created() { - this.ticketId = this.$route.params.id - try { - const {data} = await LNbits.api.request( - 'GET', - `/events/api/v1/tickets/${this.ticketId}` - ) - this.ticket = data - } catch (error) { - LNbits.utils.notifyApiError(error) - } - } -} diff --git a/static/js/ticket.vue b/static/js/ticket.vue deleted file mode 100644 index 3c932e1..0000000 --- a/static/js/ticket.vue +++ /dev/null @@ -1,39 +0,0 @@ - diff --git a/static/routes.json b/static/routes.json deleted file mode 100644 index ae46a9a..0000000 --- a/static/routes.json +++ /dev/null @@ -1,26 +0,0 @@ -[ - { - "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 1641a75..f7300bb 100644 --- a/tasks.py +++ b/tasks.py @@ -4,24 +4,8 @@ from lnbits.core.models import Payment from lnbits.tasks import register_invoice_listener from loguru import logger -from .crud import get_ticket, get_tickets_by_payment_hash -from .models import Ticket -from .services import send_ticket_notification_in_background, set_ticket_paid - -payment_listeners: dict[str, list[asyncio.Queue[Ticket]]] = {} - - -def register_payment_listener(payment_hash, queue: asyncio.Queue[Ticket]) -> None: - if payment_hash not in payment_listeners: - payment_listeners[payment_hash] = [] - payment_listeners[payment_hash].append(queue) - - -def deregister_payment_listener(payment_hash, queue: asyncio.Queue[Ticket]) -> None: - if payment_hash in payment_listeners: - payment_listeners[payment_hash].remove(queue) - if not payment_listeners[payment_hash]: - del payment_listeners[payment_hash] +from .crud import get_ticket +from .services import set_ticket_paid async def wait_for_paid_invoices(): @@ -37,32 +21,13 @@ async def on_invoice_paid(payment: Payment) -> None: if not payment.extra or "events" != payment.extra.get("tag"): return - # Multi-ticket purchases land as N rows sharing this payment_hash; - # each one needs to be marked paid + counted against capacity, and - # each gets its own buyer notification (mostly a no-op when all - # rows are owned by the same buyer, but cheap and consistent). - tickets = await get_tickets_by_payment_hash(payment.payment_hash) - if not tickets: - # Backstop for any legacy row created before the payment_hash - # column was populated by the migration backfill. - legacy = await get_ticket(payment.payment_hash) - if legacy: - tickets = [legacy] - - if not tickets: - logger.warning(f"No tickets for payment {payment.payment_hash}.") + if not payment.extra.get("name") or not payment.extra.get("email"): + logger.warning(f"Ticket {payment.payment_hash} missing name or email.") return - paid_tickets: list[Ticket] = [] - for ticket in tickets: - paid_tickets.append(await set_ticket_paid(ticket)) + ticket = await get_ticket(payment.payment_hash) + if not ticket: + logger.warning(f"Ticket for payment {payment.payment_hash} not found.") + return - for paid_ticket in paid_tickets: - send_ticket_notification_in_background(paid_ticket) - - # Wake up the WebSocket / poll listeners. Forward the first paid - # ticket so the existing single-ticket subscribers still work; the - # webapp re-fetches all ids via the polling endpoint anyway. - if payment_listeners.get(payment.payment_hash): - for paid_ticket_queue in payment_listeners[payment.payment_hash]: - paid_ticket_queue.put_nowait(paid_tickets[0]) + await set_ticket_paid(ticket) diff --git a/templates/events/_api_docs.html b/templates/events/_api_docs.html new file mode 100644 index 0000000..dbf0131 --- /dev/null +++ b/templates/events/_api_docs.html @@ -0,0 +1,25 @@ + + + +
+ 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 new file mode 100644 index 0000000..73d279d --- /dev/null +++ b/templates/events/display.html @@ -0,0 +1,116 @@ +{% 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 new file mode 100644 index 0000000..3993db5 --- /dev/null +++ b/templates/events/error.html @@ -0,0 +1,31 @@ +{% 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 new file mode 100644 index 0000000..62752d1 --- /dev/null +++ b/templates/events/index.html @@ -0,0 +1,464 @@ +{% 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 new file mode 100644 index 0000000..92589a3 --- /dev/null +++ b/templates/events/register.html @@ -0,0 +1,84 @@ +{% 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 new file mode 100644 index 0000000..bcf7e82 --- /dev/null +++ b/templates/events/ticket.html @@ -0,0 +1,39 @@ +{% 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/tests/test_nostr_timestamp.py b/tests/test_nostr_timestamp.py deleted file mode 100644 index 693a997..0000000 --- a/tests/test_nostr_timestamp.py +++ /dev/null @@ -1,32 +0,0 @@ -from itertools import pairwise - -from ..nostr_timestamp import monotonic_created_at - - -def test_no_prior_uses_now(): - assert monotonic_created_at(None, now=1000) == 1000 - - -def test_same_second_bumps_past_prior(): - # now == last: a naive int(time.time()) would tie and the relay would - # drop the update; we must produce a strictly newer stamp. - assert monotonic_created_at(1000, now=1000) == 1001 - - -def test_tracks_wallclock_once_seconds_elapse(): - assert monotonic_created_at(1000, now=1005) == 1005 - - -def test_steps_past_future_dated_prior(): - # clock skew / rapid bursts left the stored value ahead of now - assert monotonic_created_at(2000, now=1000) == 2001 - - -def test_strictly_increasing_same_second_burst(): - last = None - stamps = [] - for _ in range(5): - last = monotonic_created_at(last, now=1000) # clock frozen at 1000 - stamps.append(last) - assert stamps == [1000, 1001, 1002, 1003, 1004] - assert all(b > a for a, b in pairwise(stamps)) diff --git a/transport_rpcs.py b/transport_rpcs.py deleted file mode 100644 index e278f91..0000000 --- a/transport_rpcs.py +++ /dev/null @@ -1,120 +0,0 @@ -""" -Nostr-transport RPC handlers for the aiolabs/events extension. - -Each handler is registered with `lnbits.core.services.nostr_transport. -dispatcher.register_rpc` in `events_start()`. The dispatcher resolves -the caller's Nostr pubkey to an LNbits Account → wallet (`AUTH_WALLET`) -and passes a `WalletTypeInfo` as the first argument; handlers verify -event-level ownership on top. - -Errors raise `PermissionError` / `ValueError` so the dispatcher maps -them into `{status: "ERROR", error: }` responses; any other -exception falls through to a generic "Internal error" reply. -""" - -from __future__ import annotations - -from datetime import datetime, timezone - -from lnbits.core.crud import get_user -from lnbits.core.models import WalletTypeInfo -from lnbits.core.services.nostr_transport.models import NostrRpcRequest - -from .crud import get_event, get_ticket, get_tickets_by_event, update_ticket - - -async def handle_events_ticket_register( - auth: WalletTypeInfo, - request: NostrRpcRequest, -) -> dict: - """Mark a ticket as registered at the door (organizer flow). - - The Nostr-transport dispatcher already verified the caller signed - the kind-21000 RPC event and bound them to `auth.wallet`. This - handler adds the event-level check: the ticket's event must be - owned by one of the caller's wallets. - - Idempotence mirrors the HTTP endpoint: scanning the same ticket - twice fails with "Ticket already registered". The buyer-side flow - (notifications etc.) reuses whatever the legacy register endpoint - does — we just flip the flag + timestamp. - """ - body = request.body or {} - event_id = body.get("event_id") - ticket_id = body.get("ticket_id") - if not event_id or not ticket_id: - raise ValueError("event_id and ticket_id are required") - - ticket = await get_ticket(ticket_id) - if not ticket or ticket.event != event_id: - raise ValueError("Ticket does not exist on this event") - if not ticket.paid: - raise PermissionError("Ticket not paid for") - if ticket.registered: - raise PermissionError("Ticket already registered") - - event = await get_event(event_id) - if not event: - raise ValueError("Event does not exist") - - user = await get_user(auth.wallet.user) - owned_wallet_ids = user.wallet_ids if user else [auth.wallet.id] - if event.wallet not in owned_wallet_ids: - raise PermissionError("You do not own this event") - - ticket.registered = True - ticket.reg_timestamp = datetime.now(timezone.utc) - await update_ticket(ticket) - return ticket.dict() - - -async def handle_events_list_event_tickets( - auth: WalletTypeInfo, - request: NostrRpcRequest, -) -> dict: - """Return paid + registered counts plus the per-ticket roster for - one calendar event, organizer-only. - - Backs the door scanner's counts strip and "All scanned" tab so the - UI reads authoritative state from the backend instead of relying - on per-device localStorage (which diverges the moment a second - organizer scans, or the operator switches devices). - - The roster only includes paid tickets — proposed/unpaid rows are - irrelevant at the door. - """ - body = request.body or {} - event_id = body.get("event_id") - if not event_id: - raise ValueError("event_id is required") - - event = await get_event(event_id) - if not event: - raise ValueError("Event does not exist") - - user = await get_user(auth.wallet.user) - owned_wallet_ids = user.wallet_ids if user else [auth.wallet.id] - if event.wallet not in owned_wallet_ids: - raise PermissionError("You do not own this event") - - tickets = await get_tickets_by_event(event_id) - paid_tickets = [t for t in tickets if t.paid] - registered_count = sum(1 for t in paid_tickets if t.registered) - - return { - "event_id": event_id, - "sold": len(paid_tickets), - "registered": registered_count, - "remaining": len(paid_tickets) - registered_count, - "tickets": [ - { - "id": t.id, - "name": t.name, - "registered": t.registered, - "registered_at": ( - t.reg_timestamp.isoformat() if t.reg_timestamp else None - ), - } - for t in paid_tickets - ], - } diff --git a/views.py b/views.py index 4a3f142..0680dcc 100644 --- a/views.py +++ b/views.py @@ -1,24 +1,139 @@ -from fastapi import APIRouter, Depends -from lnbits.core.views.generic import index, index_public -from lnbits.decorators import check_account_id_exists +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 events_generic_router = APIRouter() -events_generic_router.add_api_route( - "/", - methods=["GET"], - endpoint=index, - dependencies=[Depends(check_account_id_exists)], -) -events_generic_router.add_api_route( - "/{event_id}", methods=["GET"], endpoint=index_public -) +def events_renderer(): + return template_renderer(["events/templates"]) -events_generic_router.add_api_route( - "/ticket/{ticket_id}", methods=["GET"], endpoint=index_public -) -events_generic_router.add_api_route( - "/register/{event_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, + }, + ) diff --git a/views_api.py b/views_api.py index 5ced0ef..e06bfde 100644 --- a/views_api.py +++ b/views_api.py @@ -1,36 +1,19 @@ -import asyncio from datetime import datetime, timezone from http import HTTPStatus -from typing import Any -from fastapi import ( - APIRouter, - Depends, - HTTPException, - Query, - Request, - WebSocket, - WebSocketDisconnect, -) -from lnbits.core.crud import get_user -from lnbits.core.crud.wallets import get_wallet -from lnbits.core.models import Account, User, WalletTypeInfo -from lnbits.core.models.payments import CreateInvoice -from lnbits.core.services import create_payment_request -from lnbits.helpers import urlsafe_short_hash +from fastapi import APIRouter, Depends, Query +from lnbits.core.crud import get_standalone_payment, get_user +from lnbits.core.models import WalletTypeInfo +from lnbits.core.services import create_invoice from lnbits.decorators import ( - check_admin, - check_user_exists, require_admin_key, require_invoice_key, ) -from lnbits.settings import settings from lnbits.utils.exchange_rates import ( fiat_amount_as_satoshis, get_fiat_rate_satoshis, - satoshis_amount_as_fiat, ) -from lnbits.utils.nostr import normalize_public_key +from starlette.exceptions import HTTPException from .crud import ( create_event, @@ -42,582 +25,147 @@ from .crud import ( get_event, get_event_tickets, get_events, - get_pending_events, - get_public_events, - get_settings, get_ticket, get_tickets, - get_tickets_by_event, - get_tickets_by_payment_hash, get_tickets_by_user_id, - purge_unpaid_tickets, update_event, - update_settings, update_ticket, ) -from .models import ( - CreateEvent, - CreateTicket, - Event, - EventsSettings, - PublicEvent, - PublicTicket, - Ticket, - TicketPaymentRequest, -) -from .nostr_hooks import publish_or_delete_nostr_event -from .services import ( - refund_tickets, - resend_ticket_email_notification, - send_ticket_notification_in_background, - set_ticket_paid, -) -from .tasks import deregister_payment_listener, register_payment_listener +from .models import CreateEvent, CreateTicket, Ticket +from .services import refund_tickets, set_ticket_paid -events_api_router = APIRouter(prefix="/api/v1/events") -tickets_api_router = APIRouter(prefix="/api/v1/tickets") +events_api_router = APIRouter() -def _is_fiat_currency(currency: str | None) -> bool: - return str(currency or "").lower() not in {"sat", "sats"} - - -# Literal-prefix routes (/public, /all, /pending, /settings) MUST be declared -# before any "/{event_id}" route or FastAPI matches them as a path parameter. - - -@events_api_router.get("") +@events_api_router.get("/api/v1/events") 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 await get_events(wallet_ids) + + return [event.dict() for event in await get_events(wallet_ids)] -@events_api_router.get("/public") -async def api_events_public() -> list[Event]: - """Approved, non-canceled events for an anonymous public listing.""" - return await get_public_events() +@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.get("/all") -async def api_events_all( - admin: Account = Depends(check_admin), -) -> list[dict]: - """All events across all wallets, with each row's wallet owner - resolved to a user_id. LNbits admin only. - - Returns dicts (not strict `Event` rows) so the response can carry - the synthetic `wallet_user_id` column the admin UI uses to attribute - each cross-tenant event to a user. - """ - events = await get_all_events() - enriched: list[dict] = [] - for event in events: - wallet = await get_wallet(event.wallet) - row = event.dict() - row["wallet_user_id"] = wallet.user if wallet else None - enriched.append(row) - return enriched - - -@events_api_router.get("/pending") -async def api_events_pending( - admin: Account = Depends(check_admin), -) -> list[Event]: - """Proposed events awaiting admin approval. LNbits admin only.""" - return await get_pending_events() - - -@events_api_router.post("/republish-all") -async def api_republish_all( - admin: Account = Depends(check_admin), -) -> dict: - """Force-republish every approved event to Nostr relays. Admin only. - - Used by the catalog-bump migration that introduced the AIO ticket - tags: existing events on a deployed instance were published before - the publisher learned the new tag set, so they don't carry - tickets_available / tickets_sold / etc. until something triggers - a republish. This endpoint walks the approved list and re-emits - each calendar event so connected clients see the new metadata - without waiting for a per-event edit. - - Errors are swallowed per-event (logged inside the publisher) so - one bad event doesn't block the rest. Returns a count summary. - """ - events = await get_all_events() - approved = [e for e in events if e.status == "approved" and not e.canceled] - for event in approved: - await publish_or_delete_nostr_event(event) - return {"republished": len(approved), "total": len(events)} - - -@events_api_router.post("/republish-mine") -async def api_republish_mine( - all_wallets: bool = Query(False), - key_info: WalletTypeInfo = Depends(require_admin_key), -) -> dict: - """Force-republish the caller's own approved events to Nostr relays. - - Same shape as /republish-all but scoped to events owned by the - authenticated wallet (or all wallets belonging to the wallet's - user when `?all_wallets=true`). Lets the organizer trigger the - same migration the admin uses, without needing instance-admin - rights — useful when the AIO publisher gains a new tag set and - an organizer wants their published events to carry it. - - Only events with `status == "approved"` are republished; pending - and rejected rows aren't on relays in the first place, so a - republish for them would be a no-op (or worse, surface a - proposed-but-not-approved row to subscribers). - """ - wallet_ids: list[str] = [key_info.wallet.id] - if all_wallets: - user = await get_user(key_info.wallet.user) - wallet_ids = user.wallet_ids if user else [] - - events = await get_events(wallet_ids) - approved = [e for e in events if e.status == "approved" and not e.canceled] - for event in approved: - await publish_or_delete_nostr_event(event) - return {"republished": len(approved), "total": len(events)} - - -@events_api_router.get("/settings") -async def api_get_settings( - admin: Account = Depends(check_admin), -) -> EventsSettings: - return await get_settings() - - -@events_api_router.put("/settings") -async def api_update_settings( - data: EventsSettings, - admin: Account = Depends(check_admin), -) -> EventsSettings: - return await update_settings(data) - - -@events_api_router.get("/settings/public") -async def api_get_settings_public( - wallet: WalletTypeInfo = Depends(require_invoice_key), -) -> dict: - """Subset of EventsSettings safe to expose to any authenticated - caller. The webapp needs `auto_approve` to render accurate edit-flow - copy ("your edit will go back to pending" vs "edit stays approved") - without forcing every event-creator to also be an LNbits admin. - """ - settings = await get_settings() - return {"auto_approve": settings.auto_approve} - - -@events_api_router.get("/{event_id}", response_model=PublicEvent) -async def api_get_event(event_id: str) -> Event: - """Public event detail used by display.vue. - - For approved events we run the upstream sold-out / closing-window / - conditional gates. For non-approved events (proposed / rejected) we - return the trimmed PublicEvent with status set so the SFC can render - the pending-approval banner without a separate request. - """ - event = await get_event(event_id) - if not event: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist." - ) - - if event.status != "approved": - # Proposed/rejected events are not yet ticketable; skip ticket gates. - return event - - await purge_unpaid_tickets(event_id) - - # closing_date is filled in by create_event (defaults to end_date or - # start_date) but the field is typed Optional, so guard for the typechecker. - closing_date = event.closing_date or event.event_end_date or event.event_start_date - # Accept either YYYY-MM-DD or full ISO 8601 datetime (event_end_date - # may carry a time component since v1.3.0-aio.3 / our start-end-time - # feature). - try: - closing_dt = datetime.fromisoformat(closing_date) - except ValueError: - closing_dt = datetime.strptime(closing_date[:10], "%Y-%m-%d") - if closing_dt.tzinfo is None: - closing_dt = closing_dt.replace(tzinfo=timezone.utc) - is_window_open = datetime.now(timezone.utc) < closing_dt - is_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.post("/api/v1/events") +@events_api_router.put("/api/v1/events/{event_id}") async def api_event_create( - data: CreateEvent, - wallet: WalletTypeInfo = Depends(require_invoice_key), -) -> Event: - """Create a new event. - - Anyone with a wallet invoice key can submit. Non-LNbits-admins land in - `proposed` status unless `auto_approve` is enabled in extension settings. - """ - if not data.wallet: - data.wallet = wallet.wallet.id - - from lnbits.settings import settings - - ext_settings = await get_settings() - user_id = wallet.wallet.user - is_admin = user_id == settings.super_user or user_id in settings.lnbits_admin_users - if not is_admin and not ext_settings.auto_approve: - data.status = "proposed" - - event = await create_event(data) - - if event.status == "approved": - await publish_or_delete_nostr_event(event) - - return event - - -@events_api_router.put("/{event_id}") -async def api_event_update( - event_id: str, data: CreateEvent, wallet: WalletTypeInfo = Depends(require_admin_key), -) -> Event: - """Update an event. The owner can edit any mutable field; the status - is derived (admin / `auto_approve` ⇒ approved, otherwise proposed) - and is NEVER taken from the request body — that would let owners - self-approve. + event_id: str | None = None, +): + if event_id: + event = await get_event(event_id) + if not event: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist." + ) - Nostr is reconciled against the status transition: - approved → approved : re-publish the replaceable NIP-52 event - proposed → approved : fresh publish - approved → proposed : NIP-09 delete so the public feed drops it - until the edit is re-approved - proposed → proposed : no-op - """ - event = await get_event(event_id) - if not event: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist." - ) - if event.wallet != wallet.wallet.id: - raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your event.") + if event.wallet != wallet.wallet.id: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, detail="Not your event." + ) + for k, v in data.dict().items(): + setattr(event, k, v) + event = await update_event(event) + else: + event = await create_event(data) - from lnbits.settings import settings - - ext_settings = await get_settings() - user_id = wallet.wallet.user - is_admin = user_id == settings.super_user or user_id in settings.lnbits_admin_users - - previous_status = event.status - - # Same defaulting as create_event: optional end/closing dates fall - # back to start_date when omitted, so an edit that doesn't restate - # them doesn't wipe them. - if not data.event_end_date: - data.event_end_date = data.event_start_date - if not data.closing_date: - data.closing_date = data.event_end_date - - # Explicit field list — never copy `status` from the request body. - # Includes upstream v1.6.1 fields (allow_fiat, fiat_currency) so an - # owner editing a fiat-enabled event keeps the fiat config. - for field in ( - "name", - "info", - "closing_date", - "event_start_date", - "event_end_date", - "currency", - "allow_fiat", - "fiat_currency", - "amount_tickets", - "price_per_ticket", - "banner", - "location", - "categories", - "extra", - ): - setattr(event, field, getattr(data, field)) - - event.status = "approved" if (is_admin or ext_settings.auto_approve) else "proposed" - - event = await update_event(event) - - if event.status == "approved": - await publish_or_delete_nostr_event(event) - elif previous_status == "approved": - # Take it down from the public feed while it waits for re-approval. - await publish_or_delete_nostr_event(event, delete=True) - - return event + return event.dict() -@events_api_router.put("/{event_id}/cancel") +@events_api_router.put("/api/v1/events/{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( status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist." ) + if event.wallet != wallet.wallet.id: raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your event.") event.canceled = True event = await update_event(event) await refund_tickets(event.id) - if event.nostr_event_id: - await publish_or_delete_nostr_event(event, delete=True) - - return event + return event.dict() -@events_api_router.delete("/{event_id}") +@events_api_router.delete("/api/v1/events/{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( status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist." ) + if event.wallet != wallet.wallet.id: raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your event.") - if event.nostr_event_id: - await publish_or_delete_nostr_event(event, delete=True) - await delete_event(event_id) await delete_event_tickets(event_id) + return "", HTTPStatus.NO_CONTENT -@events_api_router.put("/{event_id}/approve") -async def api_event_approve( - event_id: str, - admin: Account = Depends(check_admin), -) -> Event: - event = await get_event(event_id) - if not event: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist." - ) - if event.status != "proposed": - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail=f"Event is already {event.status}.", - ) - event.status = "approved" - event = await update_event(event) - await publish_or_delete_nostr_event(event) - return event +#########Tickets########## -@events_api_router.put("/{event_id}/reject") -async def api_event_reject( - event_id: str, - admin: Account = Depends(check_admin), -) -> Event: - event = await get_event(event_id) - if not event: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist." - ) - if event.status != "proposed": - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail=f"Event is already {event.status}.", - ) - event.status = "rejected" - return await update_event(event) - - -@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) - - -@tickets_api_router.get("") +@events_api_router.get("/api/v1/tickets") async def api_tickets( all_wallets: bool = Query(False), - key_info: WalletTypeInfo = Depends(require_admin_key), + wallet: WalletTypeInfo = Depends(require_invoice_key), ) -> list[Ticket]: - wallet_ids = [key_info.wallet.id] + wallet_ids = [wallet.wallet.id] if all_wallets: - user = await get_user(key_info.wallet.user) + user = await get_user(wallet.wallet.user) wallet_ids = user.wallet_ids if user else [] return await get_tickets(wallet_ids) -@tickets_api_router.get("/user/{user_id}") -async def api_tickets_by_user( - user_id: str, - user: User = Depends(check_user_exists), -) -> list[Ticket]: - """All tickets for the authenticated user. - - The `user_id` path param must match the token-bound user so a - Bearer-authenticated session can only enumerate its own tickets. - Returns full `Ticket` rows (not `PublicTicket`) since the owner - needs the payment_hash to render the QR + the `extra` envelope - to surface payment/refund state in My Tickets. - """ - if user_id != user.id: - raise HTTPException( - status_code=HTTPStatus.FORBIDDEN, - detail="Can only fetch your own tickets.", - ) +@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) -@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 - - -async def _issue_free_tickets( - *, - event: Event, - quantity: int, - name: str | None, - email: str | None, - user_id: str | None, - promo_code: str | None, - nostr_identifier: str | None, - request: Request, -) -> TicketPaymentRequest: - """Issue `quantity` free tickets without minting an invoice. - - Each row is created then run through `set_ticket_paid` — the exact path - `on_invoice_paid` drives for a settled payment: it flips `paid`, bumps - the sold / available counters under the per-event lock, and republishes - the NIP-52 calendar event so connected clients see the new counts. - Notifications fire the same way. No invoice exists, so `sats_paid` is 0 - and these tickets are naturally skipped by `refund_tickets`. - - All rows in the batch share one synthetic `payment_hash` — the join key - the poll / WebSocket / My-Tickets lookups use — mirroring how the paid - multi-ticket path shares the real invoice hash. - """ - payment_hash = urlsafe_short_hash() - ticket_ids: list[str] = [] - for _ in range(quantity): - row_id = urlsafe_short_hash() - ticket = await create_ticket( - payment_hash=payment_hash, - wallet=event.wallet, - event=event.id, - name=name, - email=email, - user_id=user_id, - ticket_id=row_id, - extra={ - "applied_promo_code": promo_code, - "nostr_identifier": nostr_identifier, - "ticket_base_url": str(request.base_url).rstrip("/"), - "sats_paid": 0, - }, - ) - await set_ticket_paid(ticket) - send_ticket_notification_in_background(ticket) - ticket_ids.append(row_id) - - return TicketPaymentRequest( - payment_hash=payment_hash, - payment_request=None, - is_fiat=False, - paid=True, - ticket_ids=ticket_ids, +@events_api_router.post("/api/v1/tickets/{event_id}") +async def api_ticket_create(event_id: str, data: CreateTicket): + 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, data.name, data.email, promo_code, refund_address ) -@tickets_api_router.post("/{event_id}") -async def api_ticket_create( - event_id: str, data: CreateTicket, request: Request -) -> TicketPaymentRequest: +@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): event = await get_event(event_id) if not event: raise HTTPException( status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist." ) - if event.status != "approved": - raise HTTPException( - status_code=HTTPStatus.GONE, - detail="Event is not yet open for tickets.", - ) - if event.canceled: - raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is canceled.") - quantity = data.quantity - if event.amount_tickets > 0: - if event.sold >= event.amount_tickets: - raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is sold out.") - remaining = event.amount_tickets - event.sold - if quantity > remaining: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail=f"Only {remaining} ticket(s) remaining for this event.", - ) - name = data.name - email = data.email - user_id = data.user_id - promo_code = data.promo_code.upper() if data.promo_code else None - refund_address = data.refund_address - nostr_identifier = data.nostr_identifier.strip() if data.nostr_identifier else None - payment_method = (data.payment_method or "lightning").lower() - if payment_method not in {"lightning", "fiat"}: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail="Unsupported payment method.", - ) - if nostr_identifier and "@" not in nostr_identifier: - try: - nostr_identifier = normalize_public_key(nostr_identifier) - except Exception as exc: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail="Invalid Nostr identifier.", - ) from exc - unit_price = event.price_per_ticket - extra: dict[str, Any] = {"tag": "events", "name": name, "email": email} + price = event.price_per_ticket + extra = {"tag": "events", "name": name, "email": email} if promo_code: # check if promo_code exists in event.extra.promo_codes @@ -628,190 +176,131 @@ async def api_ticket_create( # get the promocode promo = next(pc for pc in event.extra.promo_codes if pc.code == promo_code) extra["promo_code"] = promo.code - unit_price = event.price_per_ticket * (1 - promo.discount_percent / 100) - # Scale by quantity AFTER the promo applies. One invoice, N tickets. - price = unit_price * quantity + price = event.price_per_ticket * (1 - promo.discount_percent / 100) - # Free tickets (final charge 0 — a free event or a 100%-off promo). - # Short-circuit before any invoice / fiat-provider logic: no Lightning - # invoice can settle for 0, so we issue the rows and mark them paid - # directly. payment_method is irrelevant here (nothing is charged). - if price <= 0: - return await _issue_free_tickets( - event=event, - quantity=quantity, - name=name, - email=email, - user_id=user_id, - promo_code=promo_code, - nostr_identifier=nostr_identifier, - request=request, - ) - - if payment_method == "fiat" and not event.allow_fiat: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail="Fiat payments are not enabled for this event.", - ) - - if _is_fiat_currency(event.currency): + if event.currency != "sats": extra["fiat"] = True extra["currency"] = event.currency extra["fiatAmount"] = price extra["rate"] = await get_fiat_rate_satoshis(event.currency) - if payment_method != "fiat": - price = await fiat_amount_as_satoshis(price, event.currency) + price = await fiat_amount_as_satoshis(price, event.currency) - invoice_unit = event.currency - fiat_amount = price - fiat_provider = None - if payment_method == "fiat": - if _is_fiat_currency(event.currency): - invoice_unit = event.currency - else: - invoice_unit = event.fiat_currency - fiat_amount = await satoshis_amount_as_fiat(price, invoice_unit) - extra["fiat"] = True - extra["currency"] = invoice_unit - extra["fiatAmount"] = fiat_amount - extra["rate"] = await get_fiat_rate_satoshis(invoice_unit) - wallet = await get_wallet(event.wallet) - if not wallet: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, - detail="Event wallet does not exist.", - ) - providers = settings.get_fiat_providers_for_user(wallet.user) - fiat_provider = data.fiat_provider or (providers[0] if providers else None) - if not fiat_provider: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail="No fiat payment provider configured for this event.", - ) - else: - invoice_unit = "sat" - - payment = await create_payment_request( - wallet_id=event.wallet, - invoice_data=CreateInvoice( - out=False, - amount=fiat_amount if payment_method == "fiat" else price, - unit=invoice_unit, - fiat_provider=fiat_provider, + try: + payment = await create_invoice( + wallet_id=event.wallet, + amount=price, memo=f"{event_id}", extra=extra, - ), - ) - # Each row gets a fresh urlsafe_short_hash id so single- and - # multi-ticket purchases stay shape-consistent — every scannable - # ticket id is a short hash, never the long bolt11 payment_hash. - # The shared `payment_hash` column is the join key for invoice - # lookup (poll endpoint, ws notifier, set_ticket_paid loop). - ticket_ids: list[str] = [] - sats_per_ticket = payment.sat // quantity if quantity else payment.sat - for _ in range(quantity): - row_id = urlsafe_short_hash() + ) await create_ticket( payment_hash=payment.payment_hash, wallet=event.wallet, event=event.id, name=name, email=email, - user_id=user_id, - ticket_id=row_id, extra={ "applied_promo_code": promo_code, "refund_address": refund_address, - "nostr_identifier": nostr_identifier, - "ticket_base_url": str(request.base_url).rstrip("/"), - "sats_paid": sats_per_ticket, + "sats_paid": int(price), }, ) - ticket_ids.append(row_id) - - return TicketPaymentRequest( - payment_hash=payment.payment_hash, - payment_request=getattr(payment, "bolt11", None), - fiat_payment_request=getattr(payment, "extra", {}).get("fiat_payment_request"), - fiat_provider=getattr(payment, "fiat_provider", None) or fiat_provider, - is_fiat=bool(getattr(payment, "fiat_provider", None) or fiat_provider), - ticket_ids=ticket_ids, - ) + 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} -@tickets_api_router.post("/{event_id}/{payment_hash}") -async def api_ticket_payment_status(event_id: str, payment_hash: str) -> dict: - """Poll-style payment confirmation for a pending ticket purchase. +async def 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." + ) - The webapp polls this every 2s after presenting the invoice until - `paid: true` comes back, then advances to the success state. The - companion WebSocket at `/tickets/ws/{payment_hash}` is more - efficient for pushes — this endpoint is the fallback. + price = event.price_per_ticket + extra = {"tag": "events", "user_id": user_id} - Returns `{paid, ticket_ids: [...]}` so multi-ticket buyers get - every scannable id back in one response (one for single-ticket - purchases). A missing / cross-event purchase returns - `paid: false` rather than 404 so the poll doesn't have to - special-case the not-yet-created race. - """ - tickets = await get_tickets_by_payment_hash(payment_hash) - relevant = [t for t in tickets if t.event == event_id] - if not relevant: - return {"paid": False} - return { - "paid": all(t.paid for t in relevant), - "ticket_id": relevant[0].id, # back-compat with single-ticket clients - "ticket_ids": [t.id for t in relevant], - } - - -@tickets_api_router.websocket("/ws/{payment_hash}") -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 + 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: - 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) + 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} -@tickets_api_router.delete("/{ticket_id}") +@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 + ) + + # 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} + + +@events_api_router.delete("/api/v1/tickets/{ticket_id}") async def api_ticket_delete( - ticket_id: str, wallet: WalletTypeInfo = Depends(require_admin_key) -) -> None: + ticket_id: str, wallet: WalletTypeInfo = Depends(require_invoice_key) +): ticket = await get_ticket(ticket_id) if not ticket: raise HTTPException( @@ -824,57 +313,14 @@ async def api_ticket_delete( await delete_ticket(ticket_id) -@tickets_api_router.post("/{ticket_id}/resend-email") -async def api_ticket_resend_email( - ticket_id: str, wallet: WalletTypeInfo = Depends(require_admin_key) -) -> Ticket: - ticket = await get_ticket(ticket_id) - if not ticket: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, detail="Ticket does not exist." - ) - - if ticket.wallet != wallet.wallet.id: - raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your ticket.") - - if not ticket.paid: - raise HTTPException( - status_code=HTTPStatus.FORBIDDEN, - detail="Only paid tickets can be resent by email.", - ) - - try: - return await resend_ticket_email_notification(ticket) - except ValueError as exc: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, detail=str(exc) - ) from exc - except Exception as exc: - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail="Failed to resend ticket email.", - ) from exc +@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) -@tickets_api_router.put("/register/{ticket_id}") -async def api_event_register_ticket( - ticket_id: str, - key_info: WalletTypeInfo = Depends(require_admin_key), -) -> Ticket: - """Mark a ticket as registered at the door. - - Auth: wallet admin_key. Caller must own the event the ticket - belongs to — we check `event.wallet` against the user's full - wallet set so an organizer with multiple wallets can scan - regardless of which wallet's key they're using. - - Until v1.6.1-aio.3 this endpoint had no auth, which meant any - caller who knew a ticket id could register it. The - Nostr-transport flow at `events_ticket_register` is now the - preferred call site for the webapp; this HTTP path stays for - the legacy LNbits Quasar register page which already sends - the wallet admin_key through `LNbits.api.request`. - """ +# 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]: ticket = await get_ticket(ticket_id) if not ticket: @@ -882,20 +328,6 @@ async def api_event_register_ticket( status_code=HTTPStatus.NOT_FOUND, detail="Ticket does not exist." ) - event = await get_event(ticket.event) - if not event: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist." - ) - - user = await get_user(key_info.wallet.user) - owned_wallet_ids = user.wallet_ids if user else [key_info.wallet.id] - if event.wallet not in owned_wallet_ids: - raise HTTPException( - status_code=HTTPStatus.FORBIDDEN, - detail="You do not own this event.", - ) - if not ticket.paid: raise HTTPException( status_code=HTTPStatus.FORBIDDEN, detail="Ticket not paid for." @@ -908,54 +340,5 @@ async def api_event_register_ticket( ticket.registered = True ticket.reg_timestamp = datetime.now(timezone.utc) - ticket = await update_ticket(ticket) - return ticket - - -@tickets_api_router.get("/event/{event_id}/stats") -async def api_event_ticket_stats( - event_id: str, - key_info: WalletTypeInfo = Depends(require_admin_key), -) -> dict: - """Door-scanner roster + counts for one event, organizer-only. - - Mirrors the `events_list_event_tickets` nostr-transport RPC for - callers that don't hold a raw user prvkey (the webapp post-#9, in - particular). Auth: wallet admin_key + the event's wallet must be - in the caller's wallet set. - """ - event = await get_event(event_id) - if not event: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist." - ) - - user = await get_user(key_info.wallet.user) - owned_wallet_ids = user.wallet_ids if user else [key_info.wallet.id] - if event.wallet not in owned_wallet_ids: - raise HTTPException( - status_code=HTTPStatus.FORBIDDEN, - detail="You do not own this event.", - ) - - tickets = await get_tickets_by_event(event_id) - paid_tickets = [t for t in tickets if t.paid] - registered_count = sum(1 for t in paid_tickets if t.registered) - - return { - "event_id": event_id, - "sold": len(paid_tickets), - "registered": registered_count, - "remaining": len(paid_tickets) - registered_count, - "tickets": [ - { - "id": t.id, - "name": t.name, - "registered": t.registered, - "registered_at": ( - t.reg_timestamp.isoformat() if t.reg_timestamp else None - ), - } - for t in paid_tickets - ], - } + await update_ticket(ticket) + return await get_event_tickets(ticket.event)