Compare commits

...

10 commits

Author SHA1 Message Date
b938183770 chore: bump to v1.3.0-aio.4
Some checks failed
lint.yml / chore: bump to v1.3.0-aio.4 (push) Failing after 0s
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 12:23:23 +02:00
48f3c11f88 fix: gate event edits through the approval workflow
The PUT /events/{id} endpoint blindly copied every field from the
request body onto the existing event, including `status`. A non-admin
owner with auto_approve=false could PUT {"status": "approved", ...}
and self-approve, bypassing review entirely.

Replace the blanket setattr loop with an explicit field list (status
omitted) and derive the new status from the same admin / auto_approve
gate that api_event_create uses. Reconcile Nostr 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

Also apply the same end/closing-date defaulting as create_event so an
edit that omits those fields doesn't wipe them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 12:23:10 +02:00
d7a25e9bb3 chore: bump to v1.3.0-aio.3
Some checks failed
lint.yml / chore: bump to v1.3.0-aio.3 (push) Failing after 0s
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 01:22:50 +02:00
4aa90d80ad feat: support optional start/end time on events
event_start_date / event_end_date now accept either YYYY-MM-DD (date-only)
or YYYY-MM-DDTHH:MM (ISO datetime). The NIP-52 publisher switches kind
on the "T" delimiter: kind 31922 (date-based, YYYY-MM-DD start/end) when
absent, kind 31923 (time-based, unix-timestamp start/end + day-granularity
D tags) when present. Delete events match the original publish kind.

Closing-date parsing accepts both formats. The LNbits admin form gains
optional HH:MM inputs alongside each date picker; they fold into the
wire-format string on submit and split back on edit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 01:22:38 +02:00
80a934be06 chore: bump to v1.3.0-aio.2
Some checks failed
lint.yml / chore: bump to v1.3.0-aio.2 (push) Failing after 0s
Picks up the upstream-lint pass (mypy/pyright/black/prettier).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:14:33 +02:00
b428b0dca8 chore: satisfy upstream lint (black, mypy, prettier, ruff)
Some checks failed
lint.yml / chore: satisfy upstream lint (black, mypy, prettier, ruff) (push) Failing after 0s
- black/prettier reformatting across new aio code
- type annotations on db.fetchone/fetchall callsites in crud.py
- explicit dict[str, list[str]] for tag_lists in nostr_sync.py
- type:ignore[attr-defined] on Account.prvkey access — the field is
  added by the aio-fork lnbits.core.models.Account; upstream lnbits
  does not yet have it, so consumers without the fork must add a
  prvkey column to accounts before the Nostr publisher can sign.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:04:15 +02:00
42a373bff1 feat: add NIP-52 Nostr publish + sync of calendar events
Some checks failed
lint.yml / feat: add NIP-52 Nostr publish + sync of calendar events (push) Failing after 0s
Approved events are mirrored to Nostr as NIP-52 calendar events (kind
31922) signed by the wallet owner's pubkey, and incoming kind 31922/31923
events from subscribed relays are synced into the local DB so events
created on other LNbits instances or Nostr clients show up locally.

- m009 stores nostr_event_id + nostr_event_created_at on each event
  (used for replaceable updates and NIP-09 deletes); m011 adds location
  + JSON-encoded categories list (NIP-52 location/`t` tags).
- models: Event/PublicEvent/CreateEvent gain location, categories,
  nostr_event_id, nostr_event_created_at; parse_categories validator
  decodes the JSON column on read.
- nostr/{event,nostr_client}.py: Schnorr signing, websocket relay client,
  and a NostrEvent model (publish-only and subscribe variants).
- nostr_publisher.py: build/sign NIP-52 kind 31922 events and NIP-09
  delete events; publish via the relay client.
- nostr_sync.py: subscribe to kinds 31922/31923, dedupe by nostr_event_id
  / d-tag, upsert Events; auto-approves discovered Nostr events since
  they're already public.
- nostr_hooks.py: thin bridge that views_api handlers call to publish
  or delete a NIP-52 event for a given local event. Lives in its own
  module to keep `from . import nostr_client` out of the view layer
  and avoid the views_api -> publisher import cycle.
- views_api: hooks publish_or_delete_nostr_event into create-on-approved,
  update-when-already-published, cancel (delete), delete (delete), and
  approve (publish).
- __init__.py: 3-task lifespan — wait_for_paid_invoices (upstream),
  NostrClient bootstrap, and the NIP-52 sync loop. Module-level
  nostr_client global is set by the bootstrap and read dynamically by
  publish_or_delete_nostr_event so the import order works regardless of
  whether nostrclient is up at startup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:31:06 +02:00
4c8e06a6a9 feat: add event approval workflow with admin UI
Non-admin event submissions now land in a "proposed" queue that LNbits
admins review before the event becomes ticketable and publicly listed.

- m008 adds events.events.status (proposed/approved/rejected); m010 seeds
  an events.settings singleton row with the auto_approve toggle.
- Models: Event/CreateEvent.status, EventsSettings, optional date fields
  with sensible defaults (closing_date defaults to event_end_date which
  defaults to event_start_date), PublicEvent.status surfaces the workflow
  state on the public endpoint.
- crud: get_all/public/pending_events for the admin views; get/update_settings
  for the auto_approve toggle; create_event auto-fills missing date defaults.
- views_api:
  * POST /api/v1/events accepts wallet invoice keys so anyone can submit;
    handler stamps status="proposed" for non-admins when auto_approve is off
  * /public, /all, /pending, /settings (GET+PUT), /{id}/{approve,reject},
    /{id}/tickets endpoints; literal-prefix routes declared before /{event_id}
    so FastAPI matches them correctly
  * Public GET /{event_id} bypasses sold-out / closing-window gates for
    proposed/rejected events and returns the trimmed PublicEvent so the SFC
    can render a "pending approval" banner
  * POST /tickets/{event_id} rejects when event.status != "approved"
- Frontend: index.vue gains an admin Settings card, Pending Approvals list,
  status badge column and approve/reject row actions, plus an All Users'
  Events admin table; index.js gains the data + methods + an isAdmin probe
  via GET /events/all; display.vue shows pending/rejected banners and
  hides the Buy Ticket form unless status === "approved".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:31:06 +02:00
11043ec8a7 feat: support optional user_id ticket identifier
Add an alternative ticket identifier scheme: instead of (name, email),
external integrations can issue tickets bound to an LNbits user_id.

- m007 adds the user_id column on events.ticket
- CreateTicket validator enforces exactly one identifier scheme per ticket
- Ticket / PublicTicket: name, email, user_id all Optional
- _parse_ticket_row reverses the empty-string sentinel used to keep the
  NOT NULL name/email columns satisfied when user_id is the identifier
- POST /tickets/{event_id} dispatches to _create_user_id_ticket vs
  _create_named_ticket based on the supplied identifier
- New GET /tickets/user/{user_id} returns tickets for a given user

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:31:06 +02:00
708d15629c chore: bump to v1.3.0-aio.1 and make m006 idempotent
Sets the rebase foundation: version metadata, point repo at the aio fork,
and a duplicate-column-tolerant m006 so AIO installs that already ran our
pre-rebase m007_add_extra_fields don't fail when LNbits replays upstream's
m006 under its new name.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:31:06 +02:00
15 changed files with 1464 additions and 89 deletions

View file

@ -22,6 +22,11 @@ events_static_files = [
scheduled_tasks: list[asyncio.Task] = [] 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(): def events_stop():
for task in scheduled_tasks: for task in scheduled_tasks:
@ -30,12 +35,48 @@ def events_stop():
except Exception as ex: except Exception as ex:
logger.warning(ex) logger.warning(ex)
global nostr_client
if nostr_client:
asyncio.get_event_loop().create_task(nostr_client.stop())
def events_start(): def events_start():
from lnbits.tasks import create_permanent_unique_task from lnbits.tasks import create_permanent_unique_task
task = create_permanent_unique_task("ext_events", wait_for_paid_invoices) task1 = create_permanent_unique_task("ext_events", wait_for_paid_invoices)
scheduled_tasks.append(task) scheduled_tasks.append(task1)
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)
__all__ = ["db", "events_ext", "events_start", "events_static_files", "events_stop"] __all__ = ["db", "events_ext", "events_start", "events_static_files", "events_stop"]

View file

@ -1,8 +1,8 @@
{ {
"id": "events", "id": "events",
"version": "1.3.0", "version": "1.3.0-aio.4",
"name": "Events", "name": "Events",
"repo": "https://github.com/lnbits/events", "repo": "https://git.atitlan.io/aiolabs/events",
"short_description": "Sell and register event tickets", "short_description": "Sell and register event tickets",
"description": "", "description": "",
"tile": "/events/static/image/events.png", "tile": "/events/static/image/events.png",
@ -32,6 +32,11 @@
"name": "motorina0", "name": "motorina0",
"uri": "https://github.com/motorina0", "uri": "https://github.com/motorina0",
"role": "Developer" "role": "Developer"
},
{
"name": "padreug",
"uri": "https://git.atitlan.io/padreug",
"role": "Developer (aio fork: approval workflow + NIP-52 Nostr sync)"
} }
], ],
"images": [ "images": [

150
crud.py
View file

@ -1,54 +1,125 @@
import json
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from lnbits.db import Database from lnbits.db import Database
from lnbits.helpers import urlsafe_short_hash from lnbits.helpers import urlsafe_short_hash
from .models import CreateEvent, Event, Ticket, TicketExtra from .models import CreateEvent, Event, EventsSettings, Ticket, TicketExtra
db = Database("ext_events") 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( 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 | None = None,
email: str | None = None,
user_id: str | None = None,
extra: dict | None = None,
) -> Ticket: ) -> Ticket:
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
ticket = Ticket(
# 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=payment_hash, id=payment_hash,
wallet=wallet, wallet=wallet,
event=event, event=event,
name=name, name=db_name,
email=email, email=db_email,
user_id=user_id,
registered=False,
paid=False,
reg_timestamp=now,
time=now,
extra=TicketExtra(**extra) if extra else TicketExtra(),
)
await db.insert("events.ticket", db_ticket)
return Ticket(
id=payment_hash,
wallet=wallet,
event=event,
name=name,
email=email,
user_id=user_id,
registered=False, registered=False,
paid=False, paid=False,
reg_timestamp=now, reg_timestamp=now,
time=now, time=now,
extra=TicketExtra(**extra) if extra else TicketExtra(), extra=TicketExtra(**extra) if extra else TicketExtra(),
) )
await db.insert("events.ticket", ticket)
return ticket
async def update_ticket(ticket: Ticket) -> Ticket: async def update_ticket(ticket: Ticket) -> Ticket:
await db.update("events.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))
return ticket return ticket
async def get_ticket(payment_hash: str) -> Ticket | None: async def get_ticket(payment_hash: str) -> Ticket | None:
return await db.fetchone( row: dict | None = await db.fetchone(
"SELECT * FROM events.ticket WHERE id = :id", "SELECT * FROM events.ticket WHERE id = :id",
{"id": payment_hash}, {"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]: async def get_tickets(wallet_ids: str | list[str]) -> list[Ticket]:
if isinstance(wallet_ids, str): if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids] wallet_ids = [wallet_ids]
q = ",".join([f"'{wallet_id}'" for wallet_id in wallet_ids]) q = ",".join([f"'{wallet_id}'" for wallet_id in wallet_ids])
return await db.fetchall( rows: list[dict] = await db.fetchall(
f"SELECT * FROM events.ticket WHERE wallet IN ({q})", 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: list[dict] = 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: async def delete_ticket(payment_hash: str) -> None:
@ -74,6 +145,11 @@ async def purge_unpaid_tickets(event_id: str) -> None:
async def create_event(data: CreateEvent) -> Event: async def create_event(data: CreateEvent) -> Event:
event_id = urlsafe_short_hash() 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()) event = Event(id=event_id, time=datetime.now(timezone.utc), **data.dict())
await db.insert("events.events", event) await db.insert("events.events", event)
return event return event
@ -102,13 +178,57 @@ 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."""
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."""
return await db.fetchall(
"""
SELECT * FROM events.events
WHERE status = 'approved' AND canceled = FALSE
ORDER BY event_start_date ASC
""",
model=Event,
)
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: dict | None = 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: 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})
async def get_event_tickets(event_id: str) -> list[Ticket]: async def get_event_tickets(event_id: str) -> list[Ticket]:
return await db.fetchall( rows: list[dict] = await db.fetchall(
"SELECT * FROM events.ticket WHERE event = :event", "SELECT * FROM events.ticket WHERE event = :event",
{"event": event_id}, {"event": event_id},
Ticket,
) )
return [Ticket(**_parse_ticket_row(row)) for row in rows]

View file

@ -162,16 +162,96 @@ async def m005_add_image_banner(db):
await db.execute("ALTER TABLE events.events ADD COLUMN banner TEXT;") await db.execute("ALTER TABLE events.events ADD COLUMN banner TEXT;")
async def _alter_add_column_safe(db, sql: str) -> None:
"""ALTER TABLE ADD COLUMN that swallows duplicate-column errors.
Earlier aiolabs/events forks added some of these columns under different
migration names (e.g. our former m007). Skipping the error keeps the
migration log monotonic for both fresh installs and pre-rebase upgrades.
"""
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 m006_add_extra_fields(db): async def m006_add_extra_fields(db):
""" """
Add a canceled and 'extra' column to events and ticket tables Add a canceled and 'extra' column to events and ticket tables
to support promo codes and ticket metadata. to support promo codes and ticket metadata.
""" """
# Add canceled and 'extra' columns to events table await _alter_add_column_safe(
await db.execute( db,
"ALTER TABLE events.events ADD COLUMN canceled BOOLEAN NOT NULL DEFAULT FALSE;" "ALTER TABLE events.events ADD COLUMN canceled BOOLEAN NOT NULL DEFAULT FALSE",
) )
await db.execute("ALTER TABLE events.events ADD COLUMN extra TEXT;") await _alter_add_column_safe(db, "ALTER TABLE events.events ADD COLUMN extra TEXT")
await _alter_add_column_safe(db, "ALTER TABLE events.ticket ADD COLUMN extra TEXT")
# Add 'extra' column to ticket table
await db.execute("ALTER TABLE events.ticket ADD COLUMN extra TEXT;") async def m007_add_user_id_support(db):
"""
Add user_id column to ticket table so a ticket can 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"
)
async def m008_add_event_status(db):
"""
Add status column to events table for the proposal/approval workflow.
Values: 'proposed', 'approved', 'rejected'. 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'",
)
async def m009_add_nostr_columns(db):
"""
Track the most recent NIP-52 calendar event we published for this event
(used for replaceable updates and NIP-09 deletes).
"""
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"
)
async def m010_add_events_settings(db):
"""
Create the extension settings singleton row used by the admin UI to
toggle e.g. auto_approve.
"""
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)"
)
async def m011_add_location_and_categories(db):
"""
Add NIP-52 calendar metadata (location and a JSON-encoded category list).
"""
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"
)

View file

