chore(activities): reroute CreateActivityDialog through TicketApiService.createEvent

The aiolabs/events extension on its signer-abstraction branch (commit
66076d6) constructs and publishes kind-31922 NIP-52 calendar events
server-side via NostrSigner — POST /events/api/v1/events accepts a
CreateEventRequest payload, signs through the operator's signer, and
broadcasts to configured relays. The webapp no longer needs to sign
calendar events client-side.

Changes:
- ActivitiesNostrService.ts: delete publishCalendarEvent() and its
  helper imports (finalizeEvent, EventTemplate, buildCalendarTimeEventTags,
  the local hexToUint8Array). The subscribe / query paths stay — the
  service still reads NIP-52 events off relays for the activity feed.
  Docstring updated to reflect the read-only role and point at the
  events extension for the publish path.
- CreateActivityDialog.vue: swap the publish flow.
  - Drop ActivitiesNostrService injection + currentUser.value.prvkey read.
  - Inject TicketApiService instead; pull invoiceKey from
    currentUser.value.wallets[0].inkey (same pattern as EventsPage.vue
    handleCreateEvent).
  - Build CreateEventRequest with amount_tickets: 0, price_per_ticket: 0
    (events extension treats 0 as unlimited/not-ticketed per
    models.py:45-46 per lnbits 22:30Z audit).
  - Fold summary + description into the events extension's `info`
    field since CreateEventRequest has no separate summary slot.
  - Update toast on success to "Activity created!" (server publishes
    to relays via the signer, not the webapp).

Approval-workflow caveat documented inline in the submit handler:
non-admin users on instances with auto_approve=false (the default)
land in the proposal queue and don't publish to relays until an admin
approves. Admins / auto_approve=true instances publish immediately.
This is the intended new behavior — operators can flip auto_approve
on the events extension config per-instance if they want the legacy
direct-publish moderation posture.

This is webapp's second bucket-A leg per aiolabs/lnbits#9 phase-1.
The remaining `currentUser.value.prvkey` reads stay until the
atomic User.prvkey field-removal PR (Q1.2 Option (b)).

Refs:
- log:2026-05-28T22:30Z (lnbits Q2.1 audit verifying ticket-less
  acceptance + approval-workflow caveat)
- ~/dev/coordination/webapp-design-questions.md Q2.1
- aiolabs/events signer-abstraction commit 66076d6 (the server-side
  publish path)
- aiolabs/lnbits cascade tip 861f427c deployed to aio-demo

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-29 21:43:35 +02:00
commit 26a661891a
2 changed files with 54 additions and 74 deletions

View file

