Merge pull request 'chore(nostr-feed): delete legacy ScheduledEventService duplicate' (#81) from chore/dedup-scheduled-event-service into dev
Reviewed-on: #81
This commit is contained in:
commit
114d2837c9
5 changed files with 22 additions and 991 deletions
|
|
@ -139,8 +139,6 @@ export const SERVICE_TOKENS = {
|
||||||
|
|
||||||
// Tasks services
|
// Tasks services
|
||||||
TASK_SERVICE: Symbol('taskService'),
|
TASK_SERVICE: Symbol('taskService'),
|
||||||
/** @deprecated Use TASK_SERVICE instead */
|
|
||||||
SCHEDULED_EVENT_SERVICE: Symbol('scheduledEventService'),
|
|
||||||
|
|
||||||
// Links services
|
// Links services
|
||||||
SUBMISSION_SERVICE: Symbol('submissionService'),
|
SUBMISSION_SERVICE: Symbol('submissionService'),
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import { Megaphone, RefreshCw, AlertCircle, ChevronLeft, ChevronRight } from 'lu
|
||||||
import { useFeed } from '../composables/useFeed'
|
import { useFeed } from '../composables/useFeed'
|
||||||
import { useProfiles } from '@/modules/base/composables/useProfiles'
|
import { useProfiles } from '@/modules/base/composables/useProfiles'
|
||||||
import { useReactions } from '@/modules/base/composables/useReactions'
|
import { useReactions } from '@/modules/base/composables/useReactions'
|
||||||
import { useScheduledEvents } from '../composables/useScheduledEvents'
|
import { useTasks } from '@/modules/tasks/composables/useTasks'
|
||||||
import ThreadedPost from './ThreadedPost.vue'
|
import ThreadedPost from './ThreadedPost.vue'
|
||||||
import ScheduledEventCard from './ScheduledEventCard.vue'
|
import ScheduledEventCard from './ScheduledEventCard.vue'
|
||||||
import appConfig from '@/app.config'
|
import appConfig from '@/app.config'
|
||||||
|
|
@ -98,7 +98,9 @@ const { getDisplayName, fetchProfiles } = useProfiles()
|
||||||
// Use reactions service for likes/hearts
|
// Use reactions service for likes/hearts
|
||||||
const { getEventReactions, subscribeToReactions, toggleLike } = useReactions()
|
const { getEventReactions, subscribeToReactions, toggleLike } = useReactions()
|
||||||
|
|
||||||
// Use scheduled events service
|
// Task service is shared with the standalone tasks app; FeedService
|
||||||
|
// already routes kind 31922/31925/5 events to it, so opt out of the
|
||||||
|
// composable's own subscription lifecycle.
|
||||||
const {
|
const {
|
||||||
getEventsForSpecificDate,
|
getEventsForSpecificDate,
|
||||||
getCompletion,
|
getCompletion,
|
||||||
|
|
@ -109,7 +111,7 @@ const {
|
||||||
unclaimTask,
|
unclaimTask,
|
||||||
deleteTask,
|
deleteTask,
|
||||||
allCompletions
|
allCompletions
|
||||||
} = useScheduledEvents()
|
} = useTasks({ autoSubscribe: false })
|
||||||
|
|
||||||
// Selected date for viewing scheduled tasks (defaults to today)
|
// Selected date for viewing scheduled tasks (defaults to today)
|
||||||
const selectedDate = ref(new Date().toISOString().split('T')[0])
|
const selectedDate = ref(new Date().toISOString().split('T')[0])
|
||||||
|
|
@ -405,7 +407,7 @@ async function confirmDeletePost() {
|
||||||
|
|
||||||
const userPrivkey = authService?.user.value?.prvkey
|
const userPrivkey = authService?.user.value?.prvkey
|
||||||
if (!userPrivkey) {
|
if (!userPrivkey) {
|
||||||
toast.error("User private key not available")
|
toast.error("User private key not available") // pragma: allowlist secret
|
||||||
showDeleteDialog.value = false
|
showDeleteDialog.value = false
|
||||||
postToDelete.value = null
|
postToDelete.value = null
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -1,261 +0,0 @@
|
||||||
import { computed } from 'vue'
|
|
||||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
|
||||||
import type { ScheduledEventService, ScheduledEvent, EventCompletion, TaskStatus } from '../services/ScheduledEventService'
|
|
||||||
import type { AuthService } from '@/modules/base/auth/auth-service'
|
|
||||||
import { useToast } from '@/core/composables/useToast'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Composable for managing scheduled events in the feed
|
|
||||||
*/
|
|
||||||
export function useScheduledEvents() {
|
|
||||||
const scheduledEventService = injectService<ScheduledEventService>(SERVICE_TOKENS.SCHEDULED_EVENT_SERVICE)
|
|
||||||
const authService = injectService<AuthService>(SERVICE_TOKENS.AUTH_SERVICE)
|
|
||||||
const toast = useToast()
|
|
||||||
|
|
||||||
// Get current user's pubkey
|
|
||||||
const currentUserPubkey = computed(() => authService?.user.value?.pubkey)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all scheduled events
|
|
||||||
*/
|
|
||||||
const getScheduledEvents = (): ScheduledEvent[] => {
|
|
||||||
if (!scheduledEventService) return []
|
|
||||||
return scheduledEventService.getScheduledEvents()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get events for a specific date (YYYY-MM-DD)
|
|
||||||
*/
|
|
||||||
const getEventsForDate = (date: string): ScheduledEvent[] => {
|
|
||||||
if (!scheduledEventService) return []
|
|
||||||
return scheduledEventService.getEventsForDate(date)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get events for a specific date (filtered by current user participation)
|
|
||||||
* @param date - ISO date string (YYYY-MM-DD). Defaults to today.
|
|
||||||
*/
|
|
||||||
const getEventsForSpecificDate = (date?: string): ScheduledEvent[] => {
|
|
||||||
if (!scheduledEventService) return []
|
|
||||||
return scheduledEventService.getEventsForSpecificDate(date, currentUserPubkey.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get today's scheduled events (filtered by current user participation)
|
|
||||||
*/
|
|
||||||
const getTodaysEvents = (): ScheduledEvent[] => {
|
|
||||||
if (!scheduledEventService) return []
|
|
||||||
return scheduledEventService.getTodaysEvents(currentUserPubkey.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get completion status for an event
|
|
||||||
*/
|
|
||||||
const getCompletion = (eventAddress: string): EventCompletion | undefined => {
|
|
||||||
if (!scheduledEventService) return undefined
|
|
||||||
return scheduledEventService.getCompletion(eventAddress)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if an event is completed
|
|
||||||
*/
|
|
||||||
const isCompleted = (eventAddress: string): boolean => {
|
|
||||||
if (!scheduledEventService) return false
|
|
||||||
return scheduledEventService.isCompleted(eventAddress)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get task status for an event
|
|
||||||
*/
|
|
||||||
const getTaskStatus = (eventAddress: string, occurrence?: string): TaskStatus | null => {
|
|
||||||
if (!scheduledEventService) return null
|
|
||||||
return scheduledEventService.getTaskStatus(eventAddress, occurrence)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Claim a task
|
|
||||||
*/
|
|
||||||
const claimTask = async (event: ScheduledEvent, notes: string = '', occurrence?: string): Promise<void> => {
|
|
||||||
if (!scheduledEventService) {
|
|
||||||
toast.error('Scheduled event service not available')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await scheduledEventService.claimTask(event, notes, occurrence)
|
|
||||||
toast.success('Task claimed!')
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to claim task'
|
|
||||||
if (message.includes('authenticated')) {
|
|
||||||
toast.error('Please sign in to claim tasks')
|
|
||||||
} else {
|
|
||||||
toast.error(message)
|
|
||||||
}
|
|
||||||
console.error('Failed to claim task:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start a task (mark as in-progress)
|
|
||||||
*/
|
|
||||||
const startTask = async (event: ScheduledEvent, notes: string = '', occurrence?: string): Promise<void> => {
|
|
||||||
if (!scheduledEventService) {
|
|
||||||
toast.error('Scheduled event service not available')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await scheduledEventService.startTask(event, notes, occurrence)
|
|
||||||
toast.success('Task started!')
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to start task'
|
|
||||||
toast.error(message)
|
|
||||||
console.error('Failed to start task:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unclaim a task (remove task status)
|
|
||||||
*/
|
|
||||||
const unclaimTask = async (event: ScheduledEvent, occurrence?: string): Promise<void> => {
|
|
||||||
if (!scheduledEventService) {
|
|
||||||
toast.error('Scheduled event service not available')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await scheduledEventService.unclaimTask(event, occurrence)
|
|
||||||
toast.success('Task unclaimed')
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to unclaim task'
|
|
||||||
toast.error(message)
|
|
||||||
console.error('Failed to unclaim task:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Toggle completion status of an event (optionally for a specific occurrence)
|
|
||||||
* DEPRECATED: Use claimTask, startTask, completeEvent, or unclaimTask instead for more granular control
|
|
||||||
*/
|
|
||||||
const toggleComplete = async (event: ScheduledEvent, occurrence?: string, notes: string = ''): Promise<void> => {
|
|
||||||
console.log('🔧 useScheduledEvents: toggleComplete called for event:', event.title, 'occurrence:', occurrence)
|
|
||||||
|
|
||||||
if (!scheduledEventService) {
|
|
||||||
console.error('❌ useScheduledEvents: Scheduled event service not available')
|
|
||||||
toast.error('Scheduled event service not available')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const eventAddress = `31922:${event.pubkey}:${event.dTag}`
|
|
||||||
const currentlyCompleted = scheduledEventService.isCompleted(eventAddress, occurrence)
|
|
||||||
console.log('📊 useScheduledEvents: Current completion status:', currentlyCompleted)
|
|
||||||
|
|
||||||
if (currentlyCompleted) {
|
|
||||||
console.log('⬇️ useScheduledEvents: Unclaiming task...')
|
|
||||||
await scheduledEventService.unclaimTask(event, occurrence)
|
|
||||||
toast.success('Task unclaimed')
|
|
||||||
} else {
|
|
||||||
console.log('⬆️ useScheduledEvents: Marking as complete...')
|
|
||||||
await scheduledEventService.completeEvent(event, notes, occurrence)
|
|
||||||
toast.success('Task completed!')
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to toggle completion'
|
|
||||||
|
|
||||||
if (message.includes('authenticated')) {
|
|
||||||
toast.error('Please sign in to complete tasks')
|
|
||||||
} else if (message.includes('Not connected')) {
|
|
||||||
toast.error('Not connected to relays')
|
|
||||||
} else {
|
|
||||||
toast.error(message)
|
|
||||||
}
|
|
||||||
|
|
||||||
console.error('❌ useScheduledEvents: Failed to toggle completion:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Complete an event with optional notes
|
|
||||||
*/
|
|
||||||
const completeEvent = async (event: ScheduledEvent, occurrence?: string, notes: string = ''): Promise<void> => {
|
|
||||||
if (!scheduledEventService) {
|
|
||||||
toast.error('Scheduled event service not available')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await scheduledEventService.completeEvent(event, notes, occurrence)
|
|
||||||
toast.success('Task completed!')
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to complete task'
|
|
||||||
toast.error(message)
|
|
||||||
console.error('Failed to complete task:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get loading state
|
|
||||||
*/
|
|
||||||
const isLoading = computed(() => {
|
|
||||||
return scheduledEventService?.isLoading ?? false
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all scheduled events (reactive)
|
|
||||||
*/
|
|
||||||
const allScheduledEvents = computed(() => {
|
|
||||||
return scheduledEventService?.scheduledEvents ?? new Map()
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a task (only author can delete)
|
|
||||||
*/
|
|
||||||
const deleteTask = async (event: ScheduledEvent): Promise<void> => {
|
|
||||||
if (!scheduledEventService) {
|
|
||||||
toast.error('Scheduled event service not available')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await scheduledEventService.deleteTask(event)
|
|
||||||
toast.success('Task deleted!')
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to delete task'
|
|
||||||
toast.error(message)
|
|
||||||
console.error('Failed to delete task:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all completions (reactive) - returns array for better reactivity
|
|
||||||
*/
|
|
||||||
const allCompletions = computed(() => {
|
|
||||||
if (!scheduledEventService?.completions) return []
|
|
||||||
return Array.from(scheduledEventService.completions.values())
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
// Methods - Getters
|
|
||||||
getScheduledEvents,
|
|
||||||
getEventsForDate,
|
|
||||||
getEventsForSpecificDate,
|
|
||||||
getTodaysEvents,
|
|
||||||
getCompletion,
|
|
||||||
isCompleted,
|
|
||||||
getTaskStatus,
|
|
||||||
|
|
||||||
// Methods - Actions
|
|
||||||
claimTask,
|
|
||||||
startTask,
|
|
||||||
completeEvent,
|
|
||||||
unclaimTask,
|
|
||||||
deleteTask,
|
|
||||||
toggleComplete, // DEPRECATED: Use specific actions instead
|
|
||||||
|
|
||||||
// State
|
|
||||||
isLoading,
|
|
||||||
allScheduledEvents,
|
|
||||||
allCompletions
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { BaseService } from '@/core/base/BaseService'
|
import { BaseService } from '@/core/base/BaseService'
|
||||||
import { injectService, tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
|
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
import { eventBus } from '@/core/event-bus'
|
import { eventBus } from '@/core/event-bus'
|
||||||
import type { Event as NostrEvent, Filter } from 'nostr-tools'
|
import type { Event as NostrEvent, Filter } from 'nostr-tools'
|
||||||
|
|
||||||
|
|
@ -47,7 +47,7 @@ export class FeedService extends BaseService {
|
||||||
protected relayHub: any = null
|
protected relayHub: any = null
|
||||||
protected visibilityService: any = null
|
protected visibilityService: any = null
|
||||||
protected reactionService: any = null
|
protected reactionService: any = null
|
||||||
protected scheduledEventService: any = null
|
protected taskService: any = null
|
||||||
|
|
||||||
// Event ID tracking for deduplication
|
// Event ID tracking for deduplication
|
||||||
private seenEventIds = new Set<string>()
|
private seenEventIds = new Set<string>()
|
||||||
|
|
@ -73,13 +73,12 @@ export class FeedService extends BaseService {
|
||||||
this.relayHub = injectService(SERVICE_TOKENS.RELAY_HUB)
|
this.relayHub = injectService(SERVICE_TOKENS.RELAY_HUB)
|
||||||
this.visibilityService = injectService(SERVICE_TOKENS.VISIBILITY_SERVICE)
|
this.visibilityService = injectService(SERVICE_TOKENS.VISIBILITY_SERVICE)
|
||||||
this.reactionService = injectService(SERVICE_TOKENS.REACTION_SERVICE)
|
this.reactionService = injectService(SERVICE_TOKENS.REACTION_SERVICE)
|
||||||
// ScheduledEventService moved to tasks module - use tryInjectService for backward compat
|
this.taskService = injectService(SERVICE_TOKENS.TASK_SERVICE)
|
||||||
this.scheduledEventService = tryInjectService(SERVICE_TOKENS.TASK_SERVICE)
|
|
||||||
|
|
||||||
console.log('FeedService: RelayHub injected:', !!this.relayHub)
|
console.log('FeedService: RelayHub injected:', !!this.relayHub)
|
||||||
console.log('FeedService: VisibilityService injected:', !!this.visibilityService)
|
console.log('FeedService: VisibilityService injected:', !!this.visibilityService)
|
||||||
console.log('FeedService: ReactionService injected:', !!this.reactionService)
|
console.log('FeedService: ReactionService injected:', !!this.reactionService)
|
||||||
console.log('FeedService: TaskService injected:', !!this.scheduledEventService)
|
console.log('FeedService: TaskService injected:', !!this.taskService)
|
||||||
|
|
||||||
if (!this.relayHub) {
|
if (!this.relayHub) {
|
||||||
throw new Error('RelayHub service not available')
|
throw new Error('RelayHub service not available')
|
||||||
|
|
@ -261,28 +260,19 @@ export class FeedService extends BaseService {
|
||||||
|
|
||||||
// Route reaction events (kind 7) to ReactionService
|
// Route reaction events (kind 7) to ReactionService
|
||||||
if (event.kind === 7) {
|
if (event.kind === 7) {
|
||||||
if (this.reactionService) {
|
this.reactionService.handleReactionEvent(event)
|
||||||
this.reactionService.handleReactionEvent(event)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Route scheduled events (kind 31922) to ScheduledEventService
|
// Route scheduled events (kind 31922) to TaskService
|
||||||
if (event.kind === 31922) {
|
if (event.kind === 31922) {
|
||||||
if (this.scheduledEventService) {
|
this.taskService.handleScheduledEvent(event)
|
||||||
this.scheduledEventService.handleScheduledEvent(event)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Route RSVP/completion events (kind 31925) to ScheduledEventService
|
// Route RSVP/completion events (kind 31925) to TaskService
|
||||||
if (event.kind === 31925) {
|
if (event.kind === 31925) {
|
||||||
console.log('🔀 FeedService: Routing kind 31925 (completion) to ScheduledEventService')
|
this.taskService.handleCompletionEvent(event)
|
||||||
if (this.scheduledEventService) {
|
|
||||||
this.scheduledEventService.handleCompletionEvent(event)
|
|
||||||
} else {
|
|
||||||
console.warn('⚠️ FeedService: ScheduledEventService not available')
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -378,31 +368,19 @@ export class FeedService extends BaseService {
|
||||||
|
|
||||||
// Route to ReactionService for reaction deletions (kind 7)
|
// Route to ReactionService for reaction deletions (kind 7)
|
||||||
if (deletedKind === '7') {
|
if (deletedKind === '7') {
|
||||||
if (this.reactionService) {
|
this.reactionService.handleDeletionEvent(event)
|
||||||
this.reactionService.handleDeletionEvent(event)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Route to ScheduledEventService for completion/RSVP deletions (kind 31925)
|
// Route to TaskService for completion/RSVP deletions (kind 31925)
|
||||||
if (deletedKind === '31925') {
|
if (deletedKind === '31925') {
|
||||||
console.log('🔀 FeedService: Routing kind 5 (deletion of kind 31925) to ScheduledEventService')
|
this.taskService.handleDeletionEvent(event)
|
||||||
if (this.scheduledEventService) {
|
|
||||||
this.scheduledEventService.handleDeletionEvent(event)
|
|
||||||
} else {
|
|
||||||
console.warn('⚠️ FeedService: ScheduledEventService not available')
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Route to ScheduledEventService for scheduled event deletions (kind 31922)
|
// Route to TaskService for scheduled event deletions (kind 31922)
|
||||||
if (deletedKind === '31922') {
|
if (deletedKind === '31922') {
|
||||||
console.log('🔀 FeedService: Routing kind 5 (deletion of kind 31922) to ScheduledEventService')
|
this.taskService.handleTaskDeletion(event)
|
||||||
if (this.scheduledEventService) {
|
|
||||||
this.scheduledEventService.handleTaskDeletion(event)
|
|
||||||
} else {
|
|
||||||
console.warn('⚠️ FeedService: ScheduledEventService not available')
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -623,16 +601,8 @@ export class FeedService extends BaseService {
|
||||||
* Get like count for a post from ReactionService
|
* Get like count for a post from ReactionService
|
||||||
*/
|
*/
|
||||||
private getLikeCount(postId: string): number {
|
private getLikeCount(postId: string): number {
|
||||||
try {
|
const reactions = this.reactionService.getEventReactions(postId)
|
||||||
if (this.reactionService && typeof this.reactionService.getEventReactions === 'function') {
|
return reactions?.likes || 0
|
||||||
const reactions = this.reactionService.getEventReactions(postId)
|
|
||||||
return reactions?.likes || 0
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Silently fail if reaction service is not available
|
|
||||||
console.debug('FeedService: Could not get like count for post', postId, error)
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,678 +0,0 @@
|
||||||
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 RecurrencePattern {
|
|
||||||
frequency: 'daily' | 'weekly'
|
|
||||||
dayOfWeek?: string // For weekly: 'monday', 'tuesday', etc.
|
|
||||||
endDate?: string // ISO date string - when to stop recurring (optional)
|
|
||||||
}
|
|
||||||
|
|
||||||
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[][]
|
|
||||||
recurrence?: RecurrencePattern // Optional: for recurring events
|
|
||||||
}
|
|
||||||
|
|
||||||
export type TaskStatus = 'claimed' | 'in-progress' | 'completed' | 'blocked' | 'cancelled'
|
|
||||||
|
|
||||||
export interface EventCompletion {
|
|
||||||
id: string
|
|
||||||
eventAddress: string // "31922:pubkey:d-tag"
|
|
||||||
occurrence?: string // ISO date string for the specific occurrence (YYYY-MM-DD)
|
|
||||||
pubkey: string // Who claimed/completed it
|
|
||||||
created_at: number
|
|
||||||
taskStatus: TaskStatus
|
|
||||||
completedAt?: number // Unix timestamp when completed
|
|
||||||
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'
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Parse recurrence tags
|
|
||||||
const recurrenceFreq = event.tags.find(tag => tag[0] === 'recurrence')?.[1] as 'daily' | 'weekly' | undefined
|
|
||||||
const recurrenceDayOfWeek = event.tags.find(tag => tag[0] === 'recurrence-day')?.[1]
|
|
||||||
const recurrenceEndDate = event.tags.find(tag => tag[0] === 'recurrence-end')?.[1]
|
|
||||||
|
|
||||||
let recurrence: RecurrencePattern | undefined
|
|
||||||
if (recurrenceFreq === 'daily' || recurrenceFreq === 'weekly') {
|
|
||||||
recurrence = {
|
|
||||||
frequency: recurrenceFreq,
|
|
||||||
dayOfWeek: recurrenceDayOfWeek,
|
|
||||||
endDate: recurrenceEndDate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
recurrence
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse task status (new approach)
|
|
||||||
const taskStatusTag = event.tags.find(tag => tag[0] === 'task-status')?.[1] as TaskStatus | undefined
|
|
||||||
|
|
||||||
// Backward compatibility: check old 'completed' tag if task-status not present
|
|
||||||
let taskStatus: TaskStatus
|
|
||||||
if (taskStatusTag) {
|
|
||||||
taskStatus = taskStatusTag
|
|
||||||
} else {
|
|
||||||
// Legacy support: convert old 'completed' tag to new taskStatus
|
|
||||||
const completed = event.tags.find(tag => tag[0] === 'completed')?.[1] === 'true'
|
|
||||||
taskStatus = completed ? 'completed' : 'claimed'
|
|
||||||
}
|
|
||||||
|
|
||||||
const completedAtTag = event.tags.find(tag => tag[0] === 'completed_at')?.[1]
|
|
||||||
const completedAt = completedAtTag ? parseInt(completedAtTag) : undefined
|
|
||||||
const occurrence = event.tags.find(tag => tag[0] === 'occurrence')?.[1] // ISO date string
|
|
||||||
|
|
||||||
console.log('📋 Completion details:', {
|
|
||||||
aTag,
|
|
||||||
occurrence,
|
|
||||||
taskStatus,
|
|
||||||
pubkey: event.pubkey,
|
|
||||||
eventId: event.id
|
|
||||||
})
|
|
||||||
|
|
||||||
const completion: EventCompletion = {
|
|
||||||
id: event.id,
|
|
||||||
eventAddress: aTag,
|
|
||||||
occurrence,
|
|
||||||
pubkey: event.pubkey,
|
|
||||||
created_at: event.created_at,
|
|
||||||
taskStatus,
|
|
||||||
completedAt,
|
|
||||||
notes: event.content
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store completion (most recent one wins)
|
|
||||||
// For recurring events, include occurrence in the key: "eventAddress:occurrence"
|
|
||||||
// For non-recurring, just use eventAddress
|
|
||||||
const completionKey = occurrence ? `${aTag}:${occurrence}` : aTag
|
|
||||||
const existing = this._completions.get(completionKey)
|
|
||||||
if (!existing || event.created_at > existing.created_at) {
|
|
||||||
this._completions.set(completionKey, completion)
|
|
||||||
console.log('✅ Stored completion for:', completionKey, '- status:', taskStatus)
|
|
||||||
} else {
|
|
||||||
console.log('⏭️ Skipped older completion for:', completionKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to handle completion event:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle deletion event (kind 5) for completion events
|
|
||||||
* Made public so FeedService can route deletion events to this service
|
|
||||||
*/
|
|
||||||
public handleDeletionEvent(event: NostrEvent): void {
|
|
||||||
console.log('🗑️ ScheduledEventService: Received deletion event (kind 5)', event.id)
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Extract event IDs to delete from 'e' tags
|
|
||||||
const eventIdsToDelete = event.tags
|
|
||||||
?.filter((tag: string[]) => tag[0] === 'e')
|
|
||||||
.map((tag: string[]) => tag[1]) || []
|
|
||||||
|
|
||||||
if (eventIdsToDelete.length === 0) {
|
|
||||||
console.warn('Deletion event missing e tags:', event.id)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('🔍 Looking for completions to delete:', eventIdsToDelete)
|
|
||||||
|
|
||||||
// Find and remove completions that match the deleted event IDs
|
|
||||||
let deletedCount = 0
|
|
||||||
for (const [completionKey, completion] of this._completions.entries()) {
|
|
||||||
// Only delete if:
|
|
||||||
// 1. The completion event ID matches one being deleted
|
|
||||||
// 2. The deletion request comes from the same author (NIP-09 validation)
|
|
||||||
if (eventIdsToDelete.includes(completion.id) && completion.pubkey === event.pubkey) {
|
|
||||||
this._completions.delete(completionKey)
|
|
||||||
console.log('✅ Deleted completion:', completionKey, 'event ID:', completion.id)
|
|
||||||
deletedCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`🗑️ Deleted ${deletedCount} completion(s) from deletion event`)
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to handle deletion event:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle deletion event (kind 5) for scheduled events (kind 31922)
|
|
||||||
* Made public so FeedService can route deletion events to this service
|
|
||||||
*/
|
|
||||||
public handleTaskDeletion(event: NostrEvent): void {
|
|
||||||
console.log('🗑️ ScheduledEventService: Received task deletion event (kind 5)', event.id)
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Extract event addresses to delete from 'a' tags
|
|
||||||
const eventAddressesToDelete = event.tags
|
|
||||||
?.filter((tag: string[]) => tag[0] === 'a')
|
|
||||||
.map((tag: string[]) => tag[1]) || []
|
|
||||||
|
|
||||||
if (eventAddressesToDelete.length === 0) {
|
|
||||||
console.warn('Task deletion event missing a tags:', event.id)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('🔍 Looking for tasks to delete:', eventAddressesToDelete)
|
|
||||||
|
|
||||||
// Find and remove tasks that match the deleted event addresses
|
|
||||||
let deletedCount = 0
|
|
||||||
for (const eventAddress of eventAddressesToDelete) {
|
|
||||||
const task = this._scheduledEvents.get(eventAddress)
|
|
||||||
|
|
||||||
// Only delete if:
|
|
||||||
// 1. The task exists
|
|
||||||
// 2. The deletion request comes from the task author (NIP-09 validation)
|
|
||||||
if (task && task.pubkey === event.pubkey) {
|
|
||||||
this._scheduledEvents.delete(eventAddress)
|
|
||||||
console.log('✅ Deleted task:', eventAddress)
|
|
||||||
deletedCount++
|
|
||||||
} else if (task) {
|
|
||||||
console.warn('⚠️ Deletion request not from task author:', eventAddress)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`🗑️ Deleted ${deletedCount} task(s) from deletion event`)
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to handle task deletion 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
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a recurring event occurs on a specific date
|
|
||||||
*/
|
|
||||||
private doesRecurringEventOccurOnDate(event: ScheduledEvent, targetDate: string): boolean {
|
|
||||||
if (!event.recurrence) return false
|
|
||||||
|
|
||||||
const target = new Date(targetDate)
|
|
||||||
const eventStart = new Date(event.start.split('T')[0]) // Get date part only
|
|
||||||
|
|
||||||
// Check if target date is before the event start date
|
|
||||||
if (target < eventStart) return false
|
|
||||||
|
|
||||||
// Check if target date is after the event end date (if specified)
|
|
||||||
if (event.recurrence.endDate) {
|
|
||||||
const endDate = new Date(event.recurrence.endDate)
|
|
||||||
if (target > endDate) return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check frequency-specific rules
|
|
||||||
if (event.recurrence.frequency === 'daily') {
|
|
||||||
// Daily events occur every day within the range
|
|
||||||
return true
|
|
||||||
} else if (event.recurrence.frequency === 'weekly') {
|
|
||||||
// Weekly events occur on specific day of week
|
|
||||||
const targetDayOfWeek = target.toLocaleDateString('en-US', { weekday: 'long' }).toLowerCase()
|
|
||||||
const eventDayOfWeek = event.recurrence.dayOfWeek?.toLowerCase()
|
|
||||||
return targetDayOfWeek === eventDayOfWeek
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get events for a specific date, optionally filtered by user participation
|
|
||||||
* @param date - ISO date string (YYYY-MM-DD). Defaults to today.
|
|
||||||
* @param userPubkey - Optional user pubkey to filter by participation
|
|
||||||
*/
|
|
||||||
getEventsForSpecificDate(date?: string, userPubkey?: string): ScheduledEvent[] {
|
|
||||||
const targetDate = date || new Date().toISOString().split('T')[0]
|
|
||||||
|
|
||||||
// Get one-time events for the date (exclude recurring events to avoid duplicates)
|
|
||||||
const oneTimeEvents = this.getEventsForDate(targetDate).filter(event => !event.recurrence)
|
|
||||||
|
|
||||||
// Get all events and check for recurring events that occur on this date
|
|
||||||
const allEvents = this.getScheduledEvents()
|
|
||||||
const recurringEventsOnDate = allEvents.filter(event =>
|
|
||||||
event.recurrence && this.doesRecurringEventOccurOnDate(event, targetDate)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Combine one-time and recurring events
|
|
||||||
let events = [...oneTimeEvents, ...recurringEventsOnDate]
|
|
||||||
|
|
||||||
// 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 events for today, optionally filtered by user participation
|
|
||||||
*/
|
|
||||||
getTodaysEvents(userPubkey?: string): ScheduledEvent[] {
|
|
||||||
return this.getEventsForSpecificDate(undefined, userPubkey)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get completion status for an event (optionally for a specific occurrence)
|
|
||||||
*/
|
|
||||||
getCompletion(eventAddress: string, occurrence?: string): EventCompletion | undefined {
|
|
||||||
const completionKey = occurrence ? `${eventAddress}:${occurrence}` : eventAddress
|
|
||||||
return this._completions.get(completionKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if an event is completed (optionally for a specific occurrence)
|
|
||||||
*/
|
|
||||||
isCompleted(eventAddress: string, occurrence?: string): boolean {
|
|
||||||
const completion = this.getCompletion(eventAddress, occurrence)
|
|
||||||
return completion?.taskStatus === 'completed'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get task status for an event
|
|
||||||
*/
|
|
||||||
getTaskStatus(eventAddress: string, occurrence?: string): TaskStatus | null {
|
|
||||||
const completion = this.getCompletion(eventAddress, occurrence)
|
|
||||||
return completion?.taskStatus || null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Claim a task (mark as claimed)
|
|
||||||
*/
|
|
||||||
async claimTask(event: ScheduledEvent, notes: string = '', occurrence?: string): Promise<void> {
|
|
||||||
await this.updateTaskStatus(event, 'claimed', notes, occurrence)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start a task (mark as in-progress)
|
|
||||||
*/
|
|
||||||
async startTask(event: ScheduledEvent, notes: string = '', occurrence?: string): Promise<void> {
|
|
||||||
await this.updateTaskStatus(event, 'in-progress', notes, occurrence)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mark an event as complete (optionally for a specific occurrence)
|
|
||||||
*/
|
|
||||||
async completeEvent(event: ScheduledEvent, notes: string = '', occurrence?: string): Promise<void> {
|
|
||||||
await this.updateTaskStatus(event, 'completed', notes, occurrence)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Internal method to update task status
|
|
||||||
*/
|
|
||||||
private async updateTaskStatus(
|
|
||||||
event: ScheduledEvent,
|
|
||||||
taskStatus: TaskStatus,
|
|
||||||
notes: string = '',
|
|
||||||
occurrence?: string
|
|
||||||
): Promise<void> {
|
|
||||||
if (!this.authService?.isAuthenticated?.value) {
|
|
||||||
throw new Error('Must be authenticated to update task status')
|
|
||||||
}
|
|
||||||
|
|
||||||
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 task-status tag
|
|
||||||
const tags: string[][] = [
|
|
||||||
['a', eventAddress],
|
|
||||||
['task-status', taskStatus]
|
|
||||||
]
|
|
||||||
|
|
||||||
// Add completed_at timestamp if task is completed
|
|
||||||
if (taskStatus === 'completed') {
|
|
||||||
tags.push(['completed_at', Math.floor(Date.now() / 1000).toString()])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add occurrence tag if provided (for recurring events)
|
|
||||||
if (occurrence) {
|
|
||||||
tags.push(['occurrence', occurrence])
|
|
||||||
}
|
|
||||||
|
|
||||||
const eventTemplate: EventTemplate = {
|
|
||||||
kind: 31925, // Calendar Event RSVP
|
|
||||||
content: notes,
|
|
||||||
tags,
|
|
||||||
created_at: Math.floor(Date.now() / 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sign the event
|
|
||||||
const privkeyBytes = this.hexToUint8Array(userPrivkey)
|
|
||||||
const signedEvent = finalizeEvent(eventTemplate, privkeyBytes)
|
|
||||||
|
|
||||||
// Publish the status update
|
|
||||||
console.log(`📤 Publishing task status update (${taskStatus}) for:`, eventAddress)
|
|
||||||
const result = await this.relayHub.publishEvent(signedEvent)
|
|
||||||
console.log('✅ Task status published to', result.success, '/', result.total, 'relays')
|
|
||||||
|
|
||||||
// Update local state (publishEvent throws if no relays accepted)
|
|
||||||
console.log('🔄 Updating local state (event published successfully)')
|
|
||||||
this.handleCompletionEvent(signedEvent)
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to update task status:', error)
|
|
||||||
throw error
|
|
||||||
} finally {
|
|
||||||
this._isLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unclaim/reset a task (removes task status - makes it unclaimed)
|
|
||||||
* Note: In Nostr, we can't truly "delete" an event, but we can publish
|
|
||||||
* a deletion request (kind 5) to ask relays to remove our RSVP
|
|
||||||
*/
|
|
||||||
async unclaimTask(event: ScheduledEvent, occurrence?: string): Promise<void> {
|
|
||||||
if (!this.authService?.isAuthenticated?.value) {
|
|
||||||
throw new Error('Must be authenticated to unclaim tasks')
|
|
||||||
}
|
|
||||||
|
|
||||||
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}`
|
|
||||||
const completionKey = occurrence ? `${eventAddress}:${occurrence}` : eventAddress
|
|
||||||
const completion = this._completions.get(completionKey)
|
|
||||||
|
|
||||||
if (!completion) {
|
|
||||||
console.log('No completion to unclaim')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create deletion event (kind 5) for the RSVP
|
|
||||||
const deletionEvent: EventTemplate = {
|
|
||||||
kind: 5,
|
|
||||||
content: 'Task unclaimed',
|
|
||||||
tags: [
|
|
||||||
['e', completion.id], // Reference to the RSVP event being deleted
|
|
||||||
['k', '31925'] // Kind of event being deleted
|
|
||||||
],
|
|
||||||
created_at: Math.floor(Date.now() / 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sign the event
|
|
||||||
const privkeyBytes = this.hexToUint8Array(userPrivkey)
|
|
||||||
const signedEvent = finalizeEvent(deletionEvent, privkeyBytes)
|
|
||||||
|
|
||||||
// Publish the deletion request
|
|
||||||
console.log('📤 Publishing deletion request for task RSVP:', completion.id)
|
|
||||||
const result = await this.relayHub.publishEvent(signedEvent)
|
|
||||||
console.log('✅ Deletion request published to', result.success, '/', result.total, 'relays')
|
|
||||||
|
|
||||||
// Remove from local state (publishEvent throws if no relays accepted)
|
|
||||||
this._completions.delete(completionKey)
|
|
||||||
console.log('🗑️ Removed completion from local state:', completionKey)
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to unclaim task:', error)
|
|
||||||
throw error
|
|
||||||
} finally {
|
|
||||||
this._isLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a scheduled event (kind 31922)
|
|
||||||
* Only the author can delete their own event
|
|
||||||
*/
|
|
||||||
async deleteTask(event: ScheduledEvent): Promise<void> {
|
|
||||||
if (!this.authService?.isAuthenticated?.value) {
|
|
||||||
throw new Error('Must be authenticated to delete tasks')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.relayHub?.isConnected) {
|
|
||||||
throw new Error('Not connected to relays')
|
|
||||||
}
|
|
||||||
|
|
||||||
const userPrivkey = this.authService.user.value?.prvkey
|
|
||||||
const userPubkey = this.authService.user.value?.pubkey
|
|
||||||
|
|
||||||
if (!userPrivkey || !userPubkey) {
|
|
||||||
throw new Error('User credentials not available')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only author can delete
|
|
||||||
if (userPubkey !== event.pubkey) {
|
|
||||||
throw new Error('Only the task author can delete this task')
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
this._isLoading.value = true
|
|
||||||
|
|
||||||
const eventAddress = `31922:${event.pubkey}:${event.dTag}`
|
|
||||||
|
|
||||||
// Create deletion event (kind 5) for the scheduled event
|
|
||||||
const deletionEvent: EventTemplate = {
|
|
||||||
kind: 5,
|
|
||||||
content: 'Task deleted',
|
|
||||||
tags: [
|
|
||||||
['a', eventAddress], // Reference to the parameterized replaceable event being deleted
|
|
||||||
['k', '31922'] // Kind of event being deleted
|
|
||||||
],
|
|
||||||
created_at: Math.floor(Date.now() / 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sign the event
|
|
||||||
const privkeyBytes = this.hexToUint8Array(userPrivkey)
|
|
||||||
const signedEvent = finalizeEvent(deletionEvent, privkeyBytes)
|
|
||||||
|
|
||||||
// Publish the deletion request
|
|
||||||
console.log('📤 Publishing deletion request for task:', eventAddress)
|
|
||||||
const result = await this.relayHub.publishEvent(signedEvent)
|
|
||||||
console.log('✅ Task deletion request published to', result.success, '/', result.total, 'relays')
|
|
||||||
|
|
||||||
// Remove from local state (publishEvent throws if no relays accepted)
|
|
||||||
this._scheduledEvents.delete(eventAddress)
|
|
||||||
console.log('🗑️ Removed task from local state:', eventAddress)
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to delete task:', 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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue