Implement account deletion handling and improve user feedback
Some checks failed
Chore App Build and Push Docker Images / build-and-push (push) Has been cancelled

- Added checks for accounts marked for deletion in signup, verification, and password reset processes.
- Updated reward and task listing to sort user-created items first.
- Enhanced user API to clear verification and reset tokens when marking accounts for deletion.
- Introduced tests for marked accounts to ensure proper handling in various scenarios.
- Updated profile and reward edit components to reflect changes in validation and data handling.
This commit is contained in:
2026-02-17 10:38:26 -05:00
parent 3e1715e487
commit 7e7a2ef49e
15 changed files with 724 additions and 35 deletions

View File

@@ -165,20 +165,53 @@ function handleRewardModified(event: Event) {
}
}
const triggerTask = (task: Task) => {
if ('speechSynthesis' in window && task.name) {
const utter = new window.SpeechSynthesisUtterance(task.name)
window.speechSynthesis.speak(utter)
const triggerTask = async (task: Task) => {
// Cancel any pending speech to avoid conflicts
if ('speechSynthesis' in window) {
window.speechSynthesis.cancel()
if (task.name) {
const utter = new window.SpeechSynthesisUtterance(task.name)
utter.rate = 1.0
utter.pitch = 1.0
utter.volume = 1.0
window.speechSynthesis.speak(utter)
}
}
// Trigger the task via API
if (child.value?.id && task.id) {
try {
const resp = await fetch(`/api/child/${child.value.id}/trigger-task`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ task_id: task.id }),
})
if (!resp.ok) {
console.error('Failed to trigger task')
}
} catch (err) {
console.error('Error triggering task:', err)
}
}
}
const triggerReward = (reward: RewardStatus) => {
if ('speechSynthesis' in window && reward.name) {
const utterString =
reward.name +
(reward.points_needed <= 0 ? '' : `, You still need ${reward.points_needed} points.`)
const utter = new window.SpeechSynthesisUtterance(utterString)
window.speechSynthesis.speak(utter)
// Cancel any pending speech to avoid conflicts
if ('speechSynthesis' in window) {
window.speechSynthesis.cancel()
if (reward.name) {
const utterString =
reward.name +
(reward.points_needed <= 0 ? '' : `, You still need ${reward.points_needed} points.`)
const utter = new window.SpeechSynthesisUtterance(utterString)
utter.rate = 1.0
utter.pitch = 1.0
utter.volume = 1.0
window.speechSynthesis.speak(utter)
}
if (reward.redeeming) {
dialogReward.value = reward
showCancelDialog.value = true
@@ -271,6 +304,12 @@ function removeInactivityListeners() {
if (inactivityTimer) clearTimeout(inactivityTimer)
}
const readyItemId = ref<string | null>(null)
function handleItemReady(itemId: string) {
readyItemId.value = itemId
}
const hasPendingRewards = computed(() =>
childRewardListRef.value?.items.some((r: RewardStatus) => r.redeeming),
)
@@ -333,6 +372,9 @@ onUnmounted(() => {
:ids="tasks"
itemKey="tasks"
imageField="image_id"
:isParentAuthenticated="false"
:readyItemId="readyItemId"
@item-ready="handleItemReady"
@trigger-item="triggerTask"
:getItemClass="(item) => ({ bad: !item.is_good, good: item.is_good })"
:filter-fn="
@@ -364,6 +406,9 @@ onUnmounted(() => {
:ids="tasks"
itemKey="tasks"
imageField="image_id"
:isParentAuthenticated="false"
:readyItemId="readyItemId"
@item-ready="handleItemReady"
@trigger-item="triggerTask"
:getItemClass="(item) => ({ bad: !item.is_good, good: item.is_good })"
:filter-fn="
@@ -394,6 +439,9 @@ onUnmounted(() => {
:fetchBaseUrl="`/api/child/${child?.id}/reward-status`"
itemKey="reward_status"
imageField="image_id"
:isParentAuthenticated="false"
:readyItemId="readyItemId"
@item-ready="handleItemReady"
@trigger-item="triggerReward"
:getItemClass="
(item) => ({ reward: true, disabled: hasPendingRewards && !item.redeeming })

View File

@@ -85,6 +85,7 @@ describe('ChildView', () => {
// Mock speech synthesis
global.window.speechSynthesis = {
speak: vi.fn(),
cancel: vi.fn(),
} as any
global.window.SpeechSynthesisUtterance = vi.fn() as any
})
@@ -186,13 +187,18 @@ describe('ChildView', () => {
it('speaks task name when triggered', () => {
wrapper.vm.triggerTask(mockChore)
expect(window.speechSynthesis.cancel).toHaveBeenCalled()
expect(window.speechSynthesis.speak).toHaveBeenCalled()
})
it('does not crash if speechSynthesis is not available', () => {
const originalSpeechSynthesis = global.window.speechSynthesis
delete (global.window as any).speechSynthesis
expect(() => wrapper.vm.triggerTask(mockChore)).not.toThrow()
// Restore for other tests
global.window.speechSynthesis = originalSpeechSynthesis
})
})
@@ -309,4 +315,95 @@ describe('ChildView', () => {
expect(mockRefresh).not.toHaveBeenCalled()
})
})
describe('Item Ready State Management', () => {
beforeEach(async () => {
wrapper = mount(ChildView)
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
})
it('initializes readyItemId to null', () => {
expect(wrapper.vm.readyItemId).toBe(null)
})
it('updates readyItemId when handleItemReady is called with an item ID', () => {
wrapper.vm.handleItemReady('task-1')
expect(wrapper.vm.readyItemId).toBe('task-1')
wrapper.vm.handleItemReady('reward-2')
expect(wrapper.vm.readyItemId).toBe('reward-2')
})
it('clears readyItemId when handleItemReady is called with empty string', () => {
wrapper.vm.readyItemId = 'task-1'
wrapper.vm.handleItemReady('')
expect(wrapper.vm.readyItemId).toBe('')
})
it('passes readyItemId prop to Chores ScrollingList', async () => {
wrapper.vm.readyItemId = 'task-1'
await nextTick()
const choresScrollingList = wrapper.findAllComponents({ name: 'ScrollingList' })[0]
expect(choresScrollingList.props('readyItemId')).toBe('task-1')
})
it('passes readyItemId prop to Penalties ScrollingList', async () => {
wrapper.vm.readyItemId = 'task-2'
await nextTick()
const penaltiesScrollingList = wrapper.findAllComponents({ name: 'ScrollingList' })[1]
expect(penaltiesScrollingList.props('readyItemId')).toBe('task-2')
})
it('passes readyItemId prop to Rewards ScrollingList', async () => {
wrapper.vm.readyItemId = 'reward-1'
await nextTick()
const rewardsScrollingList = wrapper.findAllComponents({ name: 'ScrollingList' })[2]
expect(rewardsScrollingList.props('readyItemId')).toBe('reward-1')
})
it('handles item-ready event from Chores ScrollingList', async () => {
const choresScrollingList = wrapper.findAllComponents({ name: 'ScrollingList' })[0]
choresScrollingList.vm.$emit('item-ready', 'task-1')
await nextTick()
expect(wrapper.vm.readyItemId).toBe('task-1')
})
it('handles item-ready event from Penalties ScrollingList', async () => {
const penaltiesScrollingList = wrapper.findAllComponents({ name: 'ScrollingList' })[1]
penaltiesScrollingList.vm.$emit('item-ready', 'task-2')
await nextTick()
expect(wrapper.vm.readyItemId).toBe('task-2')
})
it('handles item-ready event from Rewards ScrollingList', async () => {
const rewardsScrollingList = wrapper.findAllComponents({ name: 'ScrollingList' })[2]
rewardsScrollingList.vm.$emit('item-ready', 'reward-1')
await nextTick()
expect(wrapper.vm.readyItemId).toBe('reward-1')
})
it('maintains 2-step click workflow: first click sets ready, second click triggers', async () => {
// Initial state
expect(wrapper.vm.readyItemId).toBe(null)
// First click - item should become ready
wrapper.vm.handleItemReady('task-1')
expect(wrapper.vm.readyItemId).toBe('task-1')
// Second click would trigger the item (tested via ScrollingList component)
// After trigger, ready state should be cleared
wrapper.vm.handleItemReady('')
expect(wrapper.vm.readyItemId).toBe('')
})
})
})