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

@@ -39,7 +39,11 @@ def signup():
email = data.get('email', '') email = data.get('email', '')
norm_email = normalize_email(email) norm_email = normalize_email(email)
if users_db.search(UserQuery.email == norm_email): existing = users_db.get(UserQuery.email == norm_email)
if existing:
user = User.from_dict(existing)
if user.marked_for_deletion:
return jsonify({'error': 'Account marked for deletion', 'code': ACCOUNT_MARKED_FOR_DELETION}), 403
return jsonify({'error': 'Email already exists', 'code': EMAIL_EXISTS}), 400 return jsonify({'error': 'Email already exists', 'code': EMAIL_EXISTS}), 400
token = secrets.token_urlsafe(32) token = secrets.token_urlsafe(32)
@@ -78,6 +82,10 @@ def verify():
status = 'error' status = 'error'
reason = 'Invalid token' reason = 'Invalid token'
code = INVALID_TOKEN code = INVALID_TOKEN
elif user.marked_for_deletion:
status = 'error'
reason = 'Account marked for deletion'
code = ACCOUNT_MARKED_FOR_DELETION
else: else:
created_str = user.verify_token_created created_str = user.verify_token_created
if not created_str: if not created_str:
@@ -175,6 +183,8 @@ def me():
user = User.from_dict(user_dict) if user_dict else None user = User.from_dict(user_dict) if user_dict else None
if not user: if not user:
return jsonify({'error': 'User not found', 'code': USER_NOT_FOUND}), 404 return jsonify({'error': 'User not found', 'code': USER_NOT_FOUND}), 404
if user.marked_for_deletion:
return jsonify({'error': 'Account marked for deletion', 'code': ACCOUNT_MARKED_FOR_DELETION}), 403
return jsonify({ return jsonify({
'email': user.email, 'email': user.email,
'id': user_id, 'id': user_id,
@@ -201,14 +211,14 @@ def request_password_reset():
user_dict = users_db.get(UserQuery.email == norm_email) user_dict = users_db.get(UserQuery.email == norm_email)
user = User.from_dict(user_dict) if user_dict else None user = User.from_dict(user_dict) if user_dict else None
if user: if user:
# Silently ignore reset requests for marked accounts (don't leak account status) if user.marked_for_deletion:
if not user.marked_for_deletion: return jsonify({'error': 'Account marked for deletion', 'code': ACCOUNT_MARKED_FOR_DELETION}), 403
token = secrets.token_urlsafe(32) token = secrets.token_urlsafe(32)
now_iso = datetime.utcnow().isoformat() now_iso = datetime.utcnow().isoformat()
user.reset_token = token user.reset_token = token
user.reset_token_created = now_iso user.reset_token_created = now_iso
users_db.update(user.to_dict(), UserQuery.email == norm_email) users_db.update(user.to_dict(), UserQuery.email == norm_email)
send_reset_password_email(norm_email, token) send_reset_password_email(norm_email, token)
return jsonify({'message': success_msg}), 200 return jsonify({'message': success_msg}), 200

View File

@@ -65,7 +65,13 @@ def list_rewards():
if r.get('user_id') is None and r['name'].strip().lower() in user_rewards: if r.get('user_id') is None and r['name'].strip().lower() in user_rewards:
continue # Skip default if user version exists continue # Skip default if user version exists
filtered_rewards.append(r) filtered_rewards.append(r)
return jsonify({'rewards': filtered_rewards}), 200
# Sort: user-created items first (by name), then default items (by name)
user_created = sorted([r for r in filtered_rewards if r.get('user_id') == user_id], key=lambda x: x['name'].lower())
default_items = sorted([r for r in filtered_rewards if r.get('user_id') is None], key=lambda x: x['name'].lower())
sorted_rewards = user_created + default_items
return jsonify({'rewards': sorted_rewards}), 200
@reward_api.route('/reward/<id>', methods=['DELETE']) @reward_api.route('/reward/<id>', methods=['DELETE'])
def delete_reward(id): def delete_reward(id):

View File

@@ -63,7 +63,13 @@ def list_tasks():
if t.get('user_id') is None and t['name'].strip().lower() in user_tasks: if t.get('user_id') is None and t['name'].strip().lower() in user_tasks:
continue # Skip default if user version exists continue # Skip default if user version exists
filtered_tasks.append(t) filtered_tasks.append(t)
return jsonify({'tasks': filtered_tasks}), 200
# Sort: user-created items first (by name), then default items (by name)
user_created = sorted([t for t in filtered_tasks if t.get('user_id') == user_id], key=lambda x: x['name'].lower())
default_items = sorted([t for t in filtered_tasks if t.get('user_id') is None], key=lambda x: x['name'].lower())
sorted_tasks = user_created + default_items
return jsonify({'tasks': sorted_tasks}), 200
@task_api.route('/task/<id>', methods=['DELETE']) @task_api.route('/task/<id>', methods=['DELETE'])
def delete_task(id): def delete_task(id):

View File

@@ -231,6 +231,13 @@ def mark_for_deletion():
# Mark for deletion # Mark for deletion
user.marked_for_deletion = True user.marked_for_deletion = True
user.marked_for_deletion_at = datetime.now(timezone.utc).isoformat() user.marked_for_deletion_at = datetime.now(timezone.utc).isoformat()
# Invalidate any outstanding verification/reset tokens so they cannot be used after marking
user.verify_token = None
user.verify_token_created = None
user.reset_token = None
user.reset_token_created = None
users_db.update(user.to_dict(), UserQuery.id == user.id) users_db.update(user.to_dict(), UserQuery.id == user.id)
# Trigger SSE event # Trigger SSE event

View File

@@ -0,0 +1,82 @@
import pytest
from flask import Flask
from api.auth_api import auth_api
from db.db import users_db
from tinydb import Query
from models.user import User
from werkzeug.security import generate_password_hash
from datetime import datetime, timedelta
import jwt
@pytest.fixture
def client():
app = Flask(__name__)
app.register_blueprint(auth_api, url_prefix='/auth')
app.config['TESTING'] = True
app.config['SECRET_KEY'] = 'supersecretkey'
with app.test_client() as client:
yield client
def setup_marked_user(email, verified=False, verify_token=None, reset_token=None):
users_db.remove(Query().email == email)
user = User(
first_name='Marked',
last_name='User',
email=email,
password=generate_password_hash('password123'),
verified=verified,
marked_for_deletion=True,
verify_token=verify_token,
verify_token_created=datetime.utcnow().isoformat() if verify_token else None,
reset_token=reset_token,
reset_token_created=datetime.utcnow().isoformat() if reset_token else None
)
users_db.insert(user.to_dict())
def test_signup_marked_for_deletion(client):
setup_marked_user('marked@example.com')
data = {
'first_name': 'Marked',
'last_name': 'User',
'email': 'marked@example.com',
'password': 'password123'
}
response = client.post('/auth/signup', json=data)
assert response.status_code == 403
assert response.json['code'] == 'ACCOUNT_MARKED_FOR_DELETION'
def test_verify_marked_for_deletion(client):
setup_marked_user('marked2@example.com', verify_token='verifytoken123')
response = client.get('/auth/verify', query_string={'token': 'verifytoken123'})
assert response.status_code == 400
assert response.json['code'] == 'ACCOUNT_MARKED_FOR_DELETION'
def test_request_password_reset_marked_for_deletion(client):
setup_marked_user('marked3@example.com')
response = client.post('/auth/request-password-reset', json={'email': 'marked3@example.com'})
assert response.status_code == 403
assert response.json['code'] == 'ACCOUNT_MARKED_FOR_DELETION'
def test_me_marked_for_deletion(client):
email = 'marked4@example.com'
setup_marked_user(email, verified=True)
# Get the user to access the ID
user_dict = users_db.get(Query().email == email)
user = User.from_dict(user_dict)
# Create a valid JWT token for the marked user
payload = {
'email': email,
'user_id': user.id,
'exp': datetime.utcnow() + timedelta(hours=24)
}
token = jwt.encode(payload, 'supersecretkey', algorithm='HS256')
# Make request with token cookie
client.set_cookie('token', token)
response = client.get('/auth/me')
assert response.status_code == 403
assert response.json['code'] == 'ACCOUNT_MARKED_FOR_DELETION'

View File

@@ -15,6 +15,7 @@ from utils.account_deletion_scheduler import (
delete_user_data, delete_user_data,
process_deletion_queue, process_deletion_queue,
check_interrupted_deletions, check_interrupted_deletions,
trigger_deletion_manually,
MAX_DELETION_ATTEMPTS MAX_DELETION_ATTEMPTS
) )
from models.user import User from models.user import User
@@ -953,3 +954,163 @@ class TestIntegration:
assert users_db.get(Query_.id == user_id) is None assert users_db.get(Query_.id == user_id) is None
assert child_db.get(Query_.id == child_id) is None assert child_db.get(Query_.id == child_id) is None
assert not os.path.exists(user_image_dir) assert not os.path.exists(user_image_dir)
class TestManualDeletionTrigger:
"""Tests for manually triggered deletion (admin endpoint)."""
def setup_method(self):
"""Clear test databases before each test."""
users_db.truncate()
child_db.truncate()
task_db.truncate()
reward_db.truncate()
image_db.truncate()
pending_reward_db.truncate()
def teardown_method(self):
"""Clean up test directories after each test."""
for user_id in ['manual_user_1', 'manual_user_2', 'manual_user_3', 'manual_user_retry', 'recent_user']:
user_dir = get_user_image_dir(user_id)
if os.path.exists(user_dir):
try:
shutil.rmtree(user_dir)
except:
pass
def test_manual_trigger_deletes_immediately(self):
"""Test that manual trigger deletes users marked recently (not past threshold)."""
user_id = 'manual_user_1'
# Create user marked only 1 hour ago (well before 720 hour threshold)
marked_time = (datetime.now() - timedelta(hours=1)).isoformat()
user = User(
id=user_id,
email='manual1@example.com',
first_name='Manual',
last_name='Test',
password='hash',
marked_for_deletion=True,
marked_for_deletion_at=marked_time,
deletion_in_progress=False,
deletion_attempted_at=None
)
users_db.insert(user.to_dict())
# Verify user is NOT due for deletion under normal circumstances
assert is_user_due_for_deletion(user) is False
# Manually trigger deletion
result = trigger_deletion_manually()
# Verify user was deleted despite not being past threshold
Query_ = Query()
assert users_db.get(Query_.id == user_id) is None
assert result['triggered'] is True
def test_manual_trigger_deletes_multiple_users(self):
"""Test that manual trigger deletes all marked users regardless of time."""
# Create multiple users marked at different times
users_data = [
('manual_user_1', 1), # 1 hour ago
('manual_user_2', 100), # 100 hours ago
('manual_user_3', 800), # 800 hours ago (past threshold)
]
for user_id, hours_ago in users_data:
marked_time = (datetime.now() - timedelta(hours=hours_ago)).isoformat()
user = User(
id=user_id,
email=f'{user_id}@example.com',
first_name='Manual',
last_name='Test',
password='hash',
marked_for_deletion=True,
marked_for_deletion_at=marked_time,
deletion_in_progress=False,
deletion_attempted_at=None
)
users_db.insert(user.to_dict())
# Verify only one is due under normal circumstances
all_users = users_db.all()
due_count = sum(1 for u in all_users if is_user_due_for_deletion(User.from_dict(u)))
assert due_count == 1 # Only the 800 hour old one
# Manually trigger deletion
trigger_deletion_manually()
# Verify ALL marked users were deleted
Query_ = Query()
assert len(users_db.all()) == 0
def test_manual_trigger_respects_retry_limit(self):
"""Test that manual trigger still respects max retry limit."""
user_id = 'manual_user_retry'
# Create user marked recently with max attempts already
marked_time = (datetime.now() - timedelta(hours=1)).isoformat()
attempted_time = (datetime.now() - timedelta(hours=1)).isoformat()
user = User(
id=user_id,
email='retry@example.com',
first_name='Retry',
last_name='Test',
password='hash',
marked_for_deletion=True,
marked_for_deletion_at=marked_time,
deletion_in_progress=False,
deletion_attempted_at=attempted_time # Has 1 attempt
)
users_db.insert(user.to_dict())
# Mock delete_user_data to fail consistently
with patch('utils.account_deletion_scheduler.delete_user_data', return_value=False):
# Trigger multiple times to exceed retry limit
for _ in range(MAX_DELETION_ATTEMPTS):
trigger_deletion_manually()
# User should still exist after max attempts
Query_ = Query()
remaining_user = users_db.get(Query_.id == user_id)
assert remaining_user is not None
def test_manual_trigger_with_no_marked_users(self):
"""Test that manual trigger handles empty queue gracefully."""
result = trigger_deletion_manually()
assert result['triggered'] is True
assert result['queued_users'] == 0
def test_normal_scheduler_still_respects_threshold(self):
"""Test that normal scheduler run (force=False) still respects time threshold."""
user_id = 'recent_user'
# Create user marked only 1 hour ago
marked_time = (datetime.now() - timedelta(hours=1)).isoformat()
user = User(
id=user_id,
email='recent@example.com',
first_name='Recent',
last_name='Test',
password='hash',
marked_for_deletion=True,
marked_for_deletion_at=marked_time,
deletion_in_progress=False,
deletion_attempted_at=None
)
users_db.insert(user.to_dict())
# Run normal scheduler (not manual trigger)
process_deletion_queue(force=False)
# User should still exist because not past threshold
Query_ = Query()
assert users_db.get(Query_.id == user_id) is not None
# Now run with force=True
process_deletion_queue(force=True)
# User should be deleted
assert users_db.get(Query_.id == user_id) is None

View File

@@ -138,11 +138,12 @@ def test_login_succeeds_for_unmarked_user(client):
assert 'message' in data assert 'message' in data
def test_password_reset_ignored_for_marked_user(client): def test_password_reset_ignored_for_marked_user(client):
"""Test that password reset requests are silently ignored for marked users.""" """Test that password reset requests return 403 for marked users."""
response = client.post('/request-password-reset', json={"email": MARKED_EMAIL}) response = client.post('/request-password-reset', json={"email": MARKED_EMAIL})
assert response.status_code == 200 assert response.status_code == 403
data = response.get_json() data = response.get_json()
assert 'message' in data assert 'error' in data
assert data['code'] == 'ACCOUNT_MARKED_FOR_DELETION'
def test_password_reset_works_for_unmarked_user(client): def test_password_reset_works_for_unmarked_user(client):
"""Test that password reset works normally for unmarked users.""" """Test that password reset works normally for unmarked users."""
@@ -167,6 +168,35 @@ def test_mark_for_deletion_updates_timestamp(authenticated_client):
assert before_time <= marked_at <= after_time assert before_time <= marked_at <= after_time
def test_mark_for_deletion_clears_tokens(authenticated_client):
"""When an account is marked for deletion, verify/reset tokens must be cleared."""
# Seed verify/reset tokens for the user
UserQuery = Query()
now_iso = datetime.utcnow().isoformat()
users_db.update({
'verify_token': 'verify-abc',
'verify_token_created': now_iso,
'reset_token': 'reset-xyz',
'reset_token_created': now_iso
}, UserQuery.email == TEST_EMAIL)
# Ensure tokens are present before marking
user_before = users_db.search(UserQuery.email == TEST_EMAIL)[0]
assert user_before['verify_token'] is not None
assert user_before['reset_token'] is not None
# Mark account for deletion
response = authenticated_client.post('/user/mark-for-deletion', json={"email": TEST_EMAIL})
assert response.status_code == 200
# Verify tokens were cleared in the DB
user_after = users_db.search(UserQuery.email == TEST_EMAIL)[0]
assert user_after.get('verify_token') is None
assert user_after.get('verify_token_created') is None
assert user_after.get('reset_token') is None
assert user_after.get('reset_token_created') is None
def test_mark_for_deletion_with_invalid_jwt(client): def test_mark_for_deletion_with_invalid_jwt(client):
"""Test marking for deletion with invalid JWT token.""" """Test marking for deletion with invalid JWT token."""
# Set invalid cookie manually # Set invalid cookie manually

View File

@@ -210,11 +210,18 @@ def delete_user_data(user: User) -> bool:
pass pass
return False return False
def process_deletion_queue(): def process_deletion_queue(force=False):
""" """
Process the deletion queue: find users due for deletion and delete them. Process the deletion queue: find users due for deletion and delete them.
Args:
force (bool): If True, delete all marked users immediately without checking threshold.
If False, only delete users past the threshold time.
""" """
logger.info("Starting deletion scheduler run") if force:
logger.info("Starting FORCED deletion scheduler run (bypassing time threshold)")
else:
logger.info("Starting deletion scheduler run")
processed = 0 processed = 0
deleted = 0 deleted = 0
@@ -235,8 +242,8 @@ def process_deletion_queue():
user = User.from_dict(user_dict) user = User.from_dict(user_dict)
processed += 1 processed += 1
# Check if user is due for deletion # Check if user is due for deletion (skip check if force=True)
if not is_user_due_for_deletion(user): if not force and not is_user_due_for_deletion(user):
continue continue
# Check retry limit # Check retry limit
@@ -346,10 +353,11 @@ def stop_deletion_scheduler():
def trigger_deletion_manually(): def trigger_deletion_manually():
""" """
Manually trigger the deletion process (for admin use). Manually trigger the deletion process (for admin use).
Deletes all marked users immediately without waiting for threshold.
Returns stats about the run. Returns stats about the run.
""" """
logger.info("Manual deletion trigger requested") logger.info("Manual deletion trigger requested - forcing immediate deletion")
process_deletion_queue() process_deletion_queue(force=True)
# Return stats (simplified version) # Return stats (simplified version)
Query_ = Query() Query_ = Query()

View File

@@ -283,3 +283,207 @@ describe('UserProfile - Delete Account', () => {
expect(wrapper.vm.deleteErrorMessage).toBe('This account is already marked for deletion.') expect(wrapper.vm.deleteErrorMessage).toBe('This account is already marked for deletion.')
}) })
}) })
describe('UserProfile - Profile Update', () => {
let wrapper: VueWrapper<any>
beforeEach(() => {
vi.clearAllMocks()
;(global.fetch as any).mockClear()
// Mock fetch for profile loading in onMounted
;(global.fetch as any).mockResolvedValue({
ok: true,
json: async () => ({
image_id: 'initial-image-id',
first_name: 'Test',
last_name: 'User',
email: 'test@example.com',
}),
})
// Mount component with router
wrapper = mount(UserProfile, {
global: {
plugins: [mockRouter],
stubs: {
EntityEditForm: {
template: '<div class="mock-form"><slot /></div>',
props: ['initialData', 'fields', 'loading', 'error', 'isEdit', 'entityLabel', 'title'],
emits: ['submit', 'cancel', 'add-image'],
},
ModalDialog: {
template: '<div class="mock-modal"><slot /></div>',
},
},
},
})
})
it('updates initialData after successful profile save', async () => {
await flushPromises()
await nextTick()
// Initial image_id should be set from mount
expect(wrapper.vm.initialData.image_id).toBe('initial-image-id')
// Mock successful save response
;(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => ({}),
})
// Simulate form submission with new image_id
const newFormData = {
image_id: 'new-image-id',
first_name: 'Updated',
last_name: 'Name',
email: 'test@example.com',
}
await wrapper.vm.handleSubmit(newFormData)
await flushPromises()
// initialData should now be updated to match the saved form
expect(wrapper.vm.initialData.image_id).toBe('new-image-id')
expect(wrapper.vm.initialData.first_name).toBe('Updated')
expect(wrapper.vm.initialData.last_name).toBe('Name')
})
it('allows dirty detection after save when reverting to original value', async () => {
await flushPromises()
await nextTick()
// Start with initial-image-id
expect(wrapper.vm.initialData.image_id).toBe('initial-image-id')
// Mock successful save
;(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => ({}),
})
// Change and save to new-image-id
await wrapper.vm.handleSubmit({
image_id: 'new-image-id',
first_name: 'Test',
last_name: 'User',
email: 'test@example.com',
})
await flushPromises()
// initialData should now be new-image-id
expect(wrapper.vm.initialData.image_id).toBe('new-image-id')
// Now if user changes back to initial-image-id, it should be detected as different
// (because initialData is now new-image-id)
const currentInitial = wrapper.vm.initialData.image_id
expect(currentInitial).toBe('new-image-id')
expect(currentInitial).not.toBe('initial-image-id')
})
it('handles image upload during profile save', async () => {
await flushPromises()
await nextTick()
const mockFile = new File(['test'], 'test.png', { type: 'image/png' })
wrapper.vm.localImageFile = mockFile
// Mock image upload response
;(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => ({ id: 'uploaded-image-id' }),
})
// Mock profile update response
;(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => ({}),
})
await wrapper.vm.handleSubmit({
image_id: 'local-upload',
first_name: 'Test',
last_name: 'User',
email: 'test@example.com',
})
await flushPromises()
// Should have called image upload
expect(global.fetch).toHaveBeenCalledWith(
'/api/image/upload',
expect.objectContaining({
method: 'POST',
}),
)
// initialData should be updated with uploaded image ID
expect(wrapper.vm.initialData.image_id).toBe('uploaded-image-id')
})
it('shows error message on failed image upload', async () => {
await flushPromises()
await nextTick()
const mockFile = new File(['test'], 'test.png', { type: 'image/png' })
wrapper.vm.localImageFile = mockFile
// Mock failed image upload
;(global.fetch as any).mockResolvedValueOnce({
ok: false,
status: 500,
})
await wrapper.vm.handleSubmit({
image_id: 'local-upload',
first_name: 'Test',
last_name: 'User',
email: 'test@example.com',
})
await flushPromises()
expect(wrapper.vm.errorMsg).toBe('Failed to upload image.')
expect(wrapper.vm.loading).toBe(false)
})
it('shows success modal after profile update', async () => {
await flushPromises()
await nextTick()
;(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => ({}),
})
await wrapper.vm.handleSubmit({
image_id: 'some-image-id',
first_name: 'Test',
last_name: 'User',
email: 'test@example.com',
})
await flushPromises()
expect(wrapper.vm.showModal).toBe(true)
expect(wrapper.vm.modalTitle).toBe('Profile Updated')
expect(wrapper.vm.modalMessage).toBe('Your profile was updated successfully.')
})
it('shows error message on failed profile update', async () => {
await flushPromises()
await nextTick()
;(global.fetch as any).mockResolvedValueOnce({
ok: false,
status: 500,
})
await wrapper.vm.handleSubmit({
image_id: 'some-image-id',
first_name: 'Test',
last_name: 'User',
email: 'test@example.com',
})
await flushPromises()
expect(wrapper.vm.errorMsg).toBe('Failed to update profile.')
expect(wrapper.vm.loading).toBe(false)
})
})

