Compare commits

...

2 commits

Author SHA1 Message Date
141e59da82 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>
2026-05-30 08:04:01 +02:00
cb6e1351fb fix(activities): scope detail-page query by NIP-52 d-tag
`useActivityDetail.load()` previously asked every relay for every
kind-31922/31923 event and raced a 5s timeout to find the one
matching the route param. On a cold refresh of the detail page, the
race was often lost — the store starts empty (no feed subscription
to populate it), the relay sprays the whole calendar, and the
matching event may arrive after the timeout, leaving the user with
"Activity not found" on a valid URL.

Add a `dTags` field to `CalendarEventFilters` and emit it as the
nostr `#d` tag filter. Detail-page subscribe + query both scope to
the single activity, so the relay resolves a parameterized-replaceable
lookup in milliseconds instead of streaming the whole calendar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 08:03:55 +02:00
3 changed files with 67 additions and 78 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

@ -32,18 +32,24 @@ export function useActivityDetail(activityId: string) {
isLoading.value = true isLoading.value = true
error.value = null error.value = null
// Subscribe and wait for this specific event // Scope both the subscription and the one-shot query to this
// activity's d-tag. Without this scope, the query asks every
// relay for every kind-31922/31923 event and races a 5s timeout
// to find ours — on a cold page refresh that race is often lost
// even when the activity is reachable.
const detailFilters = { dTags: [activityId] }
unsubscribe = nostrService.subscribeToCalendarEvents( unsubscribe = nostrService.subscribeToCalendarEvents(
(incoming) => { (incoming) => {
store.upsertActivity(incoming) store.upsertActivity(incoming)
if (incoming.id === activityId) { if (incoming.id === activityId) {
isLoading.value = false isLoading.value = false
} }
} },
detailFilters
) )
// Also do a one-shot query const results = await nostrService.queryCalendarEvents(detailFilters)
const results = await nostrService.queryCalendarEvents()
store.upsertActivities(results) store.upsertActivities(results)
// If we still don't have it after query, stop loading // If we still don't have it after query, stop loading

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,
@ -25,10 +23,20 @@ export interface CalendarEventFilters {
hashtags?: string[] hashtags?: string[]
/** Filter by geohash prefix (NIP-52 'g' tag) */ /** Filter by geohash prefix (NIP-52 'g' tag) */
geohash?: string geohash?: string
/** Filter by NIP-52 'd' tag — scopes the query to specific parameterized-replaceable events */
dTags?: string[]
} }
/** /**
* 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 +113,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.
*/ */
@ -168,6 +150,7 @@ export class ActivitiesNostrService extends BaseService {
if (filters?.authors?.length) filter.authors = filters.authors if (filters?.authors?.length) filter.authors = filters.authors
if (filters?.hashtags?.length) filter['#t'] = filters.hashtags if (filters?.hashtags?.length) filter['#t'] = filters.hashtags
if (filters?.geohash) filter['#g'] = [filters.geohash] if (filters?.geohash) filter['#g'] = [filters.geohash]
if (filters?.dTags?.length) filter['#d'] = filters.dTags
return [filter] return [filter]
} }
@ -179,11 +162,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
}