feat: republish endpoints + polling + multi-ticket via N-rows model #16

Merged
padreug merged 7 commits from tickets-nostr-sync into main 2026-05-23 21:11:38 +00:00
Owner

Summary

Follow-up to PR #15 (which already merged the GET /tickets/user
endpoint + NIP-52 ticket tags + republish-on-sale). This PR carries
the seven commits that landed on tickets-nostr-sync after that
merge, rebased to drop a superseded multi-ticket intermediate
(5afc888 — extra.quantity approach) so the history reads cleanly.

Commits (oldest → newest)

Commit What
05593c9 POST /events/republish-all admin endpoint. Loops approved events through publish_or_delete_nostr_event — useful for catalog-bump migrations when the publisher tag set changes.
fa2a6e4 "Republish all" button on the admin Settings card (LNbits UMD UI).
ced6ca2 Organizer-side counterpart: POST /republish-mine scoped via require_admin_key + an organizer-visible "Republish mine" button on the New Event card. Lets an organizer trigger the migration for just their own events without instance-admin rights.
902bafe POST /tickets/{event_id}/{payment_hash} polling endpoint. The webapp polls this after presenting the invoice; without it the post-payment poll loop 404'd indefinitely and the buyer never saw the success state, even though set_ticket_paid ran.
36568d3 Propagate CreateTicket.user_id to the persisted ticket row. The endpoint accepted user_id on the request body (its root_validator even requires it XOR name+email) but dropped it on the way to crud.create_ticket, leaving every webapp-bought ticket with user_id = NULL and breaking the GET /tickets/user endpoint.
59068fe Multi-ticket purchases as N rows sharing one payment_hash. Each attendee gets a distinct scannable id (short-hash uuid); the LNbits invoice payment_hash is shared via a new ticket.payment_hash column. Migration m002_ticket_payment_hash adds the column + backfills existing rows from id (legacy id == payment_hash invariant). The door scanner (existing /register/{ticket_id} PUT) works unchanged — each attendee scans their own QR independently. CreateTicket grows quantity: Field(ge=1, le=10); the API loops to create N rows, the polling endpoint returns every ticket_ids so clients render N QRs; on_invoice_paid iterates all rows for a payment_hash and set_ticket_paid increments counters by 1 per row, all under the existing per-event asyncio lock.
7b761a1 Every ticket row gets a fresh urlsafe_short_hash id — no payment-hash reuse for the first row, so single- and multi-ticket purchases produce uniform short ids on the wire (rather than one 64-hex id + N-1 short-hash siblings).

Smoke

Locally:

  • DB: migration m002 runs cleanly on the regtest stack; existing rows get payment_hash = id backfilled. New 3-ticket purchase creates 3 rows with shared payment_hash, distinct short-hash ids, extra.quantity not used (gone). Counters increment by 3.
  • Polling: POST /tickets/{event_id}/{payment_hash} returns {paid, ticket_id, ticket_ids: [...]} once the invoice settles.
  • Republish: POST /events/republish-all re-emits every approved event; POST /events/republish-mine scopes to the caller's wallets.

Test plan

  • Single-ticket purchase: one row, id is a short hash, payment_hash equals the invoice hash, polling endpoint returns ticket_ids=[id].
  • 3-ticket purchase: three rows, shared payment_hash, three distinct short-hash ids. Each PUT /register/{id} registers exactly that attendee.
  • Buying more than event.amount_tickets - event.sold returns 400 with the remaining-count detail string (not 410 sold-out).
  • Migration is a no-op on a freshly-installed instance (the _alter_add_column_safe helper swallows duplicate-column errors).
  • Republish-all / republish-mine emit one Nostr event per approved row and reflect the new tag set.

🤖 Generated with Claude Code

