Add delete task functionality for task authors

Added ability for task authors to delete their own tasks from the expanded
view in the task feed.

**Features:**
- Delete button visible only to task author in expanded task view
- Confirmation dialog with destructive styling
- Publishes NIP-09 deletion event (kind 5) with 'a' tag referencing the
  task's event address (kind:pubkey:d-tag format)
- Real-time deletion handling via FeedService routing
- Optimistic local state update for immediate UI feedback

**Implementation:**
- Added deleteTask() method to ScheduledEventService
- Added handleTaskDeletion() for processing incoming deletion events
- Updated FeedService to route kind 31922 deletions to ScheduledEventService
- Added delete button and dialog flow to ScheduledEventCard component
- Integrated with existing confirmation dialog pattern

**Permissions:**
- Only task authors can delete tasks (enforced by isAuthor check)
- NIP-09 validation: relays only accept deletion from event author
- Pubkey verification in handleTaskDeletion()

**Testing:**
- Created tasks and verified delete button appears for author only
- Confirmed deletion removes task from UI immediately
- Verified deletion persists after refresh
- Tested with multiple users - others cannot delete

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
padreug 2025-11-16 22:39:38 +01:00
parent 8f05f4ec7c
commit 3b8c82514a
5 changed files with 185 additions and 2 deletions

View file

@ -107,6 +107,7 @@ const {
startTask,
completeEvent,
unclaimTask,
deleteTask,
allCompletions
} = useScheduledEvents()
@ -301,6 +302,15 @@ async function onUnclaimTask(event: ScheduledEvent, occurrence?: string) {
}
}
async function onDeleteTask(event: ScheduledEvent) {
console.log('🗑️ NostrFeed: Deleting task:', event.title)
try {
await deleteTask(event)
} catch (error) {
console.error('❌ Failed to delete task:', error)
}
}
// Handle collapse toggle with cascading behavior
function onToggleCollapse(postId: string) {
const newCollapsed = new Set(collapsedPosts.value)
@ -555,6 +565,7 @@ function cancelDelete() {
@start-task="onStartTask"
@complete-task="onCompleteTask"
@unclaim-task="onUnclaimTask"
@delete-task="onDeleteTask"
/>
</div>
<div v-else class="text-center py-3 text-muted-foreground text-sm px-4">

View file

@ -16,7 +16,7 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { Calendar, MapPin, Clock, CheckCircle, PlayCircle, Hand } from 'lucide-vue-next'
import { Calendar, MapPin, Clock, CheckCircle, PlayCircle, Hand, Trash2 } from 'lucide-vue-next'
import type { ScheduledEvent, EventCompletion, TaskStatus } from '../services/ScheduledEventService'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { AuthService } from '@/modules/base/auth/auth-service'
@ -34,6 +34,7 @@ interface Emits {
(e: 'start-task', event: ScheduledEvent, occurrence?: string): void
(e: 'complete-task', event: ScheduledEvent, occurrence?: string): void
(e: 'unclaim-task', event: ScheduledEvent, occurrence?: string): void
(e: 'delete-task', event: ScheduledEvent): void
}
const props = withDefaults(defineProps<Props>(), {
@ -84,6 +85,12 @@ const canUnclaim = computed(() => {
return completion.value.pubkey === currentUserPubkey.value
})
// Check if current user is the author of the task
const isAuthor = computed(() => {
if (!currentUserPubkey.value) return false
return props.event.pubkey === currentUserPubkey.value
})
// Status badges configuration
const statusConfig = computed(() => {
switch (taskStatus.value) {
@ -150,7 +157,7 @@ const formattedTimeRange = computed(() => {
})
// Action type for confirmation dialog
const pendingAction = ref<'claim' | 'start' | 'complete' | 'unclaim' | null>(null)
const pendingAction = ref<'claim' | 'start' | 'complete' | 'unclaim' | 'delete' | null>(null)
// Handle claim task
function handleClaimTask() {
@ -176,6 +183,12 @@ function handleUnclaimTask() {
showConfirmDialog.value = true
}
// Handle delete task
function handleDeleteTask() {
pendingAction.value = 'delete'
showConfirmDialog.value = true
}
// Confirm action
function confirmAction() {
if (!pendingAction.value) return
@ -198,6 +211,9 @@ function confirmAction() {
case 'unclaim':
emit('unclaim-task', props.event, occurrence.value)
break
case 'delete':
emit('delete-task', props.event)
break
}
showConfirmDialog.value = false
@ -239,6 +255,12 @@ const dialogContent = computed(() => {
description: `This will remove your claim on "${props.event.title}" and make it available for others.\n\nHave you communicated to others that you are unclaiming this task?`,
confirmText: 'Unclaim Task'
}
case 'delete':
return {
title: 'Delete Task?',
description: `This will permanently delete "${props.event.title}". This action cannot be undone.`,
confirmText: 'Delete Task'
}
default:
return {
title: '',
@ -461,6 +483,19 @@ const dialogContent = computed(() => {
</Button>
</template>
</div>
<!-- Delete Task Button (only for task author) -->
<div v-if="isAuthor" class="mt-4 pt-4 border-t border-border">
<Button
@click.stop="handleDeleteTask"
variant="destructive"
size="sm"
class="gap-2"
>
<Trash2 class="h-4 w-4" />
Delete Task
</Button>
</div>
</div>
</CollapsibleContent>

View file

@ -208,6 +208,25 @@ export function useScheduledEvents() {
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
*/
@ -231,6 +250,7 @@ export function useScheduledEvents() {
startTask,
completeEvent,
unclaimTask,
deleteTask,
toggleComplete, // DEPRECATED: Use specific actions instead
// State

View file

@ -394,6 +394,17 @@ export class FeedService extends BaseService {
return
}
// Route to ScheduledEventService for scheduled event deletions (kind 31922)
if (deletedKind === '31922') {
console.log('🔀 FeedService: Routing kind 5 (deletion of kind 31922) to ScheduledEventService')
if (this.scheduledEventService) {
this.scheduledEventService.handleTaskDeletion(event)
} else {
console.warn('⚠️ FeedService: ScheduledEventService not available')
}
return
}
// Handle post deletions (kind 1) in FeedService
if (deletedKind === '1' || !deletedKind) {
// Extract event IDs to delete from 'e' tags

View file

@ -253,6 +253,50 @@ export class ScheduledEventService extends BaseService {
}
}
/**
* 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
*/
@ -530,6 +574,68 @@ export class ScheduledEventService extends BaseService {
}
}
/**
* 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')
// Optimistically remove from local state
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
*/