From c669da5822429e1a650dd9dd12536afdf4de485e Mon Sep 17 00:00:00 2001 From: padreug Date: Mon, 3 Nov 2025 23:05:31 +0100 Subject: [PATCH 01/37] Adds public events endpoint and user tickets Adds a public events endpoint that allows read-only access to all events. Improves ticket management by adding support for user IDs as an identifier, alongside name and email. This simplifies ticket creation for authenticated users and enhances security. Also introduces an API endpoint to fetch tickets by user ID. --- API_DOCUMENTATION.md | 56 +++++++++++++++++++++ crud.py | 117 +++++++++++++++++++++++++++++++++++++++---- migrations.py | 14 ++++++ models.py | 25 +++++++-- tasks.py | 8 ++- tests/test_api.py | 108 +++++++++++++++++++++++++++++++++++++++ views_api.py | 69 +++++++++++++++++++++++-- 7 files changed, 377 insertions(+), 20 deletions(-) create mode 100644 API_DOCUMENTATION.md create mode 100644 tests/test_api.py diff --git a/API_DOCUMENTATION.md b/API_DOCUMENTATION.md new file mode 100644 index 0000000..9ad0d5f --- /dev/null +++ b/API_DOCUMENTATION.md @@ -0,0 +1,56 @@ +# Events API Documentation + +## Public Events Endpoint + +### GET `/api/v1/events/public` + +Retrieve all events in the database with read-only access. No authentication required. + +**Authentication:** None required (public endpoint) + +**Headers:** +``` +None required +``` + +**Query Parameters:** +- None + +**Response:** +```json +[ + { + "id": "event_id", + "wallet": "wallet_id", + "name": "Event Name", + "info": "Event description", + "closing_date": "2024-12-31", + "event_start_date": "2024-12-01", + "event_end_date": "2024-12-02", + "currency": "sat", + "amount_tickets": 100, + "price_per_ticket": 1000.0, + "time": "2024-01-01T00:00:00Z", + "sold": 0, + "banner": null + } +] +``` + +**Example Usage:** +```bash +curl http://your-lnbits-instance/events/api/v1/events/public +``` + +**Notes:** +- This endpoint allows read-only access to all events in the database +- No authentication required (truly public endpoint) +- Returns events ordered by creation time (newest first) +- Suitable for public event listings or read-only integrations + +## Comparison with Existing Endpoints + +| Endpoint | Authentication | Scope | Use Case | +|----------|---------------|-------|----------| +| `/api/v1/events` | Invoice Key | User's wallets only | Private event management | +| `/api/v1/events/public` | None | All events | Public event browsing | \ No newline at end of file diff --git a/crud.py b/crud.py index 51839b0..ef4bbca 100644 --- a/crud.py +++ b/crud.py @@ -10,45 +10,123 @@ db = Database("ext_events") async def create_ticket( - payment_hash: str, wallet: str, event: str, name: str, email: str + payment_hash: str, + wallet: str, + event: str, + name: Optional[str] = None, + email: Optional[str] = None, + user_id: Optional[str] = None ) -> Ticket: now = datetime.now(timezone.utc) + + # Handle database constraints: if user_id is provided, use empty strings for name/email + if user_id: + db_name = "" + db_email = "" + else: + db_name = name or "" + db_email = email or "" + ticket = Ticket( id=payment_hash, wallet=wallet, event=event, name=name, email=email, + user_id=user_id, registered=False, paid=False, reg_timestamp=now, time=now, ) - await db.insert("events.ticket", ticket) + + # Create a dict for database insertion with proper handling of constraints + ticket_dict = ticket.dict() + ticket_dict["name"] = db_name + ticket_dict["email"] = db_email + + await db.execute( + """ + INSERT INTO events.ticket (id, wallet, event, name, email, user_id, registered, paid, time, reg_timestamp) + VALUES (:id, :wallet, :event, :name, :email, :user_id, :registered, :paid, :time, :reg_timestamp) + """, + ticket_dict + ) return ticket async def update_ticket(ticket: Ticket) -> Ticket: - await db.update("events.ticket", ticket) + # Create a new Ticket object with corrected values for database constraints + ticket_dict = ticket.dict() + + # Convert None values to empty strings for database constraints + if ticket_dict.get("name") is None: + ticket_dict["name"] = "" + if ticket_dict.get("email") is None: + ticket_dict["email"] = "" + + # Create a new Ticket object with the corrected values + corrected_ticket = Ticket(**ticket_dict) + + await db.update("events.ticket", corrected_ticket) return ticket async def get_ticket(payment_hash: str) -> Optional[Ticket]: - return await db.fetchone( + row = await db.fetchone( "SELECT * FROM events.ticket WHERE id = :id", {"id": payment_hash}, - Ticket, ) + if not row: + return None + + # Convert empty strings back to None for the model + ticket_data = dict(row) + if ticket_data.get("name") == "": + ticket_data["name"] = None + if ticket_data.get("email") == "": + ticket_data["email"] = None + + return Ticket(**ticket_data) async def get_tickets(wallet_ids: Union[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]) - return await db.fetchall( - f"SELECT * FROM events.ticket WHERE wallet IN ({q})", - model=Ticket, + rows = await db.fetchall(f"SELECT * FROM events.ticket WHERE wallet IN ({q})") + + tickets = [] + for row in rows: + # Convert empty strings back to None for the model + ticket_data = dict(row) + if ticket_data.get("name") == "": + ticket_data["name"] = None + if ticket_data.get("email") == "": + ticket_data["email"] = None + tickets.append(Ticket(**ticket_data)) + + return tickets + + +async def get_tickets_by_user_id(user_id: str) -> list[Ticket]: + """Get all tickets for a specific user by their user_id""" + rows = await db.fetchall( + "SELECT * FROM events.ticket WHERE user_id = :user_id ORDER BY time DESC", + {"user_id": user_id} ) + + tickets = [] + for row in rows: + # Convert empty strings back to None for the model + ticket_data = dict(row) + if ticket_data.get("name") == "": + ticket_data["name"] = None + if ticket_data.get("email") == "": + ticket_data["email"] = None + tickets.append(Ticket(**ticket_data)) + + return tickets async def delete_ticket(payment_hash: str) -> None: @@ -102,13 +180,32 @@ async def get_events(wallet_ids: Union[str, list[str]]) -> list[Event]: ) +async def get_all_events() -> list[Event]: + """Get all events from the database without wallet filtering.""" + return await db.fetchall( + "SELECT * FROM events.events ORDER BY time DESC", + model=Event, + ) + + 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]: - return await db.fetchall( + rows = await db.fetchall( "SELECT * FROM events.ticket WHERE event = :event", {"event": event_id}, - Ticket, ) + + tickets = [] + for row in rows: + # Convert empty strings back to None for the model + ticket_data = dict(row) + if ticket_data.get("name") == "": + ticket_data["name"] = None + if ticket_data.get("email") == "": + ticket_data["email"] = None + tickets.append(Ticket(**ticket_data)) + + return tickets diff --git a/migrations.py b/migrations.py index 87a0dd4..660fa83 100644 --- a/migrations.py +++ b/migrations.py @@ -160,3 +160,17 @@ async def m005_add_image_banner(db): Add a column to allow an image banner for the event """ await db.execute("ALTER TABLE events.events ADD COLUMN banner TEXT;") + + +async def m006_add_user_id_support(db): + """ + Add user_id column to tickets table to support LNbits user-id as identifier + Make name and email optional when user_id is provided + """ + await db.execute("ALTER TABLE events.ticket ADD COLUMN user_id TEXT;") + + # Since SQLite doesn't support changing column constraints directly, + # we'll work around this by allowing the application logic to handle + # the validation that either (name AND email) OR user_id is provided + # The database will continue to expect name and email as NOT NULL + # but we'll insert empty strings for user_id tickets diff --git a/models.py b/models.py index f475308..d5f3031 100644 --- a/models.py +++ b/models.py @@ -2,7 +2,7 @@ from datetime import datetime from typing import Optional from fastapi import Query -from pydantic import BaseModel, EmailStr +from pydantic import BaseModel, EmailStr, root_validator class CreateEvent(BaseModel): @@ -19,8 +19,22 @@ class CreateEvent(BaseModel): class CreateTicket(BaseModel): - name: str - email: EmailStr + name: Optional[str] = None + email: Optional[EmailStr] = None + user_id: Optional[str] = None + + @root_validator + def validate_identifiers(cls, values): + # Ensure either (name AND email) OR user_id is provided + 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 Event(BaseModel): @@ -43,8 +57,9 @@ class Ticket(BaseModel): id: str wallet: str event: str - name: str - email: str + name: Optional[str] = None + email: Optional[str] = None + user_id: Optional[str] = None registered: bool paid: bool time: datetime diff --git a/tasks.py b/tasks.py index f7300bb..67d5d45 100644 --- a/tasks.py +++ b/tasks.py @@ -21,8 +21,12 @@ async def on_invoice_paid(payment: Payment) -> None: if not payment.extra or "events" != payment.extra.get("tag"): return - if not payment.extra.get("name") or not payment.extra.get("email"): - logger.warning(f"Ticket {payment.payment_hash} missing name or email.") + # Check if ticket has either name/email or user_id + has_name_email = payment.extra.get("name") and payment.extra.get("email") + has_user_id = payment.extra.get("user_id") + + if not has_name_email and not has_user_id: + logger.warning(f"Ticket {payment.payment_hash} missing name/email or user_id.") return ticket = await get_ticket(payment.payment_hash) diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..8c74b39 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,108 @@ +import pytest +from fastapi.testclient import TestClient +from unittest.mock import AsyncMock, patch + +from ..views_api import events_api_router +from ..models import Event +from datetime import datetime, timezone + + +@pytest.mark.asyncio +async def test_api_events_public(): + """Test the new public events API endpoint""" + from fastapi import FastAPI + + app = FastAPI() + app.include_router(events_api_router) + + # Mock the database + with patch('events.crud.get_all_events') as mock_get_all_events: + # Create mock events + mock_events = [ + Event( + id="test_event_1", + wallet="test_wallet_1", + name="Test Event 1", + info="Test event description", + closing_date="2024-12-31", + event_start_date="2024-12-01", + event_end_date="2024-12-02", + currency="sat", + amount_tickets=100, + price_per_ticket=1000.0, + time=datetime.now(timezone.utc), + sold=0, + banner=None + ), + Event( + id="test_event_2", + wallet="test_wallet_2", + name="Test Event 2", + info="Another test event", + closing_date="2024-12-31", + event_start_date="2024-12-03", + event_end_date="2024-12-04", + currency="sat", + amount_tickets=50, + price_per_ticket=500.0, + time=datetime.now(timezone.utc), + sold=0, + banner=None + ) + ] + + mock_get_all_events.return_value = mock_events + + client = TestClient(app) + + # Test the endpoint without any authentication + response = client.get("/api/v1/events/public") + + # Verify the response + assert response.status_code == 200 + data = response.json() + assert len(data) == 2 + assert data[0]["id"] == "test_event_1" + assert data[1]["id"] == "test_event_2" + assert data[0]["name"] == "Test Event 1" + assert data[1]["name"] == "Test Event 2" + + +@pytest.mark.asyncio +async def test_get_all_events_crud(): + """Test the get_all_events CRUD function""" + from events.crud import get_all_events + + with patch('events.crud.db.fetchall') as mock_fetchall: + # Mock database response + mock_events = [ + { + "id": "test_event_1", + "wallet": "test_wallet_1", + "name": "Test Event 1", + "info": "Test event description", + "closing_date": "2024-12-31", + "event_start_date": "2024-12-01", + "event_end_date": "2024-12-02", + "currency": "sat", + "amount_tickets": 100, + "price_per_ticket": 1000.0, + "time": datetime.now(timezone.utc), + "sold": 0, + "banner": None + } + ] + + mock_fetchall.return_value = mock_events + + events = await get_all_events() + + # Verify the function was called with correct parameters + mock_fetchall.assert_called_once_with( + "SELECT * FROM events.events ORDER BY time DESC", + model=Event, + ) + + # Verify the result + assert len(events) == 1 + assert events[0]["id"] == "test_event_1" \ No newline at end of file diff --git a/views_api.py b/views_api.py index cbd8b29..353a4bc 100644 --- a/views_api.py +++ b/views_api.py @@ -27,6 +27,7 @@ from .crud import ( get_events, get_ticket, get_tickets, + get_tickets_by_user_id, purge_unpaid_tickets, update_event, update_ticket, @@ -51,6 +52,18 @@ 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 in the database with read-only access. + This endpoint allows access to all events using any valid API key (read access). + """ + # Get all events from the database without wallet filtering + from .crud import get_all_events + events = await get_all_events() + return [event.dict() for event in events] + + @events_api_router.post("/api/v1/events") @events_api_router.put("/api/v1/events/{event_id}") async def api_event_create( @@ -113,11 +126,61 @@ 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 - return await api_ticket_make_ticket(event_id, name, email) + if data.user_id: + return await api_ticket_make_ticket_with_user_id(event_id, data.user_id) + else: + return await api_ticket_make_ticket(event_id, data.name, data.email) + + +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": + 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) + + 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.get("/api/v1/tickets/{event_id}/user/{user_id}") +async def api_ticket_make_ticket_user_id(event_id: str, user_id: str): + return await api_ticket_make_ticket_with_user_id(event_id, user_id) @events_api_router.get("/api/v1/tickets/{event_id}/{name}/{email}") From 33977c53d6ad7e3dc20ce851dc2f5b4762989556 Mon Sep 17 00:00:00 2001 From: padreug Date: Tue, 4 Nov 2025 01:42:14 +0100 Subject: [PATCH 02/37] Imports Optional type hint Imports the `Optional` type hint from the `typing` module into `crud.py` and `models.py`. This provides more explicit type annotations where values can be `None`. --- crud.py | 1 + models.py | 1 + 2 files changed, 2 insertions(+) diff --git a/crud.py b/crud.py index 90c7c9d..8e48475 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 diff --git a/models.py b/models.py index 06f4d8e..d3bf696 100644 --- a/models.py +++ b/models.py @@ -1,4 +1,5 @@ from datetime import datetime +from typing import Optional from fastapi import Query from pydantic import BaseModel, EmailStr, root_validator From 44f2cb5a6294415bb3555071c2d70130fa746264 Mon Sep 17 00:00:00 2001 From: arbadacarba <63317640+arbadacarbaYK@users.noreply.github.com> Date: Tue, 9 Dec 2025 10:28:48 +0100 Subject: [PATCH 03/37] Fix typos (#39) --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ebd7194..f356ba7 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ For more about LNBits extension check [this tutorial](https://github.com/lnbits/lnbits/wiki/LNbits-Extensions) -## Sell tickets for events and use the built-in scanner for registering attendants +## Sell tickets for events and use the built-in scanner for registering attendees -Events alows you to make tickets for an event. Each ticket is in the form of a uniqque QR code. After registering, and paying for ticket, the user gets a QR code to present at registration/entrance. +Events alows you to make tickets for an event. Each ticket is in the form of a unique QR code. After registering, and paying for ticket, the user gets a QR code to present at registration/entrance. Events includes a shareable ticket scanner, which can be used to register attendees. From a9ac6dcfc1c9ca29b90be9929fb218b652ecc1b7 Mon Sep 17 00:00:00 2001 From: Tiago Vasconcelos Date: Tue, 9 Dec 2025 10:48:00 +0000 Subject: [PATCH 04/37] feat: add promo codes and conditional events (#40) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * add extra column * add conditional events * refunds * conditional events working * adding promo codes * promo codes logic --------- Co-authored-by: dni ⚡ --- crud.py | 26 +++--- migrations.py | 17 ++++ models.py | 41 ++++++++- services.py | 38 ++++++++- static/js/display.js | 18 ++-- static/js/index.js | 104 +++++++++++++++++++---- templates/events/display.html | 22 ++++- templates/events/index.html | 154 +++++++++++++++++++++++++++++++++- views.py | 36 +++++++- views_api.py | 78 +++++++++++++---- 10 files changed, 463 insertions(+), 71 deletions(-) diff --git a/crud.py b/crud.py index 8e48475..fd82a52 100644 --- a/crud.py +++ b/crud.py @@ -4,18 +4,19 @@ from typing import Optional from lnbits.db import Database from lnbits.helpers import urlsafe_short_hash -from .models import CreateEvent, Event, Ticket +from .models import CreateEvent, Event, Ticket, TicketExtra db = Database("ext_events") async def create_ticket( - payment_hash: str, - wallet: str, - event: str, - name: Optional[str] = None, + payment_hash: str, + wallet: str, + event: str, + name: Optional[str] = None, email: Optional[str] = None, - user_id: Optional[str] = None + user_id: Optional[str] = None, + extra: Optional[dict] = None, ) -> Ticket: now = datetime.now(timezone.utc) @@ -38,6 +39,7 @@ async def create_ticket( paid=False, reg_timestamp=now, time=now, + extra=TicketExtra(**extra) if extra else TicketExtra(), ) # Create a dict for database insertion with proper handling of constraints @@ -47,8 +49,8 @@ async def create_ticket( await db.execute( """ - INSERT INTO events.ticket (id, wallet, event, name, email, user_id, registered, paid, time, reg_timestamp) - VALUES (:id, :wallet, :event, :name, :email, :user_id, :registered, :paid, :time, :reg_timestamp) + INSERT INTO events.ticket (id, wallet, event, name, email, user_id, registered, paid, time, reg_timestamp, extra) + VALUES (:id, :wallet, :event, :name, :email, :user_id, :registered, :paid, :time, :reg_timestamp, :extra) """, ticket_dict ) @@ -72,21 +74,21 @@ async def update_ticket(ticket: Ticket) -> Ticket: return ticket -async def get_ticket(payment_hash: str) -> Ticket | None: - return await db.fetchone( +async def get_ticket(payment_hash: str) -> Optional[Ticket]: + row = await db.fetchone( "SELECT * FROM events.ticket WHERE id = :id", {"id": payment_hash}, ) if not row: return None - + # Convert empty strings back to None for the model ticket_data = dict(row) if ticket_data.get("name") == "": ticket_data["name"] = None if ticket_data.get("email") == "": ticket_data["email"] = None - + return Ticket(**ticket_data) diff --git a/migrations.py b/migrations.py index 660fa83..8ed79b5 100644 --- a/migrations.py +++ b/migrations.py @@ -174,3 +174,20 @@ async def m006_add_user_id_support(db): # the validation that either (name AND email) OR user_id is provided # The database will continue to expect name and email as NOT NULL # but we'll insert empty strings for user_id tickets + +async def m007_add_extra_fields(db): + """ + Add a canceled and 'extra' column to events and ticket tables + to support promo codes and ticket metadata. + """ + # Add canceled and 'extra' columns to events table + await db.execute( + """ + ALTER TABLE events.events + ADD COLUMN canceled BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN extra TEXT; + """ + ) + + # Add 'extra' column to ticket table + await db.execute("ALTER TABLE events.ticket ADD COLUMN extra TEXT;") diff --git a/models.py b/models.py index d3bf696..31534f0 100644 --- a/models.py +++ b/models.py @@ -2,7 +2,29 @@ from datetime import datetime from typing import Optional from fastapi import Query -from pydantic import BaseModel, EmailStr, root_validator +from pydantic import BaseModel, EmailStr, Field, root_validator, validator + + +class PromoCode(BaseModel): + code: str + discount_percent: float = 0.0 + active: bool = True + + # make the promo code uppercase + @validator("code") + def uppercase_code(cls, v): + return v.upper() + + @validator("discount_percent") + def validate_discount_percent(cls, v): + assert 0 <= v <= 100, "Discount must be between 0 and 100." + return v + + +class EventExtra(BaseModel): + promo_codes: list[PromoCode] = Field(default_factory=list) + conditional: bool = False + min_tickets: int = 1 class CreateEvent(BaseModel): @@ -16,20 +38,23 @@ class CreateEvent(BaseModel): amount_tickets: int = Query(..., ge=0) price_per_ticket: float = Query(..., ge=0) banner: str | None = None + extra: EventExtra = Field(default_factory=EventExtra) class CreateTicket(BaseModel): name: Optional[str] = None email: Optional[EmailStr] = None user_id: Optional[str] = None - + promo_code: Optional[str] = None + refund_address: Optional[str] = None + @root_validator def validate_identifiers(cls, values): # Ensure either (name AND email) OR user_id is provided 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): @@ -43,6 +68,7 @@ class Event(BaseModel): name: str info: str closing_date: str + canceled: bool = False event_start_date: str event_end_date: str currency: str @@ -51,6 +77,14 @@ class Event(BaseModel): time: datetime sold: int = 0 banner: str | None = None + extra: EventExtra = Field(default_factory=EventExtra) + + +class TicketExtra(BaseModel): + applied_promo_code: str | None = None + sats_paid: int | None = None + refund_address: str | None = None + refunded: bool = False class Ticket(BaseModel): @@ -64,3 +98,4 @@ class Ticket(BaseModel): paid: bool time: datetime reg_timestamp: datetime + extra: TicketExtra = Field(default_factory=TicketExtra) diff --git a/services.py b/services.py index 1286534..9099ef0 100644 --- a/services.py +++ b/services.py @@ -1,4 +1,13 @@ -from .crud import get_event, update_event, update_ticket +from lnurl import execute +from loguru import logger + +from .crud import ( + get_event, + get_event_tickets, + purge_unpaid_tickets, + update_event, + update_ticket, +) from .models import Ticket @@ -16,3 +25,30 @@ async def set_ticket_paid(ticket: Ticket) -> Ticket: await update_event(event) return ticket + + +async def refund_tickets(event_id: str): + """ + Refund tickets for an event that has not met the minimum ticket requirement. + This function should be called when the event is closed and the minimum ticket + condition is not met. + """ + await purge_unpaid_tickets(event_id) + tickets = await get_event_tickets(event_id) + + if not tickets: + return + + for ticket in tickets: + if ticket.extra.refunded: + continue + if ticket.paid and ticket.extra.refund_address and ticket.extra.sats_paid: + try: + res = await execute( + ticket.extra.refund_address, str(ticket.extra.sats_paid) + ) + if res: + ticket.extra.refunded = True + await update_ticket(ticket) + except Exception as e: + logger.error(f"Error refunding ticket {ticket.id}: {e}") diff --git a/static/js/display.js b/static/js/display.js index 4884751..6098e5a 100644 --- a/static/js/display.js +++ b/static/js/display.js @@ -9,7 +9,8 @@ window.app = Vue.createApp({ show: false, data: { name: '', - email: '' + email: '', + refund: '' } }, ticketLink: { @@ -29,7 +30,8 @@ window.app = Vue.createApp({ this.info = event_info this.info = this.info.substring(1, this.info.length - 1) this.banner = event_banner - await this.purgeUnpaidTickets() + this.extra = event_extra + this.hasPromoCodes = has_promoCodes }, computed: { formatDescription() { @@ -41,6 +43,7 @@ window.app = Vue.createApp({ e.preventDefault() this.formDialog.data.name = '' this.formDialog.data.email = '' + this.formDialog.data.refund = '' }, closeReceiveDialog() { @@ -60,12 +63,12 @@ window.app = Vue.createApp({ const regex = /^[\w\.-]+@[a-zA-Z\d\.-]+\.[a-zA-Z]{2,}$/ return regex.test(val) || 'Please enter valid email.' }, - Invoice() { axios .post(`/events/api/v1/tickets/${event_id}`, { name: this.formDialog.data.name, - email: this.formDialog.data.email + email: this.formDialog.data.email, + promo_code: this.formDialog.data.promo_code || null }) .then(response => { this.paymentReq = response.data.payment_request @@ -122,13 +125,6 @@ window.app = Vue.createApp({ }, 2000) }) .catch(LNbits.utils.notifyApiError) - }, - async purgeUnpaidTickets() { - try { - await LNbits.api.request('GET', `/events/api/v1/purge/${event_id}`) - } catch (error) { - LNbits.utils.notifyApiError(error) - } } } }) diff --git a/static/js/index.js b/static/js/index.js index cccbe82..d26133c 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -1,9 +1,6 @@ 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.price_per_ticket) + 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 } @@ -20,8 +17,6 @@ window.app = Vue.createApp({ columns: [ {name: 'id', align: 'left', label: 'ID', field: 'id'}, {name: 'name', align: 'left', label: 'Name', field: 'name'}, - {name: 'info', align: 'left', label: 'Info', field: 'info'}, - {name: 'banner', align: 'left', label: 'Banner', field: 'banner'}, { name: 'event_start_date', align: 'left', @@ -40,6 +35,17 @@ window.app = Vue.createApp({ label: 'Ticket close', field: 'closing_date' }, + { + name: 'canceled', + align: 'left', + label: 'Canceled', + field: row => { + if (row.extra.conditional && row.canceled) { + return 'Yes' + } + return 'No' + } + }, { name: 'price_per_ticket', align: 'left', @@ -65,7 +71,9 @@ window.app = Vue.createApp({ align: 'left', label: 'Sold', field: 'sold' - } + }, + {name: 'info', align: 'left', label: 'Info', field: 'info'}, + {name: 'banner', align: 'left', label: 'Banner', field: 'banner'} ], pagination: { rowsPerPage: 10 @@ -73,7 +81,6 @@ window.app = Vue.createApp({ }, ticketsTable: { columns: [ - {name: 'id', align: 'left', label: 'ID', field: 'id'}, {name: 'event', align: 'left', label: 'Event', field: 'event'}, {name: 'name', align: 'left', label: 'Name', field: 'name'}, {name: 'email', align: 'left', label: 'Email', field: 'email'}, @@ -82,7 +89,14 @@ window.app = Vue.createApp({ align: 'left', label: 'Registered', field: 'registered' - } + }, + { + name: 'promo_code', + align: 'left', + label: 'Promo Code', + field: row => row.extra.applied_promo_code || '' + }, + {name: 'id', align: 'left', label: 'ID', field: 'id'} ], pagination: { rowsPerPage: 10 @@ -90,7 +104,11 @@ window.app = Vue.createApp({ }, formDialog: { show: false, - data: {} + data: { + extra: { + promo_codes: [] + } + } } } }, @@ -143,9 +161,10 @@ window.app = Vue.createApp({ this.g.user.wallets[0].inkey ) .then(response => { - this.events = response.data.map(function (obj) { + this.events = response.data.map(obj => { return mapEvents(obj) }) + this.checkCanceledEvents() }) }, sendEventData() { @@ -153,6 +172,11 @@ window.app = Vue.createApp({ id: this.formDialog.data.wallet }) const data = this.formDialog.data + if (data.extra && !data.extra.promo_codes) { + data.extra.promo_codes = data.extra.promo_codes + .filter(code => code.trim() !== '') + .map(code => code.trim().toUpperCase()) + } if (data.id) { this.updateEvent(wallet, data) @@ -161,20 +185,41 @@ window.app = Vue.createApp({ } }, + openEventDialog(data = false) { + if (data && data.id) { + this.formDialog.data = {...data} + } else { + this.formDialog.data = { + extra: { + conditional: false, + min_tickets: 1, + promo_codes: [] + } + } + } + this.formDialog.show = true + }, + resetEventDialog() { + this.formDialog.show = false + this.formDialog.data = { + extra: { + promo_codes: [] + } + } + }, + createEvent(wallet, data) { LNbits.api .request('POST', '/events/api/v1/events', wallet.adminkey, data) .then(response => { this.events.push(mapEvents(response.data)) - this.formDialog.show = false - this.formDialog.data = {} + this.resetEventDialog() }) .catch(LNbits.utils.notifyApiError) }, updateformDialog(formId) { const link = _.findWhere(this.events, {id: formId}) - this.formDialog.data = {...link} - this.formDialog.show = true + this.openEventDialog(link) }, updateEvent(wallet, data) { LNbits.api @@ -189,8 +234,7 @@ window.app = Vue.createApp({ return obj.id == data.id }) this.events.push(mapEvents(response.data)) - this.formDialog.show = false - this.formDialog.data = {} + this.resetEventDialog() }) .catch(LNbits.utils.notifyApiError) }, @@ -216,6 +260,30 @@ window.app = Vue.createApp({ }, exporteventsCSV() { LNbits.utils.exportCSV(this.eventsTable.columns, this.events) + }, + async checkCanceledEvents() { + const events = this.events + .filter(event => event.extra.conditional) + .filter(e => !e.canceled) + if (!events.length) return + const now = new Date() + events.forEach(async ev => { + if (new Date(ev.closing_date) < now && ev.sold < ev.extra.min_tickets) { + const {data} = await LNbits.api.request( + 'PUT', + '/events/api/v1/events/' + ev.id + '/cancel', + _.findWhere(this.g.user.wallets, {id: ev.wallet}).adminkey + ) + Quasar.Notify.create({ + type: 'warning', + message: `Event ${ev.name} has been canceled and refunds have been issued.`, + icon: null + }) + this.events = this.events.map(e => + e.id === ev.id ? mapEvents(data) : e + ) + } + }) } }, async created() { diff --git a/templates/events/display.html b/templates/events/display.html index 24a64e4..73d279d 100644 --- a/templates/events/display.html +++ b/templates/events/display.html @@ -6,7 +6,7 @@

