Improves scheduled event retrieval by filtering events based on user participation and sorting them by start time. This ensures that users only see events they are participating in or events that are open to the entire community.
381 lines
11 KiB
TypeScript
381 lines
11 KiB
TypeScript
import { ref, reactive } from 'vue'
|
|
import { BaseService } from '@/core/base/BaseService'
|
|
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
|
import { finalizeEvent, type EventTemplate } from 'nostr-tools'
|
|
import type { Event as NostrEvent } from 'nostr-tools'
|
|
|
|
export interface ScheduledEvent {
|
|
id: string
|
|
pubkey: string
|
|
created_at: number
|
|
dTag: string // Unique identifier from 'd' tag
|
|
title: string
|
|
start: string // ISO date string (YYYY-MM-DD or ISO datetime)
|
|
end?: string
|
|
description?: string
|
|
location?: string
|
|
status: string
|
|
eventType?: string // 'task' for completable events, 'announcement' for informational
|
|
participants?: Array<{ pubkey: string; type?: string }> // 'required', 'optional', 'organizer'
|
|
content: string
|
|
tags: string[][]
|
|
}
|
|
|
|
export interface EventCompletion {
|
|
id: string
|
|
eventAddress: string // "31922:pubkey:d-tag"
|
|
pubkey: string // Who completed it
|
|
created_at: number
|
|
completed: boolean
|
|
completedAt?: number
|
|
notes: string
|
|
}
|
|
|
|
export class ScheduledEventService extends BaseService {
|
|
protected readonly metadata = {
|
|
name: 'ScheduledEventService',
|
|
version: '1.0.0',
|
|
dependencies: []
|
|
}
|
|
|
|
protected relayHub: any = null
|
|
protected authService: any = null
|
|
|
|
// Scheduled events state - indexed by event address
|
|
private _scheduledEvents = reactive(new Map<string, ScheduledEvent>())
|
|
private _completions = reactive(new Map<string, EventCompletion>())
|
|
private _isLoading = ref(false)
|
|
|
|
protected async onInitialize(): Promise<void> {
|
|
console.log('ScheduledEventService: Starting initialization...')
|
|
|
|
this.relayHub = injectService(SERVICE_TOKENS.RELAY_HUB)
|
|
this.authService = injectService(SERVICE_TOKENS.AUTH_SERVICE)
|
|
|
|
if (!this.relayHub) {
|
|
throw new Error('RelayHub service not available')
|
|
}
|
|
|
|
console.log('ScheduledEventService: Initialization complete')
|
|
}
|
|
|
|
/**
|
|
* Handle incoming scheduled event (kind 31922)
|
|
* Made public so FeedService can route kind 31922 events to this service
|
|
*/
|
|
public handleScheduledEvent(event: NostrEvent): void {
|
|
try {
|
|
// Extract event data from tags
|
|
const dTag = event.tags.find(tag => tag[0] === 'd')?.[1]
|
|
if (!dTag) {
|
|
console.warn('Scheduled event missing d tag:', event.id)
|
|
return
|
|
}
|
|
|
|
const title = event.tags.find(tag => tag[0] === 'title')?.[1] || 'Untitled Event'
|
|
const start = event.tags.find(tag => tag[0] === 'start')?.[1]
|
|
const end = event.tags.find(tag => tag[0] === 'end')?.[1]
|
|
const description = event.tags.find(tag => tag[0] === 'description')?.[1]
|
|
const location = event.tags.find(tag => tag[0] === 'location')?.[1]
|
|
const status = event.tags.find(tag => tag[0] === 'status')?.[1] || 'pending'
|
|
const eventType = event.tags.find(tag => tag[0] === 'event-type')?.[1]
|
|
|
|
// Parse participant tags: ["p", "<pubkey>", "<relay-hint>", "<participation-type>"]
|
|
const participantTags = event.tags.filter(tag => tag[0] === 'p')
|
|
const participants = participantTags.map(tag => ({
|
|
pubkey: tag[1],
|
|
type: tag[3] // 'required', 'optional', 'organizer'
|
|
}))
|
|
|
|
if (!start) {
|
|
console.warn('Scheduled event missing start date:', event.id)
|
|
return
|
|
}
|
|
|
|
// Create event address: "kind:pubkey:d-tag"
|
|
const eventAddress = `31922:${event.pubkey}:${dTag}`
|
|
|
|
const scheduledEvent: ScheduledEvent = {
|
|
id: event.id,
|
|
pubkey: event.pubkey,
|
|
created_at: event.created_at,
|
|
dTag,
|
|
title,
|
|
start,
|
|
end,
|
|
description,
|
|
location,
|
|
status,
|
|
eventType,
|
|
participants: participants.length > 0 ? participants : undefined,
|
|
content: event.content,
|
|
tags: event.tags
|
|
}
|
|
|
|
// Store or update the event (replaceable by d-tag)
|
|
this._scheduledEvents.set(eventAddress, scheduledEvent)
|
|
|
|
} catch (error) {
|
|
console.error('Failed to handle scheduled event:', error)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle RSVP/completion event (kind 31925)
|
|
* Made public so FeedService can route kind 31925 events to this service
|
|
*/
|
|
public handleCompletionEvent(event: NostrEvent): void {
|
|
console.log('🔔 ScheduledEventService: Received completion event (kind 31925)', event.id)
|
|
|
|
try {
|
|
// Find the event being responded to
|
|
const aTag = event.tags.find(tag => tag[0] === 'a')?.[1]
|
|
if (!aTag) {
|
|
console.warn('Completion event missing a tag:', event.id)
|
|
return
|
|
}
|
|
|
|
const completed = event.tags.find(tag => tag[0] === 'completed')?.[1] === 'true'
|
|
const completedAtTag = event.tags.find(tag => tag[0] === 'completed_at')?.[1]
|
|
const completedAt = completedAtTag ? parseInt(completedAtTag) : undefined
|
|
|
|
console.log('📋 Completion details:', {
|
|
aTag,
|
|
completed,
|
|
pubkey: event.pubkey,
|
|
eventId: event.id
|
|
})
|
|
|
|
const completion: EventCompletion = {
|
|
id: event.id,
|
|
eventAddress: aTag,
|
|
pubkey: event.pubkey,
|
|
created_at: event.created_at,
|
|
completed,
|
|
completedAt,
|
|
notes: event.content
|
|
}
|
|
|
|
// Store completion (most recent one wins)
|
|
const existing = this._completions.get(aTag)
|
|
if (!existing || event.created_at > existing.created_at) {
|
|
this._completions.set(aTag, completion)
|
|
console.log('✅ Stored completion for:', aTag, '- completed:', completed)
|
|
} else {
|
|
console.log('⏭️ Skipped older completion for:', aTag)
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('Failed to handle completion event:', error)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get all scheduled events
|
|
*/
|
|
getScheduledEvents(): ScheduledEvent[] {
|
|
return Array.from(this._scheduledEvents.values())
|
|
}
|
|
|
|
/**
|
|
* Get events scheduled for a specific date (YYYY-MM-DD)
|
|
*/
|
|
getEventsForDate(date: string): ScheduledEvent[] {
|
|
return this.getScheduledEvents().filter(event => {
|
|
// Simple date matching (start date)
|
|
// For ISO datetime strings, extract just the date part
|
|
const eventDate = event.start.split('T')[0]
|
|
return eventDate === date
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Get events for today, optionally filtered by user participation
|
|
*/
|
|
getTodaysEvents(userPubkey?: string): ScheduledEvent[] {
|
|
const today = new Date().toISOString().split('T')[0]
|
|
let events = this.getEventsForDate(today)
|
|
|
|
// Filter events based on participation (if user pubkey provided)
|
|
if (userPubkey) {
|
|
events = events.filter(event => {
|
|
// If event has no participants, it's community-wide (show to everyone)
|
|
if (!event.participants || event.participants.length === 0) return true
|
|
|
|
// Otherwise, only show if user is a participant
|
|
return event.participants.some(p => p.pubkey === userPubkey)
|
|
})
|
|
}
|
|
|
|
// Sort by start time (ascending order)
|
|
events.sort((a, b) => {
|
|
// ISO datetime strings can be compared lexicographically
|
|
return a.start.localeCompare(b.start)
|
|
})
|
|
|
|
return events
|
|
}
|
|
|
|
/**
|
|
* Get completion status for an event
|
|
*/
|
|
getCompletion(eventAddress: string): EventCompletion | undefined {
|
|
return this._completions.get(eventAddress)
|
|
}
|
|
|
|
/**
|
|
* Check if an event is completed
|
|
*/
|
|
isCompleted(eventAddress: string): boolean {
|
|
const completion = this.getCompletion(eventAddress)
|
|
return completion?.completed || false
|
|
}
|
|
|
|
/**
|
|
* Mark an event as complete
|
|
*/
|
|
async completeEvent(event: ScheduledEvent, notes: string = ''): Promise<void> {
|
|
if (!this.authService?.isAuthenticated?.value) {
|
|
throw new Error('Must be authenticated to complete events')
|
|
}
|
|
|
|
if (!this.relayHub?.isConnected) {
|
|
throw new Error('Not connected to relays')
|
|
}
|
|
|
|
const userPrivkey = this.authService.user.value?.prvkey
|
|
if (!userPrivkey) {
|
|
throw new Error('User private key not available')
|
|
}
|
|
|
|
try {
|
|
this._isLoading.value = true
|
|
|
|
const eventAddress = `31922:${event.pubkey}:${event.dTag}`
|
|
|
|
// Create RSVP/completion event (NIP-52)
|
|
const eventTemplate: EventTemplate = {
|
|
kind: 31925, // Calendar Event RSVP
|
|
content: notes,
|
|
tags: [
|
|
['a', eventAddress],
|
|
['status', 'accepted'],
|
|
['completed', 'true'],
|
|
['completed_at', Math.floor(Date.now() / 1000).toString()]
|
|
],
|
|
created_at: Math.floor(Date.now() / 1000)
|
|
}
|
|
|
|
// Sign the event
|
|
const privkeyBytes = this.hexToUint8Array(userPrivkey)
|
|
const signedEvent = finalizeEvent(eventTemplate, privkeyBytes)
|
|
|
|
// Publish the completion
|
|
console.log('📤 Publishing completion event (kind 31925) for:', eventAddress)
|
|
const result = await this.relayHub.publishEvent(signedEvent)
|
|
console.log('✅ Completion event published to', result.success, '/', result.total, 'relays')
|
|
|
|
// Optimistically update local state
|
|
console.log('🔄 Optimistically updating local state')
|
|
this.handleCompletionEvent(signedEvent)
|
|
|
|
} catch (error) {
|
|
console.error('Failed to complete event:', error)
|
|
throw error
|
|
} finally {
|
|
this._isLoading.value = false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Uncomplete an event (publish new RSVP with completed=false)
|
|
*/
|
|
async uncompleteEvent(event: ScheduledEvent): Promise<void> {
|
|
if (!this.authService?.isAuthenticated?.value) {
|
|
throw new Error('Must be authenticated to uncomplete events')
|
|
}
|
|
|
|
if (!this.relayHub?.isConnected) {
|
|
throw new Error('Not connected to relays')
|
|
}
|
|
|
|
const userPrivkey = this.authService.user.value?.prvkey
|
|
if (!userPrivkey) {
|
|
throw new Error('User private key not available')
|
|
}
|
|
|
|
try {
|
|
this._isLoading.value = true
|
|
|
|
const eventAddress = `31922:${event.pubkey}:${event.dTag}`
|
|
|
|
// Create RSVP event with completed=false
|
|
const eventTemplate: EventTemplate = {
|
|
kind: 31925,
|
|
content: '',
|
|
tags: [
|
|
['a', eventAddress],
|
|
['status', 'tentative'],
|
|
['completed', 'false']
|
|
],
|
|
created_at: Math.floor(Date.now() / 1000)
|
|
}
|
|
|
|
// Sign the event
|
|
const privkeyBytes = this.hexToUint8Array(userPrivkey)
|
|
const signedEvent = finalizeEvent(eventTemplate, privkeyBytes)
|
|
|
|
// Publish the uncomplete
|
|
await this.relayHub.publishEvent(signedEvent)
|
|
|
|
// Optimistically update local state
|
|
this.handleCompletionEvent(signedEvent)
|
|
|
|
} catch (error) {
|
|
console.error('Failed to uncomplete event:', error)
|
|
throw error
|
|
} finally {
|
|
this._isLoading.value = false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Helper function to convert hex string to Uint8Array
|
|
*/
|
|
private 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
|
|
}
|
|
|
|
/**
|
|
* Get all scheduled events
|
|
*/
|
|
get scheduledEvents(): Map<string, ScheduledEvent> {
|
|
return this._scheduledEvents
|
|
}
|
|
|
|
/**
|
|
* Get all completions
|
|
*/
|
|
get completions(): Map<string, EventCompletion> {
|
|
return this._completions
|
|
}
|
|
|
|
/**
|
|
* Check if currently loading
|
|
*/
|
|
get isLoading(): boolean {
|
|
return this._isLoading.value
|
|
}
|
|
|
|
/**
|
|
* Cleanup
|
|
*/
|
|
protected async onDestroy(): Promise<void> {
|
|
this._scheduledEvents.clear()
|
|
this._completions.clear()
|
|
}
|
|
}
|