@ -1,7 +1,7 @@
import json
from datetime import datetime from datetime import datetime
from fastapi import Query from pydantic import BaseModel, EmailStr, Field, root_validator, validator
from pydantic import BaseModel, EmailStr, Field, validator
class PromoCode(BaseModel): class PromoCode(BaseModel):
@ -27,46 +27,77 @@ class EventExtra(BaseModel):
class CreateEvent(BaseModel): class CreateEvent(BaseModel):
wallet: str wallet: str | None = None # filled from caller's wallet if absent
name: str name: str # title (required)
info: str info: str = "" # description (optional)
closing_date: str 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).
event_start_date: str event_start_date: str
event_end_date: str event_end_date: str | None = None # same format as event_start_date
currency: str = "sat" currency: str = "sat"
amount_tickets: int = Query(..., ge=0) amount_tickets: int = 0 # 0 = unlimited / not ticketed
price_per_ticket: float = Query(..., ge=0) price_per_ticket: float = 0 # 0 = free
banner: str | None = None 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) extra: EventExtra = Field(default_factory=EventExtra)
status: str = "approved" # proposed, approved, rejected
class Event(BaseModel): class Event(BaseModel):
id: str id: str
wallet: str wallet: str
name: str name: str
info: str info: str = ""
closing_date: str closing_date: str | None = None
canceled: bool = False canceled: bool = False
event_start_date: str event_start_date: str
event_end_date: str event_end_date: str | None = None
currency: str currency: str = "sat"
amount_tickets: int amount_tickets: int = 0
price_per_ticket: float price_per_ticket: float = 0
time: datetime time: datetime
sold: int = 0 sold: int = 0
banner: str | None = None banner: str | None = None
location: str | None = None
categories: list[str] = Field(default_factory=list)
extra: EventExtra = Field(default_factory=EventExtra) 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): class PublicEvent(BaseModel):
id: str id: str
name: str name: str
info: str info: str
closing_date: str closing_date: str | None = None
canceled: bool canceled: bool
event_start_date: str event_start_date: str
event_end_date: str event_end_date: str | None = None
banner: str | None banner: str | None
location: str | None = None
categories: list[str] = Field(default_factory=list)
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): class TicketExtra(BaseModel):
@ -77,18 +108,31 @@ class TicketExtra(BaseModel):
class CreateTicket(BaseModel): class CreateTicket(BaseModel):
name: str name: str | None = None
email: EmailStr email: EmailStr | None = None
user_id: str | None = None # LNbits user id (alternative to name+email)
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):
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
class Ticket(BaseModel): class Ticket(BaseModel):
id: str id: str
wallet: str wallet: str
event: str event: str
name: str name: str | None = None
email: str email: str | None = None
user_id: str | None = None
registered: bool registered: bool
paid: bool paid: bool
time: datetime time: datetime
@ -98,7 +142,7 @@ class Ticket(BaseModel):
class PublicTicket(BaseModel): class PublicTicket(BaseModel):
event: str event: str
name: str name: str | None = None
registered: bool registered: bool
paid: bool paid: bool
time: datetime time: datetime

0
nostr/__init__.py Normal file
View file

26
nostr/event.py Normal file
View file

@ -0,0 +1,26 @@
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()

135
nostr/nostr_client.py Normal file
View file

