From 2740d73678994a626781c51f1e64d81db84aa135 Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Fri, 24 Apr 2026 02:55:18 -0400 Subject: [PATCH] feat: add user_id ticket support and public events endpoint - Tickets can be created with user_id instead of name/email - name/email default to empty string in DB (not NULL-safe) - New endpoints: GET /api/v1/events/public, GET /api/v1/tickets/user/{user_id} - POST /api/v1/tickets/{event_id} accepts user_id in body - Migration m007 adds user_id column to tickets table - CreateTicket validates: either user_id or (name + email) required Co-Authored-By: Claude Opus 4.6 (1M context) --- crud.py | 27 +++++++++++++++++++++++- migrations.py | 8 ++++++++ models.py | 22 +++++++++++++++----- views_api.py | 57 ++++++++++++++++++++++++++++++++++++++++++++++++--- 4 files changed, 105 insertions(+), 9 deletions(-) diff --git a/crud.py b/crud.py index 3046f0e..6460f7e 100644 --- a/crud.py +++ b/crud.py @@ -1,4 +1,5 @@ from datetime import datetime, timedelta, timezone +from typing import Optional from lnbits.db import Database from lnbits.helpers import urlsafe_short_hash @@ -9,7 +10,13 @@ db = Database("ext_events") async def create_ticket( - payment_hash: str, wallet: str, event: str, name: str, email: str, extra: dict + payment_hash: str, + wallet: str, + event: str, + name: str = "", + email: str = "", + user_id: Optional[str] = None, + extra: Optional[dict] = None, ) -> Ticket: now = datetime.now(timezone.utc) ticket = Ticket( @@ -18,6 +25,7 @@ async def create_ticket( event=event, name=name, email=email, + user_id=user_id, registered=False, paid=False, reg_timestamp=now, @@ -102,6 +110,23 @@ async def get_events(wallet_ids: str | list[str]) -> list[Event]: ) +async def get_all_events() -> list[Event]: + """Get all events without wallet filtering (public endpoint).""" + return await db.fetchall( + "SELECT * FROM events.events ORDER BY time DESC", + model=Event, + ) + + +async def get_tickets_by_user_id(user_id: str) -> list[Ticket]: + """Get all tickets for a specific user by their user_id.""" + return await db.fetchall( + "SELECT * FROM events.ticket WHERE user_id = :user_id ORDER BY time DESC", + {"user_id": user_id}, + model=Ticket, + ) + + async def delete_event(event_id: str) -> None: await db.execute("DELETE FROM events.events WHERE id = :id", {"id": event_id}) diff --git a/migrations.py b/migrations.py index 6f8e838..1d9cfe4 100644 --- a/migrations.py +++ b/migrations.py @@ -175,3 +175,11 @@ async def m006_add_extra_fields(db): # Add 'extra' column to ticket table await db.execute("ALTER TABLE events.ticket ADD COLUMN extra TEXT;") + + +async def m007_add_user_id(db): + """ + Add user_id column to tickets table. + Allows ticket purchase via LNbits user-id without name/email. + """ + await db.execute("ALTER TABLE events.ticket ADD COLUMN user_id TEXT;") diff --git a/models.py b/models.py index f82890e..1073806 100644 --- a/models.py +++ b/models.py @@ -1,7 +1,8 @@ from datetime import datetime +from typing import Optional from fastapi import Query -from pydantic import BaseModel, EmailStr, Field, validator +from pydantic import BaseModel, EmailStr, Field, root_validator, validator class PromoCode(BaseModel): @@ -66,18 +67,29 @@ class TicketExtra(BaseModel): class CreateTicket(BaseModel): - name: str - email: EmailStr + name: Optional[str] = None + email: Optional[str] = None + user_id: Optional[str] = None promo_code: str | None = None refund_address: str | None = None + @root_validator + def validate_identifiers(cls, values): + user_id = values.get("user_id") + name = values.get("name") + email = values.get("email") + if not user_id and not (name and email): + raise ValueError("Either user_id or both name and email must be provided") + return values + class Ticket(BaseModel): id: str wallet: str event: str - name: str - email: str + name: str = "" + email: str = "" + user_id: Optional[str] = None registered: bool paid: bool time: datetime diff --git a/views_api.py b/views_api.py index 58c9bca..e06bfde 100644 --- a/views_api.py +++ b/views_api.py @@ -21,11 +21,13 @@ from .crud import ( delete_event, delete_event_tickets, delete_ticket, + get_all_events, get_event, get_event_tickets, get_events, get_ticket, get_tickets, + get_tickets_by_user_id, update_event, update_ticket, ) @@ -49,6 +51,12 @@ async def api_events( return [event.dict() for event in await get_events(wallet_ids)] +@events_api_router.get("/api/v1/events/public") +async def api_events_public(): + """Retrieve all events (read-only, no auth required).""" + return [event.dict() for event in await get_all_events()] + + @events_api_router.post("/api/v1/events") @events_api_router.put("/api/v1/events/{event_id}") async def api_event_create( @@ -131,14 +139,20 @@ async def api_tickets( return await get_tickets(wallet_ids) +@events_api_router.get("/api/v1/tickets/user/{user_id}") +async def api_tickets_by_user_id(user_id: str) -> list[Ticket]: + """Get all tickets for a specific user by their user_id.""" + return await get_tickets_by_user_id(user_id) + + @events_api_router.post("/api/v1/tickets/{event_id}") async def api_ticket_create(event_id: str, data: CreateTicket): - name = data.name - email = data.email + if data.user_id: + return await api_ticket_make_ticket_with_user_id(event_id, data.user_id) promo_code = data.promo_code.upper() if data.promo_code else None refund_address = data.refund_address return await api_ticket_make_ticket( - event_id, name, email, promo_code, refund_address + event_id, data.name, data.email, promo_code, refund_address ) @@ -198,6 +212,43 @@ async def api_ticket_make_ticket(event_id, name, email, promo_code, refund_addre return {"payment_hash": payment.payment_hash, "payment_request": payment.bolt11} +async def api_ticket_make_ticket_with_user_id(event_id: str, user_id: str): + event = await get_event(event_id) + if not event: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist." + ) + + price = event.price_per_ticket + extra = {"tag": "events", "user_id": user_id} + + if event.currency != "sats": + extra["fiat"] = True + extra["currency"] = event.currency + extra["fiatAmount"] = event.price_per_ticket + extra["rate"] = await get_fiat_rate_satoshis(event.currency) + price = await fiat_amount_as_satoshis(event.price_per_ticket, event.currency) + + try: + payment = await create_invoice( + wallet_id=event.wallet, + amount=price, + memo=f"{event_id}", + extra=extra, + ) + await create_ticket( + payment_hash=payment.payment_hash, + wallet=event.wallet, + event=event.id, + user_id=user_id, + ) + except Exception as exc: + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(exc) + ) from exc + return {"payment_hash": payment.payment_hash, "payment_request": payment.bolt11} + + @events_api_router.post("/api/v1/tickets/{event_id}/{payment_hash}") async def api_ticket_send_ticket(event_id, payment_hash): event = await get_event(event_id)