From 4bea1a65924e22e35ac6b814789c643146926e1c Mon Sep 17 00:00:00 2001 From: Padreug Date: Thu, 21 May 2026 12:29:37 +0200 Subject: [PATCH 01/16] feat(activities): TicketApiService.updateEvent + admin/auto_approve probes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `updateEvent` calls PUT /events/{id} with the event's wallet admin key — mirrors the backend's `require_admin_key` decorator (different key than the inkey used by createEvent). Add `isAdmin` and `getAutoApprove` probes so the dialog can decide whether to show "edit will go back to pending approval" copy. Both degrade to `false` on failure, which biases the warning toward being shown when in doubt. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../activities/services/TicketApiService.ts | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/src/modules/activities/services/TicketApiService.ts b/src/modules/activities/services/TicketApiService.ts index a9f3975..432771d 100644 --- a/src/modules/activities/services/TicketApiService.ts +++ b/src/modules/activities/services/TicketApiService.ts @@ -148,6 +148,60 @@ export class TicketApiService { }) } + /** + * Update an existing event. Requires the event's wallet admin key. + * Status is re-derived server-side from admin/auto_approve — a non- + * admin owner editing under `auto_approve=false` lands back at + * `proposed` regardless of the current state. + */ + async updateEvent( + eventId: string, + eventData: CreateEventRequest, + adminKey: string, + ): Promise { + return this.request(`/events/api/v1/events/${eventId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'X-API-KEY': adminKey, + }, + body: JSON.stringify(eventData), + }) + } + + /** + * Probe whether the current user has LNbits admin privileges. The + * `/all` endpoint is `check_admin`-gated, so a 200 means "admin", + * any other response means "not admin". + */ + async isAdmin(adminKey: string): Promise { + try { + await this.request('/events/api/v1/events/all', { + method: 'GET', + headers: { 'X-API-KEY': adminKey }, + }) + return true + } catch { + return false + } + } + + /** + * Read the extension's auto_approve flag. Admin-only endpoint, so + * non-admin callers see false (the safe default for UI gating). + */ + async getAutoApprove(adminKey: string): Promise { + try { + const settings = await this.request('/events/api/v1/events/settings', { + method: 'GET', + headers: { 'X-API-KEY': adminKey }, + }) + return Boolean(settings?.auto_approve) + } catch { + return false + } + } + /** * Fetch available currencies from LNbits. */ From cd35fae674821c90e651d67778cb03acba7b58e0 Mon Sep 17 00:00:00 2001 From: Padreug Date: Thu, 21 May 2026 12:29:49 +0200 Subject: [PATCH 02/16] feat(activities): dual-mode CreateEventDialog supports edit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Accept optional `event` prop and `onUpdateEvent` handler. Dialog toggles title, description, submit button text, and a warning Alert based on edit mode plus an `isAdmin`/`autoApprove` pair the parent supplies. On open in edit mode, populate the form from the event — split stored "YYYY-MM-DD[THH:MM]" back into date+time inputs, restore categories, and seed bannerImages from the stored URL by extracting the pict-rs file ID (same pattern as market's CreateProductDialog). A clearing-the-banner action during edit sends `banner: null` so the backend wipes the field instead of keeping the old image. Auto-mirror watcher is guarded against firing during the initial population. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/CreateEventDialog.vue | 165 +++++++++++++++--- 1 file changed, 143 insertions(+), 22 deletions(-) diff --git a/src/modules/activities/components/CreateEventDialog.vue b/src/modules/activities/components/CreateEventDialog.vue index dd5ab5f..34477a2 100644 --- a/src/modules/activities/components/CreateEventDialog.vue +++ b/src/modules/activities/components/CreateEventDialog.vue @@ -32,28 +32,45 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select' -import { Calendar, Loader2, MapPin } from 'lucide-vue-next' +import { Calendar, Loader2, MapPin, AlertCircle } from 'lucide-vue-next' import { toastService } from '@/core/services/ToastService' import { injectService, SERVICE_TOKENS } from '@/core/di-container' import ImageUpload from '@/modules/base/components/ImageUpload.vue' import DatePicker from '@/modules/base/components/DatePicker.vue' import TimePicker from '@/modules/base/components/TimePicker.vue' +import { Alert, AlertDescription } from '@/components/ui/alert' import type { ImageUploadService, UploadedImage } from '@/modules/base/services/ImageUploadService' import type { TicketApiService } from '../services/TicketApiService' -import type { CreateEventRequest } from '../types/ticket' +import type { CreateEventRequest, TicketedEvent } from '../types/ticket' import { ALL_CATEGORIES } from '../types/category' interface Props { open: boolean - onCreateEvent: (eventData: CreateEventRequest) => Promise + /** When set, dialog opens in edit mode for this event. */ + event?: TicketedEvent | null + /** Create handler. Required when not editing. */ + onCreateEvent?: (eventData: CreateEventRequest) => Promise + /** Update handler. Required when editing. */ + onUpdateEvent?: (eventId: string, eventData: CreateEventRequest) => Promise + /** Whether the current user is an LNbits admin. Drives the + * "edit will go back to pending approval" warning copy. */ + isAdmin?: boolean + /** Whether the events extension has auto_approve enabled. */ + autoApprove?: boolean } const props = defineProps() const emit = defineEmits<{ 'update:open': [value: boolean] 'event-created': [] + 'event-updated': [] }>() +const isEditMode = computed(() => Boolean(props.event?.id)) +const willGoToPending = computed( + () => isEditMode.value && !props.isAdmin && !props.autoApprove +) + const { t } = useI18n() // Fold a date input ("YYYY-MM-DD") and an optional time input ("HH:MM") @@ -117,6 +134,18 @@ interface BannerImage extends UploadedImage { } const bannerImages = ref([]) +// Inverse of foldDateTime: split a stored "YYYY-MM-DD[THH:MM]" back +// into separate date + time pieces for the form inputs. +function splitDateTime(value: string | null | undefined): { date: string; time: string } { + if (!value) return { date: '', time: '' } + const [date, time = ''] = value.split('T') + return { date, time: time.slice(0, 5) } +} + +// When `true`, suppress the auto-mirror watcher so we don't clobber an +// edit-mode population with start-date side effects mid-setValues. +const isPopulating = ref(false) + // Auto-mirror end date to start: when the user picks a start date, // surface that same date in the end-date picker so a one-day event // requires no extra clicks. Don't overwrite an end date the user @@ -125,6 +154,7 @@ const bannerImages = ref([]) watch( () => form.values.event_start_date, (start, prev) => { + if (isPopulating.value) return if (!start) return const end = form.values.event_end_date if (!end || end < start || end === prev) { @@ -141,18 +171,61 @@ const availableCurrencies = ref(['sat']) const loadingCurrencies = ref(false) const selectedCategories = ref([]) -watch(() => props.open, async (isOpen) => { - if (isOpen && ticketApi && !loadingCurrencies.value) { - loadingCurrencies.value = true - try { - availableCurrencies.value = await ticketApi.getCurrencies() - } catch (error) { - console.warn('Failed to load currencies:', error) - } finally { - loadingCurrencies.value = false - } +function populateFromEvent(event: TicketedEvent) { + isPopulating.value = true + const start = splitDateTime(event.event_start_date) + const end = splitDateTime(event.event_end_date) + form.setValues({ + name: event.name, + info: event.info ?? '', + event_start_date: start.date, + event_start_time: start.time, + event_end_date: end.date, + event_end_time: end.time, + location: event.location ?? '', + currency: event.currency ?? 'sat', + amount_tickets: event.amount_tickets ?? 0, + price_per_ticket: event.price_per_ticket ?? 0, + }) + selectedCategories.value = [...(event.categories ?? [])] + if (event.banner) { + // Mirror the URL-to-alias bridge from market's CreateProductDialog + // so the renders the existing banner via its pict-rs + // file ID. delete_token is unknown for already-uploaded images, so + // removal just clears the slot client-side. + const url = event.banner + const alias = url.includes('/image/original/') + ? url.split('/image/original/')[1] + : url + bannerImages.value = [ + { alias, isPrimary: true, delete_token: '', details: {} as any }, + ] + } else { + bannerImages.value = [] } - if (!isOpen) { + // Release the watcher guard on the next tick so vee-validate's batched + // updates settle before user input can drive the auto-mirror. + setTimeout(() => { + isPopulating.value = false + }, 0) +} + +watch(() => props.open, async (isOpen) => { + if (isOpen) { + if (ticketApi && !loadingCurrencies.value) { + loadingCurrencies.value = true + try { + availableCurrencies.value = await ticketApi.getCurrencies() + } catch (error) { + console.warn('Failed to load currencies:', error) + } finally { + loadingCurrencies.value = false + } + } + if (props.event) { + populateFromEvent(props.event) + } + } else { selectedCategories.value = [] } }) @@ -195,7 +268,11 @@ const onSubmit = form.handleSubmit(async (formValues) => { formValues.event_start_date, formValues.event_start_time ), - wallet: preferredWallet.id, + } + if (!isEditMode.value) { + // Wallet binds at creation. The backend ignores the field on + // update so we leave it off the edit payload for clean wire. + eventData.wallet = preferredWallet.id } // Optional fields — only include if provided @@ -209,21 +286,49 @@ const onSubmit = form.handleSubmit(async (formValues) => { if (formValues.location) eventData.location = formValues.location if (bannerImages.value.length > 0) { eventData.banner = imageService.getImageUrl(bannerImages.value[0].alias) + } else if (isEditMode.value) { + // User cleared the banner during edit — propagate the null so the + // backend wipes the field instead of keeping the old image. + eventData.banner = null } if (formValues.currency) eventData.currency = formValues.currency if (formValues.amount_tickets) eventData.amount_tickets = formValues.amount_tickets if (formValues.price_per_ticket) eventData.price_per_ticket = formValues.price_per_ticket if (selectedCategories.value.length > 0) eventData.categories = selectedCategories.value - await props.onCreateEvent(eventData) - toastService.success('Event submitted!') + if (isEditMode.value) { + if (!props.onUpdateEvent || !props.event?.id) { + toastService.error('Update handler missing') + return + } + await props.onUpdateEvent(props.event.id, eventData) + toastService.success( + willGoToPending.value + ? 'Event updated — pending re-approval' + : 'Event updated!' + ) + emit('event-updated') + } else { + if (!props.onCreateEvent) { + toastService.error('Create handler missing') + return + } + await props.onCreateEvent(eventData) + toastService.success('Event submitted!') + emit('event-created') + } + resetForm() selectedCategories.value = [] bannerImages.value = [] emit('update:open', false) - emit('event-created') } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Failed to create event' + const errorMessage = + error instanceof Error + ? error.message + : isEditMode.value + ? 'Failed to update event' + : 'Failed to create event' toastService.error(errorMessage) } finally { isLoading.value = false @@ -246,15 +351,27 @@ const handleOpenChange = (open: boolean) => { - Create Event + {{ isEditMode ? 'Edit Event' : 'Create Event' }} - Only a title and start date are required. + {{ + isEditMode + ? 'Update event details. Tickets already sold are not affected.' + : 'Only a title and start date are required.' + }}
+ + + + Saving will resubmit for approval. The event will be removed + from public feeds until reviewed. + + + @@ -451,7 +568,11 @@ const handleOpenChange = (open: boolean) => { From af3c9853c0a9968891fd32ecda26c2856d448a01 Mon Sep 17 00:00:00 2001 From: Padreug Date: Thu, 21 May 2026 12:30:00 +0200 Subject: [PATCH 03/16] feat(activities): edit button on user-owned events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pencil button in the card footer of upcoming events the current user owns (event.wallet ∈ currentUser.wallets). Clicking opens the same CreateEventDialog in edit mode, pre-populated with the event. Probe `is_admin` and `auto_approve` once at mount so the dialog can render the "going back to pending" warning copy accurately for non-admin owners. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/modules/activities/views/EventsPage.vue | 97 ++++++++++++++++++--- 1 file changed, 84 insertions(+), 13 deletions(-) diff --git a/src/modules/activities/views/EventsPage.vue b/src/modules/activities/views/EventsPage.vue index ab4feb8..2a4adac 100644 --- a/src/modules/activities/views/EventsPage.vue +++ b/src/modules/activities/views/EventsPage.vue @@ -1,5 +1,5 @@ @@ -83,7 +137,7 @@ function handleEventCreated() {
- @@ -128,9 +182,9 @@ function handleEventCreated() {
- + + @@ -189,10 +252,18 @@ function handleEventCreated() { From 304756592013ee0a60ad61fafee5816da9ed7204 Mon Sep 17 00:00:00 2001 From: Padreug Date: Thu, 21 May 2026 15:55:19 +0200 Subject: [PATCH 04/16] feat(activities): fetchMyEvents + invoice-key auto_approve probe `fetchMyEvents` hits the existing all_wallets=true endpoint to surface the caller's own events regardless of status. `getAutoApprove` now calls the public probe (invoice-key-gated) added in events extension v1.3.0-aio.5 so non-admin webapp users get accurate edit-flow copy. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../activities/services/TicketApiService.ts | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/src/modules/activities/services/TicketApiService.ts b/src/modules/activities/services/TicketApiService.ts index 432771d..64c5fdb 100644 --- a/src/modules/activities/services/TicketApiService.ts +++ b/src/modules/activities/services/TicketApiService.ts @@ -34,6 +34,20 @@ export class TicketApiService { return response } + /** + * Fetch the authenticated user's own events across all their wallets, + * regardless of status. Lets the webapp show the user's pending / + * rejected events alongside the public approved feed — without this + * a user who edits under `auto_approve=false` loses sight of their + * own event the moment it drops to `proposed`. + */ + async fetchMyEvents(invoiceKey: string): Promise { + return this.request('/events/api/v1/events?all_wallets=true', { + method: 'GET', + headers: { 'X-API-KEY': invoiceKey }, + }) + } + /** * Request a ticket purchase (creates a Lightning invoice). * Uses POST /tickets/{event_id} with user_id in body (upstream API). @@ -187,14 +201,16 @@ export class TicketApiService { } /** - * Read the extension's auto_approve flag. Admin-only endpoint, so - * non-admin callers see false (the safe default for UI gating). + * Read the extension's auto_approve flag. Hits the public probe + * (invoice-key-gated, available to any wallet holder), so non-admin + * users see the real value and the edit-flow copy is accurate. + * Degrades to `false` on failure — the safer default for warning UI. */ - async getAutoApprove(adminKey: string): Promise { + async getAutoApprove(invoiceKey: string): Promise { try { - const settings = await this.request('/events/api/v1/events/settings', { + const settings = await this.request('/events/api/v1/events/settings/public', { method: 'GET', - headers: { 'X-API-KEY': adminKey }, + headers: { 'X-API-KEY': invoiceKey }, }) return Boolean(settings?.auto_approve) } catch { From 01b871e7fad7aac5c5ca6eee2231586335d0703e Mon Sep 17 00:00:00 2001 From: Padreug Date: Thu, 21 May 2026 15:55:19 +0200 Subject: [PATCH 05/16] feat(activities): merge own events into the feed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When authenticated, parallel-fetch the caller's own events (any status) alongside the public approved feed and merge with public-wins dedup. Without this, an event that drops to `proposed` after a non-admin edit disappears from the user's view — they couldn't find it to make a follow-up edit or watch for re-approval. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../activities/composables/useEvents.ts | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/src/modules/activities/composables/useEvents.ts b/src/modules/activities/composables/useEvents.ts index a67cf69..cdc5902 100644 --- a/src/modules/activities/composables/useEvents.ts +++ b/src/modules/activities/composables/useEvents.ts @@ -1,14 +1,43 @@ import { computed } from 'vue' import { useAsyncState } from '@vueuse/core' import { injectService, SERVICE_TOKENS } from '@/core/di-container' +import { useAuth } from '@/composables/useAuthService' import type { TicketApiService } from '../services/TicketApiService' import type { TicketedEvent } from '../types/ticket' export function useEvents() { const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService + const { isAuthenticated, currentUser } = useAuth() + + // When authenticated, also fetch the user's own events (any status) + // and merge them into the feed. Otherwise an event that drops to + // `proposed` after a non-admin edit disappears from the user's view + // entirely — they'd be unable to find it to make a follow-up edit or + // monitor its approval status. Public approved events from other + // users take precedence on dedup (server is the source of truth for + // the public view). + const fetchAll = async (): Promise => { + const publicEvents = (await ticketApi.fetchTicketedEvents()) as TicketedEvent[] + + if (!isAuthenticated.value) return publicEvents + + const invoiceKey = currentUser.value?.wallets?.[0]?.inkey + if (!invoiceKey) return publicEvents + + try { + const myEvents = (await ticketApi.fetchMyEvents(invoiceKey)) as TicketedEvent[] + const seen = new Set(publicEvents.map((e) => e.id)) + const own = myEvents.filter((e) => !seen.has(e.id)) + return [...publicEvents, ...own] + } catch { + // Falling back to just the public feed is acceptable — the user + // can still browse, they just won't see their own pending events. + return publicEvents + } + } const { state: events, isLoading, error: asyncError, execute: refresh } = useAsyncState( - () => ticketApi.fetchTicketedEvents() as Promise, + fetchAll, [] as TicketedEvent[], { immediate: true, From 9b1b56e05d3a4c04cd93ca3e16a970c242395743 Mon Sep 17 00:00:00 2001 From: Padreug Date: Thu, 21 May 2026 15:55:19 +0200 Subject: [PATCH 06/16] feat(activities): status badge + buy-disabled on own pending events Show a "Pending review" (or "Rejected") badge on the user's own non-approved events, and disable the Buy Ticket button on any non-approved event with a "Not yet available" label. Probe auto_approve via the public endpoint with inkey, not adminkey, so the warning copy works for non-admin owners. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/modules/activities/views/EventsPage.vue | 28 ++++++++++++++++----- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/src/modules/activities/views/EventsPage.vue b/src/modules/activities/views/EventsPage.vue index 2a4adac..a8eefdd 100644 --- a/src/modules/activities/views/EventsPage.vue +++ b/src/modules/activities/views/EventsPage.vue @@ -48,11 +48,13 @@ function canEdit(event: TicketedEvent): boolean { onMounted(async () => { if (!isAuthenticated.value) return - const adminKey = currentUser.value?.wallets?.[0]?.adminkey - if (!adminKey) return + const wallet = currentUser.value?.wallets?.[0] + if (!wallet?.inkey) return const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService - isAdmin.value = await ticketApi.isAdmin(adminKey) - autoApprove.value = await ticketApi.getAutoApprove(adminKey) + autoApprove.value = await ticketApi.getAutoApprove(wallet.inkey) + if (wallet.adminkey) { + isAdmin.value = await ticketApi.isAdmin(wallet.adminkey) + } }) function formatDate(dateStr: string | null | undefined) { @@ -159,7 +161,16 @@ function handleEventChanged() {
- {{ event.name }} +
+ {{ event.name }} + + {{ event.status === 'rejected' ? 'Rejected' : 'Pending review' }} + +
{{ event.info }}
@@ -186,13 +197,18 @@ function handleEventChanged() {