@ -0,0 +1,135 @@
"""
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

47
nostr_hooks.py Normal file
View file

@ -0,0 +1,47 @@
"""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`.
Pulls the wallet owner's pubkey/prvkey to sign with the user's identity.
Failures are logged and swallowed so a Nostr outage doesn't break the
HTTP flow that triggered the publish.
"""
try:
from lnbits.core.crud.users import get_account
from lnbits.core.crud.wallets import get_wallet
from . import nostr_client
wallet_obj = await get_wallet(event.wallet)
if not wallet_obj:
return
account = await get_account(wallet_obj.user)
if not account or not account.pubkey or not account.prvkey: # type: ignore[attr-defined]
return
nostr_event = await publish_event_to_nostr(
nostr_client,
event,
account.pubkey,
account.prvkey, # type: ignore[attr-defined]
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}")

157
nostr_publisher.py Normal file
View file

@ -0,0 +1,157 @@
"""
NIP-52 calendar event publishing for the events extension.
Builds NIP-52 calendar events from the Event model, signs them with the
creator's Account keypair, 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
import coincurve
from loguru import logger
from .models import Event
from .nostr.event import NostrEvent
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
Content: event.info
"""
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])
nostr_event = NostrEvent(
pubkey=pubkey,
created_at=int(time.time()),
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
def sign_nostr_event(nostr_event: NostrEvent, private_key_hex: str) -> None:
"""Sign a NostrEvent in-place using Schnorr signature."""
privkey = coincurve.PrivateKey(bytes.fromhex(private_key_hex))
sig = privkey.sign_schnorr(bytes.fromhex(nostr_event.id))
nostr_event.sig = sig.hex()
async def publish_event_to_nostr(
nostr_client,
event: Event,
account_pubkey: str,
account_prvkey: str,
delete: bool = False,
) -> NostrEvent | None:
"""
Build, sign, and publish a NIP-52 calendar event (or delete event).
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, account_pubkey)
else:
nostr_event = build_nip52_event(event, account_pubkey)
sign_nostr_event(nostr_event, account_prvkey)
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

157
nostr_sync.py Normal file
View file

@ -0,0 +1,157 @@
"""
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: dict[str, list[str]] = {}
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)

View file

@ -12,7 +12,32 @@
<div v-html="event.info" class="q-pa-lg"></div> <div v-html="event.info" class="q-pa-lg"></div>
</q-card-section> </q-card-section>
</q-card> </q-card>
<q-card class="q-pa-lg">
<q-banner
v-if="event.status === 'proposed'"
class="bg-orange-2 text-orange-10"
rounded
>
<template v-slot:avatar>
<q-icon name="pending" color="orange-10"></q-icon>
</template>
<span class="text-weight-medium">Pending approval</span> &mdash; this
event is awaiting an admin review and is not yet open for tickets.
</q-banner>
<q-banner
v-else-if="event.status === 'rejected'"
class="bg-red-2 text-red-10"
rounded
>
<template v-slot:avatar>
<q-icon name="block" color="red-10"></q-icon>
</template>
<span class="text-weight-medium">Not approved</span> &mdash; this event
was reviewed and is not being published.
</q-banner>
<q-card v-if="event.status === 'approved'" class="q-pa-lg">
<q-card-section class="q-pa-none"> <q-card-section class="q-pa-none">
<h5 class="q-mt-none">Buy Ticket</h5> <h5 class="q-mt-none">Buy Ticket</h5>
<q-form @submit="createInvoice()" class="q-gutter-md"> <q-form @submit="createInvoice()" class="q-gutter-md">

View file

@ -5,6 +5,12 @@ window.PageEvents = {
events: [], events: [],
tickets: [], tickets: [],
currencies: [], currencies: [],
pendingEvents: [],
allUserEvents: [],
isAdmin: false,
settings: {
auto_approve: false
},
eventsTable: { eventsTable: {
columns: [ columns: [
{name: 'id', align: 'left', label: 'ID', field: 'id'}, {name: 'id', align: 'left', label: 'ID', field: 'id'},
@ -65,7 +71,8 @@ window.PageEvents = {
field: 'sold' field: 'sold'
}, },
{name: 'info', align: 'left', label: 'Info', field: 'info'}, {name: 'info', align: 'left', label: 'Info', field: 'info'},
{name: 'banner', align: 'left', label: 'Banner', field: 'banner'} {name: 'banner', align: 'left', label: 'Banner', field: 'banner'},
{name: 'status', align: 'left', label: 'Status', field: 'status'}
], ],
pagination: { pagination: {
rowsPerPage: 10 rowsPerPage: 10
@ -152,12 +159,113 @@ window.PageEvents = {
this.events = response.data this.events = response.data
this.checkCanceledEvents() 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)
})
},
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() { sendEventData() {
const wallet = _.findWhere(this.g.user.wallets, { const wallet = _.findWhere(this.g.user.wallets, {
id: this.formDialog.data.wallet id: this.formDialog.data.wallet
}) })
const data = this.formDialog.data 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 && !data.extra.promo_codes) { if (data.extra && !data.extra.promo_codes) {
data.extra.promo_codes = data.extra.promo_codes data.extra.promo_codes = data.extra.promo_codes
.filter(code => code.trim() !== '') .filter(code => code.trim() !== '')
@ -173,10 +281,22 @@ window.PageEvents = {
openEventDialog(data = false) { openEventDialog(data = false) {
if (data && data.id) { if (data && data.id) {
this.formDialog.data = {...data} 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
}
} else { } else {
this.formDialog.data = { this.formDialog.data = {
currency: 'sats', currency: 'sats',
event_start_day: '',
event_start_time: '',
event_end_day: '',
event_end_time: '',
extra: { extra: {
conditional: false, conditional: false,
min_tickets: 1, min_tickets: 1,
@ -275,6 +395,8 @@ window.PageEvents = {
if (this.g.user.wallets.length) { if (this.g.user.wallets.length) {
this.getTickets() this.getTickets()
this.getEvents() this.getEvents()
this.getSettings()
this.getPendingEvents()
if (this.g.allowedCurrencies && this.g.allowedCurrencies.length > 0) { if (this.g.allowedCurrencies && this.g.allowedCurrencies.length > 0) {
this.currencies = ['sats', ...this.g.allowedCurrencies] this.currencies = ['sats', ...this.g.allowedCurrencies]
} else { } else {

View file

@ -1,6 +1,23 @@
<template id="page-events"> <template id="page-events">
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md"> <div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
<q-card v-if="isAdmin">
<q-card-section>
<div class="row items-center justify-between">
<div class="col">
<span class="text-subtitle1">Settings</span>
</div>
<div class="col-auto">
<q-toggle
v-model="settings.auto_approve"
label="Auto-approve events"
@update:model-value="saveSettings"
></q-toggle>
</div>
</div>
</q-card-section>
</q-card>
<q-card> <q-card>
<q-card-section> <q-card-section>
<q-btn unelevated color="primary" @click="openEventDialog" <q-btn unelevated color="primary" @click="openEventDialog"
@ -9,6 +26,63 @@
</q-card-section> </q-card-section>
</q-card> </q-card>
<q-card v-if="pendingEvents.length > 0">
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">
<q-icon name="pending" color="orange" class="q-mr-sm"></q-icon>
Pending Approvals
<q-badge
color="orange"
:label="pendingEvents.length"
class="q-ml-sm"
></q-badge>
</h5>
</div>
</div>
<q-list separator>
<q-item v-for="event in pendingEvents" :key="event.id">
<q-item-section>
<q-item-label v-text="event.name"></q-item-label>
<q-item-label caption>
<span v-text="event.event_start_date"></span>
&mdash;
<span v-text="event.info.substring(0, 80)"></span
><span v-if="event.info.length > 80">...</span>
</q-item-label>
<q-item-label caption>
<span v-text="event.amount_tickets"></span> tickets &bull;
<span v-text="event.price_per_ticket"></span>
<span v-text="event.currency"></span>
</q-item-label>
</q-item-section>
<q-item-section side>
<div class="row q-gutter-sm">
<q-btn
dense
color="green"
icon="check_circle"
label="Approve"
size="sm"
@click="approveEvent(event.id)"
></q-btn>
<q-btn
dense
outline
color="red"
icon="block"
label="Reject"
size="sm"
@click="rejectEvent(event.id)"
></q-btn>
</div>
</q-item-section>
</q-item>
</q-list>
</q-card-section>
</q-card>
<q-card> <q-card>
<q-card-section> <q-card-section>
<div class="row items-center no-wrap q-mb-md"> <div class="row items-center no-wrap q-mb-md">
@ -75,6 +149,28 @@
></q-btn> ></q-btn>
</q-td> </q-td>
<q-td auto-width> <q-td auto-width>
<q-btn
v-if="isAdmin && props.row.status === 'proposed'"
flat
dense
size="xs"
@click="approveEvent(props.row.id)"
icon="check_circle"
color="green"
>
<q-tooltip>Approve</q-tooltip>
</q-btn>
<q-btn
v-if="isAdmin && props.row.status === 'proposed'"
flat
dense
size="xs"
@click="rejectEvent(props.row.id)"
icon="block"
color="red"
>
<q-tooltip>Reject</q-tooltip>
</q-btn>
<q-btn <q-btn
flat flat
dense dense
@ -94,7 +190,18 @@
></q-btn> ></q-btn>
</q-td> </q-td>
<q-td v-for="col in props.cols" :key="col.name" :props="props"> <q-td v-for="col in props.cols" :key="col.name" :props="props">
<span v-text="col.value"></span> <q-badge
v-if="col.name === 'status'"
:color="
col.value === 'approved'
? 'green'
: col.value === 'proposed'
? 'orange'
: 'red'
"
:label="col.value"
></q-badge>
<span v-else v-text="col.value"></span>
</q-td> </q-td>
</q-tr> </q-tr>
<q-tr v-show="props.expand" :props="props"> <q-tr v-show="props.expand" :props="props">
@ -149,6 +256,57 @@
</q-card-section> </q-card-section>
</q-card> </q-card>
<q-card v-if="isAdmin && allUserEvents.length > 0">
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">
All Users' Events
<q-badge
color="blue"
:label="allUserEvents.length"
class="q-ml-sm"
></q-badge>
</h5>
</div>
</div>
<q-table
dense
flat
:rows="allUserEvents"
row-key="id"
:columns="eventsTable.columns"
:pagination="{rowsPerPage: 10}"
>
<template v-slot:header="props">
<q-tr :props="props">
<q-th v-for="col in props.cols" :key="col.name" :props="props">
<span v-text="col.label"></span>
</q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td v-for="col in props.cols" :key="col.name" :props="props">
<q-badge
v-if="col.name === 'status'"
:color="
col.value === 'approved'
? 'green'
: col.value === 'proposed'
? 'orange'
: 'red'
"
:label="col.value"
></q-badge>
<span v-else v-text="col.value"></span>
</q-td>
</q-tr>
</template>
</q-table>
</q-card-section>
</q-card>
<q-card> <q-card>
<q-card-section> <q-card-section>
<div class="row items-center no-wrap q-mb-md"> <div class="row items-center no-wrap q-mb-md">
@ -313,28 +471,46 @@
></q-input> ></q-input>
</div> </div>
</div> </div>
<div class="row"> <div class="row q-col-gutter-sm">
<div class="col-4">Event begins</div> <div class="col-4">Event begins</div>
<div class="col-8"> <div class="col-5">
<q-input <q-input
filled filled
dense dense
v-model.trim="formDialog.data.event_start_date" v-model.trim="formDialog.data.event_start_day"
type="date" type="date"
></q-input> ></q-input>
</div> </div>
<div class="col-3">
<q-input
filled
dense
v-model.trim="formDialog.data.event_start_time"
type="time"
hint="Optional"
></q-input>
</div>
</div> </div>
<div class="row"> <div class="row q-col-gutter-sm">
<div class="col-4">Event ends</div> <div class="col-4">Event ends</div>
<div class="col-8"> <div class="col-5">
<q-input <q-input
filled filled
dense dense
v-model.trim="formDialog.data.event_end_date" v-model.trim="formDialog.data.event_end_day"
type="date" type="date"
></q-input> ></q-input>
</div> </div>
<div class="col-3">
<q-input
filled
dense
v-model.trim="formDialog.data.event_end_time"
type="time"
hint="Optional"
></q-input>
</div>
</div> </div>
<div class="row q-col-gutter-sm"> <div class="row q-col-gutter-sm">
<div class="col"> <div class="col">
@ -492,8 +668,8 @@
formDialog.data.name == null || formDialog.data.name == null ||
formDialog.data.info == null || formDialog.data.info == null ||
formDialog.data.closing_date == null || formDialog.data.closing_date == null ||
formDialog.data.event_start_date == null || formDialog.data.event_start_day == null ||
formDialog.data.event_end_date == null || formDialog.data.event_end_day == null ||
formDialog.data.amount_tickets == null || formDialog.data.amount_tickets == null ||
formDialog.data.price_per_ticket == null formDialog.data.price_per_ticket == null
" "

View file

@ -12,9 +12,10 @@ from fastapi import (
WebSocketDisconnect, WebSocketDisconnect,
) )
from lnbits.core.crud import get_user from lnbits.core.crud import get_user
from lnbits.core.models import WalletTypeInfo from lnbits.core.models import Account, WalletTypeInfo
from lnbits.core.services import create_invoice from lnbits.core.services import create_invoice
from lnbits.decorators import ( from lnbits.decorators import (
check_admin,
require_admin_key, require_admin_key,
require_invoice_key, require_invoice_key,
) )
@ -29,23 +30,32 @@ 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_events, get_events,
get_pending_events,
get_public_events,
get_settings,
get_ticket, get_ticket,
get_tickets, get_tickets,
get_tickets_by_user_id,
purge_unpaid_tickets, purge_unpaid_tickets,
update_event, update_event,
update_settings,
update_ticket, update_ticket,
) )
from .models import ( from .models import (
CreateEvent, CreateEvent,
CreateTicket, CreateTicket,
Event, Event,
EventsSettings,
PublicEvent, PublicEvent,
PublicTicket, PublicTicket,
Ticket, Ticket,
TicketPaymentRequest, TicketPaymentRequest,
) )
from .nostr_hooks import publish_or_delete_nostr_event
from .services import refund_tickets from .services import refund_tickets
from .tasks import deregister_payment_listener, register_payment_listener from .tasks import deregister_payment_listener, register_payment_listener
@ -53,32 +63,92 @@ events_api_router = APIRouter(prefix="/api/v1/events")
tickets_api_router = APIRouter(prefix="/api/v1/tickets") tickets_api_router = APIRouter(prefix="/api/v1/tickets")
# 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("")
async def api_events( async def api_events(
all_wallets: bool = Query(False), all_wallets: bool = Query(False),
wallet: WalletTypeInfo = Depends(require_invoice_key), wallet: WalletTypeInfo = Depends(require_invoice_key),
) -> list[Event]: ) -> list[Event]:
wallet_ids = [wallet.wallet.id] wallet_ids = [wallet.wallet.id]
if all_wallets: if all_wallets:
user = await get_user(wallet.wallet.user) user = await get_user(wallet.wallet.user)
wallet_ids = user.wallet_ids if user else [] wallet_ids = user.wallet_ids if user else []
return await get_events(wallet_ids) return 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("/all")
async def api_events_all(
admin: Account = Depends(check_admin),
) -> list[Event]:
"""All events across all wallets. LNbits admin only."""
return await get_all_events()
@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.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("/{event_id}", response_model=PublicEvent) @events_api_router.get("/{event_id}", response_model=PublicEvent)
async def api_get_event(event_id: str) -> Event: 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) event = await get_event(event_id)
if not event: if not event:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist." 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) await purge_unpaid_tickets(event_id)
is_window_open = datetime.now(timezone.utc) < datetime.strptime( # closing_date is filled in by create_event (defaults to end_date or
event.closing_date, "%Y-%m-%d" # start_date) but the field is typed Optional, so guard for the typechecker.
).replace(tzinfo=timezone.utc) 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 (e.g. event_end_date
# may carry a time component since v1.3.0-aio.3).
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 = ( is_min_tickets_met = (
event.sold >= event.extra.min_tickets if event.extra.conditional else True event.sold >= event.extra.min_tickets if event.extra.conditional else True
) )
@ -88,7 +158,6 @@ async def api_get_event(event_id: str) -> Event:
event.canceled = True event.canceled = True
await update_event(event) await update_event(event)
await refund_tickets(event_id) await refund_tickets(event_id)
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event canceled.") raise HTTPException(status_code=HTTPStatus.GONE, detail="Event canceled.")
if not is_window_open: if not is_window_open:
@ -100,28 +169,102 @@ async def api_get_event(event_id: str) -> Event:
@events_api_router.post("") @events_api_router.post("")
@events_api_router.put("/{event_id}")
async def api_event_create( async def api_event_create(
data: CreateEvent, data: CreateEvent,
wallet: WalletTypeInfo = Depends(require_admin_key), wallet: WalletTypeInfo = Depends(require_invoice_key),
event_id: str | None = None,
) -> Event: ) -> Event:
if event_id: """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.
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) event = await get_event(event_id)
if not event: if not event:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist." status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
) )
if event.wallet != wallet.wallet.id: if event.wallet != wallet.wallet.id:
raise HTTPException( raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your event.")
status_code=HTTPStatus.FORBIDDEN, detail="Not your event."
) from lnbits.settings import settings
for k, v in data.dict().items():
setattr(event, k, v) 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.
for field in (
"name",
"info",
"closing_date",
"event_start_date",
"event_end_date",
"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) event = await update_event(event)
else:
event = await create_event(data) 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
@ -136,13 +279,15 @@ async def api_event_cancel(
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist." status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
) )
if event.wallet != wallet.wallet.id: if event.wallet != wallet.wallet.id:
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your event.") raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your event.")
event.canceled = True event.canceled = True
event = await update_event(event) event = await update_event(event)
await refund_tickets(event.id) await refund_tickets(event.id)
if event.nostr_event_id:
await publish_or_delete_nostr_event(event, delete=True)
return event return event
@ -155,14 +300,64 @@ async def api_form_delete(
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist." status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
) )
if event.wallet != wallet.wallet.id: if event.wallet != wallet.wallet.id:
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your event.") 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(event_id)
await delete_event_tickets(event_id) await delete_event_tickets(event_id)
@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
@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("") @tickets_api_router.get("")
async def api_tickets( async def api_tickets(
all_wallets: bool = Query(False), all_wallets: bool = Query(False),
@ -177,6 +372,16 @@ async def api_tickets(
return await get_tickets(wallet_ids) return await get_tickets(wallet_ids)
@tickets_api_router.get("/user/{user_id}")
async def api_tickets_by_user_id(user_id: str) -> list[Ticket]:
"""Tickets bound to an LNbits user_id (used by external integrations).
Declared before /{ticket_id} so FastAPI matches the literal `/user/`
prefix instead of treating "user" as a ticket id.
"""
return await get_tickets_by_user_id(user_id)
@tickets_api_router.get("/{ticket_id}", response_model=PublicTicket) @tickets_api_router.get("/{ticket_id}", response_model=PublicTicket)
async def api_get_ticket(ticket_id: str) -> Ticket: async def api_get_ticket(ticket_id: str) -> Ticket:
ticket = await get_ticket(ticket_id) ticket = await get_ticket(ticket_id)
@ -199,13 +404,24 @@ async def api_ticket_create(event_id: str, data: CreateTicket) -> TicketPaymentR
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist." 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: if event.canceled:
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is canceled.") raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is canceled.")
if event.amount_tickets > 0 and event.sold >= event.amount_tickets: if event.amount_tickets > 0 and event.sold >= event.amount_tickets:
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is sold out.") raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is sold out.")
if data.user_id:
return await _create_user_id_ticket(event, data.user_id)
return await _create_named_ticket(event, data)
async def _create_named_ticket(
event: Event, data: CreateTicket
) -> TicketPaymentRequest:
name = data.name name = data.name
email = data.email email = data.email
promo_code = data.promo_code.upper() if data.promo_code else None promo_code = data.promo_code.upper() if data.promo_code else None
@ -214,12 +430,10 @@ async def api_ticket_create(event_id: str, data: CreateTicket) -> TicketPaymentR
extra: dict[str, Any] = {"tag": "events", "name": name, "email": email} extra: dict[str, Any] = {"tag": "events", "name": name, "email": email}
if promo_code: if promo_code:
# check if promo_code exists in event.extra.promo_codes
if promo_code not in [pc.code for pc in event.extra.promo_codes]: if promo_code not in [pc.code for pc in event.extra.promo_codes]:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="Invalid promo code." status_code=HTTPStatus.BAD_REQUEST, detail="Invalid promo code."
) )
# get the promocode
promo = next(pc for pc in event.extra.promo_codes if pc.code == promo_code) promo = next(pc for pc in event.extra.promo_codes if pc.code == promo_code)
extra["promo_code"] = promo.code extra["promo_code"] = promo.code
price = event.price_per_ticket * (1 - promo.discount_percent / 100) price = event.price_per_ticket * (1 - promo.discount_percent / 100)
@ -229,13 +443,12 @@ async def api_ticket_create(event_id: str, data: CreateTicket) -> TicketPaymentR
extra["currency"] = event.currency extra["currency"] = event.currency
extra["fiatAmount"] = price extra["fiatAmount"] = price
extra["rate"] = await get_fiat_rate_satoshis(event.currency) extra["rate"] = await get_fiat_rate_satoshis(event.currency)
price = await fiat_amount_as_satoshis(price, event.currency) price = await fiat_amount_as_satoshis(price, event.currency)
payment = await create_invoice( payment = await create_invoice(
wallet_id=event.wallet, wallet_id=event.wallet,
amount=price, amount=price,
memo=f"{event_id}", memo=f"{event.id}",
extra=extra, extra=extra,
) )
await create_ticket( await create_ticket(
@ -250,7 +463,34 @@ async def api_ticket_create(event_id: str, data: CreateTicket) -> TicketPaymentR
"sats_paid": int(price), "sats_paid": int(price),
}, },
) )
return TicketPaymentRequest(
payment_hash=payment.payment_hash, payment_request=payment.bolt11
)
async def _create_user_id_ticket(event: Event, user_id: str) -> TicketPaymentRequest:
price = event.price_per_ticket
extra: dict[str, Any] = {"tag": "events", "user_id": user_id}
if event.currency != "sats":
price = await fiat_amount_as_satoshis(event.price_per_ticket, event.currency)
extra["fiat"] = True
extra["currency"] = event.currency
extra["fiatAmount"] = event.price_per_ticket
extra["rate"] = await get_fiat_rate_satoshis(event.currency)
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,
)
return TicketPaymentRequest( return TicketPaymentRequest(
payment_hash=payment.payment_hash, payment_request=payment.bolt11 payment_hash=payment.payment_hash, payment_request=payment.bolt11
) )