feat: add event approval workflow with admin UI
Non-admin event submissions now land in a "proposed" queue that LNbits
admins review before the event becomes ticketable and publicly listed.
- m008 adds events.events.status (proposed/approved/rejected); m010 seeds
an events.settings singleton row with the auto_approve toggle.
- Models: Event/CreateEvent.status, EventsSettings, optional date fields
with sensible defaults (closing_date defaults to event_end_date which
defaults to event_start_date), PublicEvent.status surfaces the workflow
state on the public endpoint.
- crud: get_all/public/pending_events for the admin views; get/update_settings
for the auto_approve toggle; create_event auto-fills missing date defaults.
- views_api:
* POST /api/v1/events accepts wallet invoice keys so anyone can submit;
handler stamps status="proposed" for non-admins when auto_approve is off
* /public, /all, /pending, /settings (GET+PUT), /{id}/{approve,reject},
/{id}/tickets endpoints; literal-prefix routes declared before /{event_id}
so FastAPI matches them correctly
* Public GET /{event_id} bypasses sold-out / closing-window gates for
proposed/rejected events and returns the trimmed PublicEvent so the SFC
can render a "pending approval" banner
* POST /tickets/{event_id} rejects when event.status != "approved"
- Frontend: index.vue gains an admin Settings card, Pending Approvals list,
status badge column and approve/reject row actions, plus an All Users'
Events admin table; index.js gains the data + methods + an isAdmin probe
via GET /events/all; display.vue shows pending/rejected banners and
hides the Buy Ticket form unless status === "approved".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
11043ec8a7
commit
4c8e06a6a9
7 changed files with 526 additions and 51 deletions
|
|
@ -12,7 +12,32 @@
|
|||
<div v-html="event.info" class="q-pa-lg"></div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
<q-card class="q-pa-lg">
|
||||
|
||||
<q-banner
|
||||
v-if="event.status === 'proposed'"
|
||||
class="bg-orange-2 text-orange-10"
|
||||
rounded
|
||||
>
|
||||
<template v-slot:avatar>
|
||||
<q-icon name="pending" color="orange-10"></q-icon>
|
||||
</template>
|
||||
<span class="text-weight-medium">Pending approval</span> — this
|
||||
event is awaiting an admin review and is not yet open for tickets.
|
||||
</q-banner>
|
||||
|
||||
<q-banner
|
||||
v-else-if="event.status === 'rejected'"
|
||||
class="bg-red-2 text-red-10"
|
||||
rounded
|
||||
>
|
||||
<template v-slot:avatar>
|
||||
<q-icon name="block" color="red-10"></q-icon>
|
||||
</template>
|
||||
<span class="text-weight-medium">Not approved</span> — this event
|
||||
was reviewed and is not being published.
|
||||
</q-banner>
|
||||
|
||||
<q-card v-if="event.status === 'approved'" class="q-pa-lg">
|
||||
<q-card-section class="q-pa-none">
|
||||
<h5 class="q-mt-none">Buy Ticket</h5>
|
||||
<q-form @submit="createInvoice()" class="q-gutter-md">
|
||||
|
|
|
|||
|
|
@ -5,6 +5,12 @@ window.PageEvents = {
|
|||
events: [],
|
||||
tickets: [],
|
||||
currencies: [],
|
||||
pendingEvents: [],
|
||||
allUserEvents: [],
|
||||
isAdmin: false,
|
||||
settings: {
|
||||
auto_approve: false
|
||||
},
|
||||
eventsTable: {
|
||||
columns: [
|
||||
{name: 'id', align: 'left', label: 'ID', field: 'id'},
|
||||
|
|
@ -65,7 +71,8 @@ window.PageEvents = {
|
|||
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
|
||||
|
|
@ -152,6 +159,84 @@ window.PageEvents = {
|
|||
this.events = response.data
|
||||
this.checkCanceledEvents()
|
||||
})
|
||||
|
||||
// Admin probe: a 200 from /all means we're an LNbits admin.
|
||||
LNbits.api
|
||||
.request('GET', '/events/api/v1/events/all')
|
||||
.then(response => {
|
||||
this.isAdmin = true
|
||||
const ownWalletIds = this.g.user.wallets.map(w => w.id)
|
||||
this.allUserEvents = response.data.filter(
|
||||
e => !ownWalletIds.includes(e.wallet)
|
||||
)
|
||||
})
|
||||
.catch(() => {
|
||||
this.isAdmin = false
|
||||
this.allUserEvents = []
|
||||
})
|
||||
},
|
||||
getSettings() {
|
||||
LNbits.api
|
||||
.request('GET', '/events/api/v1/events/settings')
|
||||
.then(response => {
|
||||
this.settings = response.data
|
||||
})
|
||||
.catch(() => {
|
||||
// Not admin or settings unavailable; keep defaults.
|
||||
})
|
||||
},
|
||||
saveSettings() {
|
||||
LNbits.api
|
||||
.request(
|
||||
'PUT',
|
||||
'/events/api/v1/events/settings',
|
||||
null,
|
||||
this.settings
|
||||
)
|
||||
.then(() => {
|
||||
Quasar.Notify.create({type: 'positive', message: 'Settings saved'})
|
||||
})
|
||||
.catch(LNbits.utils.notifyApiError)
|
||||
},
|
||||
getPendingEvents() {
|
||||
LNbits.api
|
||||
.request('GET', '/events/api/v1/events/pending')
|
||||
.then(response => {
|
||||
this.pendingEvents = response.data
|
||||
})
|
||||
.catch(() => {
|
||||
this.pendingEvents = []
|
||||
})
|
||||
},
|
||||
approveEvent(eventId) {
|
||||
LNbits.utils.confirmDialog('Approve this event?').onOk(() => {
|
||||
LNbits.api
|
||||
.request('PUT', '/events/api/v1/events/' + eventId + '/approve')
|
||||
.then(() => {
|
||||
Quasar.Notify.create({
|
||||
type: 'positive',
|
||||
message: 'Event approved'
|
||||
})
|
||||
this.getEvents()
|
||||
this.getPendingEvents()
|
||||
})
|
||||
.catch(LNbits.utils.notifyApiError)
|
||||
})
|
||||
},
|
||||
rejectEvent(eventId) {
|
||||
LNbits.utils.confirmDialog('Reject this event?').onOk(() => {
|
||||
LNbits.api
|
||||
.request('PUT', '/events/api/v1/events/' + eventId + '/reject')
|
||||
.then(() => {
|
||||
Quasar.Notify.create({
|
||||
type: 'positive',
|
||||
message: 'Event rejected'
|
||||
})
|
||||
this.getEvents()
|
||||
this.getPendingEvents()
|
||||
})
|
||||
.catch(LNbits.utils.notifyApiError)
|
||||
})
|
||||
},
|
||||
sendEventData() {
|
||||
const wallet = _.findWhere(this.g.user.wallets, {
|
||||
|
|
@ -275,6 +360,8 @@ window.PageEvents = {
|
|||
if (this.g.user.wallets.length) {
|
||||
this.getTickets()
|
||||
this.getEvents()
|
||||
this.getSettings()
|
||||
this.getPendingEvents()
|
||||
if (this.g.allowedCurrencies && this.g.allowedCurrencies.length > 0) {
|
||||
this.currencies = ['sats', ...this.g.allowedCurrencies]
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,23 @@
|
|||
<template id="page-events">
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
|
||||
<q-card v-if="isAdmin">
|
||||
<q-card-section>
|
||||
<div class="row items-center justify-between">
|
||||
<div class="col">
|
||||
<span class="text-subtitle1">Settings</span>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-toggle
|
||||
v-model="settings.auto_approve"
|
||||
label="Auto-approve events"
|
||||
@update:model-value="saveSettings"
|
||||
></q-toggle>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<q-btn unelevated color="primary" @click="openEventDialog"
|
||||
|
|
@ -9,6 +26,63 @@
|
|||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<q-card v-if="pendingEvents.length > 0">
|
||||
<q-card-section>
|
||||
<div class="row items-center no-wrap q-mb-md">
|
||||
<div class="col">
|
||||
<h5 class="text-subtitle1 q-my-none">
|
||||
<q-icon name="pending" color="orange" class="q-mr-sm"></q-icon>
|
||||
Pending Approvals
|
||||
<q-badge
|
||||
color="orange"
|
||||
:label="pendingEvents.length"
|
||||
class="q-ml-sm"
|
||||
></q-badge>
|
||||
</h5>
|
||||
</div>
|
||||
</div>
|
||||
<q-list separator>
|
||||
<q-item v-for="event in pendingEvents" :key="event.id">
|
||||
<q-item-section>
|
||||
<q-item-label v-text="event.name"></q-item-label>
|
||||
<q-item-label caption>
|
||||
<span v-text="event.event_start_date"></span>
|
||||
—
|
||||
<span v-text="event.info.substring(0, 80)"></span
|
||||
><span v-if="event.info.length > 80">...</span>
|
||||
</q-item-label>
|
||||
<q-item-label caption>
|
||||
<span v-text="event.amount_tickets"></span> tickets •
|
||||
<span v-text="event.price_per_ticket"></span>
|
||||
<span v-text="event.currency"></span>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<div class="row q-gutter-sm">
|
||||
<q-btn
|
||||
dense
|
||||
color="green"
|
||||
icon="check_circle"
|
||||
label="Approve"
|
||||
size="sm"
|
||||
@click="approveEvent(event.id)"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
dense
|
||||
outline
|
||||
color="red"
|
||||
icon="block"
|
||||
label="Reject"
|
||||
size="sm"
|
||||
@click="rejectEvent(event.id)"
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="row items-center no-wrap q-mb-md">
|
||||
|
|
@ -75,6 +149,28 @@
|
|||
></q-btn>
|
||||
</q-td>
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
v-if="isAdmin && props.row.status === 'proposed'"
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="approveEvent(props.row.id)"
|
||||
icon="check_circle"
|
||||
color="green"
|
||||
>
|
||||
<q-tooltip>Approve</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
v-if="isAdmin && props.row.status === 'proposed'"
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="rejectEvent(props.row.id)"
|
||||
icon="block"
|
||||
color="red"
|
||||
>
|
||||
<q-tooltip>Reject</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
|
|
@ -94,7 +190,12 @@
|
|||
></q-btn>
|
||||
</q-td>
|
||||
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||
<span v-text="col.value"></span>
|
||||
<q-badge
|
||||
v-if="col.name === 'status'"
|
||||
:color="col.value === 'approved' ? 'green' : col.value === 'proposed' ? 'orange' : 'red'"
|
||||
:label="col.value"
|
||||
></q-badge>
|
||||
<span v-else v-text="col.value"></span>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
<q-tr v-show="props.expand" :props="props">
|
||||
|
|
@ -149,6 +250,51 @@
|
|||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<q-card v-if="isAdmin && allUserEvents.length > 0">
|
||||
<q-card-section>
|
||||
<div class="row items-center no-wrap q-mb-md">
|
||||
<div class="col">
|
||||
<h5 class="text-subtitle1 q-my-none">
|
||||
All Users' Events
|
||||
<q-badge
|
||||
color="blue"
|
||||
:label="allUserEvents.length"
|
||||
class="q-ml-sm"
|
||||
></q-badge>
|
||||
</h5>
|
||||
</div>
|
||||
</div>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:rows="allUserEvents"
|
||||
row-key="id"
|
||||
:columns="eventsTable.columns"
|
||||
:pagination="{rowsPerPage: 10}"
|
||||
>
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th v-for="col in props.cols" :key="col.name" :props="props">
|
||||
<span v-text="col.label"></span>
|
||||
</q-th>
|
||||
</q-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||
<q-badge
|
||||
v-if="col.name === 'status'"
|
||||
:color="col.value === 'approved' ? 'green' : col.value === 'proposed' ? 'orange' : 'red'"
|
||||
:label="col.value"
|
||||
></q-badge>
|
||||
<span v-else v-text="col.value"></span>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="row items-center no-wrap q-mb-md">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue