28 Commits

Author SHA1 Message Date
df832e2238 wip 2026-02-16 23:30:37 -05:00
d600dde97f wip 2026-02-16 18:04:00 -05:00
3e1715e487 added universal launcher
All checks were successful
Chore App Build and Push Docker Images / build-and-push (push) Successful in 44s
2026-02-16 16:20:04 -05:00
11e7fda997 wip 2026-02-16 16:17:17 -05:00
09d42b14c5 wip 2026-02-16 16:04:44 -05:00
3848be32e8 Merge branch 'next' of https://git.ryankegel.com/ryan/chore into next
All checks were successful
Chore App Build and Push Docker Images / build-and-push (push) Successful in 47s
2026-02-16 15:37:17 -05:00
1aff366fd8 - removed test_data
All checks were successful
Chore App Build and Push Docker Images / build-and-push (push) Successful in 1m36s
2026-02-16 15:33:56 -05:00
0ab40f85a4 wip 2026-02-16 15:29:33 -05:00
22889caab4 wip 2026-02-16 15:13:22 -05:00
b538782c09 Merge remote-tracking branch 'origin/wip-sync' into next 2026-02-16 15:02:51 -05:00
Ryan Kegel
7a827b14ef wip 2026-02-16 15:00:52 -05:00
9238d7e3a5 wip 2026-02-15 22:51:51 -05:00
c17838241a WIP Sync 2026-02-14 17:00:43 -05:00
d183e0a4b6 - First round of fixes for RC1
All checks were successful
Chore App Build and Push Docker Images / build-and-push (push) Successful in 2m18s
2026-02-13 16:43:57 -05:00
b25ebaaec0 -test environment
All checks were successful
Chore App Build and Push Docker Images / build-and-push (push) Successful in 57s
2026-02-12 16:17:07 -05:00
ae5b40512c -test environment
Some checks failed
Chore App Build and Push Docker Images / build-and-push (push) Failing after 30s
2026-02-12 16:15:14 -05:00
92635a356c -test environment
Some checks failed
Chore App Build and Push Docker Images / build-and-push (push) Failing after 45s
2026-02-12 16:12:52 -05:00
235269bdb6 -test environment
Some checks failed
Chore App Build and Push Docker Images / build-and-push (push) Failing after 29s
2026-02-11 23:11:54 -05:00
5d4b0ec2c9 -test environment
Some checks failed
Chore App Build and Push Docker Images / build-and-push (push) Failing after 22s
2026-02-11 22:55:28 -05:00
a21cb60aeb -test environment
All checks were successful
Chore App Build and Push Docker Images / build-and-push (push) Successful in 1m38s
2026-02-11 21:36:45 -05:00
e604870e26 -test environment
All checks were successful
Chore App Build and Push Docker Images / build-and-push (push) Successful in 2m7s
2026-02-11 17:08:23 -05:00
c3e35258a1 -test environment
All checks were successful
Chore App Build and Push Docker Images / build-and-push (push) Successful in 52s
2026-02-11 17:00:45 -05:00
d2a56e36c7 -test environment
Some checks failed
Chore App Build and Push Docker Images / build-and-push (push) Failing after 8s
2026-02-11 16:58:32 -05:00
3bfca4e2b0 -test environment
All checks were successful
Chore App Build and Push Docker Images / build-and-push (push) Successful in 1m59s
2026-02-11 16:01:35 -05:00
f5d68aec4a -test environment
Some checks failed
Chore App Build and Push Docker Images / build-and-push (push) Failing after 25s
2026-02-11 15:24:14 -05:00
38c637cc67 updated requirements
All checks were successful
Chore App Build and Push Docker Images / build-and-push (push) Successful in 48s
2026-02-11 15:16:24 -05:00
f29c90897f -test environment
Some checks failed
Chore App Build and Push Docker Images / build-and-push (push) Failing after 21s
2026-02-11 14:56:32 -05:00
efb65b6da3 -attempt to use global ip for registry
All checks were successful
Chore App Build and Push Docker Images / build-and-push (push) Successful in 45s
2026-02-11 10:31:28 -05:00
54 changed files with 1425 additions and 275 deletions

View File