@ -16,8 +16,8 @@ import { Button } from '@/components/ui/button'
import { CalendarPlus } from 'lucide-vue-next' import { CalendarPlus } from 'lucide-vue-next'
import { useAuth } from '@/composables/useAuthService' import { useAuth } from '@/composables/useAuthService'
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container' import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
import type { ActivitiesNostrService } from '../services/ActivitiesNostrService' import type { TicketApiService } from '../services/TicketApiService'
import type { CalendarTimeEvent } from '../types/nip52' import type { CreateEventRequest } from '../types/ticket'
import type { ActivityCategory } from '../types/category' import type { ActivityCategory } from '../types/category'
import CategorySelector from './CategorySelector.vue' import CategorySelector from './CategorySelector.vue'
import LocationPicker from './LocationPicker.vue' import LocationPicker from './LocationPicker.vue'
@ -67,56 +67,64 @@ const form = useForm({
const isFormValid = computed(() => form.meta.value.valid) const isFormValid = computed(() => form.meta.value.valid)
const onSubmit = form.handleSubmit(async (values) => { const onSubmit = form.handleSubmit(async (values) => {
const nostrService = tryInjectService<ActivitiesNostrService>(SERVICE_TOKENS.ACTIVITIES_NOSTR_SERVICE) const ticketApi = tryInjectService<TicketApiService>(SERVICE_TOKENS.TICKET_API)
if (!nostrService) { if (!ticketApi) {
toast.error('Activities service not available') toast.error('Activities service not available')
return return
} }
const signingKey = currentUser.value?.prvkey const invoiceKey = currentUser.value?.wallets?.[0]?.inkey
if (!signingKey) { if (!invoiceKey) {
toast.error('Signing key not available. Please log in again.') toast.error('No wallet available. Please log in first.')
return return
} }
isPublishing.value = true isPublishing.value = true
try { try {
// Build unix timestamps // Compose ISO 8601 datetime strings the events extension parses.
const startTimestamp = Math.floor(new Date(`${values.startDate}T${values.startTime}`).getTime() / 1000) const startIso = `${values.startDate}T${values.startTime}`
let endTimestamp: number | undefined const endIso =
if (values.endDate && values.endTime) { values.endDate && values.endTime
endTimestamp = Math.floor(new Date(`${values.endDate}T${values.endTime}`).getTime() / 1000) ? `${values.endDate}T${values.endTime}`
: undefined
// Fold summary + description into `info` since the events extension
// CreateEventRequest has no separate summary field.
const info =
values.summary && values.description
? `${values.summary}\n\n${values.description}`
: values.description || values.summary || ''
// Ticket-less activity amount_tickets and price_per_ticket both
// pinned at 0 (events extension treats 0 as "unlimited / not
// ticketed" per models.py:45-46). Server-side `signer.sign_event`
// produces the kind-31922 calendar event and publishes via the
// operator's configured relays no webapp signing path needed.
const eventData: CreateEventRequest = {
name: values.title,
info,
event_start_date: startIso,
event_end_date: endIso,
location: location.value || null,
banner: values.image || null,
categories: selectedCategories.value,
amount_tickets: 0,
price_per_ticket: 0,
} }
// Generate a unique d-tag await ticketApi.createEvent(eventData, invoiceKey)
const dTag = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const eventData: Partial<CalendarTimeEvent> = { // Approval workflow caveat: non-admin users on instances with
dTag, // `auto_approve=false` (the default) land in the proposal queue;
title: values.title, // their event isn't published to relays until an admin approves.
summary: values.summary || undefined, // Admins-and-auto-approve-on instances publish immediately.
content: values.description, toast.success('Activity created!')
image: values.image || undefined, emit('created')
start: startTimestamp, handleClose()
end: endTimestamp,
startTzid: Intl.DateTimeFormat().resolvedOptions().timeZone,
location: location.value || undefined,
hashtags: selectedCategories.value,
}
const result = await nostrService.publishCalendarEvent(eventData, signingKey)
if (result.success > 0) {
toast.success(`Activity published to ${result.success} relay${result.success > 1 ? 's' : ''}`)
emit('created')
handleClose()
} else {
toast.error('Failed to publish to any relay')
}
} catch (err) { } catch (err) {
console.error('Failed to publish activity:', err) console.error('Failed to create activity:', err)
toast.error(err instanceof Error ? err.message : 'Failed to publish activity') toast.error(err instanceof Error ? err.message : 'Failed to create activity')
} finally { } finally {
isPublishing.value = false isPublishing.value = false
} }

View file

@ -1,12 +1,10 @@
import { BaseService } from '@/core/base/BaseService' import { BaseService } from '@/core/base/BaseService'
import { finalizeEvent, type Event as NostrEvent, type EventTemplate } from 'nostr-tools' import type { Event as NostrEvent } from 'nostr-tools'
import type { SubscriptionConfig } from '@/modules/base/nostr/relay-hub' import type { SubscriptionConfig } from '@/modules/base/nostr/relay-hub'
import { import {
NIP52_KINDS, NIP52_KINDS,
parseCalendarTimeEvent, parseCalendarTimeEvent,
parseCalendarDateEvent, parseCalendarDateEvent,
buildCalendarTimeEventTags,
type CalendarTimeEvent,
} from '../types/nip52' } from '../types/nip52'
import { import {
calendarTimeEventToActivity, calendarTimeEventToActivity,
@ -28,7 +26,15 @@ export interface CalendarEventFilters {
} }
/** /**
* Service for subscribing to and publishing NIP-52 Calendar Events via RelayHub. * Service for subscribing to NIP-52 Calendar Events via RelayHub.
*
* Publishing kind-31922 calendar events lives server-side in the
* `aiolabs/events` LNbits extension (signer-abstraction branch, commit
* 66076d6) `POST /events/api/v1/events` constructs and signs the
* event via NostrSigner and broadcasts it to the operator's configured
* relays. The webapp constructs only the request payload; see
* CreateActivityDialog for the flow.
*
* Extends BaseService for standardized dependency injection and lifecycle. * Extends BaseService for standardized dependency injection and lifecycle.
*/ */
export class ActivitiesNostrService extends BaseService { export class ActivitiesNostrService extends BaseService {
@ -105,32 +111,6 @@ export class ActivitiesNostrService extends BaseService {
return activities return activities
} }
/**
* Publish a NIP-52 time-based calendar event.
* Requires an authenticated user with a signing key.
*/
async publishCalendarEvent(
eventData: Partial<CalendarTimeEvent>,
signingKeyHex: string
): Promise<{ success: number; total: number }> {
if (!this.relayHub) {
throw new Error('RelayHub not available')
}
const tags = buildCalendarTimeEventTags(eventData)
const template: EventTemplate = {
kind: NIP52_KINDS.CALENDAR_TIME_EVENT,
created_at: Math.floor(Date.now() / 1000),
content: eventData.content ?? '',
tags,
}
const privkeyBytes = hexToUint8Array(signingKeyHex)
const signedEvent = finalizeEvent(template, privkeyBytes)
return await this.relayHub.publishEvent(signedEvent)
}
/** /**
* Parse a raw Nostr event into an Activity view model. * Parse a raw Nostr event into an Activity view model.
*/ */
@ -179,11 +159,3 @@ export class ActivitiesNostrService extends BaseService {
this.activeUnsubscribes = [] this.activeUnsubscribes = []
} }
} }
function hexToUint8Array(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2)
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substr(i, 2), 16)
}
return bytes
}