{{ event_name }}


-
+

@@ -30,7 +30,23 @@ :rules="[val => !!val || '* Required', val => emailValidation(val)]" lazy-rules > - + +
{% endblock %} diff --git a/templates/events/index.html b/templates/events/index.html index d9c6bb3..62752d1 100644 --- a/templates/events/index.html +++ b/templates/events/index.html @@ -4,7 +4,7 @@
- New Event @@ -33,7 +33,7 @@ @@ -224,7 +280,6 @@ >
-
Event begins
@@ -248,7 +303,6 @@ >
-
+ +
+
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 +
+
= event.extra.min_tickets if event.extra.conditional else True + ) + if event.amount_tickets < 1: return events_renderer().TemplateResponse( "events/error.html", @@ -41,8 +51,20 @@ async def display(request: Request, event_id): "event_error": "Sorry, tickets are sold out :(", }, ) - datetime_object = datetime.strptime(event.closing_date, "%Y-%m-%d").date() - if date.today() > datetime_object: + 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", { @@ -52,6 +74,12 @@ async def display(request: Request, event_id): }, ) + 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", { @@ -61,6 +89,8 @@ async def display(request: Request, event_id): "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, }, ) diff --git a/views_api.py b/views_api.py index cecf2c3..ada7323 100644 --- a/views_api.py +++ b/views_api.py @@ -32,7 +32,7 @@ from .crud import ( update_ticket, ) from .models import CreateEvent, CreateTicket, Ticket -from .services import set_ticket_paid +from .services import refund_tickets, set_ticket_paid events_api_router = APIRouter() @@ -90,6 +90,26 @@ async def api_event_create( return event.dict() +@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 = 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) + + return event.dict() + + @events_api_router.delete("/api/v1/events/{event_id}") async def api_form_delete( event_id: str, wallet: WalletTypeInfo = Depends(require_admin_key) @@ -136,7 +156,11 @@ 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) else: - return await api_ticket_make_ticket(event_id, data.name, data.email) + 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 + ) async def api_ticket_make_ticket_with_user_id(event_id: str, user_id: str): @@ -183,7 +207,7 @@ async def api_ticket_make_ticket_user_id(event_id: str, user_id: str): @events_api_router.get("/api/v1/tickets/{event_id}/{name}/{email}") -async def api_ticket_make_ticket(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( @@ -193,14 +217,25 @@ async def api_ticket_make_ticket(event_id, name, email): price = event.price_per_ticket extra = {"tag": "events", "name": name, "email": email} - if event.currency != "sats": - price = await fiat_amount_as_satoshis(event.price_per_ticket, event.currency) + 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]: + raise HTTPException( + 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) + extra["promo_code"] = promo.code + price = event.price_per_ticket * (1 - promo.discount_percent / 100) + if event.currency != "sats": extra["fiat"] = True extra["currency"] = event.currency - extra["fiatAmount"] = event.price_per_ticket + extra["fiatAmount"] = price extra["rate"] = await get_fiat_rate_satoshis(event.currency) + price = await fiat_amount_as_satoshis(price, event.currency) + try: payment = await create_invoice( wallet_id=event.wallet, @@ -214,6 +249,11 @@ async def api_ticket_make_ticket(event_id, name, email): event=event.id, name=name, email=email, + extra={ + "applied_promo_code": promo_code, + "refund_address": refund_address, + "sats_paid": int(price), + }, ) except Exception as exc: raise HTTPException( @@ -239,16 +279,31 @@ async def api_ticket_send_ticket(event_id, payment_hash): ) 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} @@ -271,17 +326,6 @@ async def api_ticket_delete( await delete_ticket(ticket_id) -# TODO: DELETE, updates db! @tal -@events_api_router.get("/api/v1/purge/{event_id}") -async def api_event_purge_tickets(event_id: str): - event = await get_event(event_id) - if not event: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist." - ) - return await purge_unpaid_tickets(event_id) - - @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) From 4fb6d90fcd40edeb807f2f78bfe8becfe1557945 Mon Sep 17 00:00:00 2001 From: padreug Date: Mon, 3 Nov 2025 23:05:31 +0100 Subject: [PATCH 05/37] Adds public events endpoint and user tickets Adds a public events endpoint that allows read-only access to all events. Improves ticket management by adding support for user IDs as an identifier, alongside name and email. This simplifies ticket creation for authenticated users and enhances security. Also introduces an API endpoint to fetch tickets by user ID. --- crud.py | 26 +++++++++++++------------- models.py | 2 +- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/crud.py b/crud.py index fd82a52..e92e47e 100644 --- a/crud.py +++ b/crud.py @@ -19,7 +19,7 @@ async def create_ticket( extra: Optional[dict] = None, ) -> Ticket: now = datetime.now(timezone.utc) - + # Handle database constraints: if user_id is provided, use empty strings for name/email if user_id: db_name = "" @@ -27,7 +27,7 @@ async def create_ticket( else: db_name = name or "" db_email = email or "" - + ticket = Ticket( id=payment_hash, wallet=wallet, @@ -41,12 +41,12 @@ async def create_ticket( time=now, extra=TicketExtra(**extra) if extra else TicketExtra(), ) - + # Create a dict for database insertion with proper handling of constraints ticket_dict = ticket.dict() ticket_dict["name"] = db_name ticket_dict["email"] = db_email - + await db.execute( """ INSERT INTO events.ticket (id, wallet, event, name, email, user_id, registered, paid, time, reg_timestamp, extra) @@ -60,16 +60,16 @@ async def create_ticket( async def update_ticket(ticket: Ticket) -> Ticket: # Create a new Ticket object with corrected values for database constraints ticket_dict = ticket.dict() - + # Convert None values to empty strings for database constraints if ticket_dict.get("name") is None: ticket_dict["name"] = "" if ticket_dict.get("email") is None: ticket_dict["email"] = "" - + # Create a new Ticket object with the corrected values corrected_ticket = Ticket(**ticket_dict) - + await db.update("events.ticket", corrected_ticket) return ticket @@ -97,7 +97,7 @@ async def get_tickets(wallet_ids: str | list[str]) -> list[Ticket]: 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})") - + tickets = [] for row in rows: # Convert empty strings back to None for the model @@ -107,7 +107,7 @@ async def get_tickets(wallet_ids: str | list[str]) -> list[Ticket]: if ticket_data.get("email") == "": ticket_data["email"] = None tickets.append(Ticket(**ticket_data)) - + return tickets @@ -117,7 +117,7 @@ async def get_tickets_by_user_id(user_id: str) -> list[Ticket]: "SELECT * FROM events.ticket WHERE user_id = :user_id ORDER BY time DESC", {"user_id": user_id} ) - + tickets = [] for row in rows: # Convert empty strings back to None for the model @@ -127,7 +127,7 @@ async def get_tickets_by_user_id(user_id: str) -> list[Ticket]: if ticket_data.get("email") == "": ticket_data["email"] = None tickets.append(Ticket(**ticket_data)) - + return tickets @@ -199,7 +199,7 @@ async def get_event_tickets(event_id: str) -> list[Ticket]: "SELECT * FROM events.ticket WHERE event = :event", {"event": event_id}, ) - + tickets = [] for row in rows: # Convert empty strings back to None for the model @@ -209,5 +209,5 @@ async def get_event_tickets(event_id: str) -> list[Ticket]: if ticket_data.get("email") == "": ticket_data["email"] = None tickets.append(Ticket(**ticket_data)) - + return tickets diff --git a/models.py b/models.py index 31534f0..b05a5da 100644 --- a/models.py +++ b/models.py @@ -37,7 +37,7 @@ class CreateEvent(BaseModel): currency: str = "sat" amount_tickets: int = Query(..., ge=0) price_per_ticket: float = Query(..., ge=0) - banner: str | None = None + banner: Optional[str] = None extra: EventExtra = Field(default_factory=EventExtra) From c49abdb53ff9a3b7def5e53ceb97ccbfd52e8762 Mon Sep 17 00:00:00 2001 From: padreug Date: Wed, 31 Dec 2025 17:07:34 +0100 Subject: [PATCH 06/37] Fix SQLite migration syntax error in m007 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SQLite doesn't support adding multiple columns in a single ALTER TABLE statement. Split into separate statements for each column. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- migrations.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/migrations.py b/migrations.py index 8ed79b5..9ed982b 100644 --- a/migrations.py +++ b/migrations.py @@ -181,12 +181,12 @@ async def m007_add_extra_fields(db): to support promo codes and ticket metadata. """ # Add canceled and 'extra' columns to events table + # SQLite requires separate ALTER TABLE statements for each column await db.execute( - """ - ALTER TABLE events.events - ADD COLUMN canceled BOOLEAN NOT NULL DEFAULT FALSE, - ADD COLUMN extra TEXT; - """ + "ALTER TABLE events.events ADD COLUMN canceled BOOLEAN NOT NULL DEFAULT FALSE;" + ) + await db.execute( + "ALTER TABLE events.events ADD COLUMN extra TEXT;" ) # Add 'extra' column to ticket table From a77145e08ee1534e2bda1d014ff580a71675aff1 Mon Sep 17 00:00:00 2001 From: padreug Date: Sat, 3 Jan 2026 17:48:46 +0100 Subject: [PATCH 07/37] fix: Use db.insert() for ticket creation to fix SQLite serialization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous implementation used db.execute() with a raw dict, which failed on SQLite because the 'extra' field (TicketExtra model) was passed as a Python dict that SQLite cannot serialize. Using db.insert() with the Pydantic model ensures proper JSON serialization of the extra field across all database backends (SQLite, PostgreSQL, CockroachDB). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- crud.py | 44 ++++++++++++++++++++++++++++---------------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/crud.py b/crud.py index e92e47e..8996a2e 100644 --- a/crud.py +++ b/crud.py @@ -20,7 +20,12 @@ async def create_ticket( ) -> Ticket: now = datetime.now(timezone.utc) - # Handle database constraints: if user_id is provided, use empty strings for name/email + # TODO: Check if this empty string workaround is still needed. + # This converts None to empty strings for database storage because: + # 1. Database may have NOT NULL constraints on name/email columns + # 2. When user_id is provided, name/email are not used (mutually exclusive) + # 3. The get_ticket() functions convert empty strings back to None when reading + # Consider using nullable columns instead of this empty string pattern. if user_id: db_name = "" db_email = "" @@ -28,7 +33,28 @@ async def create_ticket( db_name = name or "" db_email = email or "" - ticket = Ticket( + # Create ticket with database-compatible values for insertion + # Using db.insert() ensures proper serialization of the extra field (TicketExtra) + # across all database backends (SQLite, PostgreSQL, CockroachDB) + db_ticket = Ticket( + id=payment_hash, + 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(), + ) + + await db.insert("events.ticket", db_ticket) + + # Return ticket with original name/email values (not empty strings) + # This maintains consistency with how get_ticket() converts empty strings back to None + return Ticket( id=payment_hash, wallet=wallet, event=event, @@ -42,20 +68,6 @@ async def create_ticket( extra=TicketExtra(**extra) if extra else TicketExtra(), ) - # Create a dict for database insertion with proper handling of constraints - ticket_dict = ticket.dict() - ticket_dict["name"] = db_name - ticket_dict["email"] = db_email - - await db.execute( - """ - INSERT INTO events.ticket (id, wallet, event, name, email, user_id, registered, paid, time, reg_timestamp, extra) - VALUES (:id, :wallet, :event, :name, :email, :user_id, :registered, :paid, :time, :reg_timestamp, :extra) - """, - ticket_dict - ) - return ticket - async def update_ticket(ticket: Ticket) -> Ticket: # Create a new Ticket object with corrected values for database constraints From 68e6e3d02e5bfc353ddc81a890deaab69ccf522e Mon Sep 17 00:00:00 2001 From: padreug Date: Sat, 3 Jan 2026 18:03:34 +0100 Subject: [PATCH 08/37] fix: Parse JSON extra field when reading tickets from database MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fix used db.insert() which serializes the extra field to JSON. However, the read functions (get_ticket, get_tickets, etc.) use fetchone/fetchall without a model parameter, so the extra field comes back as a JSON string. Added _parse_ticket_row() helper that: - Converts empty strings to None for name/email (existing logic) - Parses extra field from JSON string if needed (new) The isinstance(extra, str) check ensures compatibility with both: - SQLite: returns JSON as string - PostgreSQL/CockroachDB: may return native JSONB as dict 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- crud.py | 70 ++++++++++++++++++++++++--------------------------------- 1 file changed, 29 insertions(+), 41 deletions(-) diff --git a/crud.py b/crud.py index 8996a2e..8abcbbb 100644 --- a/crud.py +++ b/crud.py @@ -1,3 +1,4 @@ +import json from datetime import datetime, timedelta, timezone from typing import Optional @@ -6,6 +7,30 @@ from lnbits.helpers import urlsafe_short_hash from .models import CreateEvent, Event, Ticket, TicketExtra + +def _parse_ticket_row(row) -> dict: + """ + Parse a database row into a dict suitable for Ticket model creation. + Handles: + - Empty string to None conversion for name/email + - JSON string to dict conversion for extra field + """ + ticket_data = dict(row) + + # Convert empty strings back to None for the model + if ticket_data.get("name") == "": + ticket_data["name"] = None + if ticket_data.get("email") == "": + ticket_data["email"] = None + + # Parse extra field from JSON string if needed + # (db.insert() serializes to JSON, but manual fetchone/fetchall returns string) + extra = ticket_data.get("extra") + if isinstance(extra, str): + ticket_data["extra"] = json.loads(extra) + + return ticket_data + db = Database("ext_events") @@ -94,14 +119,7 @@ async def get_ticket(payment_hash: str) -> Optional[Ticket]: if not row: return None - # Convert empty strings back to None for the model - ticket_data = dict(row) - if ticket_data.get("name") == "": - ticket_data["name"] = None - if ticket_data.get("email") == "": - ticket_data["email"] = None - - return Ticket(**ticket_data) + return Ticket(**_parse_ticket_row(row)) async def get_tickets(wallet_ids: str | list[str]) -> list[Ticket]: @@ -110,17 +128,7 @@ async def get_tickets(wallet_ids: str | list[str]) -> list[Ticket]: q = ",".join([f"'{wallet_id}'" for wallet_id in wallet_ids]) rows = await db.fetchall(f"SELECT * FROM events.ticket WHERE wallet IN ({q})") - tickets = [] - for row in rows: - # Convert empty strings back to None for the model - ticket_data = dict(row) - if ticket_data.get("name") == "": - ticket_data["name"] = None - if ticket_data.get("email") == "": - ticket_data["email"] = None - tickets.append(Ticket(**ticket_data)) - - return tickets + return [Ticket(**_parse_ticket_row(row)) for row in rows] async def get_tickets_by_user_id(user_id: str) -> list[Ticket]: @@ -130,17 +138,7 @@ async def get_tickets_by_user_id(user_id: str) -> list[Ticket]: {"user_id": user_id} ) - tickets = [] - for row in rows: - # Convert empty strings back to None for the model - ticket_data = dict(row) - if ticket_data.get("name") == "": - ticket_data["name"] = None - if ticket_data.get("email") == "": - ticket_data["email"] = None - tickets.append(Ticket(**ticket_data)) - - return tickets + return [Ticket(**_parse_ticket_row(row)) for row in rows] async def delete_ticket(payment_hash: str) -> None: @@ -212,14 +210,4 @@ async def get_event_tickets(event_id: str) -> list[Ticket]: {"event": event_id}, ) - tickets = [] - for row in rows: - # Convert empty strings back to None for the model - ticket_data = dict(row) - if ticket_data.get("name") == "": - ticket_data["name"] = None - if ticket_data.get("email") == "": - ticket_data["email"] = None - tickets.append(Ticket(**ticket_data)) - - return tickets + return [Ticket(**_parse_ticket_row(row)) for row in rows] From 1dcff37df5e61699df0ee5042741e89fafebe86c Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 27 Apr 2026 09:01:50 +0200 Subject: [PATCH 09/37] feat: add status field to Event model for approval workflow Add 'status' column (proposed/approved/rejected) to the events table with default 'approved' for backward compatibility. Existing events are unaffected. Migration m008 adds the column. Co-Authored-By: Claude Opus 4.6 (1M context) --- migrations.py | 11 +++++++++++ models.py | 2 ++ 2 files changed, 13 insertions(+) diff --git a/migrations.py b/migrations.py index 9ed982b..357c3ed 100644 --- a/migrations.py +++ b/migrations.py @@ -191,3 +191,14 @@ async def m007_add_extra_fields(db): # Add 'extra' column to ticket table await db.execute("ALTER TABLE events.ticket ADD COLUMN extra TEXT;") + + +async def m008_add_event_status(db): + """ + Add status column to events table for proposal/approval workflow. + Values: 'proposed', 'approved', 'rejected'. + Default 'approved' for backward compatibility with existing events. + """ + await db.execute( + "ALTER TABLE events.events ADD COLUMN status TEXT NOT NULL DEFAULT 'approved';" + ) diff --git a/models.py b/models.py index b05a5da..78917d4 100644 --- a/models.py +++ b/models.py @@ -39,6 +39,7 @@ class CreateEvent(BaseModel): price_per_ticket: float = Query(..., ge=0) banner: Optional[str] = None extra: EventExtra = Field(default_factory=EventExtra) + status: str = "approved" # proposed, approved, rejected class CreateTicket(BaseModel): @@ -78,6 +79,7 @@ class Event(BaseModel): sold: int = 0 banner: str | None = None extra: EventExtra = Field(default_factory=EventExtra) + status: str = "approved" # proposed, approved, rejected class TicketExtra(BaseModel): From 0c782e623959b0bd8dbca7a361b07287befce908 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 27 Apr 2026 09:02:18 +0200 Subject: [PATCH 10/37] feat: add CRUD functions for public and pending event queries - get_public_events(): returns approved, non-canceled events - get_pending_events(): returns proposed events for admin review Co-Authored-By: Claude Opus 4.6 (1M context) --- crud.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/crud.py b/crud.py index 8abcbbb..9642a83 100644 --- a/crud.py +++ b/crud.py @@ -200,6 +200,26 @@ async def get_all_events() -> list[Event]: ) +async def get_public_events() -> list[Event]: + """Get approved, non-canceled events for public display.""" + 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]: + """Get proposed events awaiting admin approval.""" + return await db.fetchall( + "SELECT * FROM events.events WHERE status = 'proposed' ORDER BY time DESC", + model=Event, + ) + + async def delete_event(event_id: str) -> None: await db.execute("DELETE FROM events.events WHERE id = :id", {"id": event_id}) From a41348df941a69d5a232788cd639a10a6846e00d Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 27 Apr 2026 09:04:25 +0200 Subject: [PATCH 11/37] feat: add event proposal and approval API endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /api/v1/events/propose — submit event for approval (invoice key) - GET /api/v1/events/pending — list proposed events (admin key) - PUT /api/v1/events/{id}/approve — approve proposed event (admin key) - PUT /api/v1/events/{id}/reject — reject proposed event (admin key) - GET /api/v1/events/public — now returns only approved, non-canceled events Co-Authored-By: Claude Opus 4.6 (1M context) --- views_api.py | 79 ++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 74 insertions(+), 5 deletions(-) diff --git a/views_api.py b/views_api.py index ada7323..d8a2b75 100644 --- a/views_api.py +++ b/views_api.py @@ -24,6 +24,8 @@ from .crud import ( get_event, get_event_tickets, get_events, + get_pending_events, + get_public_events, get_ticket, get_tickets, get_tickets_by_user_id, @@ -54,12 +56,10 @@ async def api_events( @events_api_router.get("/api/v1/events/public") async def api_events_public(): """ - Retrieve all events in the database with read-only access. - This endpoint allows access to all events using any valid API key (read access). + Retrieve approved, non-canceled events for public display. + No authentication required. """ - # Get all events from the database without wallet filtering - from .crud import get_all_events - events = await get_all_events() + events = await get_public_events() return [event.dict() for event in events] @@ -128,6 +128,75 @@ async def api_form_delete( return "", HTTPStatus.NO_CONTENT +#########Event Approval########## + + +@events_api_router.post("/api/v1/events/propose") +async def api_event_propose( + data: CreateEvent, + wallet: WalletTypeInfo = Depends(require_invoice_key), +): + """ + Propose a new event for admin approval. + Requires invoice key (any authenticated user, not admin-only). + """ + data.status = "proposed" + data.wallet = wallet.wallet.id + event = await create_event(data) + return event.dict() + + +@events_api_router.get("/api/v1/events/pending") +async def api_events_pending( + wallet: WalletTypeInfo = Depends(require_admin_key), +): + """Get all proposed events awaiting approval. Admin only.""" + events = await get_pending_events() + return [event.dict() for event in events] + + +@events_api_router.put("/api/v1/events/{event_id}/approve") +async def api_event_approve( + event_id: str, + wallet: WalletTypeInfo = Depends(require_admin_key), +): + """Approve a proposed event. Admin only.""" + 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) + return event.dict() + + +@events_api_router.put("/api/v1/events/{event_id}/reject") +async def api_event_reject( + event_id: str, + wallet: WalletTypeInfo = Depends(require_admin_key), +): + """Reject a proposed event. Admin only.""" + 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" + event = await update_event(event) + return event.dict() + + #########Tickets########## From eb474b139033a805de0f21fe5de5fbfd62290c19 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 27 Apr 2026 09:04:46 +0200 Subject: [PATCH 12/37] fix: make promo_code and refund_address optional query params These were required query params on the GET ticket endpoint, causing 400 errors when not provided. Co-Authored-By: Claude Opus 4.6 (1M context) --- views_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/views_api.py b/views_api.py index d8a2b75..6fae87a 100644 --- a/views_api.py +++ b/views_api.py @@ -276,7 +276,7 @@ async def api_ticket_make_ticket_user_id(event_id: str, user_id: str): @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): +async def api_ticket_make_ticket(event_id, name, email, promo_code=None, refund_address=None): event = await get_event(event_id) if not event: raise HTTPException( From 41e64adfdee80b8a68a5b420891d3ad470479208 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 27 Apr 2026 09:08:23 +0200 Subject: [PATCH 13/37] fix: resolve lint errors in views_api.py - Remove unused purge_unpaid_tickets import (add TODO comment) - Break long line in ticket GET endpoint signature Co-Authored-By: Claude Opus 4.6 (1M context) --- views_api.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/views_api.py b/views_api.py index 6fae87a..5638833 100644 --- a/views_api.py +++ b/views_api.py @@ -29,7 +29,7 @@ from .crud import ( get_ticket, get_tickets, get_tickets_by_user_id, - purge_unpaid_tickets, + # TODO: consider exposing purge_unpaid_tickets via an admin endpoint update_event, update_ticket, ) @@ -276,7 +276,9 @@ async def api_ticket_make_ticket_user_id(event_id: str, user_id: str): @events_api_router.get("/api/v1/tickets/{event_id}/{name}/{email}") -async def api_ticket_make_ticket(event_id, name, email, promo_code=None, refund_address=None): +async def api_ticket_make_ticket( + event_id, name, email, promo_code=None, refund_address=None +): event = await get_event(event_id) if not event: raise HTTPException( From 32ea79a137c2e621ea83b70b07d53f86dc7e2c28 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 27 Apr 2026 10:32:16 +0200 Subject: [PATCH 14/37] fix: make wallet optional in CreateEvent for propose endpoint The propose endpoint sets wallet from the authenticated user's invoice key. Making wallet optional in the model allows the request body to omit it. The admin create endpoint falls back to the auth wallet if not provided. Co-Authored-By: Claude Opus 4.6 (1M context) --- models.py | 2 +- views_api.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/models.py b/models.py index 78917d4..0b92e15 100644 --- a/models.py +++ b/models.py @@ -28,7 +28,7 @@ class EventExtra(BaseModel): class CreateEvent(BaseModel): - wallet: str + wallet: Optional[str] = None name: str info: str closing_date: str diff --git a/views_api.py b/views_api.py index 5638833..7a97a57 100644 --- a/views_api.py +++ b/views_api.py @@ -85,6 +85,8 @@ async def api_event_create( setattr(event, k, v) event = await update_event(event) else: + if not data.wallet: + data.wallet = wallet.wallet.id event = await create_event(data) return event.dict() From 702ab70559ac2b633ebda688e44a5c0b502a7baa Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 27 Apr 2026 10:37:48 +0200 Subject: [PATCH 15/37] feat: add pending approvals UI to admin panel - Separate "Pending Approvals" card with approve/reject buttons (appears only when proposed events exist) - Status badge column in events table (green/orange/red) - Inline approve/reject buttons on proposed events in table - Following castle extension's approval UI pattern Co-Authored-By: Claude Opus 4.6 (1M context) --- static/js/index.js | 52 +++++++++++++++++++++++- templates/events/index.html | 81 ++++++++++++++++++++++++++++++++++++- 2 files changed, 131 insertions(+), 2 deletions(-) diff --git a/static/js/index.js b/static/js/index.js index d26133c..74e95b4 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -73,7 +73,8 @@ window.app = Vue.createApp({ field: 'sold' }, {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: { rowsPerPage: 10 @@ -112,7 +113,56 @@ window.app = Vue.createApp({ } } }, + computed: { + pendingEvents() { + return this.events.filter(e => e.status === 'proposed') + } + }, methods: { + approveEvent(eventId) { + LNbits.utils + .confirmDialog('Approve this event?') + .onOk(() => { + LNbits.api + .request( + 'PUT', + '/events/api/v1/events/' + eventId + '/approve', + this.g.user.wallets[0].adminkey + ) + .then(() => { + this.$q.notify({ + type: 'positive', + message: 'Event approved' + }) + this.getEvents() + }) + .catch(err => { + LNbits.utils.notifyApiError(err) + }) + }) + }, + rejectEvent(eventId) { + LNbits.utils + .confirmDialog('Reject this event?') + .onOk(() => { + LNbits.api + .request( + 'PUT', + '/events/api/v1/events/' + eventId + '/reject', + this.g.user.wallets[0].adminkey + ) + .then(() => { + this.$q.notify({ + type: 'positive', + message: 'Event rejected' + }) + this.getEvents() + }) + .catch(err => { + LNbits.utils.notifyApiError(err) + }) + }) + }, getTickets() { LNbits.api .request( diff --git a/templates/events/index.html b/templates/events/index.html index 62752d1..0247458 100644 --- a/templates/events/index.html +++ b/templates/events/index.html @@ -10,6 +10,58 @@ + + + +
+
+
+ + Pending Approvals + +
+
+
+ + + + {% raw %}{{ event.name }}{% endraw %} + + {% raw %}{{ event.event_start_date }}{% endraw %} + — + {% raw %}{{ event.info.substring(0, 80) }}{% endraw %}{% raw %}{{ event.info.length > 80 ? '...' : '' }}{% endraw %} + + + {% raw %}{{ event.amount_tickets }}{% endraw %} tickets • + {% raw %}{{ event.price_per_ticket }}{% endraw %} {% raw %}{{ event.currency }}{% endraw %} + + + +
+ + +
+
+
+
+
+
+
@@ -76,9 +128,36 @@ > - + + + + Approve + + + Reject + Date: Mon, 27 Apr 2026 10:41:41 +0200 Subject: [PATCH 16/37] fix: use v-text bindings instead of raw template tags in pending UI Avoids Jinja/Vue template delimiter conflicts that cause Vue compiler-30 errors (missing end tag from unescaped > in expressions). Co-Authored-By: Claude Opus 4.6 (1M context) --- templates/events/index.html | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/templates/events/index.html b/templates/events/index.html index 0247458..131dfe9 100644 --- a/templates/events/index.html +++ b/templates/events/index.html @@ -25,15 +25,15 @@ - {% raw %}{{ event.name }}{% endraw %} + - {% raw %}{{ event.event_start_date }}{% endraw %} + — - {% raw %}{{ event.info.substring(0, 80) }}{% endraw %}{% raw %}{{ event.info.length > 80 ? '...' : '' }}{% endraw %} + ... - {% raw %}{{ event.amount_tickets }}{% endraw %} tickets • - {% raw %}{{ event.price_per_ticket }}{% endraw %} {% raw %}{{ event.currency }}{% endraw %} + tickets • + From cdfcee39ae45b04f8ef07d7dd3fff5d7a82fd41b Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 27 Apr 2026 10:48:02 +0200 Subject: [PATCH 17/37] fix: use explicit closing tags for Vue custom elements Self-closing tags on custom elements (q-icon, q-badge) cause Vue compiler-30 (missing end tag) errors in HTML-parsed templates. Co-Authored-By: Claude Opus 4.6 (1M context) --- templates/events/index.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/templates/events/index.html b/templates/events/index.html index 131dfe9..d7fea01 100644 --- a/templates/events/index.html +++ b/templates/events/index.html @@ -16,9 +16,9 @@
- + Pending Approvals - +
From 3425097a5c02b6246084402e22df9bcdcb7535ee Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 27 Apr 2026 11:09:39 +0200 Subject: [PATCH 18/37] fix: close remaining self-closing q-btn tags in pending approvals Co-Authored-By: Claude Opus 4.6 (1M context) --- templates/events/index.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/templates/events/index.html b/templates/events/index.html index d7fea01..46d4bba 100644 --- a/templates/events/index.html +++ b/templates/events/index.html @@ -45,7 +45,7 @@ label="Approve" size="sm" @click="approveEvent(event.id)" - /> + >
+ >
From d740cb1f97022f1da31041873d330692b7f5580c Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 27 Apr 2026 11:11:13 +0200 Subject: [PATCH 19/37] fix: close self-closing q-badge tag in status column Co-Authored-By: Claude Opus 4.6 (1M context) --- templates/events/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/events/index.html b/templates/events/index.html index 46d4bba..1f3172d 100644 --- a/templates/events/index.html +++ b/templates/events/index.html @@ -132,7 +132,7 @@ v-if="col.name === 'status'" :color="col.value === 'approved' ? 'green' : col.value === 'proposed' ? 'orange' : 'red'" :label="col.value" - /> + > From b467826622ff1e4414c0e658ab595f485ce1e31e Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 27 Apr 2026 11:16:29 +0200 Subject: [PATCH 20/37] fix: fetch pending events separately from admin's own events Pending events from other users' wallets weren't visible because getEvents() only returns events scoped to the admin's wallets. Add separate getPendingEvents() that calls /events/pending endpoint. Co-Authored-By: Claude Opus 4.6 (1M context) --- static/js/index.js | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/static/js/index.js b/static/js/index.js index 74e95b4..1619854 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -11,6 +11,7 @@ window.app = Vue.createApp({ data() { return { events: [], + pendingEvents: [], tickets: [], currencies: [], eventsTable: { @@ -113,11 +114,6 @@ window.app = Vue.createApp({ } } }, - computed: { - pendingEvents() { - return this.events.filter(e => e.status === 'proposed') - } - }, methods: { approveEvent(eventId) { LNbits.utils @@ -135,6 +131,7 @@ window.app = Vue.createApp({ message: 'Event approved' }) this.getEvents() + this.getPendingEvents() }) .catch(err => { LNbits.utils.notifyApiError(err) @@ -157,6 +154,7 @@ window.app = Vue.createApp({ message: 'Event rejected' }) this.getEvents() + this.getPendingEvents() }) .catch(err => { LNbits.utils.notifyApiError(err) @@ -217,6 +215,23 @@ window.app = Vue.createApp({ this.checkCanceledEvents() }) }, + getPendingEvents() { + LNbits.api + .request( + 'GET', + '/events/api/v1/events/pending', + this.g.user.wallets[0].adminkey + ) + .then(response => { + this.pendingEvents = response.data.map(obj => { + return mapEvents(obj) + }) + }) + .catch(() => { + // Not an admin or no pending events + this.pendingEvents = [] + }) + }, sendEventData() { const wallet = _.findWhere(this.g.user.wallets, { id: this.formDialog.data.wallet @@ -340,6 +355,7 @@ window.app = Vue.createApp({ if (this.g.user.wallets.length) { this.getTickets() this.getEvents() + this.getPendingEvents() this.currencies = await LNbits.api.getCurrencies() } } From 7843da21d8073fd3847127bace5f2aa3ab23d6db Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 27 Apr 2026 11:19:21 +0200 Subject: [PATCH 21/37] feat: add admin endpoint to view all events across wallets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /api/v1/events/all — returns all events regardless of wallet (admin key) - Admin UI tries /events/all first, falls back to own wallet events - Approved events from other users now visible in admin events table Co-Authored-By: Claude Opus 4.6 (1M context) --- static/js/index.js | 21 +++++++++++++++++++-- views_api.py | 11 +++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/static/js/index.js b/static/js/index.js index 1619854..458c29e 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -202,11 +202,13 @@ window.app = Vue.createApp({ LNbits.utils.exportCSV(this.ticketsTable.columns, this.tickets) }, getEvents() { + // Try admin endpoint first (shows all events across all wallets) + // Falls back to user's own events if not admin LNbits.api .request( 'GET', - '/events/api/v1/events?all_wallets=true', - this.g.user.wallets[0].inkey + '/events/api/v1/events/all', + this.g.user.wallets[0].adminkey ) .then(response => { this.events = response.data.map(obj => { @@ -214,6 +216,21 @@ window.app = Vue.createApp({ }) this.checkCanceledEvents() }) + .catch(() => { + // Not admin, fall back to own events + LNbits.api + .request( + 'GET', + '/events/api/v1/events?all_wallets=true', + this.g.user.wallets[0].inkey + ) + .then(response => { + this.events = response.data.map(obj => { + return mapEvents(obj) + }) + this.checkCanceledEvents() + }) + }) }, getPendingEvents() { LNbits.api diff --git a/views_api.py b/views_api.py index 7a97a57..b363009 100644 --- a/views_api.py +++ b/views_api.py @@ -63,6 +63,17 @@ async def api_events_public(): return [event.dict() for event in events] +@events_api_router.get("/api/v1/events/all") +async def api_events_all( + wallet: WalletTypeInfo = Depends(require_admin_key), +): + """Get all events across all wallets. Admin only.""" + from .crud import get_all_events + + events = await get_all_events() + return [event.dict() for event in events] + + @events_api_router.post("/api/v1/events") @events_api_router.put("/api/v1/events/{event_id}") async def api_event_create( From c1e66fbf7fd15f10b682033695bbac013b5c16dd Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 27 Apr 2026 11:27:21 +0200 Subject: [PATCH 22/37] fix: use check_admin for approval endpoints, not require_admin_key require_admin_key only checks that the API key is a wallet admin key, which ANY user has. check_admin verifies the user is a LNbits admin (super_user or lnbits_admin_users). JS updated to omit API key on admin endpoints, relying on session cookie auth instead. Co-Authored-By: Claude Opus 4.6 (1M context) --- static/js/index.js | 12 ++++-------- views_api.py | 19 ++++++++++--------- 2 files changed, 14 insertions(+), 17 deletions(-) diff --git a/static/js/index.js b/static/js/index.js index 458c29e..b3b6ba3 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -122,8 +122,7 @@ window.app = Vue.createApp({ LNbits.api .request( 'PUT', - '/events/api/v1/events/' + eventId + '/approve', - this.g.user.wallets[0].adminkey + '/events/api/v1/events/' + eventId + '/approve' ) .then(() => { this.$q.notify({ @@ -145,8 +144,7 @@ window.app = Vue.createApp({ LNbits.api .request( 'PUT', - '/events/api/v1/events/' + eventId + '/reject', - this.g.user.wallets[0].adminkey + '/events/api/v1/events/' + eventId + '/reject' ) .then(() => { this.$q.notify({ @@ -207,8 +205,7 @@ window.app = Vue.createApp({ LNbits.api .request( 'GET', - '/events/api/v1/events/all', - this.g.user.wallets[0].adminkey + '/events/api/v1/events/all' ) .then(response => { this.events = response.data.map(obj => { @@ -236,8 +233,7 @@ window.app = Vue.createApp({ LNbits.api .request( 'GET', - '/events/api/v1/events/pending', - this.g.user.wallets[0].adminkey + '/events/api/v1/events/pending' ) .then(response => { this.pendingEvents = response.data.map(obj => { diff --git a/views_api.py b/views_api.py index b363009..e9977d4 100644 --- a/views_api.py +++ b/views_api.py @@ -3,9 +3,10 @@ from http import HTTPStatus 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.models import Account, WalletTypeInfo from lnbits.core.services import create_invoice from lnbits.decorators import ( + check_admin, require_admin_key, require_invoice_key, ) @@ -65,9 +66,9 @@ async def api_events_public(): @events_api_router.get("/api/v1/events/all") async def api_events_all( - wallet: WalletTypeInfo = Depends(require_admin_key), + admin: Account = Depends(check_admin), ): - """Get all events across all wallets. Admin only.""" + """Get all events across all wallets. LNbits admin only.""" from .crud import get_all_events events = await get_all_events() @@ -161,9 +162,9 @@ async def api_event_propose( @events_api_router.get("/api/v1/events/pending") async def api_events_pending( - wallet: WalletTypeInfo = Depends(require_admin_key), + admin: Account = Depends(check_admin), ): - """Get all proposed events awaiting approval. Admin only.""" + """Get all proposed events awaiting approval. LNbits admin only.""" events = await get_pending_events() return [event.dict() for event in events] @@ -171,9 +172,9 @@ async def api_events_pending( @events_api_router.put("/api/v1/events/{event_id}/approve") async def api_event_approve( event_id: str, - wallet: WalletTypeInfo = Depends(require_admin_key), + admin: Account = Depends(check_admin), ): - """Approve a proposed event. Admin only.""" + """Approve a proposed event. LNbits admin only.""" event = await get_event(event_id) if not event: raise HTTPException( @@ -192,9 +193,9 @@ async def api_event_approve( @events_api_router.put("/api/v1/events/{event_id}/reject") async def api_event_reject( event_id: str, - wallet: WalletTypeInfo = Depends(require_admin_key), + admin: Account = Depends(check_admin), ): - """Reject a proposed event. Admin only.""" + """Reject a proposed event. LNbits admin only.""" event = await get_event(event_id) if not event: raise HTTPException( From ba97205592bf2cbc4fcdcb9c806c49864a40b482 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 27 Apr 2026 11:34:59 +0200 Subject: [PATCH 23/37] feat: separate admin view into own events and all users' events Admin sees two tables: "Events" (own wallet events) and "All Users' Events" (events from other users' wallets, admin only). Non-admin users only see their own events table. Co-Authored-By: Claude Opus 4.6 (1M context) --- static/js/index.js | 38 +++++++++++++++++++-------------- templates/events/index.html | 42 +++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 16 deletions(-) diff --git a/static/js/index.js b/static/js/index.js index b3b6ba3..194d30d 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -11,7 +11,9 @@ window.app = Vue.createApp({ data() { return { events: [], + allUserEvents: [], pendingEvents: [], + isAdmin: false, tickets: [], currencies: [], eventsTable: { @@ -200,12 +202,12 @@ window.app = Vue.createApp({ LNbits.utils.exportCSV(this.ticketsTable.columns, this.tickets) }, getEvents() { - // Try admin endpoint first (shows all events across all wallets) - // Falls back to user's own events if not admin + // Always fetch own events LNbits.api .request( 'GET', - '/events/api/v1/events/all' + '/events/api/v1/events?all_wallets=true', + this.g.user.wallets[0].inkey ) .then(response => { this.events = response.data.map(obj => { @@ -213,20 +215,24 @@ window.app = Vue.createApp({ }) this.checkCanceledEvents() }) + + // Admin: also fetch all users' events + LNbits.api + .request( + 'GET', + '/events/api/v1/events/all' + ) + .then(response => { + this.isAdmin = true + // Exclude own events (already in this.events) + const ownWalletIds = this.g.user.wallets.map(w => w.id) + this.allUserEvents = response.data + .filter(obj => !ownWalletIds.includes(obj.wallet)) + .map(obj => mapEvents(obj)) + }) .catch(() => { - // Not admin, fall back to own events - LNbits.api - .request( - 'GET', - '/events/api/v1/events?all_wallets=true', - this.g.user.wallets[0].inkey - ) - .then(response => { - this.events = response.data.map(obj => { - return mapEvents(obj) - }) - this.checkCanceledEvents() - }) + this.isAdmin = false + this.allUserEvents = [] }) }, getPendingEvents() { diff --git a/templates/events/index.html b/templates/events/index.html index 1f3172d..daef3d1 100644 --- a/templates/events/index.html +++ b/templates/events/index.html @@ -229,6 +229,48 @@
+ + + +
+
+
+ All Users' Events + +
+
+
+ + + + +
+
+
From 920125aaee4882e274dedb17b4d96ef2dcbbead3 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 27 Apr 2026 11:39:01 +0200 Subject: [PATCH 24/37] feat: auto-propose events from non-admin users Events created by non-admin users via POST /events are now set to 'proposed' status, requiring LNbits admin approval. Admin-created events are auto-approved. Co-Authored-By: Claude Opus 4.6 (1M context) --- views_api.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/views_api.py b/views_api.py index e9977d4..73c91c9 100644 --- a/views_api.py +++ b/views_api.py @@ -99,6 +99,16 @@ async def api_event_create( else: if not data.wallet: data.wallet = wallet.wallet.id + # Auto-approve for LNbits admins, require approval for regular users + from lnbits.settings import 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: + data.status = "proposed" event = await create_event(data) return event.dict() From 1ad99aa3d62c38fd59d2bb6dff53b727804e5c35 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 27 Apr 2026 11:45:08 +0200 Subject: [PATCH 25/37] fix: hide approve/reject buttons for non-admin users Co-Authored-By: Claude Opus 4.6 (1M context) --- templates/events/index.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/templates/events/index.html b/templates/events/index.html index daef3d1..4d50896 100644 --- a/templates/events/index.html +++ b/templates/events/index.html @@ -137,7 +137,7 @@ Approve Date: Mon, 27 Apr 2026 17:11:55 +0200 Subject: [PATCH 26/37] feat: add publish-only NostrClient and NostrEvent model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stripped-down Nostr client that connects to nostrclient's internal WebSocket for publishing NIP-52 calendar events. No subscription capabilities — publish queue only. Co-Authored-By: Claude Opus 4.6 (1M context) --- nostr/__init__.py | 0 nostr/event.py | 27 ++++++++++++ nostr/nostr_client.py | 100 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 127 insertions(+) create mode 100644 nostr/__init__.py create mode 100644 nostr/event.py create mode 100644 nostr/nostr_client.py diff --git a/nostr/__init__.py b/nostr/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nostr/event.py b/nostr/event.py new file mode 100644 index 0000000..7da8288 --- /dev/null +++ b/nostr/event.py @@ -0,0 +1,27 @@ +import hashlib +import json +from typing import List, Optional + +from pydantic import BaseModel + + +class NostrEvent(BaseModel): + id: str = "" + pubkey: str + created_at: int + kind: int + tags: List[List[str]] = [] + content: str = "" + sig: Optional[str] = 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 new file mode 100644 index 0000000..09f57d4 --- /dev/null +++ b/nostr/nostr_client.py @@ -0,0 +1,100 @@ +""" +Publish-only Nostr client for the events extension. + +Connects to the nostrclient extension's internal WebSocket to publish +NIP-52 calendar events. No subscription/receive capabilities — this +is a stripped-down version of nostrmarket's NostrClient. +""" + +import asyncio +import json +from asyncio import Queue +from typing import Optional + +from loguru import logger +from websocket import WebSocketApp + +from lnbits.helpers import encrypt_internal_message +from lnbits.settings import settings + +from .event import NostrEvent + + +class NostrClient: + def __init__(self): + self.send_req_queue: Queue = Queue() + self.ws: Optional[WebSocketApp] = None + self.running = False + + @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): + # Log relay responses (OK, NOTICE) but don't process + logger.debug(f"[EVENTS] Relay response: {message[:200]}") + + 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}" + ) + + 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) + + async def publish_nostr_event(self, e: NostrEvent): + await self.send_req_queue.put(["EVENT", e.dict()]) + + async def stop(self): + self.running = False + if self.ws: + try: + self.ws.close() + except Exception: + pass + self.ws = None From f76e21e960b851e8a47916f60be09127c984329c Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 27 Apr 2026 17:13:36 +0200 Subject: [PATCH 27/37] feat: add Nostr event tracking columns to events table Migration m009 adds nostr_event_id and nostr_event_created_at to track published NIP-52 calendar events. Enables correlation between LNbits events and their Nostr representations. Co-Authored-By: Claude Opus 4.6 (1M context) --- migrations.py | 12 ++++++++++++ models.py | 2 ++ 2 files changed, 14 insertions(+) diff --git a/migrations.py b/migrations.py index 357c3ed..22fe495 100644 --- a/migrations.py +++ b/migrations.py @@ -202,3 +202,15 @@ async def m008_add_event_status(db): await db.execute( "ALTER TABLE events.events ADD COLUMN status TEXT NOT NULL DEFAULT 'approved';" ) + + +async def m009_add_nostr_columns(db): + """ + Add columns to track published NIP-52 Nostr calendar events. + """ + await db.execute( + "ALTER TABLE events.events ADD COLUMN nostr_event_id TEXT;" + ) + await db.execute( + "ALTER TABLE events.events ADD COLUMN nostr_event_created_at INTEGER;" + ) diff --git a/models.py b/models.py index 0b92e15..56f6dff 100644 --- a/models.py +++ b/models.py @@ -80,6 +80,8 @@ class Event(BaseModel): banner: str | None = None extra: EventExtra = Field(default_factory=EventExtra) status: str = "approved" # proposed, approved, rejected + nostr_event_id: str | None = None + nostr_event_created_at: int | None = None class TicketExtra(BaseModel): From 5013709be703eae8188728b9d9551b8d8237982a Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 27 Apr 2026 17:14:22 +0200 Subject: [PATCH 28/37] feat: add NIP-52 calendar event builder and publisher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - build_nip52_event(): Event model → kind 31922 NostrEvent with d, title, start, end, image tags - build_nip52_delete_event(): kind 5 delete with 'a' tag per NIP-09 - sign_nostr_event(): Schnorr signing via coincurve - publish_event_to_nostr(): build + sign + publish, returns event for metadata storage. Graceful failure (returns None). Co-Authored-By: Claude Opus 4.6 (1M context) --- nostr_publisher.py | 115 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 nostr_publisher.py diff --git a/nostr_publisher.py b/nostr_publisher.py new file mode 100644 index 0000000..fe0431b --- /dev/null +++ b/nostr_publisher.py @@ -0,0 +1,115 @@ +""" +NIP-52 calendar event publishing for the events extension. + +Builds kind 31922 (date-based) calendar events from the Event model, +signs them with the event creator's Account keypair, and publishes +via the NostrClient to nostrclient relays. + +Reference: https://github.com/nostr-protocol/nips/blob/master/52.md +""" + +import time +from typing import Optional + +import coincurve +from loguru import logger + +from .models import Event +from .nostr.event import NostrEvent + + +def build_nip52_event(event: Event, pubkey: str) -> NostrEvent: + """ + Convert an Event model to a NIP-52 kind 31922 (date-based) calendar event. + + Tags: + d - event.id (addressable identifier) + title - event.name + start - event.event_start_date (ISO date string) + end - event.event_end_date (optional) + image - event.banner (optional) + Content: event.info (description) + """ + tags = [ + ["d", event.id], + ["title", event.name], + ["start", event.event_start_date], + ] + + if event.event_end_date: + tags.append(["end", event.event_end_date]) + if event.banner: + tags.append(["image", event.banner]) + + nostr_event = NostrEvent( + pubkey=pubkey, + created_at=int(time.time()), + kind=31922, + tags=tags, + content=event.info, + ) + 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 + (kind 31922) per NIP-09. + """ + nostr_event = NostrEvent( + pubkey=pubkey, + created_at=int(time.time()), + kind=5, + tags=[ + ["a", f"31922:{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, +) -> Optional[NostrEvent]: + """ + 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 From e8fcecac402e005d6cd1a713bf30993cc5b0b717 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 27 Apr 2026 17:21:28 +0200 Subject: [PATCH 29/37] feat: wire NostrClient into events extension lifecycle Start a publish-only NostrClient as a background task (10s delay for nostrclient readiness). Graceful degradation: if nostrclient is unavailable, events extension continues without Nostr publishing. Co-Authored-By: Claude Opus 4.6 (1M context) --- __init__.py | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/__init__.py b/__init__.py index 14c1590..974c20c 100644 --- a/__init__.py +++ b/__init__.py @@ -21,6 +21,9 @@ events_static_files = [ scheduled_tasks: list[asyncio.Task] = [] +# Module-level NostrClient — None when nostrclient is unavailable +nostr_client = None + def events_stop(): for task in scheduled_tasks: @@ -29,12 +32,32 @@ 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 - task = create_permanent_unique_task("ext_events", wait_for_paid_invoices) - scheduled_tasks.append(task) + task1 = create_permanent_unique_task("ext_events", wait_for_paid_invoices) + 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 publishing") + await nostr_client.run_forever() + except Exception as e: + logger.warning(f"[EVENTS] NostrClient failed to start: {e}") + logger.info("[EVENTS] Events will work without Nostr publishing") + + task2 = create_permanent_unique_task("ext_events_nostr", _start_nostr_client) + scheduled_tasks.append(task2) __all__ = ["db", "events_ext", "events_start", "events_static_files", "events_stop"] From 2db010285712a799cf7b399523097a687802c030 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 27 Apr 2026 17:24:17 +0200 Subject: [PATCH 30/37] feat: publish NIP-52 events on approve/create/update/cancel/delete - On approve: publish kind 31922 calendar event to Nostr - On admin create (auto-approved): publish immediately - On update (approved event): republish (kind 31922 is replaceable) - On cancel/delete: publish kind 5 delete event - All Nostr calls are wrapped in try/except for graceful degradation - Event creator's Account keypair used for signing Co-Authored-By: Claude Opus 4.6 (1M context) --- views_api.py | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/views_api.py b/views_api.py index 73c91c9..f69d3c0 100644 --- a/views_api.py +++ b/views_api.py @@ -35,11 +35,38 @@ from .crud import ( update_ticket, ) from .models import CreateEvent, CreateTicket, Ticket +from .nostr_publisher import publish_event_to_nostr from .services import refund_tickets, set_ticket_paid events_api_router = APIRouter() +async def _publish_or_delete_nostr_event(event, delete=False): + """Publish (or delete) a NIP-52 calendar event using the creator's keypair.""" + try: + from lnbits.core.crud.wallets import get_wallet + from lnbits.core.crud.users import get_account + + 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: + return + + nostr_event = await publish_event_to_nostr( + nostr_client, event, account.pubkey, account.prvkey, 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 e: + logger.warning(f"[EVENTS] Nostr publish failed: {e}") + + @events_api_router.get("/api/v1/events") async def api_events( all_wallets: bool = Query(False), @@ -96,6 +123,10 @@ async def api_event_create( for k, v in data.dict().items(): setattr(event, k, v) event = await update_event(event) + + # Republish to Nostr if event is approved (kind 31922 is replaceable) + if event.status == "approved" and event.nostr_event_id: + await _publish_or_delete_nostr_event(event) else: if not data.wallet: data.wallet = wallet.wallet.id @@ -111,6 +142,10 @@ async def api_event_create( data.status = "proposed" event = await create_event(data) + # Publish to Nostr if auto-approved (admin-created) + if event.status == "approved": + await _publish_or_delete_nostr_event(event) + return event.dict() @@ -131,6 +166,10 @@ async def api_event_cancel( event = await update_event(event) await refund_tickets(event.id) + # Delete NIP-52 event from Nostr if it was published + if event.nostr_event_id: + await _publish_or_delete_nostr_event(event, delete=True) + return event.dict() @@ -147,6 +186,10 @@ async def api_form_delete( if event.wallet != wallet.wallet.id: raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your event.") + # Delete NIP-52 event from Nostr if it was published + 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 @@ -197,6 +240,10 @@ async def api_event_approve( ) event.status = "approved" event = await update_event(event) + + # Publish NIP-52 calendar event to Nostr + await _publish_or_delete_nostr_event(event) + return event.dict() From d69ec7dda27419ca184324a08a52c5538f2f380b Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 27 Apr 2026 17:59:59 +0200 Subject: [PATCH 31/37] feat: add admin-toggleable auto-approve setting - Extension settings table with auto_approve boolean - GET/PUT /api/v1/settings endpoints (LNbits admin only) - Settings card in admin UI with toggle - When auto_approve is enabled, non-admin events skip approval Closes aiolabs/events#11 Co-Authored-By: Claude Opus 4.6 (1M context) --- crud.py | 23 ++++++++++++++++++++++- migrations.py | 17 +++++++++++++++++ models.py | 6 ++++++ static/js/index.js | 27 +++++++++++++++++++++++++++ templates/events/index.html | 18 ++++++++++++++++++ views_api.py | 29 ++++++++++++++++++++++++++--- 6 files changed, 116 insertions(+), 4 deletions(-) diff --git a/crud.py b/crud.py index 9642a83..49367f2 100644 --- a/crud.py +++ b/crud.py @@ -5,7 +5,7 @@ from typing import Optional from lnbits.db import Database from lnbits.helpers import urlsafe_short_hash -from .models import CreateEvent, Event, Ticket, TicketExtra +from .models import CreateEvent, Event, EventsSettings, Ticket, TicketExtra def _parse_ticket_row(row) -> dict: @@ -220,6 +220,27 @@ async def get_pending_events() -> list[Event]: ) +async def get_settings() -> EventsSettings: + """Get extension settings (single row, always exists after migration).""" + 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: + """Update extension settings.""" + 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}) diff --git a/migrations.py b/migrations.py index 22fe495..f728a0a 100644 --- a/migrations.py +++ b/migrations.py @@ -214,3 +214,20 @@ async def m009_add_nostr_columns(db): await db.execute( "ALTER TABLE events.events ADD COLUMN nostr_event_created_at INTEGER;" ) + + +async def m010_add_events_settings(db): + """ + Create extension settings table for admin-configurable options. + """ + 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 OR IGNORE INTO events.settings (id, auto_approve) VALUES (1, FALSE);" + ) diff --git a/models.py b/models.py index 56f6dff..ba66165 100644 --- a/models.py +++ b/models.py @@ -84,6 +84,12 @@ class Event(BaseModel): nostr_event_created_at: int | None = None +class EventsSettings(BaseModel): + """Extension-level settings for the events extension.""" + + auto_approve: bool = False # Skip approval for all users + + class TicketExtra(BaseModel): applied_promo_code: str | None = None sats_paid: int | None = None diff --git a/static/js/index.js b/static/js/index.js index 194d30d..09c5cba 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -14,6 +14,9 @@ window.app = Vue.createApp({ allUserEvents: [], pendingEvents: [], isAdmin: false, + settings: { + auto_approve: false + }, tickets: [], currencies: [], eventsTable: { @@ -117,6 +120,29 @@ window.app = Vue.createApp({ } }, methods: { + getSettings() { + LNbits.api + .request('GET', '/events/api/v1/settings') + .then(response => { + this.settings = response.data + }) + .catch(() => { + // Not admin or settings not available + }) + }, + saveSettings() { + LNbits.api + .request('PUT', '/events/api/v1/settings', null, this.settings) + .then(() => { + this.$q.notify({ + type: 'positive', + message: 'Settings saved' + }) + }) + .catch(err => { + LNbits.utils.notifyApiError(err) + }) + }, approveEvent(eventId) { LNbits.utils .confirmDialog('Approve this event?') @@ -375,6 +401,7 @@ window.app = Vue.createApp({ this.getTickets() this.getEvents() this.getPendingEvents() + this.getSettings() this.currencies = await LNbits.api.getCurrencies() } } diff --git a/templates/events/index.html b/templates/events/index.html index 4d50896..b162cf5 100644 --- a/templates/events/index.html +++ b/templates/events/index.html @@ -2,6 +2,24 @@ %} {% block page %}
+ + + +
+
+ Settings +
+
+ +
+
+
+
+ EventsSettings: + """Get extension settings. LNbits admin only.""" + return await get_settings() + + +@events_api_router.put("/api/v1/settings") +async def api_update_settings( + data: EventsSettings, + admin: Account = Depends(check_admin), +) -> EventsSettings: + """Update extension settings. LNbits admin only.""" + return await update_settings(data) + + #########Tickets########## From 29045163a30cdb94861fe3ccc48816609a092761 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 27 Apr 2026 18:05:25 +0200 Subject: [PATCH 32/37] feat: add location and categories fields, simplify event creation - Add location (text) and categories (JSON list) to Event model - Make most CreateEvent fields optional: only title + start date required - Default end_date to start_date, closing_date to end_date - Categories stored as JSON text, parsed via validator - NIP-52 publisher includes location tag and t tags for categories - Migration m011 adds location and categories columns Co-Authored-By: Claude Opus 4.6 (1M context) --- crud.py | 6 ++++++ migrations.py | 12 ++++++++++++ models.py | 39 +++++++++++++++++++++++++-------------- nostr_publisher.py | 6 +++++- 4 files changed, 48 insertions(+), 15 deletions(-) diff --git a/crud.py b/crud.py index 49367f2..1a9c761 100644 --- a/crud.py +++ b/crud.py @@ -164,6 +164,12 @@ 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 if not provided + if not data.event_end_date: + data.event_end_date = data.event_start_date + # Default closing date to end date if not provided + 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 diff --git a/migrations.py b/migrations.py index f728a0a..2800810 100644 --- a/migrations.py +++ b/migrations.py @@ -231,3 +231,15 @@ async def m010_add_events_settings(db): await db.execute( "INSERT OR IGNORE INTO events.settings (id, auto_approve) VALUES (1, FALSE);" ) + + +async def m011_add_location_and_categories(db): + """ + Add location and categories columns for NIP-52 calendar event support. + """ + await db.execute( + "ALTER TABLE events.events ADD COLUMN location TEXT;" + ) + await db.execute( + "ALTER TABLE events.events ADD COLUMN categories TEXT;" + ) diff --git a/models.py b/models.py index ba66165..47d163b 100644 --- a/models.py +++ b/models.py @@ -1,3 +1,4 @@ +import json from datetime import datetime from typing import Optional @@ -29,15 +30,17 @@ class EventExtra(BaseModel): class CreateEvent(BaseModel): wallet: Optional[str] = None - name: str - info: str - closing_date: str - event_start_date: str - event_end_date: str + name: str # title (required) + info: str = "" # description (optional, visible by default) + closing_date: Optional[str] = None # defaults to event_end_date or event_start_date + event_start_date: str # required + event_end_date: Optional[str] = None # defaults to event_start_date currency: str = "sat" - amount_tickets: int = Query(..., ge=0) - price_per_ticket: float = Query(..., ge=0) - banner: Optional[str] = None + amount_tickets: int = 0 # 0 = unlimited / not ticketed + price_per_ticket: float = 0 # 0 = free + banner: Optional[str] = None # image URL (optional, visible by default) + location: Optional[str] = None # venue/address (optional, visible by default) + categories: list[str] = Field(default_factory=list) # NIP-52 't' tags extra: EventExtra = Field(default_factory=EventExtra) status: str = "approved" # proposed, approved, rejected @@ -67,22 +70,30 @@ class Event(BaseModel): id: str wallet: str name: str - info: str - closing_date: str + info: str = "" + closing_date: str | None = None canceled: bool = False event_start_date: str - event_end_date: str - currency: str - amount_tickets: int - price_per_ticket: float + event_end_date: str | None = None + currency: str = "sat" + amount_tickets: int = 0 + price_per_ticket: float = 0 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" # proposed, approved, rejected 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 EventsSettings(BaseModel): """Extension-level settings for the events extension.""" diff --git a/nostr_publisher.py b/nostr_publisher.py index fe0431b..f76cd97 100644 --- a/nostr_publisher.py +++ b/nostr_publisher.py @@ -40,13 +40,17 @@ def build_nip52_event(event: Event, pubkey: str) -> NostrEvent: tags.append(["end", event.event_end_date]) 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=31922, tags=tags, - content=event.info, + content=event.info or "", ) nostr_event.id = nostr_event.event_id return nostr_event From b4d7653988c6682e36d0e26f5e230d85793c3f4b Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 27 Apr 2026 18:16:43 +0200 Subject: [PATCH 33/37] fix: check auto_approve setting in propose endpoint The propose endpoint always set status to 'proposed' regardless of the auto_approve setting. Now checks the setting and auto-approves (+ publishes to Nostr) when enabled. Co-Authored-By: Claude Opus 4.6 (1M context) --- views_api.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/views_api.py b/views_api.py index 2d30b3f..e505767 100644 --- a/views_api.py +++ b/views_api.py @@ -209,10 +209,17 @@ async def api_event_propose( """ Propose a new event for admin approval. Requires invoice key (any authenticated user, not admin-only). + Auto-approved if the admin has enabled auto_approve in settings. """ - data.status = "proposed" + ext_settings = await get_settings() + data.status = "approved" if ext_settings.auto_approve else "proposed" data.wallet = wallet.wallet.id event = await create_event(data) + + # Publish to Nostr if auto-approved + if event.status == "approved": + await _publish_or_delete_nostr_event(event) + return event.dict() From 4d91426e8283edf7ef76b4e1c36958c9dedbfea4 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 27 Apr 2026 18:24:10 +0200 Subject: [PATCH 34/37] refactor: consolidate create and propose endpoints into single POST /events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove separate /events/propose endpoint. POST /events now uses invoice key (any user) and determines approval status based on: - LNbits admin → auto-approved - auto_approve setting → auto-approved - Otherwise → proposed (requires admin approval) Separate PUT /events/{id} for updates (admin key, event owner). Co-Authored-By: Claude Opus 4.6 (1M context) --- views_api.py | 105 +++++++++++++++++++++++---------------------------- 1 file changed, 48 insertions(+), 57 deletions(-) diff --git a/views_api.py b/views_api.py index e505767..94195b0 100644 --- a/views_api.py +++ b/views_api.py @@ -105,49 +105,62 @@ async def api_events_all( @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_admin_key), - event_id: str | None = None, + wallet: WalletTypeInfo = Depends(require_invoice_key), ): - if event_id: - event = await get_event(event_id) - if not event: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist." - ) + """ + Create a new event. Any authenticated user can create events. + Admin-created events are auto-approved. Non-admin events require + approval unless auto_approve is enabled in extension settings. + """ + if not data.wallet: + data.wallet = wallet.wallet.id - 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) + from lnbits.settings import settings - # Republish to Nostr if event is approved (kind 31922 is replaceable) - if event.status == "approved" and event.nostr_event_id: - await _publish_or_delete_nostr_event(event) - else: - if not data.wallet: - data.wallet = wallet.wallet.id - # Check if approval is required for non-admin users - 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" - 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 + event = await create_event(data) + + # Publish to Nostr if approved + if event.status == "approved": + await _publish_or_delete_nostr_event(event) + + return event.dict() + + +@events_api_router.put("/api/v1/events/{event_id}") +async def api_event_update( + event_id: str, + data: CreateEvent, + wallet: WalletTypeInfo = Depends(require_admin_key), +): + """Update an existing event. Requires admin key (event owner).""" + event = await get_event(event_id) + if not event: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist." ) - if not is_admin and not ext_settings.auto_approve: - data.status = "proposed" - event = await create_event(data) - # Publish to Nostr if auto-approved (admin-created) - if event.status == "approved": - await _publish_or_delete_nostr_event(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) + + # Republish to Nostr if event is approved (kind 31922 is replaceable) + if event.status == "approved" and event.nostr_event_id: + await _publish_or_delete_nostr_event(event) return event.dict() @@ -201,28 +214,6 @@ async def api_form_delete( #########Event Approval########## -@events_api_router.post("/api/v1/events/propose") -async def api_event_propose( - data: CreateEvent, - wallet: WalletTypeInfo = Depends(require_invoice_key), -): - """ - Propose a new event for admin approval. - Requires invoice key (any authenticated user, not admin-only). - Auto-approved if the admin has enabled auto_approve in settings. - """ - ext_settings = await get_settings() - data.status = "approved" if ext_settings.auto_approve else "proposed" - data.wallet = wallet.wallet.id - event = await create_event(data) - - # Publish to Nostr if auto-approved - if event.status == "approved": - await _publish_or_delete_nostr_event(event) - - return event.dict() - - @events_api_router.get("/api/v1/events/pending") async def api_events_pending( admin: Account = Depends(check_admin), From 1bddb99132b1bfec9d858a30f7d1dd86227c0171 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 27 Apr 2026 18:28:21 +0200 Subject: [PATCH 35/37] feat: upgrade NostrClient to bidirectional (publish + subscribe) Add receive queue, subscription management, and event deduplication to support incoming NIP-52 calendar events from relays. Co-Authored-By: Claude Opus 4.6 (1M context) --- nostr/nostr_client.py | 56 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 50 insertions(+), 6 deletions(-) diff --git a/nostr/nostr_client.py b/nostr/nostr_client.py index 09f57d4..f8f63de 100644 --- a/nostr/nostr_client.py +++ b/nostr/nostr_client.py @@ -1,30 +1,36 @@ """ -Publish-only Nostr client for the events extension. +Bidirectional Nostr client for the events extension. Connects to the nostrclient extension's internal WebSocket to publish -NIP-52 calendar events. No subscription/receive capabilities — this -is a stripped-down version of nostrmarket's NostrClient. +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 typing import Optional from loguru import logger from websocket import WebSocketApp -from lnbits.helpers import encrypt_internal_message +from lnbits.helpers import encrypt_internal_message, urlsafe_short_hash from lnbits.settings import settings 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: Optional[WebSocketApp] = 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): @@ -45,8 +51,10 @@ class NostrClient: logger.info("[EVENTS] Connected to nostrclient WebSocket") def on_message(_, message): - # Log relay responses (OK, NOTICE) but don't process - logger.debug(f"[EVENTS] Relay response: {message[:200]}") + 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}") @@ -55,6 +63,9 @@ class NostrClient: logger.warning( f"[EVENTS] WebSocket closed: {status_code} {message}" ) + self.receive_event_queue.put_nowait( + ValueError("WebSocket closed") + ) ws = WebSocketApp( ws_url, @@ -87,11 +98,44 @@ class NostrClient: 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() From e937883564f9328bbfcdfdf4e1bbb75761e5efe9 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 27 Apr 2026 18:29:14 +0200 Subject: [PATCH 36/37] feat: add NIP-52 event sync from Nostr relays Subscribe to kind 31922/31923 events and upsert into local DB: - New events discovered from relays are auto-approved - Existing events are updated if incoming version is newer - Deduplication via event ID and d-tag correlation - Events from Nostr have empty wallet (not ticketed locally) Co-Authored-By: Claude Opus 4.6 (1M context) --- nostr_sync.py | 170 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 nostr_sync.py diff --git a/nostr_sync.py b/nostr_sync.py new file mode 100644 index 0000000..1c508dc --- /dev/null +++ b/nostr_sync.py @@ -0,0 +1,170 @@ +""" +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 json +from datetime import datetime, timezone + +from loguru import logger + +from .crud import create_event, db, get_event, update_event +from .models import CreateEvent, 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 + # Events discovered from Nostr are auto-approved (they're already public) + event = CreateEvent( + wallet="", # No wallet — discovered from Nostr, not ticketed locally + name=title, + info=description, + event_start_date=start, + event_end_date=end, + banner=image, + location=location, + categories=categories, + status="approved", + ) + # Use the d-tag as the event ID for correlation + from lnbits.db import Database + + 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) + + +import asyncio # noqa: E402 From ef5d2dcfcfb321fd32d1b276e61a543bfc1a2b91 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 27 Apr 2026 18:30:00 +0200 Subject: [PATCH 37/37] feat: wire Nostr subscription sync into extension lifecycle Add background task that subscribes to kind 31922/31923 events from relays and processes them into the local database. Starts 15s after NostrClient connects (sequenced after publish client). Co-Authored-By: Claude Opus 4.6 (1M context) --- __init__.py | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/__init__.py b/__init__.py index 974c20c..8d42ecf 100644 --- a/__init__.py +++ b/__init__.py @@ -50,14 +50,32 @@ def events_start(): from .nostr.nostr_client import NostrClient nostr_client = NostrClient() - logger.info("[EVENTS] Starting NostrClient for NIP-52 publishing") + logger.info("[EVENTS] Starting NostrClient for NIP-52 sync") await nostr_client.run_forever() except Exception as e: logger.warning(f"[EVENTS] NostrClient failed to start: {e}") - logger.info("[EVENTS] Events will work without Nostr publishing") + 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 e: + logger.error(f"[EVENTS] Nostr sync task failed: {e}") + + 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"]