View File

@@ -165,20 +165,53 @@ function handleRewardModified(event: Event) {
} }
} }
const triggerTask = (task: Task) => { const triggerTask = async (task: Task) => {
if ('speechSynthesis' in window && task.name) { // Cancel any pending speech to avoid conflicts
const utter = new window.SpeechSynthesisUtterance(task.name) if ('speechSynthesis' in window) {
window.speechSynthesis.speak(utter) 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) => { const triggerReward = (reward: RewardStatus) => {
if ('speechSynthesis' in window && reward.name) { // Cancel any pending speech to avoid conflicts
const utterString = if ('speechSynthesis' in window) {
reward.name + window.speechSynthesis.cancel()
(reward.points_needed <= 0 ? '' : `, You still need ${reward.points_needed} points.`)
const utter = new window.SpeechSynthesisUtterance(utterString) if (reward.name) {
window.speechSynthesis.speak(utter) 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) { if (reward.redeeming) {
dialogReward.value = reward dialogReward.value = reward
showCancelDialog.value = true showCancelDialog.value = true
@@ -271,6 +304,12 @@ function removeInactivityListeners() {
if (inactivityTimer) clearTimeout(inactivityTimer) if (inactivityTimer) clearTimeout(inactivityTimer)
} }
const readyItemId = ref<string | null>(null)
function handleItemReady(itemId: string) {
readyItemId.value = itemId
}
const hasPendingRewards = computed(() => const hasPendingRewards = computed(() =>
childRewardListRef.value?.items.some((r: RewardStatus) => r.redeeming), childRewardListRef.value?.items.some((r: RewardStatus) => r.redeeming),
) )
@@ -333,6 +372,9 @@ onUnmounted(() => {
:ids="tasks" :ids="tasks"
itemKey="tasks" itemKey="tasks"
imageField="image_id" imageField="image_id"
:isParentAuthenticated="false"
:readyItemId="readyItemId"
@item-ready="handleItemReady"
@trigger-item="triggerTask" @trigger-item="triggerTask"
:getItemClass="(item) => ({ bad: !item.is_good, good: item.is_good })" :getItemClass="(item) => ({ bad: !item.is_good, good: item.is_good })"
:filter-fn=" :filter-fn="
@@ -364,6 +406,9 @@ onUnmounted(() => {
:ids="tasks" :ids="tasks"
itemKey="tasks" itemKey="tasks"
imageField="image_id" imageField="image_id"
:isParentAuthenticated="false"
:readyItemId="readyItemId"
@item-ready="handleItemReady"
@trigger-item="triggerTask" @trigger-item="triggerTask"
:getItemClass="(item) => ({ bad: !item.is_good, good: item.is_good })" :getItemClass="(item) => ({ bad: !item.is_good, good: item.is_good })"
:filter-fn=" :filter-fn="
@@ -394,6 +439,9 @@ onUnmounted(() => {
:fetchBaseUrl="`/api/child/${child?.id}/reward-status`" :fetchBaseUrl="`/api/child/${child?.id}/reward-status`"
itemKey="reward_status" itemKey="reward_status"
imageField="image_id" imageField="image_id"
:isParentAuthenticated="false"
:readyItemId="readyItemId"
@item-ready="handleItemReady"
@trigger-item="triggerReward" @trigger-item="triggerReward"
:getItemClass=" :getItemClass="
(item) => ({ reward: true, disabled: hasPendingRewards && !item.redeeming }) (item) => ({ reward: true, disabled: hasPendingRewards && !item.redeeming })

View File

@@ -85,6 +85,7 @@ describe('ChildView', () => {
// Mock speech synthesis // Mock speech synthesis
global.window.speechSynthesis = { global.window.speechSynthesis = {
speak: vi.fn(), speak: vi.fn(),
cancel: vi.fn(),
} as any } as any
global.window.SpeechSynthesisUtterance = vi.fn() as any global.window.SpeechSynthesisUtterance = vi.fn() as any
}) })
@@ -186,13 +187,18 @@ describe('ChildView', () => {
it('speaks task name when triggered', () => { it('speaks task name when triggered', () => {
wrapper.vm.triggerTask(mockChore) wrapper.vm.triggerTask(mockChore)
expect(window.speechSynthesis.cancel).toHaveBeenCalled()
expect(window.speechSynthesis.speak).toHaveBeenCalled() expect(window.speechSynthesis.speak).toHaveBeenCalled()
}) })
it('does not crash if speechSynthesis is not available', () => { it('does not crash if speechSynthesis is not available', () => {
const originalSpeechSynthesis = global.window.speechSynthesis
delete (global.window as any).speechSynthesis delete (global.window as any).speechSynthesis
expect(() => wrapper.vm.triggerTask(mockChore)).not.toThrow() 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() 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('')
})
})
}) })

View File

@@ -231,6 +231,8 @@ async function updateProfile(form: {
}) })
.then(async (res) => { .then(async (res) => {
if (!res.ok) throw new Error('Failed to update profile') if (!res.ok) throw new Error('Failed to update profile')
// Update initialData to reflect the saved state
initialData.value = { ...form }
modalTitle.value = 'Profile Updated' modalTitle.value = 'Profile Updated'
modalSubtitle.value = '' modalSubtitle.value = ''
modalMessage.value = 'Your profile was updated successfully.' modalMessage.value = 'Your profile was updated successfully.'
@@ -245,7 +247,11 @@ async function updateProfile(form: {
} }
async function handlePasswordModalClose() { async function handlePasswordModalClose() {
const wasProfileUpdate = modalTitle.value === 'Profile Updated'
showModal.value = false showModal.value = false
if (wasProfileUpdate) {
router.back()
}
} }
async function resetPassword() { async function resetPassword() {

View File

@@ -36,7 +36,7 @@ const fields: {
}[] = [ }[] = [
{ name: 'name', label: 'Reward Name', type: 'text', required: true, maxlength: 64 }, { name: 'name', label: 'Reward Name', type: 'text', required: true, maxlength: 64 },
{ name: 'description', label: 'Description', type: 'text', maxlength: 128 }, { name: 'description', label: 'Description', type: 'text', maxlength: 128 },
{ name: 'cost', label: 'Cost', type: 'number', required: true, min: 1, max: 1000 }, { name: 'cost', label: 'Cost', type: 'number', required: true, min: 1, max: 10000 },
{ name: 'image_id', label: 'Image', type: 'image', imageType: 2 }, { name: 'image_id', label: 'Image', type: 'image', imageType: 2 },
] ]
// removed duplicate defineProps // removed duplicate defineProps

View File

@@ -39,7 +39,7 @@
<button type="button" class="btn btn-secondary" @click="onCancel" :disabled="loading"> <button type="button" class="btn btn-secondary" @click="onCancel" :disabled="loading">
Cancel Cancel
</button> </button>
<button type="submit" class="btn btn-primary" :disabled="loading || !isDirty"> <button type="submit" class="btn btn-primary" :disabled="loading || !isDirty || !isValid">
{{ isEdit ? 'Save' : 'Create' }} {{ isEdit ? 'Save' : 'Create' }}
</button> </button>
</div> </div>
@@ -47,7 +47,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, nextTick, watch } from 'vue' import { ref, onMounted, nextTick, watch, computed } from 'vue'
import ImagePicker from '@/components/utils/ImagePicker.vue' import ImagePicker from '@/components/utils/ImagePicker.vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import '@/assets/styles.css' import '@/assets/styles.css'
@@ -120,9 +120,33 @@ function checkDirty() {
}) })
} }
// Validation logic
const isValid = computed(() => {
return props.fields.every((field) => {
if (!field.required) return true
const value = formData.value[field.name]
if (field.type === 'text') {
return typeof value === 'string' && value.trim().length > 0
}
if (field.type === 'number') {
const numValue = Number(value)
if (isNaN(numValue)) return false
if (field.min !== undefined && numValue < field.min) return false
if (field.max !== undefined && numValue > field.max) return false
return true
}
// For other types, just check it's not null/undefined
return value != null
})
})
watch( watch(
() => ({ ...formData.value }), () => ({ ...formData.value }),
() => { (newVal) => {
console.log('formData changed:', newVal)
checkDirty() checkDirty()
}, },
{ deep: true }, { deep: true },

View File

@@ -73,7 +73,7 @@ const fields: {
imageType?: number imageType?: number
}[] = [ }[] = [
{ name: 'name', label: 'Task Name', type: 'text', required: true, maxlength: 64 }, { name: 'name', label: 'Task Name', type: 'text', required: true, maxlength: 64 },
{ name: 'points', label: 'Task Points', type: 'number', required: true, min: 1, max: 100 }, { name: 'points', label: 'Task Points', type: 'number', required: true, min: 1, max: 1000 },
{ name: 'is_good', label: 'Task Type', type: 'custom' }, { name: 'is_good', label: 'Task Type', type: 'custom' },
{ name: 'image_id', label: 'Image', type: 'image', imageType: 2 }, { name: 'image_id', label: 'Image', type: 'image', imageType: 2 },
] ]