fix(nostr): always publish tickets_available, zero is not unlimited #62

Merged
padreug merged 1 commit from fix/zero-capacity-is-not-unlimited into main 2026-09-27 21:10:34 +00:00
3 changed files with 67 additions and 8 deletions
Showing only changes of commit adfd4529f5 - Show all commits

fix(nostr): always publish tickets_available, zero is not unlimited
Some checks failed
lint.yml / fix(nostr): always publish tickets_available, zero is not unlimited (pull_request) Failing after 0s

Omitting the tag used to mean "unlimited capacity". Nothing else in the
codebase agreed: `api_get_event` and `api_ticket_create` both treat
`amount_tickets < 1` as sold out. So a zero-capacity event advertised
"Unlimited tickets" on the card while the detail page and the purchase
both returned 410.

Observed on aio-demo — three approved, listed, free events in that
state. For `PKBVuusKikfJFU4PYGtBTW`:

  relay        tickets_available absent  -> webapp renders "Unlimited"
  GET  event   410 "Event is sold out."
  POST ticket  410 "Event is sold out."

The admin form was advertising it too (`min="0"`, `hint="0 = unlimited"`),
so organizers were being invited into the broken state.

Now the tag is always emitted and zero reads as sold out, which is what
every other part of the system already believed. Clients that must handle
an absent tag — a NIP-52 event from another publisher — are unaffected,
since we simply never omit it.

Scoped to removing the contradiction. Making capacity a *required* field
is the other half of #34 and is blocked on the webapp: its create dialog
uses a falsy check (`if (formValues.amount_tickets)`), so a 0 omits the
field entirely and a server-side `ge=1` would 422 it.

Refs #34
Padreug 2026-09-27 23:03:54 +02:00

View file

@ -46,7 +46,7 @@ def build_nip52_event(event: Event, pubkey: str) -> NostrEvent:
start - unix timestamp (31923) or YYYY-MM-DD (31922) start - unix timestamp (31923) or YYYY-MM-DD (31922)
end - same encoding (optional) end - same encoding (optional)
image, location, t (categories) - optional image, location, t (categories) - optional
tickets_available - current remaining capacity (omitted when unlimited) tickets_available - current remaining capacity (always emitted; 0 = sold out)
tickets_sold - running paid-count (always emitted; clients can tickets_sold - running paid-count (always emitted; clients can
derive original_capacity = available + sold) derive original_capacity = available + sold)
tickets_price - price_per_ticket (always emitted; 0 means free) tickets_price - price_per_ticket (always emitted; 0 means free)
@ -96,10 +96,13 @@ def build_nip52_event(event: Event, pubkey: str) -> NostrEvent:
for cat in event.categories or []: for cat in event.categories or []:
tags.append(["t", cat]) tags.append(["t", cat])
# `amount_tickets == 0` means unlimited capacity in this extension's # Always emitted, including zero. Omitting it used to mean "unlimited",
# schema. Omitting the tag is how clients distinguish unlimited from # which contradicted every other reader: `api_get_event` and
# "0 left" (sold out). # `api_ticket_create` both treat `amount_tickets < 1` as sold out, so a
if event.amount_tickets > 0: # zero-capacity event advertised "Unlimited tickets" on the card while
# the detail page and the purchase both returned 410 (aiolabs/events#34).
# Clients that must still handle an absent tag — a NIP-52 event from
# some other publisher — are unaffected; we simply never omit it.
tags.append(["tickets_available", str(event.amount_tickets)]) tags.append(["tickets_available", str(event.amount_tickets)])
tags.append(["tickets_sold", str(event.sold)]) tags.append(["tickets_sold", str(event.sold)])
tags.append(["tickets_price", str(event.price_per_ticket)]) tags.append(["tickets_price", str(event.price_per_ticket)])

View file

@ -614,9 +614,9 @@
dense dense
v-model.number="formDialog.data.amount_tickets" v-model.number="formDialog.data.amount_tickets"
type="number" type="number"
min="0" min="1"
label="Amount of tickets" label="Amount of tickets"
hint="0 = unlimited" hint="Total tickets on sale"
></q-input> ></q-input>
</div> </div>
<div class="col"> <div class="col">

View file

@ -0,0 +1,56 @@
"""`tickets_available` is always published, including zero (#34).
Omitting the tag used to mean "unlimited", which contradicted every other
reader: `api_get_event` and `api_ticket_create` both treat
`amount_tickets < 1` as sold out. On aio-demo three zero-capacity events
advertised "Unlimited tickets" on the card while the detail endpoint and
the purchase both returned 410.
"""
from datetime import datetime, timezone
import pytest
from ..models import Event
from ..nostr_publisher import build_nip52_event
PUBKEY = "a" * 64
def _event(amount_tickets: int, sold: int = 0) -> Event:
return Event(
id="evt",
wallet="w",
name="Availability",
info="",
closing_date="2030-01-01",
event_start_date="2030-01-01T18:00",
event_end_date="2030-01-01T22:00",
currency="sat",
price_per_ticket=0,
amount_tickets=amount_tickets,
sold=sold,
time=datetime.now(timezone.utc),
status="approved",
)
def _tags(event: Event) -> dict[str, str]:
return {t[0]: t[1] for t in build_nip52_event(event, PUBKEY).tags if len(t) > 1}
@pytest.mark.parametrize("amount", [0, 1, 50])
def test_tickets_available_is_always_present(amount):
assert _tags(_event(amount))["tickets_available"] == str(amount)
def test_zero_capacity_reads_as_sold_out_not_unlimited():
"""The regression: an absent tag is what clients render as unlimited."""
tags = _tags(_event(0, sold=0))
assert "tickets_available" in tags
assert tags["tickets_available"] == "0"
def test_sold_out_after_selling_through_still_publishes_zero():
assert _tags(_event(0, sold=25))["tickets_available"] == "0"
assert _tags(_event(0, sold=25))["tickets_sold"] == "25"