## Summary Follow-up to PR #15 (which already merged the GET /tickets/user endpoint + NIP-52 ticket tags + republish-on-sale). This PR carries the seven commits that landed on `tickets-nostr-sync` after that merge, rebased to drop a superseded multi-ticket intermediate (`5afc888` — extra.quantity approach) so the history reads cleanly. ## Commits (oldest → newest) | Commit | What | |---|---| | `05593c9` | `POST /events/republish-all` admin endpoint. Loops approved events through `publish_or_delete_nostr_event` — useful for catalog-bump migrations when the publisher tag set changes. | | `fa2a6e4` | "Republish all" button on the admin Settings card (LNbits UMD UI). | | `ced6ca2` | Organizer-side counterpart: `POST /republish-mine` scoped via `require_admin_key` + an organizer-visible "Republish mine" button on the New Event card. Lets an organizer trigger the migration for just their own events without instance-admin rights. | | `902bafe` | `POST /tickets/{event_id}/{payment_hash}` polling endpoint. The webapp polls this after presenting the invoice; without it the post-payment poll loop 404'd indefinitely and the buyer never saw the success state, even though `set_ticket_paid` ran. | | `36568d3` | Propagate `CreateTicket.user_id` to the persisted ticket row. The endpoint accepted `user_id` on the request body (its root_validator even requires it XOR name+email) but dropped it on the way to `crud.create_ticket`, leaving every webapp-bought ticket with `user_id = NULL` and breaking the GET /tickets/user endpoint. | | `59068fe` | **Multi-ticket purchases as N rows sharing one payment_hash.** Each attendee gets a distinct scannable `id` (short-hash uuid); the LNbits invoice payment_hash is shared via a new `ticket.payment_hash` column. Migration `m002_ticket_payment_hash` adds the column + backfills existing rows from `id` (legacy `id == payment_hash` invariant). The door scanner (existing `/register/{ticket_id}` PUT) works unchanged — each attendee scans their own QR independently. CreateTicket grows `quantity: Field(ge=1, le=10)`; the API loops to create N rows, the polling endpoint returns every `ticket_ids` so clients render N QRs; `on_invoice_paid` iterates all rows for a payment_hash and `set_ticket_paid` increments counters by 1 per row, all under the existing per-event asyncio lock. | | `7b761a1` | Every ticket row gets a fresh `urlsafe_short_hash` id — no payment-hash reuse for the first row, so single- and multi-ticket purchases produce uniform short ids on the wire (rather than one 64-hex id + N-1 short-hash siblings). | ## Smoke Locally: - DB: migration `m002` runs cleanly on the regtest stack; existing rows get `payment_hash = id` backfilled. New 3-ticket purchase creates 3 rows with shared `payment_hash`, distinct short-hash ids, `extra.quantity` not used (gone). Counters increment by 3. - Polling: `POST /tickets/{event_id}/{payment_hash}` returns `{paid, ticket_id, ticket_ids: [...]}` once the invoice settles. - Republish: `POST /events/republish-all` re-emits every approved event; `POST /events/republish-mine` scopes to the caller's wallets. ## Test plan - [ ] Single-ticket purchase: one row, `id` is a short hash, `payment_hash` equals the invoice hash, polling endpoint returns `ticket_ids=[id]`. - [ ] 3-ticket purchase: three rows, shared `payment_hash`, three distinct short-hash ids. Each PUT `/register/{id}` registers exactly that attendee. - [ ] Buying more than `event.amount_tickets - event.sold` returns 400 with the remaining-count detail string (not 410 sold-out). - [ ] Migration is a no-op on a freshly-installed instance (the `_alter_add_column_safe` helper swallows duplicate-column errors). - [ ] Republish-all / republish-mine emit one Nostr event per approved row and reflect the new tag set. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Loops over approved events and re-emits each NIP-52 calendar event.
Useful as a one-shot migration after the publisher's tag set
changes (e.g. the tickets_* tag rollout introduced in this PR) so
existing events on a deployed instance pick up the new metadata
without each organizer having to edit and save.

Gated by check_admin (LNbits instance admin), errors swallowed
per-event inside the publisher so one bad row doesn't block the
rest. Returns a count summary.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Surfaces the POST /republish-all endpoint added in the previous
commit. Lives in the existing admin-gated Settings card on the
events extension landing page, so the LNbits operator can trigger
the migration without curl + access tokens.

Confirm dialog before firing (the endpoint emits one Nostr event
per approved row, fine to retry but worth a click of friction).
Notification shows the republished/total count on success.