@@ -24,40 +24,88 @@ jobs:
echo "tag=next-$version-$current_date" >> $GITHUB_OUTPUT
fi
- name: Resolve Gitea Server IP
id: gitea_ip
run: |
ip=$(getent hosts gitea-server | awk '{ print $1 }')
echo "ip=$ip" >> $GITHUB_OUTPUT
echo "Resolved Gitea server IP: $ip"
- name: Build Backend Docker Image
run: |
docker build -t ${{ steps.gitea_ip.outputs.ip }}:3000/ryan/backend:${{ steps.vars.outputs.tag }} ./backend
docker build -t git.ryankegel.com:3000/ryan/backend:${{ steps.vars.outputs.tag }} ./backend
- name: Build Frontend Docker Image
run: |
docker build -t ${{ steps.gitea_ip.outputs.ip }}:3000/ryan/frontend:${{ steps.vars.outputs.tag }} ./frontend/vue-app
docker build -t git.ryankegel.com:3000/ryan/frontend:${{ steps.vars.outputs.tag }} ./frontend/vue-app
- name: Log in to Registry
uses: docker/login-action@v2
with:
registry: ${{ steps.gitea_ip.outputs.ip }}:3000
registry: git.ryankegel.com:3000
username: ryan #${{ secrets.REGISTRY_USERNAME }} # Stored as a Gitea secret
password: 0x013h #${{ secrets.REGISTRY_TOKEN }} # Stored as a Gitea secret (use a PAT here)
- name: Push Backend Image to Gitea Registry
run: |
docker push ${{ steps.gitea_ip.outputs.ip }}:3000/ryan/backend:${{ steps.vars.outputs.tag }}
for i in {1..3}; do
echo "Attempt $i to push backend image..."
if docker push git.ryankegel.com:3000/ryan/backend:${{ steps.vars.outputs.tag }}; then
echo "Backend push succeeded on attempt $i"
break
else
echo "Backend push failed on attempt $i"
if [ $i -lt 3 ]; then
sleep 10
else
exit 1
fi
fi
done
if [ "${{ gitea.ref }}" == "refs/heads/master" ]; then
docker tag ${{ steps.gitea_ip.outputs.ip }}:3000/ryan/backend:${{ steps.vars.outputs.tag }} ${{ steps.gitea_ip.outputs.ip }}:3000/ryan/backend:latest
docker push ${{ steps.gitea_ip.outputs.ip }}:3000/ryan/backend:latest
docker tag git.ryankegel.com:3000/ryan/backend:${{ steps.vars.outputs.tag }} git.ryankegel.com:3000/ryan/backend:latest
docker push git.ryankegel.com:3000/ryan/backend:latest
elif [ "${{ gitea.ref }}" == "refs/heads/next" ]; then
docker tag git.ryankegel.com:3000/ryan/backend:${{ steps.vars.outputs.tag }} git.ryankegel.com:3000/ryan/backend:next
docker push git.ryankegel.com:3000/ryan/backend:next
fi
- name: Push Frontend Image to Gitea Registry
run: |
docker push ${{ steps.gitea_ip.outputs.ip }}:3000/ryan/frontend:${{ steps.vars.outputs.tag }}
if [ "${{ gitea.ref }}" == "refs/heads/master" ]; then
docker tag ${{ steps.gitea_ip.outputs.ip }}:3000/ryan/frontend:${{ steps.vars.outputs.tag }} ${{ steps.gitea_ip.outputs.ip }}:3000/ryan/frontend:latest
docker push ${{ steps.gitea_ip.outputs.ip }}:3000/ryan/frontend:latest
for i in {1..3}; do
echo "Attempt $i to push frontend image..."
if docker push git.ryankegel.com:3000/ryan/frontend:${{ steps.vars.outputs.tag }}; then
echo "Frontend push succeeded on attempt $i"
break
else
echo "Frontend push failed on attempt $i"
if [ $i -lt 3 ]; then
sleep 10
else
exit 1
fi
fi
done
if [ "${{ gitea.ref }}" == "refs/heads/master" ]; then
docker tag git.ryankegel.com:3000/ryan/frontend:${{ steps.vars.outputs.tag }} git.ryankegel.com:3000/ryan/frontend:latest
docker push git.ryankegel.com:3000/ryan/frontend:latest
elif [ "${{ gitea.ref }}" == "refs/heads/next" ]; then
docker tag git.ryankegel.com:3000/ryan/frontend:${{ steps.vars.outputs.tag }} git.ryankegel.com:3000/ryan/frontend:next
docker push git.ryankegel.com:3000/ryan/frontend:next
fi
- name: Deploy Test Environment
uses: appleboy/ssh-action@v1.0.3 # Or equivalent Gitea action; adjust version if needed
with:
host: ${{ secrets.DEPLOY_TEST_HOST }}
username: ${{ secrets.DEPLOY_TEST_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
port: 22 # Default SSH port; change if different
script: |
cd /tmp
# Pull the repository to get the latest docker-compose.dev.yml
if [ -d "chore" ]; then
cd chore
git pull origin next || true # Pull latest changes; ignore if it fails (e.g., first run)
else
git clone --branch next https://git.ryankegel.com/ryan/chore.git
cd chore
fi
echo "Bringing down previous test environment..."
docker-compose -f docker-compose.test.yml down --volumes --remove-orphans || true
echo "Starting new test environment..."
docker-compose -f docker-compose.test.yml pull # Ensure latest images are pulled
docker-compose -f docker-compose.test.yml up -d

19
.github/alias.txt vendored Normal file
View File

@@ -0,0 +1,19 @@
**Powershell
git config --global alias.save-wip "!f() { git add . ; if (git log -1 --format=%s -eq 'wip') { git commit --amend --no-edit } else { git commit -m 'wip' }; git push origin `$(git branch --show-current):wip-sync --force-with-lease; }; f"
git config --global alias.load-wip "!f() { if (git diff-index --quiet HEAD --) { git fetch origin wip-sync; git merge origin/wip-sync; if (git log -1 --format=%s -eq 'wip') { git reset --soft HEAD~1; echo 'WIP Loaded and unwrapped.' } else { echo 'No WIP found. Merge complete.' } } else { echo 'Error: Uncommitted changes detected.'; exit 1 }; }; f"
git config --global alias.abort-wip "git reset --hard HEAD"
**Git Bash
git config --global alias.save-wip '!f() { git add . ; if [ "$(git log -1 --format=%s)" = "wip" ]; then git commit --amend --no-edit; else git commit -m "wip"; fi; git push origin $(git branch --show-current):wip-sync --force-with-lease; }; f'
git config --global alias.load-wip '!f() { if ! git diff-index --quiet HEAD --; then echo "Error: Uncommitted changes detected."; exit 1; fi; git fetch origin wip-sync && git merge origin/wip-sync && if [ "$(git log -1 --format=%s)" = "wip" ]; then git reset --soft HEAD~1; echo "WIP Loaded and unwrapped."; else echo "No WIP found. Merge complete."; fi; }; f'
git config --global alias.abort-wip 'git reset --hard HEAD'
**Mac
git config --global alias.save-wip '!f() { git add . ; if [ "$(git log -1 --format=%s)" = "wip" ]; then git commit --amend --no-edit; else git commit -m "wip"; fi; git push origin $(git branch --show-current):wip-sync --force-with-lease; }; f'
git config --global alias.load-wip '!f() { if ! git diff-index --quiet HEAD --; then echo "Error: Uncommitted changes detected."; exit 1; fi; git fetch origin wip-sync && git merge origin/wip-sync && if [ "$(git log -1 --format=%s)" = "wip" ]; then git reset --soft HEAD~1; echo "WIP Loaded and unwrapped."; else echo "No WIP found. Merge complete."; fi; }; f'
git config --global alias.abort-wip 'git reset --hard HEAD'
***Reset wip-sync
git push origin --delete wip-sync

5
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,5 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/

15
.idea/Reward.iml generated Normal file
View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="jdk" jdkName="Python 3.12 (Reward)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
<component name="PyDocumentationSettings">
<option name="format" value="PLAIN" />
<option name="myDocStringFormat" value="Plain" />
</component>
<component name="TestRunnerService">
<option name="PROJECT_TEST_RUNNER" value="py.test" />
</component>
</module>

6
.idea/copilot.data.migration.agent.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AgentMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Ask2AgentMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>

6
.idea/copilot.data.migration.edit.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="EditMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>

View File

@@ -0,0 +1,10 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="PyPackageRequirementsInspection" enabled="true" level="WARNING" enabled_by_default="true">
<option name="ignoredPackages">
<list />
</option>
</inspection_tool>
</profile>
</component>

View File

@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>

4
.idea/misc.xml generated Normal file
View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.12 (Reward)" project-jdk-type="Python SDK" />
</project>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/Reward.iml" filepath="$PROJECT_DIR$/.idea/Reward.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

31
.vscode/launch.json vendored
View File

@@ -3,7 +3,7 @@
"configurations": [
{
"name": "Python: Flask",
"type": "debugpy",
"type": "python",
"request": "launch",
"module": "flask",
"python": "${command:python.interpreterPath}",
@@ -17,7 +17,9 @@
"--port=5000",
"--no-debugger",
"--no-reload"
]
],
"cwd": "${workspaceFolder}/backend",
"console": "integratedTerminal"
},
{
"name": "Vue: Dev Server",
@@ -32,17 +34,17 @@
"console": "integratedTerminal"
},
{
"name": "Chrome: Attach to Vue App",
"type": "chrome",
"name": "Chrome: Launch (Vue App)",
"type": "pwa-chrome",
"request": "launch",
"url": "https://localhost:5173", // or your Vite dev server port
"url": "http://localhost:5173",
"webRoot": "${workspaceFolder}/frontend/vue-app"
},
{
"name": "Python: Backend Tests",
"type": "python",
"request": "launch",
"program": "${workspaceFolder}/backend/.venv/Scripts/pytest.exe",
"module": "pytest",
"args": [
"tests/"
],
@@ -56,12 +58,21 @@
"name": "Vue: Frontend Tests",
"type": "node",
"request": "launch",
"runtimeExecutable": "npx",
"runtimeExecutable": "npm",
"windows": {
"runtimeExecutable": "npm.cmd"
},
"runtimeArgs": [
"vitest"
"run",
"test:unit"
],
"cwd": "${workspaceFolder}/frontend/vue-app",
"console": "integratedTerminal"
"console": "integratedTerminal",
"osx": {
"env": {
"PATH": "/opt/homebrew/bin:${env:PATH}"
}
}
}
],
"compounds": [
@@ -70,7 +81,7 @@
"configurations": [
"Python: Flask",
"Vue: Dev Server",
"Chrome: Attach to Vue App"
"Chrome: Launch (Vue App)"
]
}
]

77
.vscode/launch.json.bak vendored Normal file
View File

@@ -0,0 +1,77 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Python: Flask",
"type": "debugpy",
"request": "launch",
"module": "flask",
"python": "${command:python.interpreterPath}",
"env": {
"FLASK_APP": "backend/main.py",
"FLASK_DEBUG": "1"
},
"args": [
"run",
"--host=0.0.0.0",
"--port=5000",
"--no-debugger",
"--no-reload"
]
},
{
"name": "Vue: Dev Server",
"type": "node",
"request": "launch",
"runtimeExecutable": "npm",
"runtimeArgs": [
"run",
"dev"
],
"cwd": "${workspaceFolder}/frontend/vue-app",
"console": "integratedTerminal"
},
{
"name": "Chrome: Attach to Vue App",
"type": "chrome",
"request": "launch",
"url": "https://localhost:5173", // or your Vite dev server port
"webRoot": "${workspaceFolder}/frontend/vue-app"
},
{
"name": "Python: Backend Tests",
"type": "python",
"request": "launch",
"program": "${workspaceFolder}/backend/.venv/Scripts/pytest.exe",
"args": [
"tests/"
],
"cwd": "${workspaceFolder}/backend",
"console": "integratedTerminal",
"env": {
"PYTHONPATH": "${workspaceFolder}/backend"
}
},
{
"name": "Vue: Frontend Tests",
"type": "node",
"request": "launch",
"runtimeExecutable": "npx",
"runtimeArgs": [
"vitest"
],
"cwd": "${workspaceFolder}/frontend/vue-app",
"console": "integratedTerminal"
}
],
"compounds": [
{
"name": "Full Stack (Backend + Frontend)",
"configurations": [
"Python: Flask",
"Vue: Dev Server",
"Chrome: Attach to Vue App"
]
}
]
}

45
.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,45 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Git: Save WIP",
"type": "shell",
"command": "git save-wip",
"group": "build",
"presentation": {
"reveal": "always",
"panel": "shared"
}
},
{
"label": "Git: Load WIP",
"type": "shell",
"command": "git load-wip",
"group": "build",
"presentation": {
"reveal": "always",
"panel": "shared"
}
},
{
"label": "Git: Reset Cloud WIP",
"type": "shell",
"command": "git push origin --delete wip-sync",
"presentation": {
"reveal": "always",
"panel": "shared"
}
},
{
"label": "Git: Abort WIP (Reset Local)",
"type": "shell",
"command": "git abort-wip",
"group": "none",
"presentation": {
"reveal": "always",
"panel": "shared",
"echo": true
}
}
]
}

1
backend/.gitignore vendored
View File

