feat: add user_id ticket support and public events endpoint
Some checks failed
lint / lint (push) Has been cancelled

- 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) <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-04-24 02:55:18 -04:00
commit 2740d73678
4 changed files with 105 additions and 9 deletions

27
crud.py
View file

@ -1,4 +1,5 @@
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Optional
from lnbits.db import Database from lnbits.db import Database
from lnbits.helpers import urlsafe_short_hash from lnbits.helpers import urlsafe_short_hash
@ -9,7 +10,13 @@ db = Database("ext_events")
async def create_ticket( 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: ) -> Ticket:
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
ticket = Ticket( ticket = Ticket(
@ -18,6 +25,7 @@ async def create_ticket(
event=event, event=event,
name=name, name=name,
email=email, email=email,
user_id=user_id,
registered=False, registered=False,
paid=False, paid=False,
reg_timestamp=now, 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: async def delete_event(event_id: str) -> None:
await db.execute("DELETE FROM events.events WHERE id = :id", {"id": event_id}) await db.execute("DELETE FROM events.events WHERE id = :id", {"id": event_id})

View file

@ -175,3 +175,11 @@ async def m006_add_extra_fields(db):
# Add 'extra' column to ticket table # Add 'extra' column to ticket table
await db.execute("ALTER TABLE events.ticket ADD COLUMN extra TEXT;") 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;")

View file

@ -1,7 +1,8 @@
from datetime import datetime from datetime import datetime
from typing import Optional
from fastapi import Query from fastapi import Query
from pydantic import BaseModel, EmailStr, Field, validator from pydantic import BaseModel, EmailStr, Field, root_validator, validator
class PromoCode(BaseModel): class PromoCode(BaseModel):
@ -66,18 +67,29 @@ class TicketExtra(BaseModel):
class CreateTicket(BaseModel): class CreateTicket(BaseModel):
name: str name: Optional[str] = None
email: EmailStr email: Optional[str] = None
user_id: Optional[str] = None
promo_code: str | None = None promo_code: str | None = None
refund_address: 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): class Ticket(BaseModel):
id: str id: str
wallet: str wallet: str
event: str event: str
name: str name: str = ""
email: str email: str = ""
user_id: Optional[str] = None
registered: bool registered: bool
paid: bool paid: bool
time: datetime time: datetime

View file

@ -21,11 +21,13 @@ from .crud import (
delete_event, delete_event,
delete_event_tickets, delete_event_tickets,
delete_ticket, delete_ticket,
get_all_events,
get_event, get_event,
get_event_tickets, get_event_tickets,
get_events, get_events,
get_ticket, get_ticket,
get_tickets, get_tickets,
get_tickets_by_user_id,
update_event, update_event,
update_ticket, update_ticket,
) )
@ -49,6 +51,12 @@ async def api_events(
return [event.dict() for event in await get_events(wallet_ids)] 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.post("/api/v1/events")
@events_api_router.put("/api/v1/events/{event_id}") @events_api_router.put("/api/v1/events/{event_id}")
async def api_event_create( async def api_event_create(
@ -131,14 +139,20 @@ async def api_tickets(
return await get_tickets(wallet_ids) 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}") @events_api_router.post("/api/v1/tickets/{event_id}")
async def api_ticket_create(event_id: str, data: CreateTicket): async def api_ticket_create(event_id: str, data: CreateTicket):
name = data.name if data.user_id:
email = data.email 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 promo_code = data.promo_code.upper() if data.promo_code else None
refund_address = data.refund_address refund_address = data.refund_address
return await api_ticket_make_ticket( 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} 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}") @events_api_router.post("/api/v1/tickets/{event_id}/{payment_hash}")
async def api_ticket_send_ticket(event_id, payment_hash): async def api_ticket_send_ticket(event_id, payment_hash):
event = await get_event(event_id) event = await get_event(event_id)