Self-closing tags expanded per the LNbits UMD rule
(webapp CLAUDE.md > LNbits + Quasar UMD gotchas) — q-separator
and q-btn would silently nest wrong otherwise.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The admin /republish-all hits every approved event regardless of
owner — useful for the catalog migration, but heavy. Organizers
who want to re-emit just THEIR own events (e.g. after the AIO
publisher gained the tickets_* tags and an organizer's events
should pick them up) need a lighter knob.

Backend: new POST /republish-mine wallet-scoped via require_admin_key,
mirrors api_tickets's `all_wallets=true` shape so the page can
re-emit across every wallet the user owns. Filters to approved +
non-canceled rows.

UI: "Republish mine" button alongside "New Event" so every
logged-in user sees it (no isAdmin gate). Loading state +
confirm dialog + success count notification.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The webapp's useTicketPurchase polls this every 2s after firing
Pay with Wallet (or after presenting the QR) to confirm payment
before advancing to the ticket-QR success state. Without this
endpoint the post-payment poll loop returns 404 indefinitely and
the buyer never sees their ticket land — even though set_ticket_paid
fired on the invoice listener and the row is correctly marked paid
in the DB.

Returns {paid: bool, ticket_id?: str}. A missing or cross-event
ticket returns paid: false rather than 404 so the poll loop doesn't
need to special-case the not-yet-created race.

The WebSocket at /tickets/ws/{payment_hash} is more efficient for
push notifications — this POST is the fallback for clients that
can't open a relay-side socket.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
api_ticket_create accepted user_id in the CreateTicket request body
(its root_validator even requires user_id XOR name+email), but
dropped it on the way to crud.create_ticket — tickets ended up
with user_id = NULL and the new GET /tickets/user/{id} endpoint
returned an empty list for every webapp buyer.

Pull data.user_id alongside name/email and forward it to
create_ticket. Backfilling existing rows is left to the operator
(deployment-specific data fix); fresh purchases starting from this
commit are correctly attributed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the previous "one row, N seats via extra.quantity" model
with proper one-row-per-attendee semantics. Each attendee gets a
unique scannable id; the door PUT /register/{ticket_id} marks
them registered independently — so a buyer can purchase 3 tickets,
hand 2 QRs to friends arriving separately, and each attendee can
enter on their own schedule.

Schema (migrations_fork.py m002):
- ticket.payment_hash: new TEXT column shared across all rows of
  a multi-ticket purchase. Backfilled `payment_hash = id` for
  pre-migration rows (id WAS the payment_hash by invariant).

Wire:
- TicketPaymentRequest grows `ticket_ids: list[str]` so the
  webapp gets every scannable id back in the create response.
- POST /tickets/{event_id}/{payment_hash} polling endpoint now
  reports `ticket_ids` (every row) + keeps `ticket_id` for
  back-compat.
- api_ticket_create loops quantity times; the first row reuses
  payment_hash as id (preserves legacy `id == payment_hash`
  invariant for single-ticket purchases), the rest get
  urlsafe_short_hash() uuids.

Payment flow:
- on_invoice_paid fetches all rows by payment_hash and marks each
  paid via set_ticket_paid, which now increments event.sold by 1
  per row (was N per row via extra.quantity — simpler now). The
  per-event asyncio lock still serializes counter + republish so
  concurrent multi-ticket purchases for the same event don't
  reorder the published Nostr state.
- Each paid row triggers its own send_ticket_notification_in_
  background call — no-op for buyers without nostr_identifier /
  email, useful when the buyer set those on the row.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
fix: every ticket row gets a fresh short-hash id (no payment_hash reuse)
Some checks failed
lint.yml / fix: every ticket row gets a fresh short-hash id (no payment_hash reuse) (pull_request) Failing after 0s
lint.yml / fix: every ticket row gets a fresh short-hash id (no payment_hash reuse) (push) Failing after 0s
7b761a1aef
Previous commit reused the LNbits invoice payment_hash as the
first row's id, so a 3-ticket purchase ended up with one 64-hex
id and two short-hash ids — inconsistent and noisy in My Tickets.

Switch every row to urlsafe_short_hash. The shared payment_hash
column is the join key for invoice lookups (poll endpoint, ws
notifier, on_invoice_paid); rows never need to BE the payment
hash, they only need to point at it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
padreug deleted branch tickets-nostr-sync 2026-05-23 21:11:38 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
aiolabs/events!16
No description provided.