@@ -42,6 +42,7 @@ env/
*.sqlite3
data/db/*.json
data/images/
test_data/
# Flask
instance/

View File

@@ -39,7 +39,11 @@ def signup():
email = data.get('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
token = secrets.token_urlsafe(32)
@@ -78,6 +82,10 @@ def verify():
status = 'error'
reason = 'Invalid token'
code = INVALID_TOKEN
elif user.marked_for_deletion:
status = 'error'
reason = 'Account marked for deletion'
code = ACCOUNT_MARKED_FOR_DELETION
else:
created_str = user.verify_token_created
if not created_str:
@@ -175,6 +183,8 @@ def me():
user = User.from_dict(user_dict) if user_dict else None
if not user:
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({
'email': user.email,
'id': user_id,
@@ -201,8 +211,8 @@ def request_password_reset():
user_dict = users_db.get(UserQuery.email == norm_email)
user = User.from_dict(user_dict) if user_dict else None
if user:
# Silently ignore reset requests for marked accounts (don't leak account status)
if not user.marked_for_deletion:
if user.marked_for_deletion:
return jsonify({'error': 'Account marked for deletion', 'code': ACCOUNT_MARKED_FOR_DELETION}), 403
token = secrets.token_urlsafe(32)
now_iso = datetime.utcnow().isoformat()
user.reset_token = token

View File

@@ -166,7 +166,7 @@ def assign_task_to_child(id):
if task_id not in child.get('tasks', []):
child['tasks'].append(task_id)
child_db.update({'tasks': child['tasks']}, ChildQuery.id == id)
return jsonify({'message': f'Task {task_id} assigned to {child['name']}.'}), 200
return jsonify({'message': f"Task {task_id} assigned to {child.get('name')}."}), 200
# python
@child_api.route('/child/<id>/set-tasks', methods=['PUT'])

View File

@@ -65,7 +65,13 @@ def list_rewards():
if r.get('user_id') is None and r['name'].strip().lower() in user_rewards:
continue # Skip default if user version exists
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'])
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:
continue # Skip default if user version exists
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'])
def delete_task(id):

View File

@@ -12,6 +12,10 @@ from api.utils import get_validated_user_id, normalize_email, send_event_for_cur
from api.error_codes import ACCOUNT_MARKED_FOR_DELETION, ALREADY_MARKED
from events.types.event_types import EventType
from events.types.event import Event
from events.types.profile_updated import ProfileUpdated
from utils.tracking_logger import log_tracking_event
from models.tracking_event import TrackingEvent
from db.tracking import insert_tracking_event
user_api = Blueprint('user_api', __name__)
UserQuery = Query()
@@ -63,6 +67,32 @@ def update_profile():
if image_id is not None:
user.image_id = image_id
users_db.update(user.to_dict(), UserQuery.email == user.email)
# Create tracking event
metadata = {}
if first_name is not None:
metadata['first_name_updated'] = True
if last_name is not None:
metadata['last_name_updated'] = True
if image_id is not None:
metadata['image_updated'] = True
tracking_event = TrackingEvent.create_event(
user_id=user_id,
child_id=None, # No child for user profile
entity_type='user',
entity_id=user.id,
action='updated',
points_before=0, # Not relevant
points_after=0,
metadata=metadata
)
insert_tracking_event(tracking_event)
log_tracking_event(tracking_event)
# Send SSE event
send_event_for_current_user(Event(EventType.PROFILE_UPDATED.value, ProfileUpdated(user.id)))
return jsonify({'message': 'Profile updated'}), 200
@user_api.route('/user/image', methods=['PUT'])
@@ -201,6 +231,13 @@ def mark_for_deletion():
# Mark for deletion
user.marked_for_deletion = True
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)
# Trigger SSE event

View File

@@ -2,7 +2,7 @@
# file: config/version.py
import os
BASE_VERSION = "1.0.4" # update manually when releasing features
BASE_VERSION = "1.0.4RC2" # update manually when releasing features
def get_full_version() -> str:
"""

View File

@@ -21,3 +21,5 @@ class EventType(Enum):
CHILD_OVERRIDE_SET = "child_override_set"
CHILD_OVERRIDE_DELETED = "child_override_deleted"
PROFILE_UPDATED = "profile_updated"

View File

@@ -0,0 +1,12 @@
from events.types.payload import Payload
class ProfileUpdated(Payload):
def __init__(self, user_id: str):
super().__init__({
'user_id': user_id,
})
@property
def user_id(self) -> str:
return self.get("user_id")

View File

@@ -1,5 +1,6 @@
import logging
import sys
import os
from flask import Flask, request, jsonify
from flask_cors import CORS
@@ -49,7 +50,7 @@ app.config.update(
MAIL_USERNAME='ryan.kegel@gmail.com',
MAIL_PASSWORD='ruyj hxjf nmrz buar',
MAIL_DEFAULT_SENDER='ryan.kegel@gmail.com',
FRONTEND_URL='https://localhost:5173', # Adjust as needed
FRONTEND_URL=os.environ.get('FRONTEND_URL', 'https://localhost:5173'), # Dynamic via env var, defaults to localhost
SECRET_KEY='supersecretkey' # Replace with a secure key in production
)

Binary file not shown.

View File

@@ -1,94 +0,0 @@
{
"_default": {
"1": {
"id": "479920ee-4d2c-4ff9-a7e4-749691183903",
"created_at": 1770772299.9946082,
"updated_at": 1770772299.9946082,
"child_id": "child1",
"entity_id": "task1",
"entity_type": "task",
"custom_value": 20
},
"2": {
"id": "e1212f17-1986-4ae2-9936-3e8c4a487a79",
"created_at": 1770772300.0246155,
"updated_at": 1770772300.0246155,
"child_id": "child2",
"entity_id": "task2",
"entity_type": "task",
"custom_value": 25
},
"3": {
"id": "58068231-3bd8-425c-aba2-1e4444547f2b",
"created_at": 1770772300.0326169,
"updated_at": 1770772300.0326169,
"child_id": "child3",
"entity_id": "task1",
"entity_type": "task",
"custom_value": 10
},
"4": {
"id": "21299d89-29d1-4876-abc8-080a919dfa27",
"created_at": 1770772300.0326169,
"updated_at": 1770772300.0326169,
"child_id": "child3",
"entity_id": "task2",
"entity_type": "task",
"custom_value": 15
},
"5": {
"id": "4676589a-abcf-4407-806c-8d187a41dae3",
"created_at": 1770772300.0326169,
"updated_at": 1770772300.0326169,
"child_id": "child3",
"entity_id": "reward1",
"entity_type": "reward",
"custom_value": 100
},
"33": {
"id": "cd1473e2-241c-4bfd-b4b2-c2b5402d95d6",
"created_at": 1770772307.3772185,
"updated_at": 1770772307.3772185,
"child_id": "351c9e7f-5406-425c-a15a-2268aadbfdd5",
"entity_id": "90279979-e91e-4f51-af78-88ad70ffab57",
"entity_type": "task",
"custom_value": 5
},
"34": {
"id": "57ecb6f8-dff3-47a8-81a9-66979e1ce7b4",
"created_at": 1770772307.3833773,
"updated_at": 1770772307.3833773,
"child_id": "f12a42a9-105a-4a6f-84e8-1c3a8e076d33",
"entity_id": "90279979-e91e-4f51-af78-88ad70ffab57",
"entity_type": "task",
"custom_value": 20
},
"35": {
"id": "d55b8b5c-39fc-449c-9848-99c2d572fdd8",
"created_at": 1770772307.618762,
"updated_at": 1770772307.618762,
"child_id": "f84a1e5e-3f14-44fd-8b8b-b25ede62a1d2",
"entity_id": "b64435a0-8856-4c8d-bf77-8438ff5d9061",
"entity_type": "task",
"custom_value": 0
},
"36": {
"id": "a9777db2-6912-4b21-b668-4f36566d4ef8",
"created_at": 1770772307.8648667,
"updated_at": 1770772307.8648667,
"child_id": "f84a1e5e-3f14-44fd-8b8b-b25ede62a1d2",
"entity_id": "35cf2bde-9f47-4458-ac7b-36713063deb4",
"entity_type": "task",
"custom_value": 10000
},
"37": {
"id": "04c54b24-914e-4ed6-b336-4263a4701c78",
"created_at": 1770772308.104657,
"updated_at": 1770772308.104657,
"child_id": "48bccc00-6d76-4bc9-a371-836d1a7db200",
"entity_id": "ba725bf7-2dc8-4bdb-8a82-6ed88519f2ff",
"entity_type": "reward",
"custom_value": 75
}
}
}

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,
process_deletion_queue,
check_interrupted_deletions,
trigger_deletion_manually,
MAX_DELETION_ATTEMPTS
)
from models.user import User
@@ -953,3 +954,163 @@ class TestIntegration:
assert users_db.get(Query_.id == user_id) is None
assert child_db.get(Query_.id == child_id) is None
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
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})
assert response.status_code == 200
assert response.status_code == 403
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):
"""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
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):
"""Test marking for deletion with invalid JWT token."""
# Set invalid cookie manually
@@ -176,3 +206,21 @@ def test_mark_for_deletion_with_invalid_jwt(client):
assert response.status_code == 401
data = response.get_json()
assert 'error' in data
def test_update_profile_success(authenticated_client):
"""Test successfully updating user profile."""
response = authenticated_client.put('/user/profile', json={
'first_name': 'Updated',
'last_name': 'Name',
'image_id': 'new_image'
})
assert response.status_code == 200
data = response.get_json()
assert data['message'] == 'Profile updated'
# Verify database was updated
UserQuery = Query()
user = users_db.search(UserQuery.email == TEST_EMAIL)[0]
assert user['first_name'] == 'Updated'
assert user['last_name'] == 'Name'
assert user['image_id'] == 'new_image'

View File

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

29
docker-compose.test.yml Normal file
View File

@@ -0,0 +1,29 @@
# yaml
version: "3.8"
services:
chore-test-app-backend: # Test backend service name
image: git.ryankegel.com:3000/ryan/backend:next # Use latest next tag
ports:
- "5004:5000" # Host 5004 -> Container 5000
environment:
- FLASK_ENV=development
- FRONTEND_URL=https://devserver.lan:446 # Add this for test env
# Add volumes, networks, etc., as needed
chore-test-app-frontend: # Test frontend service name
image: git.ryankegel.com:3000/ryan/frontend:next # Use latest next tag
ports:
- "446:443" # Host 446 -> Container 443 (HTTPS)
environment:
- BACKEND_HOST=chore-test-app-backend # Points to internal backend service
depends_on:
- chore-test-app-backend
# Add volumes, networks, etc., as needed
networks:
chore-test-app-net:
driver: bridge
volumes:
chore-test-app-backend-data: {}

View File

@@ -1,26 +1,32 @@
# yaml
version: '3.8'
version: "3.8"
services:
chore-app-backend:
image: devserver.lan:5900/chore-app-backend:production
container_name: chore-app-backend
restart: unless-stopped
expose:
- "5000"
networks:
- chore-app-net
volumes:
- chore-app-backend-data:/app/data # persists backend data
chore-app-frontend:
image: devserver.lan:5900/chore-app-frontend:production
container_name: chore-app-frontend
restart: unless-stopped
chore-app-backend: # Production backend service name
image: git.ryankegel.com:3000/ryan/backend:latest # Or specific version tag
container_name: chore-app-backend-prod # Added for easy identification
ports:
- "4600:443"
- "5001:5000" # Host 5001 -> Container 5000
environment:
- FLASK_ENV=production
volumes:
- chore-app-backend-data:/app/data # Assuming backend data storage; adjust path as needed
networks:
- chore-app-net
# Add other volumes, networks, etc., as needed
chore-app-frontend: # Production frontend service name
image: git.ryankegel.com:3000/ryan/frontend:latest # Or specific version tag
container_name: chore-app-frontend-prod # Added for easy identification
ports:
- "443:443" # Host 443 -> Container 443 (HTTPS)
environment:
- BACKEND_HOST=chore-app-backend # Points to internal backend service
depends_on:
- chore-app-backend
networks:
- chore-app-net
# Add volumes, networks, etc., as needed
networks:
chore-app-net:

View File

@@ -9,11 +9,18 @@ RUN npm run build
# Stage 2: Serve with nginx
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/nginx.conf
COPY nginx.conf.template /etc/nginx/nginx.conf.template
# Copy SSL certificate and key
COPY 192.168.1.102+1.pem /etc/nginx/ssl/server.crt
COPY 192.168.1.102+1-key.pem /etc/nginx/ssl/server.key
EXPOSE 80
EXPOSE 443
CMD ["nginx", "-g", "daemon off;"]
# Copy nginx.conf
COPY nginx.conf.template /etc/nginx/nginx.conf.template
# Set default BACKEND_HOST (can be overridden at runtime)
ENV BACKEND_HOST=chore-app-backend
# Use sed to replace $BACKEND_HOST with the env value, then start Nginx
CMD ["/bin/sh", "-c", "sed 's/\\$BACKEND_HOST/'\"$BACKEND_HOST\"'/g' /etc/nginx/nginx.conf.template > /etc/nginx/nginx.conf && nginx -g 'daemon off;'"]

View File

@@ -17,7 +17,7 @@ http {
ssl_ciphers HIGH:!aNULL:!MD5;
location /api/ {
proxy_pass http://chore-app-backend:5000/;
proxy_pass http://$BACKEND_HOST:5000/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -25,7 +25,7 @@ http {
}
location /events {
proxy_pass http://chore-app-backend:5000/events;
proxy_pass http://$BACKEND_HOST:5000/events;
proxy_set_header Host $host;
proxy_set_header Connection '';
proxy_http_version 1.1;

View File

@@ -0,0 +1,43 @@
events {}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
server {
client_max_body_size 2M;
listen 443 ssl;
server_name _;
root /usr/share/nginx/html;
ssl_certificate /etc/nginx/ssl/server.crt;
ssl_certificate_key /etc/nginx/ssl/server.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
location /api/ {
proxy_pass http://$BACKEND_HOST:5000/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /events {
proxy_pass http://$BACKEND_HOST:5000/events;
proxy_set_header Host $host;
proxy_set_header Connection '';
proxy_http_version 1.1;
chunked_transfer_encoding off;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 36000s;
proxy_send_timeout 36000s;
}
location / {
try_files $uri $uri/ /index.html;
}
}
}

View File

@@ -111,7 +111,6 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -664,7 +663,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -708,7 +706,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@@ -1958,7 +1955,6 @@
"integrity": "sha512-tK3GPFWbirvNgsNKto+UmB/cRtn6TZfyw0D6IKrW55n6Vbs7KJoZtI//kpTKzE/DUmmnAFD8/Ca46s7Obs92/w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.46.4",
"@typescript-eslint/types": "8.46.4",
@@ -2714,7 +2710,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2909,7 +2904,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.19",
"caniuse-lite": "^1.0.30001751",
@@ -3415,7 +3409,6 @@
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -3476,7 +3469,6 @@
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
@@ -3524,7 +3516,6 @@
"integrity": "sha512-SbR9ZBUFKgvWAbq3RrdCtWaW0IKm6wwUiApxf3BVTNfqUIo4IQQmreMg2iHFJJ6C/0wss3LXURBJ1OwS/MhFcQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.4.0",
"natural-compare": "^1.4.0",
@@ -4204,7 +4195,6 @@
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
@@ -4982,7 +4972,6 @@
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -5553,7 +5542,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -5692,7 +5680,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -5816,7 +5803,6 @@
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -6037,7 +6023,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -6051,7 +6036,6 @@
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
@@ -6144,7 +6128,6 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.24.tgz",
"integrity": "sha512-uTHDOpVQTMjcGgrqFPSb8iO2m1DUvo+WbGqoXQz8Y1CeBYQ0FXf2z1gLRaBtHjlRz7zZUBHxjVB5VTLzYkvftg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.24",
"@vue/compiler-sfc": "3.5.24",

View File

@@ -283,3 +283,207 @@ describe('UserProfile - Delete Account', () => {
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

@@ -74,7 +74,7 @@
--fab-hover-bg: #5a67d8;
--fab-active-bg: #4c51bf;
--message-block-color: #fdfdfd;
--sub-message-color: #c1d0f1;
--sub-message-color: #9eaac4;
--sign-in-btn-bg: #fff;
--sign-in-btn-color: #2563eb;
--sign-in-btn-border: #2563eb;

View File

@@ -0,0 +1,108 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount, VueWrapper } from '@vue/test-utils'
import { nextTick } from 'vue'
import LoginButton from '../shared/LoginButton.vue'
// Mock dependencies
vi.mock('vue-router', () => ({
useRouter: vi.fn(() => ({ push: vi.fn() })),
}))
vi.mock('../../stores/auth', () => ({
authenticateParent: vi.fn(),
isParentAuthenticated: { value: false },
logoutParent: vi.fn(),
logoutUser: vi.fn(),
}))
vi.mock('@/common/imageCache', () => ({
getCachedImageUrl: vi.fn(),
getCachedImageBlob: vi.fn(),
}))
vi.mock('@/common/eventBus', () => ({
eventBus: {
on: vi.fn(),
off: vi.fn(),
emit: vi.fn(),
},
}))
import { eventBus } from '@/common/eventBus'
describe('LoginButton', () => {
let wrapper: VueWrapper<any>
let mockFetch: any
beforeEach(() => {
vi.clearAllMocks()
mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)
})
afterEach(() => {
if (wrapper) {
wrapper.unmount()
}
vi.unstubAllGlobals()
})
describe('Event Listeners', () => {
it('registers event listeners on mount', () => {
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ first_name: '', last_name: '', email: '', image_id: null }),
})
wrapper = mount(LoginButton)
expect(eventBus.on).toHaveBeenCalledWith('open-login', expect.any(Function))
expect(eventBus.on).toHaveBeenCalledWith('profile_updated', expect.any(Function))
})
it('unregisters event listeners on unmount', () => {
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ first_name: '', last_name: '', email: '', image_id: null }),
})
wrapper = mount(LoginButton)
wrapper.unmount()
expect(eventBus.off).toHaveBeenCalledWith('open-login', expect.any(Function))
expect(eventBus.off).toHaveBeenCalledWith('profile_updated', expect.any(Function))
})
it('refetches profile when profile_updated event is received', async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ first_name: '', last_name: '', email: '', image_id: null }),
})
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
first_name: 'Updated',
last_name: 'User',
email: 'updated@example.com',
image_id: 'new-image-id',
}),
})
wrapper = mount(LoginButton)
// Get the profile_updated callback
const profileUpdatedCall = eventBus.on.mock.calls.find(
(call) => call[0] === 'profile_updated',
)
const profileUpdatedCallback = profileUpdatedCall[1]
// Call the callback
await profileUpdatedCallback()
// Check that fetch was called for profile
expect(mockFetch).toHaveBeenCalledWith('/api/user/profile', { credentials: 'include' })
})
})
})

View File

@@ -4,7 +4,7 @@
<h1>Welcome</h1>
<p>Please sign in or create an account to continue.</p>
<div class="auth-actions">
<button class="btn btn-primary" @click="goToLogin">Log In</button>
<button class="btn btn-primary" @click="goToLogin">Sign In</button>
<button class="btn btn-secondary" @click="goToSignup">Sign Up</button>
</div>
</div>

View File

@@ -12,14 +12,14 @@
autocomplete="username"
autofocus
v-model="email"
:class="{ 'input-error': submitAttempted && !isEmailValid }"
:class="{ 'input-error': submitAttempted && !isFormValid }"
required
/>
<small v-if="submitAttempted && !email" class="error-message" aria-live="polite">
Email is required.
</small>
<small
v-else-if="submitAttempted && !isEmailValid"
v-else-if="submitAttempted && !isFormValid"
class="error-message"
aria-live="polite"
>
@@ -40,7 +40,7 @@
</div>
<div class="form-group actions" style="margin-top: 0.4rem">
<button type="submit" class="btn btn-primary" :disabled="loading || !isEmailValid">
<button type="submit" class="btn btn-primary" :disabled="loading || !isFormValid">
{{ loading ? 'Sending…' : 'Send Reset Link' }}
</button>
</div>
@@ -92,12 +92,15 @@ const successMsg = ref('')
const isEmailValidRef = computed(() => isEmailValid(email.value))
// Add computed for form validity: email must be non-empty and valid
const isFormValid = computed(() => email.value.trim() !== '' && isEmailValidRef.value)
async function submitForm() {
submitAttempted.value = true
errorMsg.value = ''
successMsg.value = ''
if (!isEmailValidRef.value) return
if (!isFormValid.value) return
loading.value = true
try {
const res = await fetch('/api/request-password-reset', {

View File

@@ -20,7 +20,14 @@
</p>
<input v-model="code" maxlength="6" class="code-input" placeholder="6-digit code" />
<div class="button-group">
<button v-if="!loading" class="btn btn-primary" @click="verifyCode">Verify Code</button>
<button
v-if="!loading"
class="btn btn-primary"
@click="verifyCode"
:disabled="!isCodeValid"
>
Verify Code
</button>
<button class="btn btn-link" @click="resendCode" v-if="showResend" :disabled="loading">
Resend Code
</button>
@@ -46,7 +53,7 @@
class="pin-input"
placeholder="Confirm PIN"
/>
<button class="btn btn-primary" @click="setPin" :disabled="loading">
<button class="btn btn-primary" @click="setPin" :disabled="loading || !isPinValid">
{{ loading ? 'Saving...' : 'Set PIN' }}
</button>
<div v-if="error" class="error-message">{{ error }}</div>
@@ -60,7 +67,7 @@
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import { ref, watch, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { logoutParent } from '@/stores/auth'
import '@/assets/styles.css'
@@ -77,6 +84,14 @@ const showResend = ref(false)
let resendTimeout: ReturnType<typeof setTimeout> | null = null
const router = useRouter()
const isCodeValid = computed(() => code.value.length === 6)
const isPinValid = computed(() => {
const p1 = pin.value
const p2 = pin2.value
return /^\d{4,6}$/.test(p1) && /^\d{4,6}$/.test(p2) && p1 === p2
})
async function requestCode() {
error.value = ''
info.value = ''

View File

@@ -112,6 +112,16 @@
</div>
<div v-else style="margin-top: 2rem; text-align: center">Checking reset link...</div>
</div>
<!-- Success Modal -->
<ModalDialog v-if="showModal" title="Password Reset Successful" @backdrop-click="closeModal">
<p class="modal-message">
Your password has been reset successfully. You can now sign in with your new password.
</p>
<div class="modal-actions">
<button @click="goToLogin" class="btn btn-primary">Sign In</button>
</div>
</ModalDialog>
</div>
</template>
@@ -119,6 +129,7 @@
import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { isPasswordStrong } from '@/common/api'
import ModalDialog from '@/components/shared/ModalDialog.vue'
import '@/assets/styles.css'
const router = useRouter()
@@ -133,6 +144,7 @@ const successMsg = ref('')
const token = ref('')
const tokenValid = ref(false)
const tokenChecked = ref(false)
const showModal = ref(false)
const isPasswordStrongRef = computed(() => isPasswordStrong(password.value))
const passwordsMatch = computed(() => password.value === confirmPassword.value)
@@ -202,10 +214,11 @@ async function submitForm() {
errorMsg.value = msg
return
}
successMsg.value = 'Your password has been reset. You may now sign in.'
// Success: Show modal instead of successMsg
showModal.value = true
password.value = ''
confirmPassword.value = ''
submitAttempted.value = false // <-- add this line
submitAttempted.value = false
} catch {
errorMsg.value = 'Network error. Please try again.'
} finally {
@@ -213,6 +226,10 @@ async function submitForm() {
}
}
function closeModal() {
showModal.value = false
}
async function goToLogin() {
await router.push({ name: 'Login' }).catch(() => (window.location.href = '/auth/login'))
}

View File

@@ -136,7 +136,7 @@
>. Please open the email and follow the instructions to verify your account.
</p>
<div class="card-actions">
<button class="form-btn" @click="goToLogin">Go to Sign In</button>
<button class="btn btn-primary" @click="goToLogin">Sign In</button>
</div>
</div>
</div>

View File

@@ -165,20 +165,53 @@ function handleRewardModified(event: Event) {
}
}
const triggerTask = (task: Task) => {
if ('speechSynthesis' in window && task.name) {
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) {
// 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('')
})
})
})

View File

@@ -105,7 +105,7 @@
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import EntityEditForm from '../shared/EntityEditForm.vue'
import ModalDialog from '../shared/ModalDialog.vue'
@@ -172,52 +172,9 @@ function onAddImage({ id, file }: { id: string; file: File }) {
} else {
localImageFile.value = null
initialData.value.image_id = id
updateAvatar(id)
}
}
async function updateAvatar(imageId: string) {
errorMsg.value = ''
successMsg.value = ''
//todo update avatar loading state
try {
const res = await fetch('/api/user/avatar', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image_id: imageId }),
})
if (!res.ok) throw new Error('Failed to update avatar')
initialData.value.image_id = imageId
successMsg.value = 'Avatar updated!'
} catch {
//errorMsg.value = 'Failed to update avatar.'
//todo update avatar error handling
errorMsg.value = ''
}
}
watch(localImageFile, async (file) => {
if (!file) return
errorMsg.value = ''
successMsg.value = ''
const formData = new FormData()
formData.append('file', file)
formData.append('type', '2')
formData.append('permanent', 'true')
try {
const resp = await fetch('/api/image/upload', {
method: 'POST',
body: formData,
})
if (!resp.ok) throw new Error('Image upload failed')
const data = await resp.json()
initialData.value.image_id = data.id
await updateAvatar(data.id)
} catch {
errorMsg.value = 'Failed to upload avatar image.'
}
})
function handleSubmit(form: {
image_id: string | null
first_name: string
@@ -226,6 +183,43 @@ function handleSubmit(form: {
}) {
errorMsg.value = ''
loading.value = true
// Handle image upload if local file
let imageId = form.image_id
if (imageId === 'local-upload' && localImageFile.value) {
const formData = new FormData()
formData.append('file', localImageFile.value)
formData.append('type', '1')
formData.append('permanent', 'true')
fetch('/api/image/upload', {
method: 'POST',
body: formData,
})
.then(async (resp) => {
if (!resp.ok) throw new Error('Image upload failed')
const data = await resp.json()
imageId = data.id
// Now update profile
return updateProfile({
...form,
image_id: imageId,
})
})
.catch(() => {
errorMsg.value = 'Failed to upload image.'
loading.value = false
})
} else {
updateProfile(form)
}
}
async function updateProfile(form: {
image_id: string | null
first_name: string
last_name: string
email: string
}) {
fetch('/api/user/profile', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
@@ -237,6 +231,8 @@ function handleSubmit(form: {
})
.then(async (res) => {
if (!res.ok) throw new Error('Failed to update profile')
// Update initialData to reflect the saved state
initialData.value = { ...form }
modalTitle.value = 'Profile Updated'
modalSubtitle.value = ''
modalMessage.value = 'Your profile was updated successfully.'
@@ -251,7 +247,11 @@ function handleSubmit(form: {
}
async function handlePasswordModalClose() {
const wasProfileUpdate = modalTitle.value === 'Profile Updated'
showModal.value = false
if (wasProfileUpdate) {
router.back()
}
}
async function resetPassword() {

View File

@@ -36,7 +36,7 @@ const fields: {
}[] = [
{ name: 'name', label: 'Reward Name', type: 'text', required: true, maxlength: 64 },
{ 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 },
]
// removed duplicate defineProps

View File

@@ -39,7 +39,7 @@
<button type="button" class="btn btn-secondary" @click="onCancel" :disabled="loading">
Cancel
</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' }}
</button>
</div>
@@ -47,7 +47,7 @@
</template>
<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 { useRouter } from 'vue-router'
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(
() => ({ ...formData.value }),
() => {
(newVal) => {
console.log('formData changed:', newVal)
checkDirty()
},
{ deep: true },

View File

@@ -239,12 +239,14 @@ function handleClickOutside(event: MouseEvent) {
onMounted(() => {
eventBus.on('open-login', open)
eventBus.on('profile_updated', fetchUserProfile)
document.addEventListener('mousedown', handleClickOutside)
fetchUserProfile()
})
onUnmounted(() => {
eventBus.off('open-login', open)
eventBus.off('profile_updated', fetchUserProfile)
document.removeEventListener('mousedown', handleClickOutside)
// Revoke object URL to free memory
@@ -372,6 +374,10 @@ onUnmounted(() => {
</template>
<style scoped>
.error {
color: var(--error);
}
.avatar-btn {
width: 44px;
min-width: 44px;

View File

@@ -73,7 +73,7 @@ const fields: {
imageType?: number
}[] = [
{ 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: 'image_id', label: 'Image', type: 'image', imageType: 2 },
]

View File

@@ -1,7 +1,7 @@
<template>
<div class="layout-root">
<header class="topbar">
<div class="back-btn-container">
<div class="end-button-container">
<button v-show="showBack" class="back-btn" @click="handleBack" tabindex="0"> Back</button>
</div>
<div class="spacer"></div>
@@ -55,7 +55,7 @@ const showBack = computed(
box-sizing: border-box;
}
.back-btn-container {
.end-button-container {
display: flex;
align-items: center;
justify-content: center;

View File

@@ -45,7 +45,7 @@ onMounted(async () => {
<template>
<div class="layout-root">
<header class="topbar">
<div class="back-btn-container edge-btn-container">
<div class="end-button-container">
<button v-show="showBack" class="back-btn" @click="handleBack" tabindex="0"> Back</button>
</div>
<nav v-if="!hideViewSelector" class="view-selector">
@@ -153,7 +153,8 @@ onMounted(async () => {
</svg>
</button>
</nav>
<div class="login-btn-container edge-btn-container">
<div v-else class="spacer"></div>
<div class="end-button-container">
<LoginButton />
</div>
</header>
@@ -186,7 +187,7 @@ onMounted(async () => {
box-sizing: border-box;
}
.edge-btn-container {
.end-button-container {
display: flex;
align-items: center;
justify-content: center;
@@ -227,6 +228,13 @@ onMounted(async () => {
color 0.18s;
}
.spacer {
flex: 1 1 auto;
height: 100%;
display: flex;
align-items: center;
}
@media (max-width: 480px) {
.back-btn {
font-size: 0.7rem;