Compare commits
166 Commits
1.0.4-rc1
...
8308d205e8
| Author | SHA1 | Date | |
|---|---|---|---|
| 8308d205e8 | |||
| e77254eabf | |||
| a68a86a6a6 | |||
| 4ac83dcf17 | |||
| ab0d32c6b0 | |||
| 28f5c43349 | |||
| a2b464af7b | |||
| 6bf10fda2f | |||
| c840825549 | |||
| 9936cfd544 | |||
| b42c36ebdd | |||
| 395199f9b2 | |||
| a37b259f47 | |||
| 0606b71890 | |||
| b4fc4fc955 | |||
| 8774e85529 | |||
| 2b2f4f46c0 | |||
| c5306a15d1 | |||
| fa1a422747 | |||
| 4093e79e50 | |||
| 39a547ca9c | |||
| 127378797c | |||
| db846f4e31 | |||
| 6d43fb23ad | |||
| 6a29e263aa | |||
| 89de4fd869 | |||
| d34910faa7 | |||
| 1a8181b8e5 | |||
| 902e6cadc9 | |||
| c9a4f92337 | |||
| ea308b28a9 | |||
| 8907184fde | |||
| 6982fa561f | |||
| bc481527c8 | |||
| 60dbb8a129 | |||
| 6b3e2cd9ba | |||
| 3d599243c7 | |||
| cdfaf7ead1 | |||
| 8da61ce335 | |||
| 3d2577e4ec | |||
| 2c7e9b8b5e | |||
| f48845c1d0 | |||
| 4ee5367742 | |||
| ee16b49020 | |||
| b529ddaa02 | |||
| fd28c89cbf | |||
| d7b1962903 | |||
| 9efbb455d7 | |||
| d3ce54a1ff | |||
| 3d5f84579b | |||
| 5e4f7b030e | |||
| 91e3c45b5a | |||
| 0a6551368b | |||
| cf2ac4b5e8 | |||
| f5dfdfbb42 | |||
| 43d647a712 | |||
| 308bf0cc72 | |||
| ad2bdf4c4f | |||
| 0d50a324a3 | |||
| ea0166d198 | |||
| 3116295980 | |||
| 876d3c5531 | |||
| 759a1c745e | |||
| bb7c4c469c | |||
| 861b3dc9d4 | |||
| 8080a59de1 | |||
| a4e23aad11 | |||
| 89097a390e | |||
| 469a2ffc24 | |||
| 028f99b5c3 | |||
| 16701278ed | |||
| 359c170b27 | |||
| f64311689b | |||
| ac29ee9bd0 | |||
| e9f4343426 | |||
| 81169da05e | |||
| ef9cb01d92 | |||
| db6e0a7ce8 | |||
| a9131242a7 | |||
| b2115ceb57 | |||
| cd902da292 | |||
| c2b022eb0b | |||
| 8da04676ca | |||
| f250c42e5e | |||
| accf596bd7 | |||
| 2c65d3ecaf | |||
| a8d7427a95 | |||
| b2618361e4 | |||
| a10836d412 | |||
| bb5330ac17 | |||
| 8cdc26cb88 | |||
| de56eb064f | |||
| 031d7c0eec | |||
| f07af135b7 | |||
| 60647bc742 | |||
| 384be2a79e | |||
| ccfc710753 | |||
| 992dd8423f | |||
| c922e1180d | |||
| 82ac820c67 | |||
| 76fef8c688 | |||
| 16d3500368 | |||
| c3538cc3d4 | |||
| 6433236191 | |||
| ebaef16daf | |||
| d7316bb00a | |||
| 65e987ceb6 | |||
| f12940dc11 | |||
| 1777700cc8 | |||
| f5a752d873 | |||
| a197f8e206 | |||
| 2403daa3f7 | |||
| 91a52c1973 | |||
| a41a357f50 | |||
| 234adbe05f | |||
| d8822b44be | |||
| d68272bb57 | |||
| 3673119ae2 | |||
| 55e7dc7568 | |||
| ba909100a7 | |||
| 8148bfac51 | |||
| c43af7d43e | |||
| 10216f49c9 | |||
| 42d3567c22 | |||
| be4a816a7c | |||
| 773840d88b | |||
| 075160941a | |||
| d2fea646de | |||
| 087aa07a74 | |||
| 8cb9199ab7 | |||
| bbdabefd62 | |||
| a7ac179e1a | |||
| 53236ab019 | |||
| 8708a1a68f | |||
| 8008f1d116 | |||
| c18d202ecc | |||
| 725bf518ea | |||
| 31ea76f013 | |||
| 5e22e5e0ee | |||
| 7e7a2ef49e | |||
| 3e1715e487 | |||
| 11e7fda997 | |||
| 09d42b14c5 | |||
| 3848be32e8 | |||
| 1aff366fd8 | |||
| 0ab40f85a4 | |||
| 22889caab4 | |||
| b538782c09 | |||
|
|
7a827b14ef | ||
| 9238d7e3a5 | |||
| c17838241a | |||
| d183e0a4b6 | |||
| b25ebaaec0 | |||
| ae5b40512c | |||
| 92635a356c | |||
| 235269bdb6 | |||
| 5d4b0ec2c9 | |||
| a21cb60aeb | |||
| e604870e26 | |||
| c3e35258a1 | |||
| d2a56e36c7 | |||
| 3bfca4e2b0 | |||
| f5d68aec4a | |||
| 38c637cc67 | |||
| f29c90897f | |||
| efb65b6da3 |
1
.env.example
Normal file
1
.env.example
Normal file
@@ -0,0 +1 @@
|
||||
FRONTEND_URL=https://yourdomain.com
|
||||
@@ -1,5 +1,5 @@
|
||||
name: Chore App Build and Push Docker Images
|
||||
run-name: ${{ gitea.actor }} is building the chore app 🚀
|
||||
name: Chore App Build, Test, and Push Docker Images
|
||||
run-name: ${{ gitea.actor }} is building Chores [${{ gitea.ref_name }}@${{ gitea.sha }}] 🚀
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
@@ -24,40 +24,164 @@ jobs:
|
||||
echo "tag=next-$version-$current_date" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Resolve Gitea Server IP
|
||||
id: gitea_ip
|
||||
- name: Set up Python for backend tests
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install backend dependencies
|
||||
run: |
|
||||
ip=$(getent hosts gitea-server | awk '{ print $1 }')
|
||||
echo "ip=$ip" >> $GITHUB_OUTPUT
|
||||
echo "Resolved Gitea server IP: $ip"
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r backend/requirements.txt
|
||||
|
||||
- name: Run backend unit tests
|
||||
run: |
|
||||
cd backend
|
||||
pytest -q
|
||||
|
||||
- name: Set up Node.js for frontend tests
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20.19.0"
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: npm ci
|
||||
working-directory: frontend
|
||||
|
||||
- name: Run frontend unit tests
|
||||
run: npm run test:unit --if-present
|
||||
working-directory: frontend
|
||||
|
||||
- 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/kegel/chores/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/kegel/chores/frontend:${{ steps.vars.outputs.tag }} ./frontend
|
||||
|
||||
- name: Log in to Registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ${{ steps.gitea_ip.outputs.ip }}: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)
|
||||
registry: git.ryankegel.com:3000
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
|
||||
- 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/kegel/chores/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/kegel/chores/backend:${{ steps.vars.outputs.tag }} git.ryankegel.com:3000/kegel/chores/backend:latest
|
||||
docker push git.ryankegel.com:3000/kegel/chores/backend:latest
|
||||
elif [ "${{ gitea.ref }}" == "refs/heads/next" ]; then
|
||||
docker tag git.ryankegel.com:3000/kegel/chores/backend:${{ steps.vars.outputs.tag }} git.ryankegel.com:3000/kegel/chores/backend:next
|
||||
docker push git.ryankegel.com:3000/kegel/chores/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/kegel/chores/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/kegel/chores/frontend:${{ steps.vars.outputs.tag }} git.ryankegel.com:3000/kegel/chores/frontend:latest
|
||||
docker push git.ryankegel.com:3000/kegel/chores/frontend:latest
|
||||
elif [ "${{ gitea.ref }}" == "refs/heads/next" ]; then
|
||||
docker tag git.ryankegel.com:3000/kegel/chores/frontend:${{ steps.vars.outputs.tag }} git.ryankegel.com:3000/kegel/chores/frontend:next
|
||||
docker push git.ryankegel.com:3000/kegel/chores/frontend:next
|
||||
fi
|
||||
|
||||
- name: Deploy Test Environment
|
||||
if: gitea.ref == 'refs/heads/next'
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: ${{ secrets.DEPLOY_TEST_HOST }}
|
||||
username: ${{ secrets.DEPLOY_TEST_USER }}
|
||||
key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
port: 22
|
||||
script: |
|
||||
cd /tmp
|
||||
if [ -d "chore" ]; then
|
||||
cd chore
|
||||
git pull origin next || true
|
||||
else
|
||||
git clone --branch next https://git.ryankegel.com/ryan/chore.git
|
||||
cd chore
|
||||
fi
|
||||
|
||||
# Write .env file — docker-compose automatically reads this
|
||||
cat > .env << EOF
|
||||
SECRET_KEY=${{ secrets.SECRET_KEY }}
|
||||
REFRESH_TOKEN_EXPIRY_DAYS=1
|
||||
DIGEST_TOKEN_SECRET=${{ secrets.DIGEST_TOKEN_SECRET }}
|
||||
VAPID_PUBLIC_KEY=${{ secrets.VAPID_PUBLIC_KEY }}
|
||||
VAPID_PRIVATE_KEY=${{ secrets.VAPID_PRIVATE_KEY }}
|
||||
SEED_EMAIL=${{ secrets.SEED_EMAIL }}
|
||||
SEED_PASSWORD=${{ secrets.SEED_PASSWORD }}
|
||||
SEED_PIN=${{ secrets.SEED_PIN }}
|
||||
SEED_FIRST_NAME=${{ secrets.SEED_FIRST_NAME }}
|
||||
SEED_LAST_NAME=${{ secrets.SEED_LAST_NAME }}
|
||||
ADMIN_EMAIL=${{ secrets.ADMIN_EMAIL }}
|
||||
ADMIN_PASSWORD=${{ secrets.ADMIN_PASSWORD }}
|
||||
ADMIN_FIRST_NAME=${{ secrets.ADMIN_FIRST_NAME }}
|
||||
ADMIN_LAST_NAME=${{ secrets.ADMIN_LAST_NAME }}
|
||||
EOF
|
||||
|
||||
echo "SECRET_KEY is set: $(grep -q 'SECRET_KEY=' .env && echo YES || echo NO)"
|
||||
|
||||
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
|
||||
docker-compose -f docker-compose.test.yml up -d
|
||||
|
||||
echo "Waiting for backend to be ready..."
|
||||
sleep 10
|
||||
echo "Seeding test user..."
|
||||
docker-compose -f docker-compose.test.yml exec -T chores-test-app-backend python scripts/seed_test_user.py
|
||||
echo "Creating admin user..."
|
||||
docker-compose -f docker-compose.test.yml exec -T chores-test-app-backend python scripts/create_admin.py
|
||||
|
||||
- name: Send mail
|
||||
if: always() # Runs on success or failure
|
||||
uses: dawidd6/action-send-mail@v3
|
||||
with:
|
||||
server_address: smtp.gmail.com
|
||||
server_port: 465
|
||||
username: ${{ secrets.MAIL_USER }}
|
||||
password: ${{ secrets.MAIL_PASSWORD }}
|
||||
secure: true
|
||||
to: ${{ secrets.MAIL_TO }}
|
||||
from: Gitea <git@git.ryankegel.com>
|
||||
subject: ${{ github.repository }} - Job ${{ job.status }}
|
||||
convert_markdown: true
|
||||
html_body: |
|
||||
### Job ${{ job.status }}
|
||||
|
||||
${{ github.repository }}: [${{ github.ref }}@${{ github.sha }}](${{ vars.GIT_SERVER }}/${{ github.repository }}/actions)
|
||||
|
||||
503
.gitea/workflows/promote-master.yaml
Normal file
503
.gitea/workflows/promote-master.yaml
Normal file
@@ -0,0 +1,503 @@
|
||||
name: Promote Master to Production
|
||||
run-name: ${{ gitea.actor }} promoting ${{ github.event.inputs.target_ref || 'master' }} [${{ gitea.sha }}]
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
target_ref:
|
||||
description: "Git ref/branch to promote"
|
||||
required: true
|
||||
default: "master"
|
||||
run_backend_tests:
|
||||
description: "Run backend pytest gate"
|
||||
required: true
|
||||
default: "true"
|
||||
run_frontend_tests:
|
||||
description: "Run frontend unit test gate"
|
||||
required: true
|
||||
default: "true"
|
||||
run_playwright_tests:
|
||||
description: "Run Playwright E2E gate"
|
||||
required: true
|
||||
default: "true"
|
||||
skip_tests:
|
||||
description: "Skip the entire tests stage"
|
||||
required: true
|
||||
default: "false"
|
||||
deploy:
|
||||
description: "Run production deploy over SSH"
|
||||
required: true
|
||||
default: "true"
|
||||
create_tag:
|
||||
description: "Create and push git release tag after successful deploy"
|
||||
required: true
|
||||
default: "true"
|
||||
require_manual_approval:
|
||||
description: "Require approval token for deploy job"
|
||||
required: true
|
||||
default: "false"
|
||||
approval_token:
|
||||
description: "Approval token value when manual approval is required"
|
||||
required: false
|
||||
default: ""
|
||||
rollback_on_failed_healthcheck:
|
||||
description: "Auto-rollback if post-deploy health checks fail"
|
||||
required: true
|
||||
default: "false"
|
||||
|
||||
concurrency:
|
||||
group: promotion-production
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
target_ref: ${{ steps.resolve.outputs.target_ref }}
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
release_tag: ${{ steps.version.outputs.release_tag }}
|
||||
commit_sha: ${{ steps.commit.outputs.commit_sha }}
|
||||
steps:
|
||||
- name: Resolve target ref
|
||||
id: resolve
|
||||
run: |
|
||||
target_ref="${{ github.event.inputs.target_ref }}"
|
||||
if [ -z "$target_ref" ]; then
|
||||
target_ref="master"
|
||||
fi
|
||||
echo "target_ref=$target_ref" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
ref: ${{ steps.resolve.outputs.target_ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Resolve version and release tag
|
||||
id: version
|
||||
run: |
|
||||
version=$(python -c "import sys; sys.path.append('./backend'); from config.version import BASE_VERSION; print(BASE_VERSION)")
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
echo "release_tag=v$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Resolve promoted commit SHA
|
||||
id: commit
|
||||
run: |
|
||||
echo "commit_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Guard against duplicate release tags
|
||||
if: ${{ github.event.inputs.create_tag != 'false' }}
|
||||
run: |
|
||||
release_tag="${{ steps.version.outputs.release_tag }}"
|
||||
if git ls-remote --exit-code --tags origin "refs/tags/${release_tag}" >/dev/null 2>&1; then
|
||||
echo "Release tag ${release_tag} already exists on origin; aborting promotion."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 120
|
||||
needs: prepare
|
||||
steps:
|
||||
- name: Skip tests stage
|
||||
if: ${{ github.event.inputs.skip_tests == 'true' }}
|
||||
run: echo "Skipping tests stage because skip_tests=true"
|
||||
|
||||
- name: Check out promoted ref
|
||||
if: ${{ github.event.inputs.skip_tests != 'true' }}
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
ref: ${{ needs.prepare.outputs.target_ref }}
|
||||
|
||||
- name: Set up Python for backend tests
|
||||
if: ${{ github.event.inputs.skip_tests != 'true' && (github.event.inputs.run_backend_tests != 'false' || github.event.inputs.run_playwright_tests != 'false') }}
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install backend dependencies (runner python)
|
||||
if: ${{ github.event.inputs.skip_tests != 'true' && github.event.inputs.run_backend_tests != 'false' }}
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r backend/requirements.txt
|
||||
|
||||
- name: Run backend unit tests
|
||||
if: ${{ github.event.inputs.skip_tests != 'true' && github.event.inputs.run_backend_tests != 'false' }}
|
||||
run: |
|
||||
cd backend
|
||||
pytest -q
|
||||
|
||||
- name: Set up Node.js
|
||||
if: ${{ github.event.inputs.skip_tests != 'true' && (github.event.inputs.run_frontend_tests != 'false' || github.event.inputs.run_playwright_tests != 'false') }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20.19.0"
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install frontend dependencies
|
||||
if: ${{ github.event.inputs.skip_tests != 'true' && (github.event.inputs.run_frontend_tests != 'false' || github.event.inputs.run_playwright_tests != 'false') }}
|
||||
run: npm ci
|
||||
working-directory: frontend
|
||||
|
||||
- name: Run frontend unit tests
|
||||
if: ${{ github.event.inputs.skip_tests != 'true' && github.event.inputs.run_frontend_tests != 'false' }}
|
||||
run: npm run test:unit --if-present
|
||||
working-directory: frontend
|
||||
|
||||
- name: Create backend venv for Playwright webServer
|
||||
if: ${{ github.event.inputs.skip_tests != 'true' && github.event.inputs.run_playwright_tests != 'false' }}
|
||||
run: |
|
||||
python -m venv backend/.venv
|
||||
backend/.venv/bin/python -m pip install --upgrade pip
|
||||
backend/.venv/bin/python -m pip install -r backend/requirements.txt
|
||||
|
||||
- name: Install Playwright browsers
|
||||
if: ${{ github.event.inputs.skip_tests != 'true' && github.event.inputs.run_playwright_tests != 'false' }}
|
||||
run: npx playwright install --with-deps
|
||||
working-directory: frontend
|
||||
|
||||
- name: Run Playwright tests
|
||||
if: ${{ github.event.inputs.skip_tests != 'true' && github.event.inputs.run_playwright_tests != 'false' }}
|
||||
run: npx playwright test --reporter=line
|
||||
working-directory: frontend
|
||||
env:
|
||||
CI: "true"
|
||||
PLAYWRIGHT_BASE_URL: "https://localhost:5173"
|
||||
E2E_ACCESS_TOKEN_EXPIRY_MINUTES: "180"
|
||||
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
needs:
|
||||
- prepare
|
||||
- tests
|
||||
if: ${{ github.event.inputs.deploy != 'false' }}
|
||||
steps:
|
||||
- name: Validate manual approval token
|
||||
if: ${{ github.event.inputs.require_manual_approval != 'false' }}
|
||||
run: |
|
||||
if [ -z "${{ secrets.PROD_APPROVAL_TOKEN }}" ]; then
|
||||
echo "PROD_APPROVAL_TOKEN secret is required when manual approval is enabled."
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${{ github.event.inputs.approval_token }}" ]; then
|
||||
echo "approval_token input is required when manual approval is enabled."
|
||||
exit 1
|
||||
fi
|
||||
if [ "${{ github.event.inputs.approval_token }}" != "${{ secrets.PROD_APPROVAL_TOKEN }}" ]; then
|
||||
echo "Approval token does not match PROD_APPROVAL_TOKEN."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Deploy to production homeserver
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: ${{ secrets.DEPLOY_PROD_HOST }}
|
||||
username: ${{ secrets.DEPLOY_PROD_USER }}
|
||||
key: ${{ secrets.PROD_SSH_PRIVATE_KEY }}
|
||||
port: 22
|
||||
script: |
|
||||
set -euo pipefail
|
||||
|
||||
repo_dir="${{ secrets.DEPLOY_PROD_PATH }}"
|
||||
if [ -z "$repo_dir" ]; then
|
||||
repo_dir="$HOME/chore"
|
||||
fi
|
||||
|
||||
repo_parent=$(dirname "$repo_dir")
|
||||
if [ ! -d "$repo_parent" ]; then
|
||||
mkdir -p "$repo_parent"
|
||||
fi
|
||||
if [ ! -w "$repo_parent" ]; then
|
||||
echo "Deploy user $(whoami) cannot write to ${repo_parent}."
|
||||
echo "Set DEPLOY_PROD_PATH to a writable location or grant write access before rerunning promotion."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -d "$repo_dir/.git" ]; then
|
||||
cd "$repo_dir"
|
||||
git fetch --all --tags
|
||||
else
|
||||
git clone --branch "${{ needs.prepare.outputs.target_ref }}" https://git.ryankegel.com/ryan/chore.git "$repo_dir"
|
||||
cd "$repo_dir"
|
||||
fi
|
||||
|
||||
git checkout "${{ needs.prepare.outputs.target_ref }}"
|
||||
git reset --hard "origin/${{ needs.prepare.outputs.target_ref }}"
|
||||
|
||||
timestamp=$(date +%Y%m%d-%H%M%S)
|
||||
mkdir -p backups
|
||||
|
||||
# Capture currently running images for rollback.
|
||||
backend_prev_id=""
|
||||
frontend_prev_id=""
|
||||
if docker ps --format '{{.Names}}' | grep -q '^chores-app-backend-prod$'; then
|
||||
backend_prev_id=$(docker inspect -f '{{.Image}}' chores-app-backend-prod)
|
||||
docker tag "$backend_prev_id" git.ryankegel.com:3000/kegel/chores/backend:predeploy-backup || true
|
||||
fi
|
||||
if docker ps --format '{{.Names}}' | grep -q '^chores-app-frontend-prod$'; then
|
||||
frontend_prev_id=$(docker inspect -f '{{.Image}}' chores-app-frontend-prod)
|
||||
docker tag "$frontend_prev_id" git.ryankegel.com:3000/kegel/chores/frontend:predeploy-backup || true
|
||||
fi
|
||||
|
||||
cat > .rollback-meta << EOF
|
||||
BACKEND_PREV_ID=${backend_prev_id}
|
||||
FRONTEND_PREV_ID=${frontend_prev_id}
|
||||
CREATED_AT=${timestamp}
|
||||
PROMOTED_SHA=${{ needs.prepare.outputs.commit_sha }}
|
||||
RELEASE_TAG=${{ needs.prepare.outputs.release_tag }}
|
||||
EOF
|
||||
|
||||
# Backup TinyDB volume before replacement.
|
||||
docker run --rm \
|
||||
-v chores-app-backend-data:/data \
|
||||
-v "$PWD/backups:/backup" \
|
||||
alpine sh -c "tar czf /backup/chores-backend-data-${timestamp}.tar.gz -C /data ." || true
|
||||
|
||||
# Keep long-lived cryptographic values stable; fall back to previous .env if secrets are absent.
|
||||
PREV_SECRET_KEY=""
|
||||
PREV_DIGEST_TOKEN_SECRET=""
|
||||
PREV_VAPID_PUBLIC_KEY=""
|
||||
PREV_VAPID_PRIVATE_KEY=""
|
||||
PREV_REFRESH_TOKEN_EXPIRY_DAYS=""
|
||||
if [ -f .env ]; then
|
||||
PREV_SECRET_KEY=$(grep '^SECRET_KEY=' .env | head -n1 | cut -d '=' -f2- || true)
|
||||
PREV_DIGEST_TOKEN_SECRET=$(grep '^DIGEST_TOKEN_SECRET=' .env | head -n1 | cut -d '=' -f2- || true)
|
||||
PREV_VAPID_PUBLIC_KEY=$(grep '^VAPID_PUBLIC_KEY=' .env | head -n1 | cut -d '=' -f2- || true)
|
||||
PREV_VAPID_PRIVATE_KEY=$(grep '^VAPID_PRIVATE_KEY=' .env | head -n1 | cut -d '=' -f2- || true)
|
||||
PREV_REFRESH_TOKEN_EXPIRY_DAYS=$(grep '^REFRESH_TOKEN_EXPIRY_DAYS=' .env | head -n1 | cut -d '=' -f2- || true)
|
||||
fi
|
||||
|
||||
SECRET_KEY_VALUE="${{ secrets.PROD_SECRET_KEY }}"
|
||||
DIGEST_TOKEN_SECRET_VALUE="${{ secrets.PROD_DIGEST_TOKEN_SECRET }}"
|
||||
VAPID_PUBLIC_KEY_VALUE="${{ secrets.PROD_VAPID_PUBLIC_KEY }}"
|
||||
VAPID_PRIVATE_KEY_VALUE="${{ secrets.PROD_VAPID_PRIVATE_KEY }}"
|
||||
REFRESH_TOKEN_EXPIRY_DAYS_VALUE="${{ secrets.PROD_REFRESH_TOKEN_EXPIRY_DAYS }}"
|
||||
|
||||
if [ -z "$SECRET_KEY_VALUE" ]; then
|
||||
if [ -n "$PREV_SECRET_KEY" ]; then
|
||||
SECRET_KEY_VALUE="$PREV_SECRET_KEY"
|
||||
else
|
||||
SECRET_KEY_VALUE=$(openssl rand -hex 32)
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$DIGEST_TOKEN_SECRET_VALUE" ]; then
|
||||
if [ -n "$PREV_DIGEST_TOKEN_SECRET" ]; then
|
||||
DIGEST_TOKEN_SECRET_VALUE="$PREV_DIGEST_TOKEN_SECRET"
|
||||
else
|
||||
DIGEST_TOKEN_SECRET_VALUE=$(openssl rand -hex 32)
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$VAPID_PUBLIC_KEY_VALUE" ] && [ -n "$PREV_VAPID_PUBLIC_KEY" ]; then
|
||||
VAPID_PUBLIC_KEY_VALUE="$PREV_VAPID_PUBLIC_KEY"
|
||||
fi
|
||||
if [ -z "$VAPID_PRIVATE_KEY_VALUE" ] && [ -n "$PREV_VAPID_PRIVATE_KEY" ]; then
|
||||
VAPID_PRIVATE_KEY_VALUE="$PREV_VAPID_PRIVATE_KEY"
|
||||
fi
|
||||
|
||||
if [ -z "$VAPID_PUBLIC_KEY_VALUE" ] || [ -z "$VAPID_PRIVATE_KEY_VALUE" ]; then
|
||||
echo "VAPID keys are required (set PROD_VAPID_PUBLIC_KEY/PROD_VAPID_PRIVATE_KEY or keep previous .env values)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$REFRESH_TOKEN_EXPIRY_DAYS_VALUE" ]; then
|
||||
if [ -n "$PREV_REFRESH_TOKEN_EXPIRY_DAYS" ]; then
|
||||
REFRESH_TOKEN_EXPIRY_DAYS_VALUE="$PREV_REFRESH_TOKEN_EXPIRY_DAYS"
|
||||
else
|
||||
REFRESH_TOKEN_EXPIRY_DAYS_VALUE="90"
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! printf '%s' "$REFRESH_TOKEN_EXPIRY_DAYS_VALUE" | grep -Eq '^[0-9]+$'; then
|
||||
echo "REFRESH_TOKEN_EXPIRY_DAYS must be a positive integer, got: $REFRESH_TOKEN_EXPIRY_DAYS_VALUE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cat > .env << EOF
|
||||
FRONTEND_URL=${{ secrets.PROD_FRONTEND_URL }}
|
||||
BACKEND_HOST_PORT=${{ secrets.PROD_BACKEND_HOST_PORT }}
|
||||
FRONTEND_HOST_PORT=${{ secrets.PROD_FRONTEND_HOST_PORT }}
|
||||
SECRET_KEY=${SECRET_KEY_VALUE}
|
||||
REFRESH_TOKEN_EXPIRY_DAYS=${REFRESH_TOKEN_EXPIRY_DAYS_VALUE}
|
||||
DIGEST_TOKEN_SECRET=${DIGEST_TOKEN_SECRET_VALUE}
|
||||
VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY_VALUE}
|
||||
VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY_VALUE}
|
||||
ADMIN_EMAIL=${{ secrets.PROD_ADMIN_EMAIL }}
|
||||
ADMIN_PASSWORD=${{ secrets.PROD_ADMIN_PASSWORD }}
|
||||
ADMIN_FIRST_NAME=${{ secrets.PROD_ADMIN_FIRST_NAME }}
|
||||
ADMIN_LAST_NAME=${{ secrets.PROD_ADMIN_LAST_NAME }}
|
||||
EOF
|
||||
|
||||
chmod 600 .env .rollback-meta
|
||||
|
||||
backend_host_port=$(grep '^BACKEND_HOST_PORT=' .env | head -n1 | cut -d '=' -f2- || true)
|
||||
frontend_host_port=$(grep '^FRONTEND_HOST_PORT=' .env | head -n1 | cut -d '=' -f2- || true)
|
||||
if [ -z "$backend_host_port" ]; then
|
||||
backend_host_port="5001"
|
||||
fi
|
||||
if [ -z "$frontend_host_port" ]; then
|
||||
frontend_host_port="4601"
|
||||
fi
|
||||
|
||||
docker-compose down
|
||||
|
||||
wait_for_container_removal() {
|
||||
container_name="$1"
|
||||
attempts=0
|
||||
while docker ps -a --format '{{.Names}}' | grep -qx "$container_name"; do
|
||||
attempts=$((attempts + 1))
|
||||
if [ "$attempts" -ge 12 ]; then
|
||||
echo "Container ${container_name} still exists after waiting for teardown."
|
||||
docker ps -a --filter "name=^${container_name}$"
|
||||
return 1
|
||||
fi
|
||||
echo "Waiting for ${container_name} to be removed..."
|
||||
sleep 5
|
||||
done
|
||||
}
|
||||
|
||||
wait_for_port_release() {
|
||||
port="$1"
|
||||
attempts=0
|
||||
while docker ps -a --format '{{.ID}} {{.Names}} {{.Ports}} {{.Status}}' | grep -E "(^|[[:space:]])0\.0\.0\.0:${port}->|:::${port}->" >/dev/null 2>&1; do
|
||||
attempts=$((attempts + 1))
|
||||
if [ "$attempts" -ge 12 ]; then
|
||||
echo "Port ${port} is still allocated after teardown. Blocking container(s):"
|
||||
docker ps -a --format '{{.ID}} {{.Names}} {{.Ports}} {{.Status}}' | grep -E "(^|[[:space:]])0\.0\.0\.0:${port}->|:::${port}->" || true
|
||||
return 1
|
||||
fi
|
||||
echo "Waiting for port ${port} to be released..."
|
||||
sleep 5
|
||||
done
|
||||
}
|
||||
|
||||
wait_for_container_removal chores-app-frontend-prod
|
||||
wait_for_container_removal chores-app-backend-prod
|
||||
wait_for_port_release "$frontend_host_port"
|
||||
wait_for_port_release "$backend_host_port"
|
||||
|
||||
docker-compose pull
|
||||
docker-compose up -d
|
||||
|
||||
sleep 20
|
||||
|
||||
backend_running=$(docker inspect -f '{{.State.Running}}' chores-app-backend-prod 2>/dev/null || echo false)
|
||||
frontend_running=$(docker inspect -f '{{.State.Running}}' chores-app-frontend-prod 2>/dev/null || echo false)
|
||||
|
||||
backend_ok=false
|
||||
if curl -fsS "http://localhost:${backend_host_port}/version" >/dev/null 2>&1; then
|
||||
backend_ok=true
|
||||
fi
|
||||
|
||||
frontend_ok=false
|
||||
if curl -kfsS "https://localhost:${frontend_host_port}/" >/dev/null 2>&1; then
|
||||
frontend_ok=true
|
||||
fi
|
||||
|
||||
if [ "$backend_running" != "true" ] || [ "$frontend_running" != "true" ] || [ "$backend_ok" != "true" ] || [ "$frontend_ok" != "true" ]; then
|
||||
echo "Post-deploy health check failed."
|
||||
docker-compose ps
|
||||
echo "--- Backend logs (last 200 lines) ---"
|
||||
docker logs --tail 200 chores-app-backend-prod || true
|
||||
echo "--- Frontend logs (last 120 lines) ---"
|
||||
docker logs --tail 120 chores-app-frontend-prod || true
|
||||
|
||||
if [ "${{ github.event.inputs.rollback_on_failed_healthcheck }}" = "true" ]; then
|
||||
echo "Attempting rollback using predeploy-backup image tags..."
|
||||
docker tag git.ryankegel.com:3000/kegel/chores/backend:predeploy-backup git.ryankegel.com:3000/kegel/chores/backend:latest || true
|
||||
docker tag git.ryankegel.com:3000/kegel/chores/frontend:predeploy-backup git.ryankegel.com:3000/kegel/chores/frontend:latest || true
|
||||
docker-compose up -d
|
||||
sleep 20
|
||||
curl -fsS "http://localhost:${backend_host_port}/version" >/dev/null
|
||||
fi
|
||||
|
||||
exit 1
|
||||
fi
|
||||
|
||||
docker-compose ps
|
||||
|
||||
create-release-tag:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
needs:
|
||||
- prepare
|
||||
- tests
|
||||
- deploy
|
||||
if: ${{ github.event.inputs.create_tag != 'false' && github.event.inputs.deploy != 'false' }}
|
||||
steps:
|
||||
- name: Check out promoted ref
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
ref: ${{ needs.prepare.outputs.target_ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Create and push annotated release tag
|
||||
run: |
|
||||
tag_name="${{ needs.prepare.outputs.release_tag }}"
|
||||
git config user.name "gitea-actions"
|
||||
git config user.email "gitea-actions@local"
|
||||
git tag -a "$tag_name" -m "Release $tag_name"
|
||||
git push origin "$tag_name"
|
||||
|
||||
notify-on-failure:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- prepare
|
||||
- tests
|
||||
- deploy
|
||||
- create-release-tag
|
||||
if: ${{ always() }}
|
||||
steps:
|
||||
- name: Send email when promotion fails
|
||||
if: ${{ needs.prepare.result == 'failure' || needs.tests.result == 'failure' || needs.deploy.result == 'failure' || needs.create-release-tag.result == 'failure' }}
|
||||
uses: dawidd6/action-send-mail@v3
|
||||
with:
|
||||
server_address: smtp.gmail.com
|
||||
server_port: 465
|
||||
username: ${{ secrets.MAIL_USER }}
|
||||
password: ${{ secrets.MAIL_PASSWORD }}
|
||||
secure: true
|
||||
to: ${{ secrets.MAIL_TO }}
|
||||
from: Gitea <git@git.ryankegel.com>
|
||||
subject: Promotion failed - ${{ gitea.repository }} [${{ needs.prepare.outputs.target_ref }}@${{ needs.prepare.outputs.commit_sha }}]
|
||||
convert_markdown: true
|
||||
html_body: |
|
||||
### Production promotion failed
|
||||
|
||||
- Repository: ${{ gitea.repository }}
|
||||
- Ref: ${{ needs.prepare.outputs.target_ref }}
|
||||
- Commit: ${{ needs.prepare.outputs.commit_sha }}
|
||||
- Intended tag: ${{ needs.prepare.outputs.release_tag }}
|
||||
- prepare: ${{ needs.prepare.result }}
|
||||
- tests: ${{ needs.tests.result }}
|
||||
- deploy: ${{ needs.deploy.result }}
|
||||
- tag: ${{ needs.create-release-tag.result }}
|
||||
|
||||
- name: Send email when promotion succeeds
|
||||
if: ${{ needs.prepare.result == 'success' && needs.tests.result == 'success' && (needs.deploy.result == 'success' || needs.deploy.result == 'skipped') && (needs.create-release-tag.result == 'success' || needs.create-release-tag.result == 'skipped') }}
|
||||
uses: dawidd6/action-send-mail@v3
|
||||
with:
|
||||
server_address: smtp.gmail.com
|
||||
server_port: 465
|
||||
username: ${{ secrets.MAIL_USER }}
|
||||
password: ${{ secrets.MAIL_PASSWORD }}
|
||||
secure: true
|
||||
to: ${{ secrets.MAIL_TO }}
|
||||
from: Gitea <git@git.ryankegel.com>
|
||||
subject: Promotion succeeded - ${{ gitea.repository }} [${{ needs.prepare.outputs.target_ref }}@${{ needs.prepare.outputs.commit_sha }}]
|
||||
convert_markdown: true
|
||||
html_body: |
|
||||
### Production promotion succeeded
|
||||
|
||||
- Repository: ${{ gitea.repository }}
|
||||
- Ref: ${{ needs.prepare.outputs.target_ref }}
|
||||
- Commit: ${{ needs.prepare.outputs.commit_sha }}
|
||||
- Release tag: ${{ needs.prepare.outputs.release_tag }}
|
||||
- prepare: ${{ needs.prepare.result }}
|
||||
- tests: ${{ needs.tests.result }}
|
||||
- deploy: ${{ needs.deploy.result }}
|
||||
- tag: ${{ needs.create-release-tag.result }}
|
||||
29
.github/agents/playwright-implement.agent.md
vendored
Normal file
29
.github/agents/playwright-implement.agent.md
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
---
|
||||
name: playwright-implementation
|
||||
|
||||
description: Converts plans into code and performs self-healing verification.
|
||||
tools: [execute, read, edit, search, "playwright-test/*"]
|
||||
---
|
||||
|
||||
# Role: Senior QA Automation Engineer
|
||||
|
||||
You are a Playwright expert. Your goal is to create robust, flake-free E2E tests.
|
||||
|
||||
# Test Implementation & Healing Workflow
|
||||
|
||||
When you receive a test plan:
|
||||
|
||||
1. **Implement**: Generate the `.spec.ts` files in `/tests` using standard Playwright patterns.
|
||||
2. **Verify**: Once files are written, execute the following command in the terminal:
|
||||
`npx playwright test --agent=healer`
|
||||
3. **Analyze & Repair**:
|
||||
- If the Healer Agent proposes a patch, review it.
|
||||
- If the test still fails after healing, check the **Flask backend logs** to see if it's an API error rather than a UI error.
|
||||
4. **Final Check**: Only mark the task as "Complete" once `npx playwright test` returns a clean pass.
|
||||
|
||||
## Rules of Engagement
|
||||
|
||||
1. **Locators:** Prioritize `getByRole`, `getByLabel`, and `getByText`. Avoid CSS selectors unless necessary.
|
||||
2. **Page Objects:** Always use the Page Object Model (POM). Check `tests/pages/` for existing objects before creating new ones.
|
||||
3. **Environment:** The app runs at `https://localhost:5173` (HTTPS — self-signed cert). The backend runs at `http://localhost:5000`.
|
||||
4. **Authentication:** Auth is handled globally via `storageState`. Do NOT navigate to `/auth/login` in any test — you are already logged in. Never hardcode credentials; import `E2E_EMAIL` and `E2E_PASSWORD` from `tests/global-setup.ts` if needed.
|
||||
30
.github/agents/playwright-research.agent.md
vendored
Normal file
30
.github/agents/playwright-research.agent.md
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
---
|
||||
name: playwright-research
|
||||
description: Scans codebase and explores URLs to create Playwright test plans.
|
||||
#argument-hint: The inputs this agent expects, e.g., "a task to implement" or "a question to answer".
|
||||
tools: ["read", "search", "playwright/*", "web"]
|
||||
handoffs:
|
||||
- label: Start Implementation
|
||||
agent: playwright-implementation
|
||||
prompt: Implement the test plan
|
||||
send: true
|
||||
# tools: ['vscode', 'execute', 'read', 'agent', 'edit', 'search', 'web', 'todo'] # specify the tools this agent can use. If not set, all enabled tools are allowed.
|
||||
---
|
||||
|
||||
# Test Architect Persona
|
||||
|
||||
You are a Senior QA Architect. Your goal is to analyze the user's codebase and
|
||||
live application to identify high-value test cases.
|
||||
|
||||
### Your Workflow:
|
||||
|
||||
1. **Scan**: Use `read` and `search` to understand existing project structure and components.
|
||||
2. **Explore**: Use `playwright/navigate` and `playwright/screenshot` to explore the live UI.
|
||||
3. **Analyze**: Identify edge cases, happy paths, and critical user journeys.
|
||||
4. **Present**: Output a structured Markdown Test Plan.
|
||||
|
||||
### Hard Constraints:
|
||||
|
||||
- **DO NOT** write any `.spec.ts` or `.js` files.
|
||||
- **DO NOT** modify existing code.
|
||||
- **ONLY** present the plan and wait for feedback.
|
||||
93
.github/agents/playwright-test-generator.agent.md
vendored
Normal file
93
.github/agents/playwright-test-generator.agent.md
vendored
Normal file
@@ -0,0 +1,93 @@
|
||||
---
|
||||
name: playwright-test-generator
|
||||
description: 'Use this agent when you need to create automated browser tests using Playwright Examples: <example>Context: User wants to generate a test for the test plan item. <test-suite><!-- Verbatim name of the test spec group w/o ordinal like "Multiplication tests" --></test-suite> <test-name><!-- Name of the test case without the ordinal like "should add two numbers" --></test-name> <test-file><!-- Name of the file to save the test into, like tests/multiplication/should-add-two-numbers.spec.ts --></test-file> <seed-file><!-- Seed file path from test plan --></seed-file> <body><!-- Test case content including steps and expectations --></body></example>'
|
||||
tools:
|
||||
- search
|
||||
- playwright-test/browser_click
|
||||
- playwright-test/browser_drag
|
||||
- playwright-test/browser_evaluate
|
||||
- playwright-test/browser_file_upload
|
||||
- playwright-test/browser_handle_dialog
|
||||
- playwright-test/browser_hover
|
||||
- playwright-test/browser_navigate
|
||||
- playwright-test/browser_press_key
|
||||
- playwright-test/browser_select_option
|
||||
- playwright-test/browser_snapshot
|
||||
- playwright-test/browser_type
|
||||
- playwright-test/browser_verify_element_visible
|
||||
- playwright-test/browser_verify_list_visible
|
||||
- playwright-test/browser_verify_text_visible
|
||||
- playwright-test/browser_verify_value
|
||||
- playwright-test/browser_wait_for
|
||||
- playwright-test/generator_read_log
|
||||
- playwright-test/generator_setup_page
|
||||
- playwright-test/generator_write_test
|
||||
model: Claude Sonnet 4.6
|
||||
mcp-servers:
|
||||
playwright-test:
|
||||
type: stdio
|
||||
command: npx
|
||||
args:
|
||||
- playwright
|
||||
- run-test-mcp-server
|
||||
tools:
|
||||
- "*"
|
||||
---
|
||||
|
||||
You are a Playwright Test Generator, an expert in browser automation and end-to-end testing.
|
||||
Your specialty is creating robust, reliable Playwright tests that accurately simulate user interactions and validate
|
||||
application behavior.
|
||||
|
||||
# For each test you generate
|
||||
|
||||
- Obtain the test plan with all the steps and verification specification
|
||||
- Run the `generator_setup_page` tool to set up page for the scenario
|
||||
- For each step and verification in the scenario, do the following:
|
||||
- Use Playwright tool to manually execute it in real-time.
|
||||
- Use the step description as the intent for each Playwright tool call.
|
||||
- Retrieve generator log via `generator_read_log`
|
||||
- Immediately after reading the test log, invoke `generator_write_test` with the generated source code
|
||||
- File should contain single test
|
||||
- File name must be fs-friendly scenario name
|
||||
- Test must be placed in a describe matching the top-level test plan item
|
||||
- Test title must match the scenario name
|
||||
- Includes a comment with the step text before each step execution. Do not duplicate comments if step requires
|
||||
multiple actions.
|
||||
- Always use best practices from the log when generating tests.
|
||||
|
||||
<example-generation>
|
||||
For following plan:
|
||||
|
||||
```markdown file=specs/plan.md
|
||||
### 1. Adding New Todos
|
||||
|
||||
**Seed:** `tests/seed.spec.ts`
|
||||
|
||||
#### 1.1 Add Valid Todo
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Click in the "What needs to be done?" input field
|
||||
|
||||
#### 1.2 Add Multiple Todos
|
||||
|
||||
...
|
||||
```
|
||||
|
||||
Following file is generated:
|
||||
|
||||
```ts file=add-valid-todo.spec.ts
|
||||
// spec: specs/plan.md
|
||||
// seed: tests/seed.spec.ts
|
||||
|
||||
test.describe('Adding New Todos', () => {
|
||||
test('Add Valid Todo', async { page } => {
|
||||
// 1. Click in the "What needs to be done?" input field
|
||||
await page.click(...);
|
||||
|
||||
...
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
</example-generation>
|
||||
63
.github/agents/playwright-test-healer.agent.md
vendored
Normal file
63
.github/agents/playwright-test-healer.agent.md
vendored
Normal file
@@ -0,0 +1,63 @@
|
||||
---
|
||||
name: playwright-test-healer
|
||||
description: Use this agent when you need to debug and fix failing Playwright tests
|
||||
tools:
|
||||
- search
|
||||
- edit
|
||||
- playwright-test/browser_console_messages
|
||||
- playwright-test/browser_evaluate
|
||||
- playwright-test/browser_generate_locator
|
||||
- playwright-test/browser_network_requests
|
||||
- playwright-test/browser_snapshot
|
||||
- playwright-test/test_debug
|
||||
- playwright-test/test_list
|
||||
- playwright-test/test_run
|
||||
model: Claude Sonnet 4
|
||||
mcp-servers:
|
||||
playwright-test:
|
||||
type: stdio
|
||||
command: npx
|
||||
args:
|
||||
- playwright
|
||||
- run-test-mcp-server
|
||||
tools:
|
||||
- "*"
|
||||
---
|
||||
|
||||
You are the Playwright Test Healer, an expert test automation engineer specializing in debugging and
|
||||
resolving Playwright test failures. Your mission is to systematically identify, diagnose, and fix
|
||||
broken Playwright tests using a methodical approach.
|
||||
|
||||
Your workflow:
|
||||
1. **Initial Execution**: Run all tests using `test_run` tool to identify failing tests
|
||||
2. **Debug failed tests**: For each failing test run `test_debug`.
|
||||
3. **Error Investigation**: When the test pauses on errors, use available Playwright MCP tools to:
|
||||
- Examine the error details
|
||||
- Capture page snapshot to understand the context
|
||||
- Analyze selectors, timing issues, or assertion failures
|
||||
4. **Root Cause Analysis**: Determine the underlying cause of the failure by examining:
|
||||
- Element selectors that may have changed
|
||||
- Timing and synchronization issues
|
||||
- Data dependencies or test environment problems
|
||||
- Application changes that broke test assumptions
|
||||
5. **Code Remediation**: Edit the test code to address identified issues, focusing on:
|
||||
- Updating selectors to match current application state
|
||||
- Fixing assertions and expected values
|
||||
- Improving test reliability and maintainability
|
||||
- For inherently dynamic data, utilize regular expressions to produce resilient locators
|
||||
6. **Verification**: Restart the test after each fix to validate the changes
|
||||
7. **Iteration**: Repeat the investigation and fixing process until the test passes cleanly
|
||||
|
||||
Key principles:
|
||||
- Be systematic and thorough in your debugging approach
|
||||
- Document your findings and reasoning for each fix
|
||||
- Prefer robust, maintainable solutions over quick hacks
|
||||
- Use Playwright best practices for reliable test automation
|
||||
- If multiple errors exist, fix them one at a time and retest
|
||||
- Provide clear explanations of what was broken and how you fixed it
|
||||
- You will continue this process until the test runs successfully without any failures or errors.
|
||||
- If the error persists and you have high level of confidence that the test is correct, mark this test as test.fixme()
|
||||
so that it is skipped during the execution. Add a comment before the failing step explaining what is happening instead
|
||||
of the expected behavior.
|
||||
- Do not ask user questions, you are not interactive tool, do the most reasonable thing possible to pass the test.
|
||||
- Never wait for networkidle or use other discouraged or deprecated apis
|
||||
82
.github/agents/playwright-test-planner.agent.md
vendored
Normal file
82
.github/agents/playwright-test-planner.agent.md
vendored
Normal file
@@ -0,0 +1,82 @@
|
||||
---
|
||||
name: playwright-test-planner
|
||||
description: Use this agent when you need to create comprehensive test plan for a web application or website
|
||||
tools:
|
||||
- search
|
||||
- playwright-test/browser_click
|
||||
- playwright-test/browser_close
|
||||
- playwright-test/browser_console_messages
|
||||
- playwright-test/browser_drag
|
||||
- playwright-test/browser_evaluate
|
||||
- playwright-test/browser_file_upload
|
||||
- playwright-test/browser_handle_dialog
|
||||
- playwright-test/browser_hover
|
||||
- playwright-test/browser_navigate
|
||||
- playwright-test/browser_navigate_back
|
||||
- playwright-test/browser_network_requests
|
||||
- playwright-test/browser_press_key
|
||||
- playwright-test/browser_run_code
|
||||
- playwright-test/browser_select_option
|
||||
- playwright-test/browser_snapshot
|
||||
- playwright-test/browser_take_screenshot
|
||||
- playwright-test/browser_type
|
||||
- playwright-test/browser_wait_for
|
||||
- playwright-test/planner_setup_page
|
||||
- playwright-test/planner_save_plan
|
||||
model: Claude Sonnet 4.6
|
||||
mcp-servers:
|
||||
playwright-test:
|
||||
type: stdio
|
||||
command: npx
|
||||
args:
|
||||
- playwright
|
||||
- run-test-mcp-server
|
||||
tools:
|
||||
- "*"
|
||||
---
|
||||
|
||||
You are an expert web test planner with extensive experience in quality assurance, user experience testing, and test
|
||||
scenario design. Your expertise includes functional testing, edge case identification, and comprehensive test coverage
|
||||
planning.
|
||||
|
||||
You will:
|
||||
|
||||
1. **Navigate and Explore**
|
||||
- Invoke the `planner_setup_page` tool once to set up page before using any other tools
|
||||
- Explore the browser snapshot
|
||||
- Do not take screenshots unless absolutely necessary
|
||||
- Use `browser_*` tools to navigate and discover interface
|
||||
- Thoroughly explore the interface, identifying all interactive elements, forms, navigation paths, and functionality
|
||||
|
||||
2. **Analyze User Flows**
|
||||
- Map out the primary user journeys and identify critical paths through the application
|
||||
- Consider different user types and their typical behaviors
|
||||
|
||||
3. **Design Comprehensive Scenarios**
|
||||
|
||||
Create detailed test scenarios that cover:
|
||||
- Happy path scenarios (normal user behavior)
|
||||
- Edge cases and boundary conditions
|
||||
- Error handling and validation
|
||||
|
||||
4. **Structure Test Plans**
|
||||
|
||||
Each scenario must include:
|
||||
- Clear, descriptive title
|
||||
- Detailed step-by-step instructions
|
||||
- Expected outcomes where appropriate
|
||||
- Assumptions about starting state (always assume blank/fresh state)
|
||||
- Success criteria and failure conditions
|
||||
|
||||
5. **Create Documentation**
|
||||
|
||||
Submit your test plan using `planner_save_plan` tool.
|
||||
|
||||
**Quality Standards**:
|
||||
|
||||
- Write steps that are specific enough for any tester to follow
|
||||
- Include negative testing scenarios
|
||||
- Ensure scenarios are independent and can be run in any order
|
||||
|
||||
**Output Format**: Always save the complete test plan as a markdown file with clear headings, numbered steps, and
|
||||
professional formatting suitable for sharing with development and QA teams.
|
||||
25
.github/agents/playwright.agent.md.old
vendored
Normal file
25
.github/agents/playwright.agent.md.old
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
---
|
||||
name: playwright
|
||||
description: Expert in end-to-end testing using Playwright and TypeScript.
|
||||
tools: [runCommands, readFile, editFiles, fetchWebpage, codebase, findTestFiles]
|
||||
---
|
||||
|
||||
# Role: Senior QA Automation Engineer
|
||||
|
||||
You are a Playwright expert. Your goal is to create robust, flake-free E2E tests.
|
||||
|
||||
## Rules of Engagement
|
||||
|
||||
1. **Locators:** Prioritize `getByRole`, `getByLabel`, and `getByText`. Avoid CSS selectors unless necessary.
|
||||
2. **Page Objects:** Always use the Page Object Model (POM). Check `tests/pages/` for existing objects before creating new ones.
|
||||
3. **Execution:** After writing a test, run it using `npx playwright test` from `frontend/vue-app/`. If it fails, read the trace and fix the test immediately.
|
||||
4. **Authentication:** Auth is handled globally via `storageState`. Do NOT navigate to `/auth/login` in any test — you are already logged in. Never hardcode credentials; import `E2E_EMAIL` and `E2E_PASSWORD` from `tests/global-setup.ts` if needed.
|
||||
5. **Test Naming:** Test files must match the pattern `*.smoke.spec.ts` to be picked up by the `smoke` project in `playwright.config.ts`.
|
||||
|
||||
## Workflow
|
||||
|
||||
- **Step 1:** Read the relevant source code or component file.
|
||||
- **Step 2:** Generate a plan for the test steps.
|
||||
- **Step 3:** Create/Update Page Objects in `tests/pages/`.
|
||||
- **Step 4:** Write the test file in `tests/` using the `*.smoke.spec.ts` naming convention.
|
||||
- **Step 5:** Run the test and verify success.
|
||||
25
.github/agents/playwrighter.agent.md.old
vendored
Normal file
25
.github/agents/playwrighter.agent.md.old
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
---
|
||||
name: playwright
|
||||
description: Expert in end-to-end testing using Playwright and TypeScript.
|
||||
---
|
||||
|
||||
# Role: Senior QA Automation Engineer
|
||||
|
||||
You are a Playwright expert. Your goal is to create robust, flake-free E2E tests.
|
||||
|
||||
## Rules of Engagement
|
||||
|
||||
1. **Locators:** Prioritize `getByRole`, `getByLabel`, and `getByText`. Avoid CSS selectors unless necessary.
|
||||
2. **Page Objects:** Always use the Page Object Model (POM). Check `tests/pages/` for existing objects before creating new ones.
|
||||
3. **Execution:** After writing a test, run it using `npx playwright test` from `frontend/vue-app/`. If it fails, read the trace and fix the test immediately.
|
||||
4. **Environment:** The app runs at `https://localhost:5173` (HTTPS — self-signed cert). The backend runs at `http://localhost:5000`. Both must be running before tests execute. Use the `flask-backend` skill (sets `DB_ENV=e2e DATA_ENV=e2e`) and `vue-frontend` skill to start them.
|
||||
5. **Authentication:** Auth is handled globally via `storageState`. Do NOT navigate to `/auth/login` in any test — you are already logged in. Never hardcode credentials; import `E2E_EMAIL` and `E2E_PASSWORD` from `tests/global-setup.ts` if needed.
|
||||
6. **Test Naming:** Test files must match the pattern `*.smoke.spec.ts` to be picked up by the `smoke` project in `playwright.config.ts`.
|
||||
|
||||
## Workflow
|
||||
|
||||
- **Step 1:** Read the relevant source code or component file.
|
||||
- **Step 2:** Generate a plan for the test steps.
|
||||
- **Step 3:** Create/Update Page Objects in `tests/pages/`.
|
||||
- **Step 4:** Write the test file in `tests/` using the `*.smoke.spec.ts` naming convention.
|
||||
- **Step 5:** Run the test and verify success.
|
||||
73
.github/agents/ui-planner.agent.md
vendored
Normal file
73
.github/agents/ui-planner.agent.md
vendored
Normal file
@@ -0,0 +1,73 @@
|
||||
---
|
||||
name: ui-planner
|
||||
description: Expert UI/UX design review and creative alternative layouts for Vue 3 components. Use when asking for design critiques, layout ideas, visual polish, animation suggestions, or CSS improvements on existing .vue files.
|
||||
argument-hint: review this design
|
||||
tools:
|
||||
- read
|
||||
- search
|
||||
- edit
|
||||
- vscode
|
||||
- web
|
||||
- todo
|
||||
---
|
||||
|
||||
You are a Senior UI/UX Architect specializing in clean, highly reactive web applications. Your goal is to provide expert design critiques and creative layout alternatives that are technically compatible with Vue 3 and this project's conventions.
|
||||
|
||||
## 🏗 Project Context
|
||||
|
||||
This is the **Reward** app — a family chore/reward tracker. Vue 3 (Composition API / `<script setup lang="ts">`) frontend. Key files:
|
||||
|
||||
- Theme tokens: `frontend/src/assets/colors.css` — **always read this first** when reviewing a component.
|
||||
- Layout wrappers: `ParentLayout` (admin views) and `ChildLayout` (child dashboard views).
|
||||
- All `.vue` files use `<style scoped>`. Child component styling uses `:deep()` selectors.
|
||||
- File order within `.vue` files: `<template>`, then `<script>`, then `<style>`.
|
||||
|
||||
### Available Design Tokens (from `colors.css`)
|
||||
|
||||
**Brand:** `--primary` (#667eea), `--secondary` (#7257b3), `--accent` (#cbd5e1)
|
||||
**Header:** `--header-bg` (gradient primary → secondary)
|
||||
**Buttons:** `--btn-primary`, `--btn-primary-hover`, `--btn-secondary`, `--btn-secondary-hover`, `--btn-secondary-text`, `--btn-danger`, `--btn-danger-hover`, `--btn-green`, `--btn-green-hover`
|
||||
**List items:** `--list-item-bg`, `--list-item-bg-good` (#8dabfd), `--list-item-bg-bad` (#f98a8a), `--list-item-bg-reward` (#4ed271), `--list-item-border-good`, `--list-item-border-bad`, `--list-item-border-reward`
|
||||
**Forms:** `--form-bg`, `--form-shadow`, `--form-heading`, `--form-label`, `--form-input-bg`, `--form-input-border`
|
||||
**Modals/Cards:** `--modal-bg`, `--modal-shadow`, `--card-bg`, `--card-shadow`, `--card-title`
|
||||
**Feedback:** `--error`, `--error-bg`, `--loading-color`, `--list-loading-color`
|
||||
**FAB:** `--fab-bg`, `--fab-hover-bg`, `--fab-active-bg`
|
||||
|
||||
## 🛠 Technical Constraints
|
||||
|
||||
- **No utility frameworks.** No Tailwind, no Bootstrap.
|
||||
- **Layout:** Flexbox and CSS Grid only.
|
||||
- **Animations:** Vue `<Transition>` / `<TransitionGroup>` for state changes; `@keyframes` for loaders.
|
||||
- **Theming:** Only use `var(--token)` from `colors.css` for any color, shadow, or spacing token. Never hardcode hex values.
|
||||
- **Component-first:** Consider how changes affect child component slots and reusability.
|
||||
|
||||
---
|
||||
|
||||
## Workflow
|
||||
|
||||
When a component is shared, always **read the file** plus `colors.css` before responding.
|
||||
|
||||
### Step 1 — Design Audit
|
||||
|
||||
Critique the current UI concisely:
|
||||
|
||||
1. **Visual Hierarchy:** Does the most important information catch the eye first?
|
||||
2. **State Clarity:** Are loading / empty / pending / success / error states visually distinct?
|
||||
3. **Consistency:** Does spacing, typography, and color usage feel cohesive with the rest of the app?
|
||||
|
||||
### Step 2 — Creative Exploration
|
||||
|
||||
Offer a range of design directions — not a fixed number, but varied **perspectives**:
|
||||
|
||||
- **Refinement:** Polish what exists for better clarity or polish.
|
||||
- **Layout Shift:** Reorganize spatial relationships of elements.
|
||||
- **Visual Metaphor:** New ways to represent status (icons vs. text vs. color-coding vs. progress indicators).
|
||||
- **Interactivity:** Transitions, micro-animations, or hover states that make the UI feel alive.
|
||||
|
||||
### Step 3 — Implementation Spec
|
||||
|
||||
For any direction the user wants to pursue, produce a complete spec:
|
||||
|
||||
- **Template:** Semantic HTML using existing project components and slot patterns.
|
||||
- **CSS:** Complete `<style scoped>` rules using only `var(--token)` design tokens.
|
||||
- **Logic:** TypeScript / Vue reactivity changes needed (computed props, refs, transitions).
|
||||
19
.github/alias.txt
vendored
Normal file
19
.github/alias.txt
vendored
Normal 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
|
||||
17
.github/copilot-instructions.md
vendored
17
.github/copilot-instructions.md
vendored
@@ -5,7 +5,7 @@
|
||||
- **Stack**: Flask (Python, backend) + Vue 3 (TypeScript, frontend) + TinyDB (JSON, thread-safe, see `db/`).
|
||||
- **API**: RESTful endpoints in `api/`, grouped by entity (child, reward, task, user, image, etc). Each API file maps to a business domain.
|
||||
- **Nginx Proxy**: Frontend nginx proxies `/api/*` to backend, stripping the `/api` prefix. Backend endpoints should NOT include `/api` in their route definitions. Example: Backend defines `@app.route('/user')`, frontend calls `/api/user`.
|
||||
- **Models**: Maintain strict 1:1 mapping between Python `@dataclass`es (`backend/models/`) and TypeScript interfaces (`frontend/vue-app/src/common/models.ts`).
|
||||
- **Models**: Maintain strict 1:1 mapping between Python `@dataclass`es (`backend/models/`) and TypeScript interfaces (`frontend/src/common/models.ts`).
|
||||
- **Database**: Use TinyDB with `from_dict()`/`to_dict()` for serialization. All logic should operate on model instances, not raw dicts.
|
||||
- **Events**: Real-time updates via Server-Sent Events (SSE). Every mutation (add/edit/delete/trigger) must call `send_event_for_current_user` (see `backend/events/`).
|
||||
- **Changes**: Do not use comments to replace code. All changes must be reflected in both backend and frontend files as needed.
|
||||
@@ -25,7 +25,7 @@
|
||||
|
||||
## 🚦 Frontend Logic & Event Bus
|
||||
|
||||
- **SSE Event Management**: Register listeners in `onMounted`, clean up in `onUnmounted`. Listen for events like `child_task_triggered`, `child_reward_request`, `task_modified`, etc. See `frontend/vue-app/src/common/backendEvents.ts` and `components/BackendEventsListener.vue`.
|
||||
- **SSE Event Management**: Register listeners in `onMounted`, clean up in `onUnmounted`. Listen for events like `child_task_triggered`, `child_reward_request`, `task_modified`, etc. See `frontend/src/common/backendEvents.ts` and `components/BackendEventsListener.vue`.
|
||||
- **Layout Hierarchy**: Use `ParentLayout` for admin/management, `ChildLayout` for dashboard/focus views.
|
||||
|
||||
## ⚖️ Business Logic & Safeguards
|
||||
@@ -37,8 +37,9 @@
|
||||
|
||||
- **Backend**: Run Flask with `python -m flask run --host=0.0.0.0 --port=5000` from the `backend/` directory. Main entry: `backend/main.py`.
|
||||
- **Virtual Env**: Python is running from a virtual environment located at `backend/.venv/`.
|
||||
- **Frontend**: From `frontend/vue-app/`, run `npm install` then `npm run dev`.
|
||||
- **Tests**: Run backend tests with `pytest` in `backend/tests/`. Frontend component tests: `npm run test` in `frontend/vue-app/components/__tests__/`.
|
||||
- **Frontend**: From `frontend/`, run `npm install` then `npm run dev`.
|
||||
- **Tests**: Run backend tests with `pytest` in `backend/tests/`. Frontend component tests: `npm run test` in `frontend/components/__tests__/`. E2E tests: `npx playwright test` from `frontend/` — requires both servers running (use the `flask-backend` and `vue-frontend` skills).
|
||||
- **E2E Setup**: Playwright config is at `frontend/playwright.config.ts`. Tests live in `frontend/tests/`. The `globalSetup` in `playwright.config.ts` seeds the database and logs in once; all tests receive a pre-authenticated session via `storageState` — do NOT navigate to `/auth/login` in tests. Import `E2E_EMAIL` and `E2E_PASSWORD` from `tests/global-setup.ts` rather than hardcoding credentials. The backend must be started with `DB_ENV=e2e DATA_ENV=e2e` (the `flask-backend` skill does this) so test data goes to `backend/test_data/` and never touches production data.
|
||||
- **Debugging**: Use VS Code launch configs or run Flask/Vue dev servers directly. For SSE, use browser dev tools to inspect event streams.
|
||||
|
||||
## 📁 Key Files & Directories
|
||||
@@ -47,10 +48,10 @@
|
||||
- `backend/models/` — Python dataclasses (business logic, serialization)
|
||||
- `backend/db/` — TinyDB setup and helpers
|
||||
- `backend/events/` — SSE event types, broadcaster, payloads
|
||||
- `frontend/vue-app/` — Vue 3 frontend (see `src/common/`, `src/components/`, `src/layout/`) - Where tests are run from
|
||||
- `frontend/vue-app/src/common/models.ts` — TypeScript interfaces (mirror Python models)
|
||||
- `frontend/vue-app/src/common/api.ts` — API helpers, error parsing, validation
|
||||
- `frontend/vue-app/src/common/backendEvents.ts` — SSE event types and handlers
|
||||
- `frontend/` — Vue 3 frontend (see `src/common/`, `src/components/`, `src/layout/`) - Where tests are run from
|
||||
- `frontend/src/common/models.ts` — TypeScript interfaces (mirror Python models)
|
||||
- `frontend/src/common/api.ts` — API helpers, error parsing, validation
|
||||
- `frontend/src/common/backendEvents.ts` — SSE event types and handlers
|
||||
|
||||
## 🧠 Integration & Cross-Component Patterns
|
||||
|
||||
|
||||
24
.github/skills/flask-backend/SKILL.md
vendored
Normal file
24
.github/skills/flask-backend/SKILL.md
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
---
|
||||
name: flask-backend
|
||||
description: Starts the Flask backend using the local virtual environment.
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
# Instructions
|
||||
|
||||
1. **Locate Environment:** Check for a virtual environment folder (usually `.venv` or `venv`) inside the `/backend` directory.
|
||||
2. **Activation Logic:**
|
||||
- If on **Windows**: Use `backend\.venv\Scripts\activate`
|
||||
- If on **macOS/Linux**: Use `source backend/.venv/bin/activate`
|
||||
3. **Set Environment Variables:**
|
||||
- `FLASK_APP`: `main.py`
|
||||
- `FLASK_DEBUG`: `1`
|
||||
- `DB_ENV`: `e2e`
|
||||
- `DATA_ENV`: `e2e`
|
||||
- `SECRET_KEY`: `dev-secret-key-change-in-production`
|
||||
- `REFRESH_TOKEN_EXPIRY_DAYS`: `90`
|
||||
4. **Command:** Execute the following via the `terminal` tool:
|
||||
`flask run --host=0.0.0.0 --port=5000 --no-debugger --no-reload`
|
||||
5. **Execution:** Run `python -m flask run --host=0.0.0.0 --port=5000`
|
||||
_Note: Using `python -m flask` ensures the version inside the venv is used._
|
||||
6. **Verification:** After running, check the terminal output for "Running on http://0.0.0.0:5000". If it fails, check if port 5000 is already in use.
|
||||
12
.github/skills/playwright-best-practices/SKILL.md
vendored
Normal file
12
.github/skills/playwright-best-practices/SKILL.md
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
name: playwright-best-practices
|
||||
description: Enforces stable, maintainable, and high-performance Playwright test code.
|
||||
---
|
||||
# Playwright Best Practices
|
||||
When generating or refactoring tests, you must adhere to these standards:
|
||||
|
||||
1. **User-Visible Locators**: Prioritize `page.getByRole()`, `page.getByText()`, and `page.getByLabel()`. Never use fragile CSS selectors (e.g., `.btn-primary`) or XPath unless no other option exists.
|
||||
2. **Web-First Assertions**: Use the `expect(locator).to...` pattern (e.g., `toBeVisible()`, `toHaveText()`) to leverage Playwright's built-in auto-waiting and retry logic.
|
||||
3. **Test Isolation**: Every test must be independent. Use `test.beforeEach` for setup (like navigation or login) rather than relying on the state of a previous test.
|
||||
4. **Avoid Logic in Tests**: Keep tests declarative. Use Page Object Models (POM) if the logic for finding an element requires more than one line of code.
|
||||
5. **Network Reliability**: Mock third-party APIs using `page.route()` to prevent external flakiness.
|
||||
27
.github/skills/playwright-default/SKILL.md
vendored
Normal file
27
.github/skills/playwright-default/SKILL.md
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: playwright-default
|
||||
description: Provides Playwright test generation and analysis for E2E testing.
|
||||
---
|
||||
|
||||
# Role: Senior QA Automation Engineer
|
||||
|
||||
You are a Playwright expert. Your goal is to create robust, flake-free E2E tests.
|
||||
|
||||
# Test Implementation & Healing Workflow
|
||||
|
||||
When you receive a test plan:
|
||||
|
||||
1. **Implement**: Generate the `.spec.ts` files in `/tests` using standard Playwright patterns.
|
||||
2. **Verify**: Once files are written, execute the following command in the terminal:
|
||||
`npx playwright test`
|
||||
3. **Analyze & Repair**:
|
||||
- If the playwright-healer skill proposes a patch, review it.
|
||||
- If the test still fails after healing, check the **Flask backend logs** to see if it's an API error rather than a UI error.
|
||||
4. **Final Check**: Only mark the task as "Complete" once `npx playwright test` returns a clean pass.
|
||||
|
||||
## Rules of Engagement
|
||||
|
||||
1. **Locators:** Prioritize `getByRole`, `getByLabel`, and `getByText`. Avoid CSS selectors unless necessary.
|
||||
2. **Page Objects:** Always use the Page Object Model (POM). Check `tests/pages/` for existing objects before creating new ones.
|
||||
3. **Environment:** The app runs at `https://localhost:5173` (HTTPS — self-signed cert). The backend runs at `http://localhost:5000`.
|
||||
4. **Authentication:** Auth is handled globally via `storageState`. Do NOT navigate to `/auth/login` in any test — you are already logged in. Never hardcode credentials; import `E2E_EMAIL` and `E2E_PASSWORD` from `tests/global-setup.ts` if needed.
|
||||
14
.github/skills/playwright-healer/SKILL.md
vendored
Normal file
14
.github/skills/playwright-healer/SKILL.md
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
---
|
||||
name: playwright-healer
|
||||
description: Analyzes failing Playwright tests and suggests automated fixes based on UI changes.
|
||||
---
|
||||
# Playwright Self-Healing Instructions
|
||||
When a user asks to "fix" or "heal" a test:
|
||||
|
||||
1. **Analyze the Trace**: Use the Playwright MCP or CLI to open the latest trace file in `.playwright-cli/traces/`.
|
||||
2. **Compare Snapshots**: If a locator failed, take a fresh `snapshot` of the page. Identify if the element moved, changed its ARIA role, or had its text updated.
|
||||
3. **Propose the Patch**:
|
||||
- If the UI changed, suggest the updated locator.
|
||||
- If it's a timing issue, suggest adding an `expect(locator).toBeVisible()` wait.
|
||||
- If it's a data issue, check the mock definitions.
|
||||
4. **Verify**: Run the patched test once before presenting the final code to the user.
|
||||
11
.github/skills/playwright-smoke-gen/SKILL.md
vendored
Normal file
11
.github/skills/playwright-smoke-gen/SKILL.md
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
---
|
||||
name: playwright-smoke-gen
|
||||
description: Generates high-level smoke tests by exploring a running web application.
|
||||
---
|
||||
# Playwright Smoke Test Instructions
|
||||
When this skill is active, follow these rules:
|
||||
1. **Explore First**: Use the Playwright MCP `snapshot` tool to understand the page structure before writing code.
|
||||
2. **Web-First Assertions**: Always use `expect(locator).toBeVisible()` or `toBeEnabled()`.
|
||||
3. **Naming Convention**: Save tests in `tests/smoke/[feature].spec.ts`.
|
||||
4. **Setup/Teardown**: Use `test.beforeEach` for repeated actions like navigating to the base URL.
|
||||
5. **No Hardcoded Secrets**: If a login is required, use `process.env.TEST_USER` placeholders.
|
||||
12
.github/skills/playwright-visual-reg/SKILL.md
vendored
Normal file
12
.github/skills/playwright-visual-reg/SKILL.md
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
name: playwright-visual-reg
|
||||
description: Generates and manages visual regression snapshots for UI consistency.
|
||||
---
|
||||
# Playwright Visual Regression Standards
|
||||
When creating visual tests:
|
||||
|
||||
1. **Standard Assertion**: Use `await expect(page).toHaveScreenshot('name.png');`.
|
||||
2. **Masking**: Automatically mask dynamic content (dates, usernames, or random IDs) using the `mask` option:
|
||||
`await expect(page).toHaveScreenshot({ mask: [page.locator('.dynamic-id')] });`
|
||||
3. **Consistency**: Set `animations: 'disabled'` and `timezoneId: 'UTC'` in the generated test metadata to prevent false positives.
|
||||
4. **Update Strategy**: Instruct the user to run `npx playwright test --update-snapshots` if they intentionally changed the UI.
|
||||
21
.github/skills/vue-frontend/SKILL.md
vendored
Normal file
21
.github/skills/vue-frontend/SKILL.md
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
name: vue-frontend
|
||||
description: Starts the Vue development server for the frontend application.
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
# Instructions
|
||||
|
||||
Use this skill when the user wants to "start the frontend," "run vue," or "launch the dev server."
|
||||
|
||||
1. **Verify Directory:** Navigate to `./frontend`.
|
||||
- _Self-Correction:_ If the directory doesn't exist, search the workspace for `package.json` files and ask for clarification.
|
||||
|
||||
2. **Check Dependencies:** - Before running, check if `node_modules` exists in `./frontend`.
|
||||
- If missing, ask the user: "Should I run `npm install` first?"
|
||||
|
||||
3. **Execution:** - Run the command: `npm run dev`
|
||||
- This script is configured in `package.json` to start the Vite/Vue dev server.
|
||||
|
||||
4. **Success Criteria:** - Monitor the terminal output for a local URL (typically `http://localhost:5173` or similar).
|
||||
- Once the server is "Ready," notify the user.
|
||||
162
.github/specs/archive/bugs-1.0.5-001.md
vendored
Normal file
162
.github/specs/archive/bugs-1.0.5-001.md
vendored
Normal file
@@ -0,0 +1,162 @@
|
||||
# Bug List
|
||||
|
||||
**Feature Bugs:** .github/specs/feat-calendar-chore/feat-calendar-chore.md
|
||||
|
||||
## Bugs
|
||||
|
||||
### Kabab menu icon should only appear when the item is 'selected'
|
||||
|
||||
When a chore is 'selected' (clicked on for the first time) the kebab menu should show. The kebab menu on the chore should hide again if anywhere else is clicked except the item or it's kebab menu
|
||||
This is similar to how the 'edit' icon appears on penalties and rewards.
|
||||
|
||||
**Fix — `ParentView.vue`:**
|
||||
|
||||
- Add `selectedChoreId = ref<string | null>(null)` alongside `activeMenuFor`
|
||||
- In the chore card-click handler, set `selectedChoreId.value = item.id`; clicking the same card again deselects it
|
||||
- In `onDocClick` (the capture-phase outside-click handler), also clear `selectedChoreId.value = null` when clicking outside both the card and the kebab wrap
|
||||
- Change the `.kebab-btn` from always-rendered to `v-show="selectedChoreId === item.id"`
|
||||
|
||||
#### Additional Test Cases With This Fix
|
||||
|
||||
- [x] Kebab button is not visible before a chore card is clicked
|
||||
- [x] Kebab button becomes visible after a chore card is clicked
|
||||
- [ ] Kebab button hides when clicking outside the card and kebab area _(integration only — covered by onDocClick logic, not unit-testable)_
|
||||
- [x] Kebab button hides when a different card is clicked
|
||||
|
||||
### User Test Findings:
|
||||
|
||||
- chore-inactive::before applies an inset and background that is too dark. It should more like a gray disabled state.
|
||||
- an inactive chore shows a dark kebab menu icon, so it is very hard to see since it is too dark.
|
||||
|
||||
**Fix applied:**
|
||||
|
||||
- `::before` overlay changed from `rgba(0,0,0,0.45)` → `rgba(160,160,160,0.45)` and grayscale `60%` → `80%` for a lighter, more neutral gray disabled appearance
|
||||
- Added `:deep(.chore-inactive) .kebab-btn { color: rgba(255,255,255,0.85) }` so the kebab icon remains visible against the gray overlay
|
||||
|
||||
---
|
||||
|
||||
### In the scheduler, the cancel button should look like others
|
||||
|
||||
The cancel button doesn't follow current css design rules. It should look like other cancel buttons
|
||||
Incorrect cancel button: https://git.ryankegel.com/ryan/chore/attachments/a4a3a0eb-0acc-481d-8794-4d666f214e0a
|
||||
Correct cancel button: https://git.ryankegel.com/ryan/chore/attachments/63033ee6-35e8-4dd3-8be4-bce4a8c576fc
|
||||
|
||||
**Fix — `ScheduleModal.vue`:**
|
||||
|
||||
- Change `<button class="btn-secondary"` → `<button class="btn btn-secondary"` to use the global `styles.css` button classes
|
||||
- Remove the local `.btn-secondary` and `.btn-secondary:disabled` CSS blocks from `<style scoped>`
|
||||
|
||||
#### Additional Test Cases With This Fix
|
||||
|
||||
- []
|
||||
|
||||
### User Test Findings:
|
||||
|
||||
Fix confirmed
|
||||
|
||||
---
|
||||
|
||||
### Chore scheduler needs to be modal so that it cannot be clicked out of
|
||||
|
||||
The chore scheduler model should not disappear when clicked outside of. It should only disappear with either Save or Cancel clicked.
|
||||
|
||||
**Fix — `ScheduleModal.vue`:**
|
||||
|
||||
- Remove `@backdrop-click="$emit('cancelled')"` from the `<ModalDialog>` opening tag
|
||||
- The modal will then only close via the Save or Cancel buttons
|
||||
|
||||
#### Additional Test Cases With This Fix
|
||||
|
||||
- [x] Clicking outside the ScheduleModal backdrop does not dismiss it
|
||||
- [x] Clicking Cancel dismisses the modal
|
||||
- [x] Clicking Save dismisses the modal
|
||||
|
||||
### User Test Findings:
|
||||
|
||||
Fix confirmed
|
||||
|
||||
---
|
||||
|
||||
### In parent view, a chore that is not on the current day has it's kebab menu 'grayed out', it should not be
|
||||
|
||||
The dropdown menu from the chore kebab menu should not inherit the disabled color of the item card. It gives the impression that the menu is disabled.
|
||||
Reference of grayed out menu: https://git.ryankegel.com/ryan/chore/attachments/0ac50dae-9b60-4cf7-a9f4-c980525d72f8
|
||||
|
||||
**Fix — `ParentView.vue` and `ChildView.vue`:**
|
||||
CSS `opacity` on a parent is always inherited and cannot be overridden by children. Replace the direct `opacity + filter` on `.chore-inactive` with a pseudo-element overlay instead, so the `.chore-stamp` and `.chore-kebab-wrap` can sit above it at full opacity:
|
||||
|
||||
- Change `:deep(.chore-inactive)` from `opacity: 0.45; filter: grayscale(60%)` to use `position: relative` only
|
||||
- Add `:deep(.chore-inactive::before)` pseudo-element: `position: absolute; inset: 0; background: rgba(0,0,0,0.45); filter: grayscale(60%); z-index: 1; pointer-events: none; border-radius: inherit`
|
||||
- Give `.chore-stamp` `z-index: 3` (above the overlay)
|
||||
- Give `.chore-kebab-wrap` `z-index: 2` (above the overlay, below the stamp)
|
||||
|
||||
#### Additional Test Cases With This Fix
|
||||
|
||||
- []
|
||||
|
||||
### User Test Findings:
|
||||
|
||||
Fix confirmed
|
||||
|
||||
---
|
||||
|
||||
### Change the due by to be bigger
|
||||
|
||||
In both parent and child view, when a chore is scheduled and has a due time, I see the text 'Due by' in parent mode, and just the time in child mode.
|
||||
In both parent and child view, the text needs to be bigger: the due-label should be 0.85 rem font size.
|
||||
In child mode, the time a chore is due has a dark color that is hard to see. It should be the same color that is in parent view.
|
||||
|
||||
**Fix — `ParentView.vue`:**
|
||||
|
||||
- Change `.due-label { font-size: 0.72rem }` → `font-size: 0.85rem`
|
||||
|
||||
**Fix — `ChildView.vue`:**
|
||||
|
||||
- Change `.due-label { font-size: 0.72rem }` → `font-size: 0.85rem`
|
||||
- Change `.due-label` color from `var(--due-label-color, #aaa)` → `var(--item-points-color, #ffd166)` to match parent view
|
||||
- Change `choreDueLabel()` to return `"Due by " + formatDueTimeLabel(...)` instead of just the time string
|
||||
|
||||
#### Additional Test Cases With This Fix
|
||||
|
||||
- [x] `choreDueLabel()` in ChildView returns `"Due by 3:00 PM"` format (not just `"3:00 PM"`)
|
||||
|
||||
### User Test Findings:
|
||||
|
||||
The text size is good, but I want to change the text color to red. Create a new color in colors.css called --text-bad-color, it should be #ef4444
|
||||
|
||||
**Fix applied:**
|
||||
|
||||
- Added `--text-bad-color: #ef4444` to `colors.css`
|
||||
- Updated `.due-label` color in both `ParentView.vue` and `ChildView.vue` to use `var(--text-bad-color, #ef4444)`
|
||||
|
||||
---
|
||||
|
||||
### In parent mode, when a chore is too late. The banner shouldn't be 'grayed' out. Same in child mode - color should also be red in the banner
|
||||
|
||||
When a chore is too late, the 'TOO LATE' banner is also grayed out but it should not be. Also the font color for 'TOO LATE' needs to be consistent. Make both colors red according to colors.css
|
||||
|
||||
**Fix — `ChildView.vue`:**
|
||||
|
||||
- The TOO LATE `<span class="pending">` uses the `.pending` class which is styled green (`--pending-block-color`). Change it to `<span class="chore-stamp">` to match ParentView's class
|
||||
- Add `.chore-stamp` CSS to ChildView matching ParentView's definition: `color: var(--btn-danger, #ef4444)`
|
||||
- The grayout issue is resolved by the pseudo-element overlay fix in Bug 4 above — `.chore-stamp` at `z-index: 3` sits above the overlay at full opacity
|
||||
|
||||
**Fix — `ParentView.vue`:**
|
||||
|
||||
- No color change needed (already `color: var(--btn-danger, #ef4444)`)
|
||||
- The grayout issue is resolved by the pseudo-element overlay fix in Bug 4 above
|
||||
|
||||
#### Additional Test Cases With This Fix
|
||||
|
||||
- []
|
||||
|
||||
### User Test Findings:
|
||||
|
||||
chore-stamp background seems too dark, change it to rgba(34,34,34,0.65). Also give it text-shadow: var(--item-points-shadow)
|
||||
the TOO LATE text should not be using color --btn-danger. It should use the newly created color --text-bad-color
|
||||
|
||||
**Fix applied:**
|
||||
|
||||
- `.chore-stamp` background changed from `rgba(34,34,43,0.85)` → `rgba(34,34,34,0.65)` in both `ParentView.vue` and `ChildView.vue`
|
||||
- Added `text-shadow: var(--item-points-shadow)` to `.chore-stamp` in both files
|
||||
- Color changed from `var(--btn-danger, #ef4444)` → `var(--text-bad-color, #ef4444)` in both files
|
||||
125
.github/specs/archive/bugs-1.0.5-002.md
vendored
Normal file
125
.github/specs/archive/bugs-1.0.5-002.md
vendored
Normal file
@@ -0,0 +1,125 @@
|
||||
# Bug List
|
||||
|
||||
**Feature Bugs:** .github/specs/feat-calendar-chore/feat-calendar-chore.md
|
||||
|
||||
## Bugs
|
||||
|
||||
### When rescheduling an extended time chore, the extended time should be reset(removed)
|
||||
|
||||
When a chore has had it's time extended, but later edited through the scheduler and saved, the extended time for this chore should be reset. (A previous too late chore would again be too late if the current time is past the newly changed schedule)
|
||||
|
||||
**Root cause:** The PUT handler in `chore_schedule_api.py` saved the new schedule but never deleted the `TaskExtension` record for that child+task pair.
|
||||
|
||||
**Fix:**
|
||||
|
||||
- `backend/db/task_extensions.py` — Added `delete_extension_for_child_task(child_id, task_id)` that removes the `TaskExtension` matching both child and task IDs.
|
||||
- `backend/api/chore_schedule_api.py` — Imported the new function and called it immediately before `upsert_schedule(schedule)` in the PUT handler.
|
||||
|
||||
#### Additional Test Cases With This Fix
|
||||
|
||||
- []
|
||||
|
||||
### User Test Findings:
|
||||
|
||||
This flow produces a bug
|
||||
|
||||
1. A chore that is 'too late' (scheduled expiry time is 8:00am, but the current time is 3pm)
|
||||
2. Extend the time on that chore causes the 'too late' to disappear. The chore is now valid again.
|
||||
3. Enter the scheduler on that chore and set the expiry time to 9am (when it is 3pm in real time) the chore stays active -- I expect the chore to go back to the 'too late' state since the extended time was reset again. It should revert back to 'too late'
|
||||
|
||||
**Analysis:** The backend fix (deleting the extension on PUT) is correct. Step 3 failing is a side effect of Bug 3 (Extend Time not refreshing the UI) — the frontend was in a stale state after step 2, so step 3 was operating on stale item data. Once Bug 3 is fixed (direct refresh in `doExtendTime`), step 2 reliably updates the UI, and step 3's `onScheduleSaved → refresh()` then correctly re-evaluates `isChoreExpired` against the fresh data (no `extension_date`, 9am schedule → TOO LATE). No additional frontend changes needed beyond the backend deletion fix.
|
||||
|
||||
---
|
||||
|
||||
### Extend time is cut off of chore kebab menu
|
||||
|
||||
The dropdown for the kebab menu does not extend far enough to show all the items. It should show all the items.
|
||||
https://git.ryankegel.com/ryan/chore/attachments/951592da-29a2-4cca-912e-9b160eb2f19c
|
||||
|
||||
**Root cause:** The `.kebab-menu` is `position: absolute` inside `.chore-kebab-wrap`, which lives inside a `ScrollingList` card. The scroll wrapper has `overflow-y: hidden`, which clips any absolutely positioned content that extends below the card boundary. CSS cannot set `overflow-x: auto` and `overflow-y: visible` simultaneously — the browser coerces `visible` to `auto`.
|
||||
|
||||
**Fix:** (`frontend/vue-app/src/components/child/ParentView.vue`)
|
||||
|
||||
- Added `menuPosition = ref({ top: 0, left: 0 })` and `kebabBtnRefs = ref<Map<string, HTMLElement>>(new Map())`.
|
||||
- Added a `:ref` callback on `.kebab-btn` to register/unregister each button element keyed by `item.id`.
|
||||
- `openChoreMenu` now captures `getBoundingClientRect()` on the trigger button and stores `{ top: rect.bottom, left: rect.right - 140 }` into `menuPosition`.
|
||||
- Wrapped `.kebab-menu` in `<Teleport to="body">` and switched it to `position: fixed` with inline `:style` driven by `menuPosition`. `z-index` raised to `9999`.
|
||||
- `onDocClick` required no changes — it already checks `.kebab-menu` via `composedPath()`, which traverses the real DOM regardless of teleport destination.
|
||||
|
||||
#### Additional Test Cases With This Fix
|
||||
|
||||
- []
|
||||
|
||||
### User Test Findings:
|
||||
|
||||
Fix confirmed
|
||||
|
||||
---
|
||||
|
||||
### When a chore has passed it's time or when an time expired chore is extended, it should be updated right away in all UIs.
|
||||
|
||||
An SSE event should happen when a chore has been extended or expired so that the UI updates. Currently, the user has to refresh to see this change.
|
||||
|
||||
**Root cause:** In `ParentView.vue`, three call sites called `resetExpiryTimers()` synchronously — before `refresh()` resolved — so new timers were set against stale item data. `ChildView.vue` already delayed the call with `setTimeout(..., 300)` correctly.
|
||||
|
||||
**Fix:** (`frontend/vue-app/src/components/child/ParentView.vue`)
|
||||
|
||||
- In `handleChoreScheduleModified`, `handleChoreTimeExtended`, and `onScheduleSaved`: replaced `resetExpiryTimers()` with `setTimeout(() => resetExpiryTimers(), 300)` to match the existing correct pattern in `ChildView.vue`.
|
||||
|
||||
#### Additional Test Cases With This Fix
|
||||
|
||||
- []
|
||||
|
||||
### User Test Findings:
|
||||
|
||||
I'm still not seeing the chore update back to active state when 'Extend time' is selected from the menu. I have to refresh the page to see the change.
|
||||
|
||||
**Root cause of remaining failure:** `doExtendTime` posted to the API and relied entirely on the SSE `chore_time_extended` event to trigger a refresh. If SSE delivery is delayed or the event is missed, the UI never updates. The previous fix (timer delay) only corrected expiry timer scheduling, not the extend-time response path.
|
||||
|
||||
**Additional fix:** (`frontend/vue-app/src/components/child/ParentView.vue`)
|
||||
|
||||
- In `doExtendTime`: after a successful API response, call `childChoreListRef.value?.refresh()` and `setTimeout(() => resetExpiryTimers(), 300)` directly. Added an early `return` on error to prevent a refresh on failure.
|
||||
|
||||
---
|
||||
|
||||
### On the Every X Days scheduler, the every [x] input box and day dropbox should be on the same line
|
||||
|
||||
Currently they are on seperate lines.
|
||||
|
||||
**Root cause:** `.interval-row` and `.interval-time-row` shared a combined CSS rule that included `flex-wrap: wrap`, causing the interval input and day select to wrap onto a new line at the modal's narrow width.
|
||||
|
||||
**Fix:** (`frontend/vue-app/src/components/shared/ScheduleModal.vue`)
|
||||
|
||||
- Split the combined `.interval-row, .interval-time-row` rule into two separate rules. Removed `flex-wrap: wrap` from `.interval-row` only; kept it on `.interval-time-row`.
|
||||
|
||||
#### Additional Test Cases With This Fix
|
||||
|
||||
- []
|
||||
|
||||
### User Test Findings:
|
||||
|
||||
Fix confirmed
|
||||
|
||||
---
|
||||
|
||||
### In the chore scheduler, the time selector needs to be much smaller
|
||||
|
||||
The time selector should have a smaller font size, borders, up/down arrows. A font size of 1rem should be fine.
|
||||
|
||||
**Root cause:** `.time-value` had `font-size: 1.4rem` and `.time-separator` had `font-size: 1.6rem`. All button/value boxes were `2.5rem` wide/tall. A `@media (max-width: 480px)` block made them even larger on small screens.
|
||||
|
||||
**Fix:** (`frontend/vue-app/src/components/shared/TimeSelector.vue`)
|
||||
|
||||
- `.arrow-btn` and `.time-value`: width/height `2.5rem` → `1.8rem`; arrow font `0.85rem` → `0.75rem`.
|
||||
- `.time-value`: `font-size: 1.4rem` → `1rem`.
|
||||
- `.time-separator`: `font-size: 1.6rem` → `1rem`.
|
||||
- `.ampm-btn`: width `3rem` → `2.2rem`; height `2.5rem` → `1.8rem`; font `0.95rem` → `0.8rem`.
|
||||
- Removed the `@media (max-width: 480px)` block that was enlarging all sizes on mobile.
|
||||
|
||||
#### Additional Test Cases With This Fix
|
||||
|
||||
- [x]
|
||||
|
||||
### User Test Findings:
|
||||
|
||||
Fix confirmed
|
||||
92
.github/specs/archive/bugs-1.0.6-01/bugs-1.0.6-set01.md
vendored
Normal file
92
.github/specs/archive/bugs-1.0.6-01/bugs-1.0.6-set01.md
vendored
Normal file
@@ -0,0 +1,92 @@
|
||||
# Bug List for 1.0.6 Set 1
|
||||
|
||||
## Uppercase text for parent verification ✅
|
||||
|
||||
When entering a parent verification code (when creating a parent PIN) all entered characters should be uppercase. Convert the characters to uppercase in the UI.
|
||||
|
||||
**Fix:** Added `text-transform: uppercase` CSS and an `@input` handler that converts the value to uppercase on the verification code input in `ParentPinSetup.vue`.
|
||||
|
||||
## Assigning tasks should have the name of the child being assigned. ✅
|
||||
|
||||

|
||||
|
||||
Instead of just saying "Assign Chores", it should say Assign Chores for [Name] or Assign Chores [Name]
|
||||
|
||||
**Fix:** All four assign views (`ChoreAssignView`, `RewardAssignView`, `KindnessAssignView`, `PenaltyAssignView`) now read the child's name from the route query parameter and display it in the heading (e.g. "Assign Chores for Emma"). The `ParentView` passes `query: { name: child.name }` when navigating to each assign route.
|
||||
|
||||
## Child completing a general chore (not scheduled) ✅
|
||||
|
||||
When a child completes a general chore and it is approved, the parent sees "COMPLETED", but the child can click and have it approved again. The chore on the parent side should not say completed, it should just go back to normal state. General chores, by default, can be completed multiple times, so it should not say "COMPLETED" on the parent side.
|
||||
|
||||
**Fix:** Backend `child_api.py` now distinguishes between general and scheduled chores during trigger and approval. General chores no longer create an "approved" `PendingConfirmation` record — the existing pending record is simply removed on approval. Only scheduled chores persist an approved confirmation (which drives the COMPLETED stamp). This allows general chores to return to normal state and be triggered again.
|
||||
|
||||
## Scheduled chore and too late ✅
|
||||
|
||||
When extending "Make Your Bed" (a scheduled chore that is too late,) the "TOO LATE" banner does not go away even though it was extended. Activating it again tells me that the chore was already extended. I'm expecting that when the chore is extended the too late banner disappears.
|
||||
|
||||
**Fix:** `ParentView.vue` `doExtendTime()` now explicitly calls `childChoreListRef.value?.refresh()` immediately after the extension API call, before resetting expiry timers. This forces the chore list to re-fetch and re-render, removing the TOO LATE banner without waiting for an SSE race condition.
|
||||
|
||||
## Notification runoff ✅
|
||||
|
||||
Notifications for completed or pending tasks can sometimes be too long for the card can run off the card.
|
||||

|
||||
in the red square, the icon for the chore has run off the card. We need a better way to fit the information. Make the card heights for only notifications dynamic?
|
||||
|
||||
**Fix:** Updated `NotificationView.vue` CSS to use `overflow: hidden` and `text-overflow: ellipsis` on notification card content, preventing icons and text from overflowing card boundaries. Card heights are now flexible to accommodate varying content lengths.
|
||||
|
||||
## Point editing events ✅
|
||||
|
||||
When changing points in parent mode, the child mode does not see the point update until refreshed. There should be a functional SSE that updates the points in the child view (or other parent mode tabs)
|
||||
|
||||
**Fix:** Added SSE event handlers in `ChildView.vue` for `child_override_set` and `child_override_deleted` events. When a parent changes point overrides, the child view now listens for these events and refreshes the affected task lists (chores, kindness, penalties) in real time. Handlers are registered in `onMounted` and cleaned up in `onUnmounted`.
|
||||
|
||||
## Child tasks list editing in view ✅
|
||||
|
||||
When a child's task is edited (points changed or scheduled, etc) and saved. The view resets. The problem is that when the view resets, if the edited task was off screen and had to be scrolled to, the user has to do this again. The task that was just edited should be automatically scrolled to so that it is in view both vertically and horizontally.
|
||||
|
||||
**Fix:** `ScrollingList.vue` now exposes a `scrollToItem(itemId)` method that scrolls an item into view both vertically (via `scrollIntoView`) and horizontally (via `centerItem`). `ParentView.vue` tracks the last edited item ID when saving an override, and after the list refreshes, calls `scrollToItem()` on the appropriate list ref to bring the edited card back into view.
|
||||
|
||||
## Task sorting in child task view ✅
|
||||
|
||||
When viewing the child's chores, kindnesses, penalties, rewards. The items should be sorted differently.
|
||||
|
||||
### Parent mode order
|
||||
|
||||
1. Pending tasks (chores and rewards)
|
||||
2. General tasks
|
||||
3. Too late (chores) when it applies
|
||||
4. Completed (chores) when it applies
|
||||
5. Chores that are not scheduled for today.
|
||||
|
||||
### Child mode order
|
||||
|
||||
- Chores
|
||||
|
||||
1. Scheduled for today chores (with earliest deadline first when it applies)
|
||||
2. General Chores
|
||||
3. Pending chores
|
||||
4. Completed Chores
|
||||
|
||||
- Rewards
|
||||
|
||||
1. Pending Rewards
|
||||
2. Other rewards sorted by how many more points are required.
|
||||
|
||||
**Fix:** Added a `sortFn` prop to `ScrollingList.vue` that applies a custom sort after filtering. `ParentView.vue` defines `parentChoreSort()` (pending → general → expired → completed → unscheduled) and `parentRewardSort()` (pending first). `ChildView.vue` defines `childChoreSort()` (scheduled today with earliest deadline first → general → pending) and `childRewardSort()` (pending first, then ascending by points needed). These are wired as `:sort-fn` on their respective ScrollingList instances.
|
||||
|
||||
### Notification clicking ✅
|
||||
|
||||
When a parent clicks on a notification, the parent view should show for the child and child's task list. However, the task that was listed in the notification should be scrolled to both vertically and horizontally.
|
||||
|
||||
**Fix:** `NotificationView.vue` `handleNotificationClick()` now passes `query: { scrollTo: item.entity_id, entityType: item.entity_type }` when navigating to the parent child view. `ParentView.vue` reads these query parameters on mount and, after data loads, calls `scrollToItem()` on the correct list ref (chores, rewards, kindness, or penalties) with a short delay to ensure the list is rendered.
|
||||
|
||||
### Pending chore / reward changes ✅
|
||||
|
||||
If a chore or reward is changed while pending (schedule change or point change), the pending status should be reset back to normal state. Completed chores however should stay in the completed state.
|
||||
|
||||
**Fix:** Three backend API endpoints now clear pending status on modification:
|
||||
|
||||
- `task_api.py` `edit_task()`: When points or type changes, removes all pending chore confirmations for that task and sends `OPERATION_RESET` SSE events.
|
||||
- `chore_schedule_api.py` `set_chore_schedule()`: After upserting a schedule, removes any pending chore confirmations for that child+task and sends reset events.
|
||||
- `reward_api.py` `edit_reward()`: When cost changes, removes all pending reward requests for that reward and sends `CANCELLED` SSE events.
|
||||
Completed chores (approved PendingConfirmation on scheduled chores) are not affected by these changes.
|
||||
BIN
.github/specs/archive/bugs-1.0.6-01/image-1.png
vendored
Normal file
BIN
.github/specs/archive/bugs-1.0.6-01/image-1.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
BIN
.github/specs/archive/bugs-1.0.6-01/image.png
vendored
Normal file
BIN
.github/specs/archive/bugs-1.0.6-01/image.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
38
.github/specs/archive/bugs-1.0.6-02/bugs-1.0.6-set02.md
vendored
Normal file
38
.github/specs/archive/bugs-1.0.6-02/bugs-1.0.6-set02.md
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
# Bug List for 1.0.6 Set 2
|
||||
|
||||
## On mobile, the COMPLETED (and possible other banners) overflow it's bounding box
|
||||
|
||||
- [x] Fixed
|
||||
|
||||

|
||||
|
||||
We need the banner text to be a little smaller on mobile screens so it does not go outside the dark border box.
|
||||
|
||||
**Fix:** Added `@media (max-width: 480px)` CSS rule for `.chore-stamp` in both ChildView.vue and ParentView.vue. Reduces `font-size` to `0.82rem`, `letter-spacing` to `1px`, and `padding` to `0.3rem 0` on small screens.
|
||||
|
||||
## Extending time on a chore that is too late causes task icons to disappear.
|
||||
|
||||
- [x] Fixed
|
||||
|
||||
When the user extends time on a chore that is TOO LATE, I think 2 identical calls are being made to the api /child/[id]/list-tasks. This is causing a condition where images no longer appear until the user refreshes.
|
||||
|
||||
Could SSE calls be causing this? Could also be a childChoreListRef.value?.refresh() is called in the UI too many times too quickly.
|
||||
|
||||
**Root Cause:** Two issues combined:
|
||||
|
||||
1. `doExtendTime` in ParentView called `childChoreListRef.value?.refresh()` directly AND the SSE `chore_time_extended` event handler also called `refresh()`, causing two concurrent fetches.
|
||||
2. `ScrollingList.fetchItems()` had no protection against concurrent calls. When two fetches raced, `items.value` was set before images were resolved, and the second call's `loading.value = false` didn't trigger a re-render (already false), leaving images from the second fetch invisible.
|
||||
|
||||
**Fix:**
|
||||
|
||||
- Removed the manual `refresh()` call from `doExtendTime` in ParentView — the SSE event handler already handles it.
|
||||
- Added a fetch generation counter to `ScrollingList.fetchItems()` that discards stale results from superseded fetches.
|
||||
- After image resolution, `items.value` is re-assigned (`[...itemList]`) to trigger Vue reactivity for image URLs set on raw objects.
|
||||
|
||||
## When adding a schedule to a task and submitting, icons dissappear.
|
||||
|
||||
- [x] Fixed
|
||||
|
||||
This is related to the previous bug. API calls to /child[id]/list-tasks are called very quickly causing a problem.
|
||||
|
||||
**Fix:** The `ScrollingList` generation counter fix (described above) resolves this for all callers. Any concurrent `refresh()` calls — whether from SSE events, manual triggers, or timer-based refreshes — are now safely handled.
|
||||
BIN
.github/specs/archive/bugs-1.0.6-02/image.png
vendored
Normal file
BIN
.github/specs/archive/bugs-1.0.6-02/image.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
BIN
.github/specs/archive/feat-calendar-chore/feat-calendar-chore-component01.png
vendored
Normal file
BIN
.github/specs/archive/feat-calendar-chore/feat-calendar-chore-component01.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
270
.github/specs/archive/feat-calendar-chore/feat-calendar-chore.md
vendored
Normal file
270
.github/specs/archive/feat-calendar-chore/feat-calendar-chore.md
vendored
Normal file
@@ -0,0 +1,270 @@
|
||||
# Feature: Chores can be scheduled to occur on certain calendar days or weeks and have a time during the day where they must be finished by.
|
||||
|
||||
## Overview
|
||||
|
||||
**Goal:** Chores can be edited to only show up in child mode on particular days and during certain times. This encourages a child to perform chores on those days before a certain time of day.
|
||||
|
||||
**User Story:**
|
||||
|
||||
- As a parent, I will be able to select an assigned chore and configure it to occur on any combination of the days of the week. I will also be able to configure the latest possible time of day on each of those configured days that the chore must be done.
|
||||
- As a child, I will not see the chore appear if it was configured for a different day of the week. I will also not be able to see a chore if the current time of day is past the configured latest time of the chore.
|
||||
|
||||
**Rules:**
|
||||
|
||||
- Follow instructions from .github/copilot-instructions.md
|
||||
|
||||
---
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
### Timezone Safety
|
||||
|
||||
Due times represent the **child's local time of day**. The server must never perform time-based computations (is it today? has the time passed?) because it may be in a different timezone than the client.
|
||||
|
||||
**All time logic runs in the browser using `new Date()` (the client's local clock).** The backend only stores and returns raw schedule data. The frontend computes `isScheduledToday`, `isPastTime`, `due_time_label`, and `setTimeout` durations locally.
|
||||
|
||||
### Auto-Expiry While Browser is Open
|
||||
|
||||
When a chore's due time passes while the browser tab is already open, the UI must update automatically without a page refresh. The frontend sets a `setTimeout` per chore (calculated from the due time and the current local time). When the timer fires, the chore's state is updated reactively in local component state — no re-fetch required.
|
||||
|
||||
---
|
||||
|
||||
## Data Model Changes
|
||||
|
||||
### Backend Models
|
||||
|
||||
**New model — `backend/models/chore_schedule.py`:**
|
||||
|
||||
- `id: str`
|
||||
- `child_id: str`
|
||||
- `task_id: str`
|
||||
- `mode: Literal['days', 'interval']`
|
||||
- For `mode='days'`: `day_configs: list[DayConfig]`
|
||||
- `DayConfig`: `{ day: int (0=Sun–6=Sat), hour: int, minute: int }`
|
||||
- For `mode='interval'`: `interval_days: int` (2–7), `anchor_weekday: int` (0=Sun–6=Sat), `interval_hour: int`, `interval_minute: int`
|
||||
- Inherits `id`, `created_at`, `updated_at` from `BaseModel`
|
||||
|
||||
**New model — `backend/models/task_extension.py`:**
|
||||
|
||||
- `id: str`
|
||||
- `child_id: str`
|
||||
- `task_id: str`
|
||||
- `date: str` — ISO date string supplied by the client, e.g. `'2026-02-22'`
|
||||
|
||||
### Frontend Models
|
||||
|
||||
In `frontend/vue-app/src/common/models.ts`:
|
||||
|
||||
- Add `DayConfig` interface: `{ day: number; hour: number; minute: number }`
|
||||
- Add `ChoreSchedule` interface mirroring the Python model (`mode`, `day_configs`, `interval_days`, `anchor_weekday`, `interval_hour`, `interval_minute`)
|
||||
- Extend `ChildTask` wire type with:
|
||||
- `schedule?: ChoreSchedule | null` — raw schedule data returned from backend
|
||||
- `extension_date?: string | null` — ISO date of the most recent `TaskExtension` for this child+task, if any
|
||||
|
||||
---
|
||||
|
||||
## Frontend Design
|
||||
|
||||
### Chore card
|
||||
|
||||
- Currently, when a chore is pressed on the ParentView, it shows an icon that represents an edit button. This is shown in `.github/feat-calendar-chore/feat-calendar-chore-component01.png` outlined in red.
|
||||
- This icon button will be changed to a kebab menu that drops down the following:
|
||||
- **Edit Points** — opens the existing override points modal
|
||||
- **Schedule** — opens the Schedule Modal
|
||||
- **Extend Time** — only visible when the chore is computed as expired (`isPastTime === true`)
|
||||
- The look and feel of the dropdown follows the kebab menu pattern in `ChildrenListView.vue`.
|
||||
- Penalties and Rewards keep the current edit icon button and existing behavior.
|
||||
- The kebab menu is placed per-card in the chore `#item` slot in `ParentView.vue` (not inside `ScrollingList`).
|
||||
|
||||
**ParentView card states (computed client-side from `item.schedule`, `item.extension_date`, and `new Date()`):**
|
||||
|
||||
- No schedule → card appears normally, no annotations
|
||||
- Scheduled but wrong day → card is grayed out (`.chore-inactive` class: `opacity: 0.45; filter: grayscale(60%)`)
|
||||
- Correct day, due time not yet passed → show `"Due by X:XX PM"` sub-text on card
|
||||
- Correct day, due time passed (and no extension for today) → card is grayed out **and** a `TOO LATE` badge overlay appears (same absolute-positioned `.pending` pattern, using `--item-card-bad-border` color)
|
||||
- Chore always shows the kebab menu and can always be triggered by the parent regardless of state
|
||||
|
||||
**ChildView card states (computed client-side):**
|
||||
|
||||
- No schedule → card appears normally
|
||||
- Scheduled but wrong day → hidden entirely (filtered out in component, not server-side)
|
||||
- Correct day, due time not yet passed → show `"Due by X:XX PM"` sub-text
|
||||
- Correct day, due time passed → grayed out with `TOO LATE` badge overlay (same pattern as `PENDING` in `ChildView.vue`)
|
||||
|
||||
**Auto-expiry timers:**
|
||||
|
||||
- After task list is fetched or updated, for each chore where a due time exists for today and `isPastTime` is `false`, compute `msUntilExpiry` using `scheduleUtils.msUntilExpiry(dueHour, dueMin, new Date())` and set a `setTimeout`
|
||||
- When the timer fires, flip that chore's reactive state to expired locally — no re-fetch needed
|
||||
- All timer handles are stored in a `ref<number[]>` and cleared in `onUnmounted`
|
||||
- Timers are also cleared and reset whenever the task list is re-fetched (e.g. on SSE events)
|
||||
|
||||
**Extend Time behavior:**
|
||||
|
||||
- When selected, calls `extendChoreTime(childId, taskId)` with the client's local ISO date
|
||||
- The chore resets to normal for the remainder of that day only
|
||||
- After a successful extend, the local chore state is updated to reflect `extension_date = today` and timers are reset
|
||||
|
||||
### Schedule Modal
|
||||
|
||||
- Triggered from the **Schedule** kebab menu item in ParentView
|
||||
- Wraps `ModalDialog` with title `"Schedule Chore"` and subtitle showing the chore's name and icon
|
||||
- Does **not** use `EntityEditForm` — custom layout required for the day/time matrix
|
||||
- Top toggle: **"Specific Days"** vs **"Every X Days"**
|
||||
|
||||
**Specific Days mode:**
|
||||
|
||||
- 7 checkbox rows, one per day (Sunday–Saturday)
|
||||
- Checking a day reveals an inline `TimeSelector` component for that day
|
||||
- Unchecking a day removes its time configuration
|
||||
- Each checked day can have its own independent due time
|
||||
|
||||
**Every X Days mode:**
|
||||
|
||||
- Number selector for interval [2–7]
|
||||
- Weekday picker to select the anchor day (0=Sun–6=Sat)
|
||||
- Anchor logic: the first occurrence is calculated as the current week's matching weekday; subsequent occurrences repeat every X days indefinitely. See `scheduleUtils.intervalHitsToday(anchorWeekday, intervalDays, localDate)`
|
||||
- One shared `TimeSelector` applies to all occurrences
|
||||
|
||||
- Save and Cancel buttons at the bottom
|
||||
|
||||
### Time Selector
|
||||
|
||||
- Custom-built `TimeSelector.vue` component (no PrimeVue dependency)
|
||||
- Props: `modelValue: { hour: number; minute: number }` (24h internally, 12h display)
|
||||
- Emits: `update:modelValue`
|
||||
- UI: Up/Down arrow buttons for Hour (1–12) and Minute (00, 15, 30, 45), AM/PM toggle button
|
||||
- Minutes increment/decrement in 15-minute steps with boundary wrapping (e.g. 11:45 AM → 12:00 PM)
|
||||
- Large tap targets for mobile devices
|
||||
- Scoped CSS using `:root` CSS variables only
|
||||
- Modeled after the Time Only component at https://primevue.org/datepicker/#time
|
||||
|
||||
---
|
||||
|
||||
## Backend Implementation
|
||||
|
||||
**New DB files:**
|
||||
|
||||
- `backend/db/chore_schedules.py` — TinyDB table `chore_schedules`
|
||||
- `backend/db/task_extensions.py` — TinyDB table `task_extensions`
|
||||
|
||||
**New API file — `backend/api/chore_schedule_api.py`:**
|
||||
|
||||
- `GET /child/<child_id>/task/<task_id>/schedule` — returns raw `ChoreSchedule`; 404 if none
|
||||
- `PUT /child/<child_id>/task/<task_id>/schedule` — create or replace schedule; fires `chore_schedule_modified` SSE (`operation: 'SET'`)
|
||||
- `DELETE /child/<child_id>/task/<task_id>/schedule` — remove schedule; fires `chore_schedule_modified` SSE (`operation: 'DELETED'`)
|
||||
- `POST /child/<child_id>/task/<task_id>/extend` — body: `{ date: "YYYY-MM-DD" }` (client's local date); inserts a `TaskExtension`; returns 409 if a `TaskExtension` already exists for this `child_id + task_id + date`; fires `chore_time_extended` SSE
|
||||
|
||||
**Modify `backend/api/child_api.py` — `GET /child/<id>/list-tasks`:**
|
||||
|
||||
- For each task, look up `ChoreSchedule` for `(child_id, task_id)` and the most recent `TaskExtension`
|
||||
- Return `schedule: ChoreSchedule | null` and `extension_date: string | null` on each task object
|
||||
- **No filtering, no time math, no annotations** — the client handles all of this
|
||||
|
||||
**New SSE event types in `backend/events/types/`:**
|
||||
|
||||
- `chore_schedule_modified.py` — payload: `child_id`, `task_id`, `operation: Literal['SET', 'DELETED']`
|
||||
- `chore_time_extended.py` — payload: `child_id`, `task_id`
|
||||
- Register both in `event_types.py`
|
||||
|
||||
---
|
||||
|
||||
## Backend Tests
|
||||
|
||||
- [x] `test_chore_schedule_api.py`: CRUD schedule endpoints, extend endpoint (409 on duplicate date, accepts client-supplied date)
|
||||
- [x] Additions to `test_child_api.py`: verify `schedule` and `extension_date` fields are returned on each task; verify no server-side time filtering or annotation occurs
|
||||
|
||||
---
|
||||
|
||||
## Frontend Implementation
|
||||
|
||||
**New utility — `frontend/vue-app/src/common/scheduleUtils.ts`:**
|
||||
|
||||
- `isScheduledToday(schedule: ChoreSchedule, localDate: Date): boolean` — handles both `days` and `interval` modes
|
||||
- `getDueTimeToday(schedule: ChoreSchedule, localDate: Date): { hour: number; minute: number } | null`
|
||||
- `isPastTime(dueHour: number, dueMin: number, localNow: Date): boolean`
|
||||
- `isExtendedToday(extensionDate: string | null | undefined, localDate: Date): boolean`
|
||||
- `formatDueTimeLabel(hour: number, minute: number): string` — returns e.g. `"3:00 PM"`
|
||||
- `msUntilExpiry(dueHour: number, dueMin: number, localNow: Date): number`
|
||||
- `intervalHitsToday(anchorWeekday: number, intervalDays: number, localDate: Date): boolean`
|
||||
|
||||
**`ParentView.vue`:**
|
||||
|
||||
- Derive computed chore state per card using `scheduleUtils` and `new Date()`
|
||||
- Remove `:enableEdit="true"` and `@edit-item` from the Chores `ScrollingList` (keep for Penalties)
|
||||
- Add `activeMenuFor = ref<string | null>(null)` and capture-phase `document` click listener (same pattern as `ChildrenListView.vue`)
|
||||
- In chore `#item` slot: add `.kebab-wrap > .kebab-btn + .kebab-menu` per card with items: Edit Points, Schedule, Extend Time (conditional on `isPastTime`)
|
||||
- Bind `.chore-inactive` class when `!isScheduledToday || isPastTime`
|
||||
- Add `TOO LATE` badge overlay when `isPastTime`
|
||||
- Show `"Due by {{ formatDueTimeLabel(...) }}"` sub-text when due time exists and not yet expired
|
||||
- Manage `setTimeout` handles in a `ref<number[]>`; clear in `onUnmounted` and on every re-fetch
|
||||
- Listen for `chore_schedule_modified` and `chore_time_extended` SSE events → re-fetch task list and reset timers
|
||||
|
||||
**`ChildView.vue`:**
|
||||
|
||||
- Derive computed chore state per card using `scheduleUtils` and `new Date()`
|
||||
- Filter out chores where `!isScheduledToday` (client-side, not server-side)
|
||||
- Add `.chore-expired` grayout + `TOO LATE` stamp when `isPastTime`
|
||||
- Show `"Due by {{ formatDueTimeLabel(...) }}"` sub-text when due time exists and not yet expired
|
||||
- Manage `setTimeout` handles the same way as ParentView
|
||||
- Listen for `chore_schedule_modified` and `chore_time_extended` SSE events → re-fetch and reset timers
|
||||
|
||||
**New `frontend/vue-app/src/components/shared/ScheduleModal.vue`:**
|
||||
|
||||
- Wraps `ModalDialog`; custom slot content for schedule form
|
||||
- Mode toggle (Specific Days / Every X Days)
|
||||
- Specific Days: 7 checkbox rows, each reveals an inline `TimeSelector` when checked
|
||||
- Interval: number input [2–7], weekday picker, single `TimeSelector`
|
||||
- Calls `setChoreSchedule()` on Save; emits `saved` and `cancelled`
|
||||
|
||||
**New `frontend/vue-app/src/components/shared/TimeSelector.vue`:**
|
||||
|
||||
- Props/emits as described in Frontend Design section
|
||||
- Up/Down arrows for Hour and Minute (15-min increments), AM/PM toggle
|
||||
- Scoped CSS, `:root` variables only
|
||||
|
||||
**`frontend/vue-app/src/common/api.ts` additions:**
|
||||
|
||||
- `getChoreSchedule(childId, taskId)`
|
||||
- `setChoreSchedule(childId, taskId, schedule)`
|
||||
- `deleteChoreSchedule(childId, taskId)`
|
||||
- `extendChoreTime(childId, taskId, localDate: string)` — passes client's local ISO date in request body
|
||||
|
||||
---
|
||||
|
||||
## Frontend Tests
|
||||
|
||||
- [x] `scheduleUtils.ts`: unit test all helpers — `isScheduledToday` (days mode, interval mode), `intervalHitsToday`, `isPastTime`, `isExtendedToday`, `formatDueTimeLabel`, `msUntilExpiry`; include timezone-agnostic cases using mocked `Date` objects
|
||||
- [x] `TimeSelector.vue`: increment/decrement, AM/PM toggle, 15-min boundary wrapping (e.g. 11:45 AM → 12:00 PM)
|
||||
- [x] `ScheduleModal.vue`: mode toggle renders correct sub-form; unchecking a day removes its time config; Save emits correct `ChoreSchedule` shape
|
||||
|
||||
---
|
||||
|
||||
## Future Considerations
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria (Definition of Done)
|
||||
|
||||
### Backend
|
||||
|
||||
- [x] `ChoreSchedule` and `TaskExtension` models created with `from_dict()`/`to_dict()` serialization
|
||||
- [x] `chore_schedules` and `task_extensions` TinyDB tables created
|
||||
- [x] `chore_schedule_api.py` CRUD and extend endpoints implemented
|
||||
- [x] `GET /child/<id>/list-tasks` returns `schedule` and `extension_date` on each task; performs no time math or filtering
|
||||
- [x] Extend endpoint accepts client-supplied ISO date; returns 409 on duplicate for same date
|
||||
- [x] All new SSE events fire on every mutation
|
||||
- [x] All backend tests pass
|
||||
|
||||
### Frontend
|
||||
|
||||
- [x] `scheduleUtils.ts` implemented and fully unit tested, including timezone-safe mocked Date cases
|
||||
- [x] `TimeSelector.vue` implemented with 15-min increments, AM/PM toggle, mobile-friendly tap targets
|
||||
- [x] `ScheduleModal.vue` implemented with Specific Days and Every X Days modes
|
||||
- [x] ParentView chore edit icon replaced with kebab menu (Edit Points, Schedule, Extend Time)
|
||||
- [x] ParentView chore cards: wrong day → grayed out; expired time → grayed out + TOO LATE badge
|
||||
- [x] ChildView chore cards: wrong day → hidden; expired time → grayed out + TOO LATE badge
|
||||
- [x] "Due by X:XX PM" sub-text shown on cards in both views when applicable
|
||||
- [x] `setTimeout` auto-expiry implemented; timers cleared on unmount and re-fetch
|
||||
- [x] API helpers added to `api.ts`
|
||||
- [x] SSE listeners added in ParentView and ChildView for `chore_schedule_modified` and `chore_time_extended`
|
||||
- [x] All frontend tests pass
|
||||
182
.github/specs/archive/feat-calendar-chore/feat-calendar-schedule-refactor-01.md
vendored
Normal file
182
.github/specs/archive/feat-calendar-chore/feat-calendar-schedule-refactor-01.md
vendored
Normal file
@@ -0,0 +1,182 @@
|
||||
# Feature: Daily chore scheduler refactor phase 1
|
||||
|
||||
## Overview
|
||||
|
||||
**Parent Feature:** .github/feat-calenar-chore/feat-calendar-chore.md
|
||||
|
||||
**Goal:** UI refactor of the 'Specific Days' portion of the chore scheduler so that it is not so complicated.
|
||||
|
||||
**User Story:**
|
||||
|
||||
- As a parent, I will be able to select an assigned chore and configure it to occur on 'Specific Days' as before, except I will be presented with a much easier to use interface.
|
||||
|
||||
**Rules:**
|
||||
|
||||
- Follow instructions from .github/copilot-instructions.md
|
||||
|
||||
**Design:**
|
||||
|
||||
- Keep the UI for 'Every X Days' the same for now, this will change in phase 2
|
||||
- Remove days of the week, time selectors, and checkboxes
|
||||
- Follow at 'Default Time' pattern with optional time for expiry
|
||||
|
||||
**Architecture:**
|
||||
|
||||
1. The Design Logic
|
||||
The interface shifts from a list of tasks to a set of rules.
|
||||
|
||||
- The "Base" State: You see 7 day chips (Su, Mo, Tu, We, Th, Fr, Sa) and one "Default Deadline" box.
|
||||
- The "Active" State: Days you click become "Active."
|
||||
- The "Silent" State: Any day not clicked is ignored by the system.
|
||||
|
||||
2. The Architecture
|
||||
Think of the system as having two layers of memory:
|
||||
|
||||
- The Global Layer: This holds the "Master Time" (e.g., 8:00 AM).
|
||||
- The Exception Layer: This is an empty list that only fills up if you explicitly say a day is "special."
|
||||
- The Merge Logic: When the system saves, it looks at each selected day. It asks: "Does this day have a special time in the Exception Layer? No? Okay, then use the Master Time."
|
||||
|
||||
3. The "When/Then" Flow
|
||||
Here is exactly how the interaction feels for the user:
|
||||
|
||||
- Step A: Establishing the Routine
|
||||
When you click Monday, Wednesday, and Friday...
|
||||
Then those days highlight, and the "Default Deadline" box becomes active.
|
||||
When you set that box to 8:00 AM...
|
||||
Then the system internally marks all three days as "8:00 AM."
|
||||
|
||||
- Step B: Adding a Day ()
|
||||
When you suddenly decide to add Sunday...
|
||||
Then Sunday highlights and automatically adopts the 8:00 AM deadline.
|
||||
- Step C: Breaking the Routine
|
||||
When you click "Set different time" and choose Sunday...
|
||||
Then a new, specific time box appears just for Sunday.
|
||||
When you change Sunday to 11:00 AM...
|
||||
Then the system "unhooks" Sunday from the Master Time. Sunday is now an Exception.
|
||||
- Step D: Changing the Master Time
|
||||
When you later change the "Default Deadline" from 8:00 AM to 9:00 AM...
|
||||
Then Monday, Wednesday, and Friday all update to 9:00 AM automatically.
|
||||
But Sunday stays at 11:00 AM because it is locked as an exception.
|
||||
|
||||
Instead of treating all 7 days as individual, equal data points, we treat them as a Group that follows a Rule.
|
||||
|
||||
- The Group: The days you selected (e.g., Mon, Wed, Fri, Sun).
|
||||
- The Rule: The "Default Deadline" that applies to the whole group (e.g., 8:00 AM).
|
||||
- The Exception: A specific day that breaks the rule (e.g., "Actually, make Sunday 11:00 AM").
|
||||
|
||||
**Time Selector Design:**
|
||||
We might need to create a new time selector or just add an additional time selector component
|
||||
|
||||
1. The "Columnar" Picker
|
||||
This popover is split into three distinct columns: Hours, Minutes, and AM/PM.
|
||||
When you click the time box...
|
||||
Then a small panel opens with three narrow, scrollable columns.
|
||||
The Logic: The "Minutes" column only contains four options: :00, :15, :30, :45.
|
||||
The Flow: The user's eye scans horizontally. "8" → "30" → "PM".
|
||||
|
||||
**The "High-Level" Combined Flow:**
|
||||
Selection: User clicks Monday.
|
||||
Trigger: User clicks the "Deadline" box.
|
||||
The Picker: The Columnar Picker pops up.
|
||||
The Snap: The user clicks "8" in the first column and "00" in the second.
|
||||
The Result: The box now displays "08:00 AM."
|
||||
|
||||
The "Auto-Apply" Flow
|
||||
When the user clicks a value (e.g., "AM" or "PM"), the selection is registered immediately.
|
||||
When the user clicks anywhere outside the popover, it closes automatically.
|
||||
Then the main UI updates to show the new time.
|
||||
|
||||
---
|
||||
|
||||
## Data Model Changes
|
||||
|
||||
### Backend Models
|
||||
|
||||
`ChoreSchedule` gains two new fields (persisted in TinyDB, returned in API responses):
|
||||
|
||||
- `default_hour: int = 8` — the master deadline hour for `mode='days'`
|
||||
- `default_minute: int = 0` — the master deadline minute for `mode='days'`
|
||||
|
||||
`from_dict` defaults both to `8` / `0` for backwards compatibility with existing DB records.
|
||||
|
||||
### Frontend Models
|
||||
|
||||
`ChoreSchedule` interface gains two optional fields (optional for backwards compat with old API responses):
|
||||
|
||||
- `default_hour?: number`
|
||||
- `default_minute?: number`
|
||||
|
||||
---
|
||||
|
||||
## Frontend Design
|
||||
|
||||
- `TimePickerPopover.vue` — new shared component at `frontend/vue-app/src/components/shared/TimePickerPopover.vue`
|
||||
- `ScheduleModal.vue` — "Specific Days" section fully replaced; "Every X Days" section unchanged
|
||||
|
||||
## Backend Implementation
|
||||
|
||||
No backend implementation required for phase 1.
|
||||
|
||||
---
|
||||
|
||||
## Backend Tests
|
||||
|
||||
- [x] No backend changes required in phase 1
|
||||
|
||||
---
|
||||
|
||||
## Frontend Implementation
|
||||
|
||||
- [x] Created `frontend/vue-app/src/components/shared/TimePickerPopover.vue`
|
||||
- Props: `modelValue: { hour: number, minute: number }`, emits `update:modelValue`
|
||||
- Displays formatted time as a clickable button (e.g. `08:00 AM`)
|
||||
- Opens a columnar popover with three columns: Hour (1–12), Minute (:00/:15/:30/:45), AM/PM
|
||||
- Clicking any column value updates the model immediately
|
||||
- Closes on outside click via `mousedown` document listener
|
||||
- Fully scoped styles using CSS variables from `colors.css`
|
||||
- [x] Refactored `ScheduleModal.vue` — "Specific Days" section
|
||||
- Replaced 7 checkbox rows + per-row `TimeSelector` with chip-based design
|
||||
- **Day chips row**: 7 short chips (Su Mo Tu We Th Fr Sa) — click to toggle active/inactive
|
||||
- **Default Deadline row**: shown when ≥1 day selected; single `TimePickerPopover` sets the master time for all non-exception days
|
||||
- **Selected day list**: one row per active day (sorted Sun→Sat); each row shows:
|
||||
- Day name
|
||||
- If no exception: italic "Default (HH:MM AM/PM)" label + "Set different time" link
|
||||
- If exception set: a `TimePickerPopover` for that day's override + "Reset to default" link
|
||||
- State: `selectedDays: Set<number>`, `defaultTime: TimeValue`, `exceptions: Map<number, TimeValue>`
|
||||
- Load logic: first `day_config` entry sets `defaultTime`; entries differing from it populate `exceptions`
|
||||
- Save logic: iterates `selectedDays`, applies exception time or falls back to `defaultTime` → `DayConfig[]`
|
||||
- "Every X Days" mode left unchanged
|
||||
- Validation: unchanged (`selectedDays.size > 0`)
|
||||
|
||||
---
|
||||
|
||||
## Frontend Tests
|
||||
|
||||
- [ ] `TimePickerPopover.vue`: renders formatted time, opens/closes popover, selecting hour/minute/period emits correct value, closes on outside click
|
||||
- [ ] `ScheduleModal.vue` (Specific Days): chip toggles add/remove from selected set; removing a day also removes its exception; setting a different time creates an exception; resetting removes exception; changing default time does not override exceptions; save payload shape matches `DayConfig[]` with correct times; loading an existing mixed-time schedule restores chips, defaultTime, and exceptions correctly
|
||||
|
||||
---
|
||||
|
||||
## Future Considerations
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria (Definition of Done)
|
||||
|
||||
### Backend
|
||||
|
||||
- [x] No backend changes required — existing `DayConfig { day, hour, minute }` model fully supports the new UI
|
||||
|
||||
### Frontend
|
||||
|
||||
- [x] "Specific Days" mode shows 7 day chips instead of checkboxes
|
||||
- [x] Selecting chips shows a single "Default Deadline" time picker
|
||||
- [x] Selected day list shows each active day with either its default label or an exception time picker
|
||||
- [x] "Set different time" link creates a per-day exception that overrides the default
|
||||
- [x] "Reset to default" link removes the exception and the day reverts to the master time
|
||||
- [x] Changing the Default Deadline updates all non-exception days (by using `defaultTime` at save time)
|
||||
- [x] "Every X Days" mode is unchanged
|
||||
- [x] Existing schedules load correctly (first entry = default, differing times = exceptions)
|
||||
- [x] Save payload is valid `DayConfig[]` consumed by the existing API unchanged
|
||||
- [x] New `TimePickerPopover` component: columnar Hour/Minute/AMPM picker, closes on outside click
|
||||
- [ ] Frontend component tests written and passing for `TimePickerPopover` and the refactored `ScheduleModal`
|
||||
238
.github/specs/archive/feat-calendar-chore/feat-calendar-schedule-refactor-02.md
vendored
Normal file
238
.github/specs/archive/feat-calendar-chore/feat-calendar-schedule-refactor-02.md
vendored
Normal file
@@ -0,0 +1,238 @@
|
||||
# Feature: Daily chore scheduler refactor phase 2
|
||||
|
||||
## Overview
|
||||
|
||||
**Parent Feature:** .github/feat-calenar-chore/feat-calendar-chore.md
|
||||
|
||||
**Goal:** UI refactor of the 'Every X Days' portion of the chore scheduler so that it is not so complicated and mobile friendly
|
||||
|
||||
**User Story:**
|
||||
|
||||
- As a parent, I will be able to select an assigned chore and configure it to occur on 'Every X Days' as before, except I will be presented with a much easier to use interface.
|
||||
|
||||
**Rules:**
|
||||
|
||||
- Follow instructions from .github/copilot-instructions.md
|
||||
|
||||
**Design:**
|
||||
|
||||
- Do not modify 'Specific Days' pattern or UI. However, reuse code if necessary
|
||||
|
||||
**Architecture:**
|
||||
|
||||
1. Shared Logic & Previewer Architecture
|
||||
The "Brain" remains centralized, but the output is now focused on the immediate next event to reduce cognitive clutter.
|
||||
|
||||
State Variables:
|
||||
|
||||
interval: Integer (Default: 1, Min: 1). "Frequency"
|
||||
|
||||
anchorDate: Date Object (Default: Today). "Start Date"
|
||||
|
||||
deadlineTime: String or Null (Default: null). "Deadline"
|
||||
|
||||
The "Next 1" Previewer Logic:
|
||||
|
||||
Input: anchorDate + interval.
|
||||
|
||||
Calculation: Result = anchorDate + (interval days).
|
||||
|
||||
Formatting: Returns a human-readable string (e.g., "Next occurrence: Friday, Oct 24").
|
||||
|
||||
Calendar Constraints:
|
||||
|
||||
Disable Past Dates: Any date prior to "Today" is disabled (greyed out and non-clickable) to prevent scheduling chores in the past.
|
||||
|
||||
2. Mobile Specification: Bottom Sheet Calendar
|
||||
Design & Calendar Details
|
||||
Interface: A full-width monthly grid inside a slide-up panel.
|
||||
|
||||
Touch Targets: Each day cell is a minimum of 44x44 pixels to meet accessibility standards.
|
||||
|
||||
Month Navigation: Uses large left/right chevron buttons at the top of the sheet.
|
||||
|
||||
Visual Indicators:
|
||||
|
||||
Current Selection: A solid primary-colored circle.
|
||||
|
||||
Today’s Date: A subtle outline or "dot" indicator.
|
||||
|
||||
Disabled Dates: 30% opacity with a "forbidden" cursor state if touched.
|
||||
|
||||
Architecture
|
||||
Gesture Control: The Bottom Sheet can be dismissed by swiping down on the "handle" at the top or tapping the dimmed backdrop.
|
||||
|
||||
Performance: The calendar should lazy-load months to ensure the sheet slides up instantly without lag.
|
||||
|
||||
The Flow
|
||||
When the user taps the "Starting on" row...
|
||||
|
||||
Then the sheet slides up. The current anchorDate is pre-selected and centered.
|
||||
|
||||
When the user taps a new date...
|
||||
|
||||
Then the sheet slides down immediately (Auto-confirm).
|
||||
|
||||
When the sheet closes...
|
||||
|
||||
Then the main UI updates the Next 1 Previewer text.
|
||||
|
||||
3. PC (Desktop) Specification: Tethered Popover Calendar
|
||||
Design & Calendar Details
|
||||
Interface: A compact monthly grid (approx. 250px–300px wide) that floats near the input.
|
||||
|
||||
Month Navigation: Small chevrons in the header. Includes a "Today" button to quickly jump back to the current month.
|
||||
|
||||
Day Headers: Single-letter abbreviations (S, M, T, W, T, F, S) to save space.
|
||||
|
||||
Hover States: As the mouse moves over valid dates, a light background highlight follows the cursor to provide immediate feedback.
|
||||
|
||||
Architecture
|
||||
Tethering: The popover is anchored to the bottom-left of the input field. If the browser window is too small, it intelligently repositions to the top-left.
|
||||
|
||||
Keyboard Support: \* Arrow Keys: Move selection between days.
|
||||
|
||||
Enter: Confirm selection and close.
|
||||
|
||||
Esc: Close without saving changes.
|
||||
|
||||
Focus Management: When the popover opens, focus shifts to the calendar grid. When it closes, focus returns to the "Starting on" input.
|
||||
|
||||
The Flow
|
||||
When the user clicks the "Starting on" field...
|
||||
|
||||
Then the popover appears. No backdrop dimming is used.
|
||||
|
||||
When the user clicks a date...
|
||||
|
||||
Then the popover disappears.
|
||||
|
||||
When the user clicks anywhere outside the popover...
|
||||
|
||||
Then the popover closes (Cancel intent).
|
||||
|
||||
4. Reusable Time Picker Reference
|
||||
Referenced from the 'Specific Days' design. TimePickerPopover.vue
|
||||
|
||||
Logic: 15-minute intervals (00, 15, 30, 45).
|
||||
|
||||
Mobile: Implemented via a Bottom Sheet with three scrollable columns.
|
||||
|
||||
PC: Implemented via a Tethered Popover with three clickable columns.
|
||||
|
||||
## Clear Action: Both versions must include a "Clear" button to set the deadline to null (Anytime).
|
||||
|
||||
## Data Model Changes
|
||||
|
||||
### Backend Models
|
||||
|
||||
`ChoreSchedule` changes:
|
||||
|
||||
- Remove `anchor_weekday: int = 0`
|
||||
- Add `anchor_date: str = ""` — ISO date string (e.g. `"2026-02-25"`). Empty string means "use today" (backward compat for old DB records).
|
||||
- Add `interval_has_deadline: bool = True` — when `False`, deadline is ignored ("Anytime").
|
||||
- Change `interval_days` valid range from `[2, 7]` to `[1, 7]`.
|
||||
|
||||
`from_dict` defaults: `anchor_date` defaults to `""`, `interval_has_deadline` defaults to `True` for backward compat with existing DB records.
|
||||
|
||||
### Frontend Models
|
||||
|
||||
`ChoreSchedule` interface changes:
|
||||
|
||||
- Remove `anchor_weekday: number`
|
||||
- Add `anchor_date: string`
|
||||
- Add `interval_has_deadline: boolean`
|
||||
|
||||
---
|
||||
|
||||
## Frontend Design
|
||||
|
||||
- `DateInputField.vue` — new shared component at `frontend/vue-app/src/components/shared/DateInputField.vue`
|
||||
- Props: `modelValue: string` (ISO date string), `min?: string` (ISO date, for disabling past dates), emits `update:modelValue`
|
||||
- Wraps a native `<input type="date">` with styling matching the `TimePickerPopover` button: `--kebab-menu-border` border, `--modal-bg` background, `--secondary` text color
|
||||
- Passes `min` to the native input so the browser disables past dates (no custom calendar needed)
|
||||
- Fully scoped styles using CSS variables from `colors.css`
|
||||
|
||||
- `ScheduleModal.vue` — "Every X Days" section fully replaced; "Specific Days" section unchanged
|
||||
|
||||
---
|
||||
|
||||
## Backend Implementation
|
||||
|
||||
- `backend/models/chore_schedule.py`
|
||||
- Remove `anchor_weekday: int = 0`
|
||||
- Add `anchor_date: str = ""`
|
||||
- Add `interval_has_deadline: bool = True`
|
||||
- Update `from_dict` to default new fields for backward compat
|
||||
|
||||
- `backend/api/chore_schedule_api.py`
|
||||
- Change `interval_days` validation from `[2, 7]` to `[1, 7]`
|
||||
- Accept `anchor_date` (string, ISO format) instead of `anchor_weekday`
|
||||
- Accept `interval_has_deadline` (boolean)
|
||||
|
||||
---
|
||||
|
||||
## Backend Tests
|
||||
|
||||
- [x] Update existing interval-mode tests to use `anchor_date` instead of `anchor_weekday`
|
||||
- [x] Add test: `interval_days: 1` is now valid (was previously rejected)
|
||||
- [x] Add test: `interval_has_deadline: false` is accepted and persisted
|
||||
- [x] Add test: old DB records without `anchor_date` / `interval_has_deadline` load with correct defaults
|
||||
|
||||
---
|
||||
|
||||
## Frontend Implementation
|
||||
|
||||
- [x] Created `frontend/vue-app/src/components/shared/DateInputField.vue`
|
||||
- Props: `modelValue: string` (ISO date), `min?: string`, emits `update:modelValue`
|
||||
- Styled to match `TimePickerPopover` button (border, background, text color)
|
||||
- Passes `min` to native `<input type="date">` to disable past dates
|
||||
- Fully scoped styles using `colors.css` variables
|
||||
- [x] Refactored `ScheduleModal.vue` — "Every X Days" section
|
||||
- Removed `anchorWeekday` state; added `anchorDate: ref<string>` (default: today ISO) and `hasDeadline: ref<boolean>` (default: `true`)
|
||||
- Changed `intervalDays` min from 2 → 1
|
||||
- Replaced `<input type="number">` with a `−` / value / `+` stepper, capped 1–7, styled with Phase 1 chip/button variables
|
||||
- Replaced `<select>` anchor weekday with `DateInputField` (min = today's ISO date)
|
||||
- Replaced `TimeSelector` with `TimePickerPopover` (exact reuse from Phase 1)
|
||||
- Added "Anytime" toggle link below the deadline row; when active, hides `TimePickerPopover` and sets `hasDeadline = false`; when inactive, shows `TimePickerPopover` and sets `hasDeadline = true`
|
||||
- Added "Next occurrence: [Weekday, Mon DD]" computed label (pure frontend, `Intl.DateTimeFormat`): starting from `anchorDate`, add `intervalDays` days repeatedly until result ≥ today; displayed as subtle italic label beneath the form rows (same style as Phase 1's "Default (HH:MM AM/PM)" label)
|
||||
- Load logic: read `schedule.anchor_date` (default to today if empty), `schedule.interval_has_deadline`, `schedule.interval_days` (clamped to ≥1)
|
||||
- Save logic: write `anchor_date`, `interval_has_deadline`; always write `interval_hour`/`interval_minute` (backend ignores them when `interval_has_deadline=false`)
|
||||
- "Specific Days" mode left unchanged
|
||||
|
||||
---
|
||||
|
||||
## Frontend Tests
|
||||
|
||||
- [x] `DateInputField.vue`: renders the formatted date value; emits `update:modelValue` on change; `min` prop prevents selection of past dates
|
||||
- [x] `ScheduleModal.vue` (Every X Days): stepper clamps to 1–7 at both ends; "Anytime" toggle hides the time picker and sets flag; restoring deadline shows the time picker; save payload contains `anchor_date`, `interval_has_deadline`, and correct `interval_days`; next occurrence label updates correctly when interval or anchor date changes; loading an existing schedule restores all fields including `anchor_date` and `interval_has_deadline`
|
||||
|
||||
---
|
||||
|
||||
## Future Considerations
|
||||
|
||||
- A fully custom calendar (bottom sheet on mobile, tethered popover on desktop) could replace `DateInputField` in a future phase for a more polished mobile experience.
|
||||
- `TimePickerPopover` could similarly gain a bottom-sheet variant for mobile.
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria (Definition of Done)
|
||||
|
||||
### Backend
|
||||
|
||||
- [x] `anchor_weekday` removed; `anchor_date` (string) added with empty-string default for old records
|
||||
- [x] `interval_has_deadline` (bool) added, defaults to `True` for old records
|
||||
- [x] `interval_days` valid range updated to `[1, 7]`
|
||||
- [x] All existing and new backend tests pass
|
||||
|
||||
### Frontend
|
||||
|
||||
- [x] New `DateInputField` component: styled native date input, respects `min`, emits ISO string
|
||||
- [x] "Every X Days" mode shows `−`/`+` stepper for interval (1–7), `DateInputField` for anchor date, `TimePickerPopover` for deadline
|
||||
- [x] "Anytime" toggle clears the deadline (sets `interval_has_deadline = false`) and hides the time picker
|
||||
- [x] "Next occurrence" label computes and displays the next date ≥ today based on anchor + interval
|
||||
- [x] Past dates are disabled in the date input (via `min`)
|
||||
- [x] Existing schedules load correctly — `anchor_date` restored, `interval_has_deadline` restored
|
||||
- [x] Save payload is valid and consumed by the existing API unchanged
|
||||
- [x] "Specific Days" mode is unchanged
|
||||
- [x] Frontend component tests written and passing for `DateInputField` and the refactored `ScheduleModal` interval section
|
||||
612
.github/specs/archive/feat-child-confirm-chore.md
vendored
Normal file
612
.github/specs/archive/feat-child-confirm-chore.md
vendored
Normal file
@@ -0,0 +1,612 @@
|
||||
# Feature: Chore Completion Confirmation + Task Refactor
|
||||
|
||||
## Overview
|
||||
|
||||
**Goal:**
|
||||
Refactor the "task" concept into three distinct entity types — **Chore**, **Kindness**, and **Penalty** — and implement a chore completion confirmation flow where children can confirm chores and parents approve them.
|
||||
|
||||
**User Stories:**
|
||||
|
||||
- As a **child**, I can click on a chore and confirm that I completed it. I see a **PENDING** banner (yellow) until a parent confirms.
|
||||
- As a **child**, I can click an already PENDING chore and cancel my confirmation.
|
||||
- As a **child**, I see a **COMPLETED** banner (green) on a chore that a parent has approved. That chore is disabled for the rest of the day.
|
||||
- As a **parent**, I see pending chore confirmations in the **Notifications** tab alongside pending reward requests.
|
||||
- As a **parent**, I can click a PENDING chore to **approve** it (awarding points) or **reject** it (resetting to available).
|
||||
- As a **parent**, I can click a non-pending/non-completed chore and award points directly (current behavior). The child view then shows the COMPLETED banner.
|
||||
- As a **parent**, I can **reset** a completed chore from the kebab menu so the child can confirm it again (points are kept).
|
||||
- As an **admin**, I can view full tracking history in the database/logs for all confirmation lifecycle events.
|
||||
|
||||
**Rules:**
|
||||
.github/copilot-instructions.md
|
||||
|
||||
---
|
||||
|
||||
## Design Decisions (Resolved)
|
||||
|
||||
### Task Refactor → Chore / Kindness / Penalty
|
||||
|
||||
**Decision: Full refactor.**
|
||||
|
||||
| Old Concept | New Concept | Behavior |
|
||||
| -------------------------------------------------------------- | ------------ | ------------------------------------------ |
|
||||
| Task with `is_good=true` (schedulable) | **Chore** | Scheduled, expirable, confirmable by child |
|
||||
| Task with `is_good=true` (ad-hoc, e.g. "Child was good today") | **Kindness** | Parent-only award, not confirmable |
|
||||
| Task with `is_good=false` | **Penalty** | Parent-only deduction |
|
||||
|
||||
- The `is_good` field is **removed**. Entity type itself determines point direction.
|
||||
- The `Task` model is retained in the backend but gains a `type` field: `'chore' | 'kindness' | 'penalty'`.
|
||||
- `task_api.py` is split into `chore_api.py`, `kindness_api.py`, `penalty_api.py`.
|
||||
- Existing `is_good=true` tasks are auto-classified as **chore**; `is_good=false` as **penalty**.
|
||||
- Kindness items must be manually created post-migration (acceptable).
|
||||
|
||||
### Merged Pending Table
|
||||
|
||||
**Decision: Single `PendingConfirmation` model replaces `PendingReward`.**
|
||||
|
||||
- Both pending reward requests and pending chore confirmations live in one `pending_confirmations` table, differentiated by `entity_type`.
|
||||
- The `/pending-rewards` endpoint is replaced by `/pending-confirmations`.
|
||||
- `pending_rewards.json` DB file is replaced by `pending_confirmations.json`.
|
||||
|
||||
### "Completed Today" Tracking
|
||||
|
||||
**Decision: `PendingConfirmation` record with `status='approved'` + `approved_at` timestamp.**
|
||||
|
||||
- An approved `PendingConfirmation` record persists (DB-backed, survives restart) and serves as the "completed today" marker.
|
||||
- The frontend checks if `approved_at` is today to determine the COMPLETED state.
|
||||
- On **reset**, the record is deleted (status returns to available).
|
||||
- **Multi-completion history** is preserved via `TrackingEvent` — each confirm/approve/reset cycle generates tracking entries. Query `TrackingEvent` by `child_id + entity_id + date + action='approved'` to count completions per day.
|
||||
|
||||
### Navigation
|
||||
|
||||
**Decision: Sub-nav under "Tasks" tab.**
|
||||
|
||||
- Top-level nav stays 4 items: **Children | Tasks | Rewards | Notifications**
|
||||
- The "Tasks" tab opens a view with 3 sub-tabs: **Chores | Kindness | Penalties**
|
||||
- Each sub-tab has its own list view, edit view, and assign view.
|
||||
- No mobile layout changes needed to the top bar.
|
||||
|
||||
### Chore Confirmation Scoping
|
||||
|
||||
- Each `PendingConfirmation` is scoped to a **single child**. If a chore is assigned to multiple children, each confirms independently.
|
||||
- Expired chores **cannot** be confirmed.
|
||||
- A chore that is already PENDING or COMPLETED today **cannot** be confirmed again (unless reset by parent).
|
||||
|
||||
---
|
||||
|
||||
## Data Model Changes
|
||||
|
||||
### Backend Models
|
||||
|
||||
#### `Task` Model (Modified)
|
||||
|
||||
File: `backend/models/task.py`
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class Task(BaseModel):
|
||||
name: str
|
||||
points: int
|
||||
type: Literal['chore', 'kindness', 'penalty'] # replaces is_good
|
||||
image_id: str | None = None
|
||||
user_id: str | None = None
|
||||
```
|
||||
|
||||
- `is_good: bool` → **removed**
|
||||
- `type: Literal['chore', 'kindness', 'penalty']` → **added**
|
||||
- Migration: `is_good=True` → `type='chore'`, `is_good=False` → `type='penalty'`
|
||||
|
||||
#### `PendingConfirmation` Model (New — replaces `PendingReward`)
|
||||
|
||||
File: `backend/models/pending_confirmation.py`
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class PendingConfirmation(BaseModel):
|
||||
child_id: str
|
||||
entity_id: str # task_id or reward_id
|
||||
entity_type: str # 'chore' | 'reward'
|
||||
user_id: str
|
||||
status: str = "pending" # 'pending' | 'approved' | 'rejected'
|
||||
approved_at: str | None = None # ISO 8601 UTC timestamp, set on approval
|
||||
```
|
||||
|
||||
- Replaces `PendingReward` (which had `child_id`, `reward_id`, `user_id`, `status`)
|
||||
- `entity_id` generalizes `reward_id` to work for both chores and rewards
|
||||
- `entity_type` differentiates between chore confirmations and reward requests
|
||||
- `approved_at` enables "completed today" checks
|
||||
|
||||
#### `TrackingEvent` Model (Extended Types)
|
||||
|
||||
File: `backend/models/tracking_event.py`
|
||||
|
||||
```python
|
||||
EntityType = Literal['task', 'reward', 'penalty', 'chore', 'kindness']
|
||||
ActionType = Literal['activated', 'requested', 'redeemed', 'cancelled', 'confirmed', 'approved', 'rejected', 'reset']
|
||||
```
|
||||
|
||||
New actions:
|
||||
|
||||
- `confirmed` — child marks chore as done
|
||||
- `approved` — parent approves chore completion (points awarded)
|
||||
- `rejected` — parent rejects chore completion (no point change)
|
||||
- `reset` — parent resets a completed chore (no point change)
|
||||
|
||||
#### `ChildOverride` Model (Extended Types)
|
||||
|
||||
File: `backend/models/child_override.py`
|
||||
|
||||
```python
|
||||
entity_type: Literal['task', 'reward'] # → Literal['chore', 'kindness', 'penalty', 'reward']
|
||||
```
|
||||
|
||||
#### `ChildTask` DTO (Modified)
|
||||
|
||||
File: `backend/api/child_tasks.py`
|
||||
|
||||
```python
|
||||
class ChildTask:
|
||||
def __init__(self, name, type, points, image_id, id):
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.type = type # replaces is_good
|
||||
self.points = points
|
||||
self.image_id = image_id
|
||||
```
|
||||
|
||||
#### SSE Event Types (New)
|
||||
|
||||
File: `backend/events/types/event_types.py`
|
||||
|
||||
```python
|
||||
class EventType(Enum):
|
||||
# ... existing ...
|
||||
CHILD_CHORE_CONFIRMATION = "child_chore_confirmation"
|
||||
```
|
||||
|
||||
New payload class — File: `backend/events/types/child_chore_confirmation.py`
|
||||
|
||||
```python
|
||||
class ChildChoreConfirmation(Payload):
|
||||
# child_id: str
|
||||
# task_id: str
|
||||
# operation: 'CONFIRMED' | 'APPROVED' | 'REJECTED' | 'CANCELLED' | 'RESET'
|
||||
```
|
||||
|
||||
#### Error Codes (New)
|
||||
|
||||
File: `backend/api/error_codes.py`
|
||||
|
||||
```python
|
||||
class ErrorCodes:
|
||||
# ... existing ...
|
||||
CHORE_EXPIRED = "CHORE_EXPIRED"
|
||||
CHORE_ALREADY_PENDING = "CHORE_ALREADY_PENDING"
|
||||
CHORE_ALREADY_COMPLETED = "CHORE_ALREADY_COMPLETED"
|
||||
PENDING_NOT_FOUND = "PENDING_NOT_FOUND"
|
||||
INSUFFICIENT_POINTS = "INSUFFICIENT_POINTS"
|
||||
```
|
||||
|
||||
### Frontend Models
|
||||
|
||||
File: `frontend/vue-app/src/common/models.ts`
|
||||
|
||||
```typescript
|
||||
// Task — modified
|
||||
export interface Task {
|
||||
id: string;
|
||||
name: string;
|
||||
points: number;
|
||||
type: "chore" | "kindness" | "penalty"; // replaces is_good
|
||||
image_id: string | null;
|
||||
image_url?: string | null;
|
||||
}
|
||||
|
||||
// ChildTask — modified
|
||||
export interface ChildTask {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "chore" | "kindness" | "penalty"; // replaces is_good
|
||||
points: number;
|
||||
image_id: string | null;
|
||||
image_url?: string | null;
|
||||
custom_value?: number | null;
|
||||
schedule?: ChoreSchedule | null;
|
||||
extension_date?: string | null;
|
||||
}
|
||||
|
||||
// PendingConfirmation — new (replaces PendingReward)
|
||||
export interface PendingConfirmation {
|
||||
id: string;
|
||||
child_id: string;
|
||||
child_name: string;
|
||||
child_image_id: string | null;
|
||||
child_image_url?: string | null;
|
||||
entity_id: string;
|
||||
entity_type: "chore" | "reward";
|
||||
entity_name: string;
|
||||
entity_image_id: string | null;
|
||||
entity_image_url?: string | null;
|
||||
status: "pending" | "approved" | "rejected";
|
||||
approved_at: string | null;
|
||||
}
|
||||
|
||||
// EntityType — extended
|
||||
export type EntityType = "chore" | "kindness" | "penalty" | "reward";
|
||||
|
||||
// ActionType — extended
|
||||
export type ActionType =
|
||||
| "activated"
|
||||
| "requested"
|
||||
| "redeemed"
|
||||
| "cancelled"
|
||||
| "confirmed"
|
||||
| "approved"
|
||||
| "rejected"
|
||||
| "reset";
|
||||
|
||||
// SSE event payload — new
|
||||
export interface ChildChoreConfirmationPayload {
|
||||
child_id: string;
|
||||
task_id: string;
|
||||
operation: "CONFIRMED" | "APPROVED" | "REJECTED" | "CANCELLED" | "RESET";
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Backend Implementation
|
||||
|
||||
### API Changes
|
||||
|
||||
#### New Files
|
||||
|
||||
| File | Description |
|
||||
| -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `backend/api/chore_api.py` | CRUD for chores (type='chore'). Routes: `/chore/add`, `/chore/<id>`, `/chore/<id>/edit`, `/chore/list`, `DELETE /chore/<id>` |
|
||||
| `backend/api/kindness_api.py` | CRUD for kindness acts (type='kindness'). Routes: `/kindness/add`, `/kindness/<id>`, `/kindness/<id>/edit`, `/kindness/list`, `DELETE /kindness/<id>` |
|
||||
| `backend/api/penalty_api.py` | CRUD for penalties (type='penalty'). Routes: `/penalty/add`, `/penalty/<id>`, `/penalty/<id>/edit`, `/penalty/list`, `DELETE /penalty/<id>` |
|
||||
| `backend/models/pending_confirmation.py` | `PendingConfirmation` dataclass |
|
||||
| `backend/events/types/child_chore_confirmation.py` | SSE payload class |
|
||||
| `backend/api/pending_confirmation.py` | Response DTO for hydrated pending confirmation |
|
||||
|
||||
#### Modified Files
|
||||
|
||||
| File | Changes |
|
||||
| ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `backend/models/task.py` | `is_good` → `type` field |
|
||||
| `backend/models/tracking_event.py` | Extend `EntityType` and `ActionType` literals |
|
||||
| `backend/models/child_override.py` | Extend `entity_type` literal |
|
||||
| `backend/api/child_tasks.py` | `is_good` → `type` field in DTO |
|
||||
| `backend/api/child_api.py` | Add chore confirmation endpoints, replace `/pending-rewards` with `/pending-confirmations`, update trigger-task to set COMPLETED state, update all `is_good` references to `type` |
|
||||
| `backend/api/task_api.py` | Deprecate/remove — logic moves to entity-specific API files |
|
||||
| `backend/api/error_codes.py` | Add new error codes |
|
||||
| `backend/events/types/event_types.py` | Add `CHILD_CHORE_CONFIRMATION` |
|
||||
| `backend/db/db.py` | Add `pending_confirmations_db`, remove `pending_reward_db` |
|
||||
| `backend/main.py` | Register new blueprints, remove `task_api` blueprint |
|
||||
|
||||
#### New Endpoints (on `child_api.py`)
|
||||
|
||||
| Method | Route | Actor | Description |
|
||||
| ------ | ---------------------------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `POST` | `/child/<id>/confirm-chore` | Child | Body: `{ task_id }`. Creates `PendingConfirmation(entity_type='chore', status='pending')`. Validates: chore assigned, not expired, not already pending/completed today. Tracking: `action='confirmed'`, `delta=0`. SSE: `CHILD_CHORE_CONFIRMATION` (CONFIRMED). |
|
||||
| `POST` | `/child/<id>/cancel-confirm-chore` | Child | Body: `{ task_id }`. Deletes the pending confirmation. Tracking: `action='cancelled'`, `delta=0`. SSE: `CHILD_CHORE_CONFIRMATION` (CANCELLED). |
|
||||
| `POST` | `/child/<id>/approve-chore` | Parent | Body: `{ task_id }`. Sets `status='approved'`, `approved_at=now()`. Awards points (respects overrides). Tracking: `action='approved'`, `delta=+points`. SSE: `CHILD_CHORE_CONFIRMATION` (APPROVED) + `CHILD_TASK_TRIGGERED`. |
|
||||
| `POST` | `/child/<id>/reject-chore` | Parent | Body: `{ task_id }`. Deletes the pending confirmation. Tracking: `action='rejected'`, `delta=0`. SSE: `CHILD_CHORE_CONFIRMATION` (REJECTED). |
|
||||
| `POST` | `/child/<id>/reset-chore` | Parent | Body: `{ task_id }`. Deletes the approved confirmation record. Tracking: `action='reset'`, `delta=0`. SSE: `CHILD_CHORE_CONFIRMATION` (RESET). |
|
||||
| `GET` | `/pending-confirmations` | Parent | Returns all pending `PendingConfirmation` records for the user, hydrated with child/entity names and images. Replaces `/pending-rewards`. |
|
||||
|
||||
#### Modified Endpoints
|
||||
|
||||
| Endpoint | Change |
|
||||
| ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||
| `POST /child/<id>/trigger-task` | When a parent triggers a chore directly (no pending), create an approved `PendingConfirmation` so child view shows COMPLETED. Update entity_type references from `'task'` to the actual type. |
|
||||
| `POST /child/<id>/request-reward` | Create `PendingConfirmation(entity_type='reward')` instead of `PendingReward`. |
|
||||
| `POST /child/<id>/cancel-request-reward` | Query `PendingConfirmation` by `entity_type='reward'` instead of `PendingReward`. |
|
||||
| `POST /child/<id>/trigger-reward` | Query/remove `PendingConfirmation` by `entity_type='reward'` instead of `PendingReward`. |
|
||||
| `GET /child/<id>/list-tasks` | Add `pending_status` and `approved_at` fields to each chore in the response (from `PendingConfirmation` lookup). |
|
||||
| `PUT /child/<id>/set-tasks` | Accept `type` parameter instead of `type: 'good' | 'bad'`. |
|
||||
|
||||
#### Database Migration
|
||||
|
||||
A one-time migration script (`backend/scripts/migrate_tasks_to_types.py`):
|
||||
|
||||
1. For each record in `tasks.json`: if `is_good=True` → set `type='chore'`, if `is_good=False` → set `type='penalty'`. Remove `is_good` field.
|
||||
2. For each record in `pending_rewards.json`: convert to `PendingConfirmation` format with `entity_type='reward'`, `entity_id=reward_id`. Write to `pending_confirmations.json`.
|
||||
3. For each record in `tracking_events.json`: update `entity_type='task'` → `'chore'` or `'penalty'` based on the referenced task's old `is_good` value.
|
||||
4. For each record in `child_overrides.json`: update `entity_type='task'` → `'chore'` or `'penalty'` based on the referenced task's old `is_good` value.
|
||||
|
||||
---
|
||||
|
||||
## Backend Tests
|
||||
|
||||
### Test File: `backend/tests/test_chore_api.py` (New)
|
||||
|
||||
- [ ] `test_add_chore` — PUT `/chore/add` with `name`, `points` → 201, type auto-set to `'chore'`
|
||||
- [ ] `test_add_chore_missing_fields` — 400 with `MISSING_FIELDS`
|
||||
- [ ] `test_list_chores` — GET `/chore/list` returns only `type='chore'` tasks
|
||||
- [ ] `test_get_chore` — GET `/chore/<id>` → 200
|
||||
- [ ] `test_get_chore_not_found` — 404
|
||||
- [ ] `test_edit_chore` — PUT `/chore/<id>/edit` → 200
|
||||
- [ ] `test_edit_system_chore_clones_to_user` — editing a `user_id=None` chore creates a user copy
|
||||
- [ ] `test_delete_chore` — DELETE `/chore/<id>` → 200, removed from children's task lists
|
||||
- [ ] `test_delete_chore_not_found` — 404
|
||||
- [ ] `test_delete_chore_removes_from_assigned_children` — cascade cleanup
|
||||
|
||||
### Test File: `backend/tests/test_kindness_api.py` (New)
|
||||
|
||||
- [ ] `test_add_kindness` — PUT `/kindness/add` → 201, type auto-set to `'kindness'`
|
||||
- [ ] `test_list_kindness` — returns only `type='kindness'` tasks
|
||||
- [ ] `test_edit_kindness` — PUT `/kindness/<id>/edit` → 200
|
||||
- [ ] `test_delete_kindness` — cascade removal
|
||||
|
||||
### Test File: `backend/tests/test_penalty_api.py` (New)
|
||||
|
||||
- [ ] `test_add_penalty` — PUT `/penalty/add` → 201, type auto-set to `'penalty'`
|
||||
- [ ] `test_list_penalties` — returns only `type='penalty'` tasks
|
||||
- [ ] `test_edit_penalty` — PUT `/penalty/<id>/edit` → 200
|
||||
- [ ] `test_delete_penalty` — cascade removal
|
||||
|
||||
### Test File: `backend/tests/test_chore_confirmation.py` (New)
|
||||
|
||||
#### Child Confirm Flow
|
||||
|
||||
- [ ] `test_child_confirm_chore_success` — POST `/child/<id>/confirm-chore` with `{ task_id }` → 200, `PendingConfirmation` record created with `status='pending'`, `entity_type='chore'`
|
||||
- [ ] `test_child_confirm_chore_not_assigned` — 400 `ENTITY_NOT_ASSIGNED` when chore is not in child's task list
|
||||
- [ ] `test_child_confirm_chore_not_found` — 404 `TASK_NOT_FOUND` when task_id doesn't exist
|
||||
- [ ] `test_child_confirm_chore_child_not_found` — 404 `CHILD_NOT_FOUND`
|
||||
- [ ] `test_child_confirm_chore_already_pending` — 400 `CHORE_ALREADY_PENDING` when a pending confirmation already exists
|
||||
- [ ] `test_child_confirm_chore_already_completed_today` — 400 `CHORE_ALREADY_COMPLETED` when an approved confirmation exists for today
|
||||
- [ ] `test_child_confirm_chore_expired` — 400 `CHORE_EXPIRED` when chore is past its deadline
|
||||
- [ ] `test_child_confirm_chore_creates_tracking_event` — TrackingEvent with `action='confirmed'`, `delta=0`, `entity_type='chore'`
|
||||
- [ ] `test_child_confirm_chore_wrong_type` — 400 when task is kindness or penalty (not confirmable)
|
||||
|
||||
#### Child Cancel Flow
|
||||
|
||||
- [ ] `test_child_cancel_confirm_success` — POST `/child/<id>/cancel-confirm-chore` → 200, pending record deleted
|
||||
- [ ] `test_child_cancel_confirm_not_pending` — 400 `PENDING_NOT_FOUND`
|
||||
- [ ] `test_child_cancel_confirm_creates_tracking_event` — TrackingEvent with `action='cancelled'`, `delta=0`
|
||||
|
||||
#### Parent Approve Flow
|
||||
|
||||
- [ ] `test_parent_approve_chore_success` — POST `/child/<id>/approve-chore` → 200, points increased, `status='approved'`, `approved_at` set
|
||||
- [ ] `test_parent_approve_chore_with_override` — uses `custom_value` from override instead of base points
|
||||
- [ ] `test_parent_approve_chore_not_pending` — 400 `PENDING_NOT_FOUND`
|
||||
- [ ] `test_parent_approve_chore_creates_tracking_event` — TrackingEvent with `action='approved'`, `delta=+points`
|
||||
- [ ] `test_parent_approve_chore_points_correct` — `points_before` + task points == `points_after` on child
|
||||
|
||||
#### Parent Reject Flow
|
||||
|
||||
- [ ] `test_parent_reject_chore_success` — POST `/child/<id>/reject-chore` → 200, pending record deleted, points unchanged
|
||||
- [ ] `test_parent_reject_chore_not_pending` — 400 `PENDING_NOT_FOUND`
|
||||
- [ ] `test_parent_reject_chore_creates_tracking_event` — TrackingEvent with `action='rejected'`, `delta=0`
|
||||
|
||||
#### Parent Reset Flow
|
||||
|
||||
- [ ] `test_parent_reset_chore_success` — POST `/child/<id>/reset-chore` → 200, approved record deleted, points unchanged
|
||||
- [ ] `test_parent_reset_chore_not_completed` — 400 when no approved record exists
|
||||
- [ ] `test_parent_reset_chore_creates_tracking_event` — TrackingEvent with `action='reset'`, `delta=0`
|
||||
- [ ] `test_parent_reset_then_child_confirm_again` — full cycle: confirm → approve → reset → confirm → approve (two approvals tracked)
|
||||
|
||||
#### Parent Direct Trigger
|
||||
|
||||
- [ ] `test_parent_trigger_chore_directly_creates_approved_confirmation` — POST `/child/<id>/trigger-task` with a chore → creates approved PendingConfirmation so child view shows COMPLETED
|
||||
|
||||
#### Pending Confirmations List
|
||||
|
||||
- [ ] `test_list_pending_confirmations_returns_chores_and_rewards` — GET `/pending-confirmations` returns both types
|
||||
- [ ] `test_list_pending_confirmations_empty` — returns empty list when none exist
|
||||
- [ ] `test_list_pending_confirmations_hydrates_names_and_images` — response includes `child_name`, `entity_name`, image IDs
|
||||
- [ ] `test_list_pending_confirmations_excludes_approved` — only pending status returned
|
||||
- [ ] `test_list_pending_confirmations_filters_by_user` — only returns confirmations for the authenticated user's children
|
||||
|
||||
### Test File: `backend/tests/test_task_api.py` (Modified)
|
||||
|
||||
- [ ] Update all existing tests that use `is_good` to use `type` instead
|
||||
- [ ] `test_add_task` → split into `test_add_chore`, `test_add_kindness`, `test_add_penalty` (or remove if fully migrated to entity-specific APIs)
|
||||
- [ ] `test_list_tasks_sorted` → update sort expectations for `type` field
|
||||
|
||||
### Test File: `backend/tests/test_child_api.py` (Modified)
|
||||
|
||||
- [ ] Update tests referencing `is_good` to use `type`
|
||||
- [ ] Update `set-tasks` tests for new `type` parameter values (`'chore'`, `'kindness'`, `'penalty'`)
|
||||
- [ ] Update `list-tasks` response assertions to check for `pending_status` and `approved_at` fields on chores
|
||||
- [ ] Update `trigger-task` tests to verify `PendingConfirmation` creation for chores
|
||||
- [ ] Update `request-reward` / `cancel-request-reward` / `trigger-reward` tests to use `PendingConfirmation` model
|
||||
- [ ] Replace `pending-rewards` endpoint tests with `pending-confirmations`
|
||||
|
||||
---
|
||||
|
||||
## Frontend Implementation
|
||||
|
||||
### New Files
|
||||
|
||||
| File | Description |
|
||||
| --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `src/components/task/ChoreView.vue` | Admin list of chores (type='chore'). Same pattern as current `TaskView.vue` with blue theme. |
|
||||
| `src/components/task/KindnessView.vue` | Admin list of kindness acts (type='kindness'). Yellow theme. |
|
||||
| `src/components/task/PenaltyView.vue` | Admin list of penalties (type='penalty'). Red theme. |
|
||||
| `src/components/task/ChoreEditView.vue` | Create/edit chore form. Fields: name, points, image. No `is_good` toggle. |
|
||||
| `src/components/task/KindnessEditView.vue` | Create/edit kindness form. Fields: name, points, image. |
|
||||
| `src/components/task/PenaltyEditView.vue` | Create/edit penalty form. Fields: name, points, image. |
|
||||
| `src/components/task/TaskSubNav.vue` | Sub-nav component with Chores / Kindness / Penalties tabs. Renders as a tab bar within the Tasks view area. |
|
||||
| `src/components/child/ChoreAssignView.vue` | Assign chores to child (replaces `TaskAssignView` with `type='good'`). |
|
||||
| `src/components/child/KindnessAssignView.vue` | Assign kindness acts to child. |
|
||||
| `src/components/child/PenaltyAssignView.vue` | Assign penalties to child (replaces `TaskAssignView` with `type='bad'`). |
|
||||
| `src/components/child/ChoreConfirmDialog.vue` | Modal dialog for child to confirm chore completion. "Did you finish [chore name]?" with Confirm / Cancel buttons. |
|
||||
| `src/components/child/ChoreApproveDialog.vue` | Modal dialog for parent to approve/reject pending chore. Shows chore name, child name, points. Approve / Reject buttons. |
|
||||
|
||||
### Modified Files
|
||||
|
||||
| File | Changes |
|
||||
| -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `src/common/models.ts` | Replace `Task.is_good` with `Task.type`, add `PendingConfirmation` interface, extend `EntityType`/`ActionType`, add `ChildChoreConfirmationPayload`, replace `PendingReward` with `PendingConfirmation`. Add `pending_status` and `approved_at` to `ChildTask`. |
|
||||
| `src/common/backendEvents.ts` | Add `child_chore_confirmation` event listener pattern. |
|
||||
| `src/common/api.ts` | Add `confirmChore()`, `cancelConfirmChore()`, `approveChore()`, `rejectChore()`, `resetChore()`, `fetchPendingConfirmations()`. Remove `fetchPendingRewards()`. |
|
||||
| `src/components/child/ChildView.vue` | Add chore tap handler → show `ChoreConfirmDialog`. Add PENDING (yellow) / COMPLETED (green) banner rendering. Handle cancel-confirm on PENDING tap. Filter kindness acts into new scrolling row. Listen for `child_chore_confirmation` SSE events. |
|
||||
| `src/components/child/ParentView.vue` | Add PENDING/COMPLETED banners on chores. Handle approve/reject on PENDING chore tap. Add "Reset" to kebab menu for completed chores. Add "Assign Kindness" button. Update `trigger-task` to create approved confirmation. Replace `is_good` filters with `type` checks. Listen for `child_chore_confirmation` SSE events. |
|
||||
| `src/components/notification/NotificationView.vue` | Fetch from `/api/pending-confirmations` instead of `/api/pending-rewards`. Render both pending chores and pending rewards with differentiation (icon/label). Listen for `child_chore_confirmation` events in addition to existing `child_reward_request`. |
|
||||
| `src/layout/ParentLayout.vue` | "Tasks" nav icon remains, routes to a view housing `TaskSubNav` with sub-tabs. |
|
||||
| `src/components/task/TaskEditView.vue` | Remove or repurpose. Logic moves to entity-specific edit views (no `is_good` toggle). |
|
||||
| `src/components/task/TaskView.vue` | Remove or repurpose into the sub-nav container view. |
|
||||
| `src/components/child/TaskAssignView.vue` | Remove. Replaced by `ChoreAssignView`, `KindnessAssignView`, `PenaltyAssignView`. |
|
||||
| Router config | Add routes for new views. Update existing task routes to chore/kindness/penalty. |
|
||||
|
||||
### Files to Remove
|
||||
|
||||
| File | Reason |
|
||||
| -------------------------------------- | ----------------------------------------- |
|
||||
| `backend/models/pending_reward.py` | Replaced by `pending_confirmation.py` |
|
||||
| `backend/api/pending_reward.py` | Replaced by `pending_confirmation.py` DTO |
|
||||
| `backend/data/db/pending_rewards.json` | Replaced by `pending_confirmations.json` |
|
||||
|
||||
### ChildView Chore Tap Flow
|
||||
|
||||
```
|
||||
Child taps chore card
|
||||
├─ Chore expired? → No action (grayed out, "TOO LATE" stamp)
|
||||
├─ Chore COMPLETED today? → No action (grayed out, "COMPLETED" stamp)
|
||||
├─ Chore PENDING? → Show ModalDialog "Cancel confirmation?"
|
||||
│ └─ Confirm → POST /child/<id>/cancel-confirm-chore
|
||||
└─ Chore available? → Show ChoreConfirmDialog "Did you finish [name]?"
|
||||
└─ Confirm → POST /child/<id>/confirm-chore
|
||||
```
|
||||
|
||||
### ParentView Chore Tap Flow
|
||||
|
||||
```
|
||||
Parent taps chore card
|
||||
├─ Chore PENDING? → Show ChoreApproveDialog
|
||||
│ ├─ Approve → POST /child/<id>/approve-chore (awards points)
|
||||
│ └─ Reject → POST /child/<id>/reject-chore (resets to available)
|
||||
├─ Chore COMPLETED today? → No tap action. Kebab menu has "Reset"
|
||||
│ └─ Reset → POST /child/<id>/reset-chore
|
||||
└─ Chore available? → Show TaskConfirmDialog (current behavior)
|
||||
└─ Confirm → POST /child/<id>/trigger-task (sets COMPLETED)
|
||||
```
|
||||
|
||||
### Banner Styling
|
||||
|
||||
| State | Banner Text | Text Color | Background | CSS Variable Suggestion |
|
||||
| --------- | ----------- | -------------------------- | ----------------------- | ----------------------- |
|
||||
| Pending | `PENDING` | Yellow (`--color-warning`) | Dark semi-transparent | `--banner-bg-pending` |
|
||||
| Completed | `COMPLETED` | Green (`--color-success`) | Dark semi-transparent | `--banner-bg-completed` |
|
||||
| Expired | `TOO LATE` | Red (existing) | Gray overlay (existing) | (existing styles) |
|
||||
|
||||
---
|
||||
|
||||
## Frontend Tests
|
||||
|
||||
### Test File: `components/__tests__/ChoreConfirmDialog.test.ts` (New)
|
||||
|
||||
- [ ] `renders chore name in dialog`
|
||||
- [ ] `emits confirm event on confirm button click`
|
||||
- [ ] `emits cancel event on cancel button click`
|
||||
|
||||
### Test File: `components/__tests__/ChoreApproveDialog.test.ts` (New)
|
||||
|
||||
- [ ] `renders chore name and points in dialog`
|
||||
- [ ] `emits approve event on approve button click`
|
||||
- [ ] `emits reject event on reject button click`
|
||||
|
||||
### Test File: `components/__tests__/TaskSubNav.test.ts` (New)
|
||||
|
||||
- [ ] `renders three sub-tabs: Chores, Kindness, Penalties`
|
||||
- [ ] `highlights active tab based on route`
|
||||
- [ ] `navigates on tab click`
|
||||
|
||||
### Test File: `components/__tests__/ChoreView.test.ts` (New)
|
||||
|
||||
- [ ] `fetches and renders chore list`
|
||||
- [ ] `navigates to edit on item click`
|
||||
- [ ] `shows delete confirmation modal`
|
||||
- [ ] `refreshes on task_modified SSE event`
|
||||
|
||||
### Test File: `components/__tests__/NotificationView.test.ts` (Modified)
|
||||
|
||||
- [ ] `fetches from /api/pending-confirmations`
|
||||
- [ ] `renders both pending chores and pending rewards`
|
||||
- [ ] `differentiates chore vs reward with label/icon`
|
||||
- [ ] `refreshes on child_chore_confirmation SSE event`
|
||||
- [ ] `refreshes on child_reward_request SSE event`
|
||||
|
||||
### Test File: `components/__tests__/ChildView.test.ts` (Modified / New)
|
||||
|
||||
- [ ] `shows PENDING banner on chore with pending confirmation`
|
||||
- [ ] `shows COMPLETED banner on chore completed today`
|
||||
- [ ] `opens ChoreConfirmDialog on available chore tap`
|
||||
- [ ] `opens cancel dialog on PENDING chore tap`
|
||||
- [ ] `does not allow tap on expired chore`
|
||||
- [ ] `does not allow tap on COMPLETED chore`
|
||||
- [ ] `renders kindness scrolling row`
|
||||
- [ ] `refreshes on child_chore_confirmation SSE event`
|
||||
|
||||
### Test File: `components/__tests__/ParentView.test.ts` (Modified / New)
|
||||
|
||||
- [ ] `shows PENDING banner on chore with pending confirmation`
|
||||
- [ ] `shows COMPLETED banner on approved chore`
|
||||
- [ ] `opens ChoreApproveDialog on PENDING chore tap`
|
||||
- [ ] `opens TaskConfirmDialog on available chore tap`
|
||||
- [ ] `shows Reset in kebab menu for completed chore`
|
||||
- [ ] `renders kindness scrolling row`
|
||||
- [ ] `shows Assign Kindness button`
|
||||
|
||||
---
|
||||
|
||||
## Future Considerations
|
||||
|
||||
- **Recurring chore auto-reset**: Automatically clear completed status on schedule rollover (e.g., daily chores reset at midnight).
|
||||
- **Chore streaks**: Track consecutive days a child completes a chore using `TrackingEvent` history.
|
||||
- **Multi-completion analytics dashboard**: Query `TrackingEvent` to show completion counts per chore per day/week.
|
||||
- **Partial credit**: Allow parents to award fewer points than the chore's value when approving.
|
||||
- **Chore delegation**: Allow one child to reassign a chore to a sibling.
|
||||
- **Photo proof**: Child attaches a photo when confirming a chore.
|
||||
- **Kindness auto-classification**: Suggested classification when creating new items based on name patterns.
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria (Definition of Done)
|
||||
|
||||
### Backend
|
||||
|
||||
- [ ] `Task` model uses `type: 'chore' | 'kindness' | 'penalty'` — `is_good` removed
|
||||
- [ ] `PendingConfirmation` model created, `PendingReward` model removed
|
||||
- [ ] `pending_confirmations_db` created in `db.py`, `pending_reward_db` removed
|
||||
- [ ] Migration script converts existing tasks, pending rewards, tracking events, and overrides
|
||||
- [ ] `chore_api.py`, `kindness_api.py`, `penalty_api.py` created with CRUD endpoints
|
||||
- [ ] `task_api.py` removed or deprecated
|
||||
- [ ] Child chore confirmation endpoints: `confirm-chore`, `cancel-confirm-chore`, `approve-chore`, `reject-chore`, `reset-chore`
|
||||
- [ ] `GET /pending-confirmations` returns hydrated pending chores and rewards
|
||||
- [ ] `trigger-task` creates approved `PendingConfirmation` when parent triggers a chore directly
|
||||
- [ ] Reward request/cancel/trigger endpoints migrated to `PendingConfirmation`
|
||||
- [ ] `list-tasks` response includes `pending_status` and `approved_at` for chores
|
||||
- [ ] `TrackingEvent` created for every mutation: confirmed, cancelled, approved, rejected, reset
|
||||
- [ ] Tracking events logged to rotating file logger
|
||||
- [ ] SSE event `CHILD_CHORE_CONFIRMATION` sent for every confirmation lifecycle event
|
||||
- [ ] All new error codes defined and returned with proper HTTP status codes
|
||||
- [ ] All existing tests updated for `type` field (no `is_good` references)
|
||||
- [ ] All new backend tests pass
|
||||
|
||||
### Frontend
|
||||
|
||||
- [ ] `Task` and `ChildTask` interfaces use `type` instead of `is_good`
|
||||
- [ ] `PendingConfirmation` interface replaces `PendingReward`
|
||||
- [ ] Sub-nav under "Tasks" with Chores / Kindness / Penalties tabs
|
||||
- [ ] `ChoreView`, `KindnessView`, `PenaltyView` list views created
|
||||
- [ ] `ChoreEditView`, `KindnessEditView`, `PenaltyEditView` edit/create views created
|
||||
- [ ] `ChoreAssignView`, `KindnessAssignView`, `PenaltyAssignView` assign views created
|
||||
- [ ] `TaskView`, `TaskEditView`, `TaskAssignView` removed or repurposed
|
||||
- [ ] `ChoreConfirmDialog` — child confirmation modal
|
||||
- [ ] `ChoreApproveDialog` — parent approve/reject modal
|
||||
- [ ] `ChildView` — chore tap opens confirm dialog, cancel dialog for pending, banners render correctly
|
||||
- [ ] `ChildView` — expired and completed chores are non-interactive
|
||||
- [ ] `ChildView` — kindness scrolling row added
|
||||
- [ ] `ParentView` — pending chore tap opens approve/reject dialog
|
||||
- [ ] `ParentView` — available chore tap uses existing trigger flow + creates completion record
|
||||
- [ ] `ParentView` — kebab menu "Reset" option for completed chores
|
||||
- [ ] `ParentView` — "Assign Kindness" button added
|
||||
- [ ] `NotificationView` — fetches from `/pending-confirmations`, renders both types
|
||||
- [ ] SSE listeners for `child_chore_confirmation` in all relevant components
|
||||
- [ ] Banner styles: yellow PENDING, green COMPLETED (using CSS variables)
|
||||
- [ ] All `is_good` references removed from frontend code
|
||||
- [ ] All frontend tests pass
|
||||
- [ ] Router updated with new routes
|
||||
125
.github/specs/archive/feat-landing-page.md
vendored
Normal file
125
.github/specs/archive/feat-landing-page.md
vendored
Normal file
@@ -0,0 +1,125 @@
|
||||
# Feature: Landing page with details
|
||||
|
||||
## Overview
|
||||
|
||||
When an unauthenticated user visits the web site, they will be sent to this landing page. The page will contain a hero with various images. A sign up/sign in component. On scrolling, various components will show describing the application features and functionality.
|
||||
|
||||
**Goal:** New users are brought to the landing page to learn more about the app.
|
||||
|
||||
**User Story:**
|
||||
As a new user, I should be presented with the landing page that tells me about the app.
|
||||
|
||||
**Rules:**
|
||||
.github/copilot-instructions.md
|
||||
You know how my app works at a high level
|
||||
|
||||
**Discussions:**
|
||||
|
||||
- I want each part of the landing page as different components (rows)
|
||||
- Should these components go into their own directory (components/landing)
|
||||
|
||||
1. Row 1
|
||||
- My logo is resources\logo\full_logo.png
|
||||
- I plan to have my logo in the center near the top of the landing page
|
||||
- I'd like to have my hero as resources\logo\hero.png
|
||||
- I'd like to have my current sign in / sign up logic copied to a new component, but with this hero, it should be modern looking - semi-transparent glassmorphism box
|
||||
- Should I have sign in or sign up? or compact both together
|
||||
2. Row 2
|
||||
- This should describe the problem - getting kids to do chores consistently - Describe how a system benefits:
|
||||
1. Develops Executive Function: Managing a "to-do list" teaches kids how to prioritize tasks, manage their time, and follow multi-step directions—skills that translate directly to schoolwork and future careers.
|
||||
2. Fosters a "Can-Do" Attitude: Completing a task provides a tangible sense of mastery. This builds genuine self-esteem because it’s based on actual competence rather than just empty praise.
|
||||
3. Encourages Accountability: A system moves chores from "Mom/Dad is nagging me" to "This is my responsibility." It teaches that their actions (or lack thereof) have a direct impact on the people they live with.
|
||||
4. Normalizes Life Skills: By the time they head out on their own, "adulting" won't feel like a mountain to climb. Cooking, cleaning, and organizing will be second nature rather than a stressful learning curve.
|
||||
5. Promotes Family Belonging: Contributing to the home makes a child feel like a teammate rather than a guest. It reinforces the idea that a family is a unit where everyone supports one another.
|
||||
3. Row 3
|
||||
- This should describe how my app helps to solve the problem by creating the system
|
||||
1. Chores can be created with customizable points that children have fun collecting.
|
||||
2. Customized reward offer insentives for a child to set a goal. (saving points, etc)
|
||||
3. Easy interface for children to see which chores need to be done and what rewards they can afford.
|
||||
4. Parental controls (hidden) behind a pin that offer powerful tools for managing the system
|
||||
- In this component we'll show some screen shots of the interface.
|
||||
4. Row 4
|
||||
- I'm not sure what else I should add for now
|
||||
|
||||
- How should I handle colors - should I follow my current theme in colors.css or should I adopt my logo / hero colors?
|
||||
- Should my landing page be more modern and sleek with animations? What kind?
|
||||
- Considerations for mobile should also be handled (whether in portrait or landscape mode)
|
||||
|
||||
- ***
|
||||
|
||||
## Data Model Changes
|
||||
|
||||
### Backend Model
|
||||
|
||||
### Frontend Model
|
||||
|
||||
---
|
||||
|
||||
## Backend Implementation
|
||||
|
||||
## Backend Tests
|
||||
|
||||
- [ ]
|
||||
|
||||
---
|
||||
|
||||
## Frontend Implementation
|
||||
|
||||
- [x] Copy `resources/logo/full_logo.png` and `hero.png` to `frontend/vue-app/public/images/`
|
||||
- [x] Add landing CSS variables to `colors.css`
|
||||
- [x] Create `src/components/landing/LandingPage.vue` — orchestrator with sticky nav
|
||||
- [x] Create `src/components/landing/LandingHero.vue` — hero section with logo, tagline, glassmorphism CTA card
|
||||
- [x] Create `src/components/landing/LandingProblem.vue` — 5-benefit problem section
|
||||
- [x] Create `src/components/landing/LandingFeatures.vue` — 4-feature alternating layout with screenshot placeholders
|
||||
- [x] Create `src/components/landing/LandingFooter.vue` — dark footer with links
|
||||
- [x] Update router: add `/` → LandingPage route (meta: isPublic), update auth guard
|
||||
|
||||
## Frontend Tests
|
||||
|
||||
- [x] Update `authGuard.spec.ts`: fix redirect assertions (`/auth` → `/`) and add 3 landing-route guard tests
|
||||
- [x] Create `src/components/landing/__tests__/LandingHero.spec.ts`: renders logo, tagline, buttons; CTA clicks push correct routes
|
||||
|
||||
---
|
||||
|
||||
## Future Considerations
|
||||
|
||||
- Eventually add a pricing section (component)
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria (Definition of Done)
|
||||
|
||||
### Backend
|
||||
|
||||
- [ ]
|
||||
|
||||
### Frontend
|
||||
|
||||
- [x] Unauthenticated user visiting `/` sees the full landing page
|
||||
- [x] Sign In CTA routes to `/auth/login`
|
||||
- [x] Get Started CTA routes to `/auth/signup`
|
||||
- [x] Logged-in user visiting `/` is redirected to `/child` or `/parent`
|
||||
- [x] Unauthenticated user visiting any protected route is redirected to `/` (landing)
|
||||
- [x] All 4 rows render: Hero, Problem, Features, Footer
|
||||
- [x] Sticky nav appears on scroll
|
||||
- [x] Mobile: hero buttons stack vertically, grid goes single-column
|
||||
- [x] All colors use `colors.css` variables only
|
||||
|
||||
---
|
||||
|
||||
## Bugs
|
||||
|
||||
### BUG-001 — Landing page instantly redirects to `/auth` on first visit
|
||||
|
||||
**Status:** Fixed
|
||||
|
||||
**Description:**
|
||||
Visiting `/` as an unauthenticated user caused a visible flash of the landing page followed by an immediate hard redirect to `/auth`, making the landing page effectively unreachable.
|
||||
|
||||
**Root Cause:**
|
||||
`App.vue` calls `checkAuth()` on mount, which hits `/api/auth/me`. For an unauthenticated user this returns a `401`. The fetch interceptor in `api.ts` (`handleUnauthorizedResponse`) then attempted a token refresh, which also failed, and finally called `window.location.assign('/auth')`. The interceptor only exempted paths already starting with `/auth` — not the landing page at `/`.
|
||||
|
||||
**Solution:**
|
||||
|
||||
- [`frontend/vue-app/src/common/api.ts`](../frontend/vue-app/src/common/api.ts) — Added `if (window.location.pathname === '/') return` inside `handleUnauthorizedResponse()` so the unauthorized interceptor does not forcibly redirect away from the public landing page.
|
||||
- [`frontend/vue-app/src/stores/auth.ts`](../frontend/vue-app/src/stores/auth.ts) — Updated the cross-tab logout storage event handler to redirect to `/` instead of `/auth/login`, and skip the redirect entirely if already on `/`.
|
||||
138
.github/specs/archive/feat-login-security.md
vendored
Normal file
138
.github/specs/archive/feat-login-security.md
vendored
Normal file
@@ -0,0 +1,138 @@
|
||||
# Feature: Long term user login through refresh tokens.
|
||||
|
||||
## Overview
|
||||
|
||||
Currently, JWT tokens have a long expiration date (62 days). However, the token cookie has no `max-age` so it's treated as a session cookie — lost when the browser closes. This feature replaces the single long-lived JWT with a dual-token system: a short-lived access token and a long-lived rotating refresh token, plus security hardening.
|
||||
|
||||
**Goal:**
|
||||
Implement long-term user login through a short-lived access token (HttpOnly session cookie) and a configurable-duration refresh token (persistent HttpOnly cookie). Include token family tracking for theft detection, and harden related security gaps.
|
||||
|
||||
**User Story:**
|
||||
As a user, I should be able to log in, and have my credentials remain valid for a configurable number of days (default 90).
|
||||
|
||||
**Rules:**
|
||||
|
||||
- Access token is a short-lived JWT in an HttpOnly session cookie (no max-age) — cleared on browser close.
|
||||
- Refresh token is a random string in a persistent HttpOnly cookie (with max-age) — survives browser close.
|
||||
- API changes are in auth_api.py.
|
||||
- Login screen does not change.
|
||||
- Secret key and refresh token expiry are required environment variables.
|
||||
|
||||
**Design:**
|
||||
|
||||
- Access Token (Short-lived): A JWT that lasts 15 minutes. Used for every API call. Stored as an HttpOnly session cookie named `access_token`.
|
||||
- Refresh Token (Long-lived): A `secrets.token_urlsafe(32)` string stored as a persistent HttpOnly cookie named `refresh_token` with `max-age` and path restricted to `/auth`.
|
||||
- The Flow: When you open the app after a restart, the access token is gone (session cookie). The frontend's 401 interceptor detects this, sends `POST /auth/refresh` with the refresh token cookie, and the server returns a brand new 15-minute access token + rotated refresh token.
|
||||
- Token Rotation: Every refresh rotates the token. Old tokens are marked `is_used=True`. Replay of a used token triggers theft detection — ALL sessions for that user are killed and logged.
|
||||
- On logout, the refresh token is deleted from the DB and both cookies are cleared.
|
||||
|
||||
---
|
||||
|
||||
## Data Model Changes
|
||||
|
||||
### Backend Model
|
||||
|
||||
New model: `RefreshToken` dataclass in `backend/models/refresh_token.py`
|
||||
|
||||
| Field | Type | Description |
|
||||
| -------------- | ------- | ------------------------------------------------- |
|
||||
| `id` | `str` | UUID (from BaseModel) |
|
||||
| `user_id` | `str` | FK to User |
|
||||
| `token_hash` | `str` | SHA-256 hash of raw token (never store raw) |
|
||||
| `token_family` | `str` | UUID grouping tokens from one login session |
|
||||
| `expires_at` | `str` | ISO datetime |
|
||||
| `is_used` | `bool` | True after rotation; replay of used token = theft |
|
||||
| `created_at` | `float` | From BaseModel |
|
||||
| `updated_at` | `float` | From BaseModel |
|
||||
|
||||
New TinyDB table: `refresh_tokens_db` in `backend/db/db.py` backed by `refresh_tokens.json`.
|
||||
|
||||
### Frontend Model
|
||||
|
||||
No changes — refresh tokens are entirely server-side.
|
||||
|
||||
---
|
||||
|
||||
## Backend Implementation
|
||||
|
||||
- [x] Create `RefreshToken` model (`backend/models/refresh_token.py`)
|
||||
- [x] Add `refresh_tokens_db` table to `backend/db/db.py`
|
||||
- [x] Add error codes: `REFRESH_TOKEN_REUSE`, `REFRESH_TOKEN_EXPIRED`, `MISSING_REFRESH_TOKEN` in `error_codes.py`
|
||||
- [x] Move secret key to `SECRET_KEY` env var in `main.py` (hard fail if missing)
|
||||
- [x] Add `REFRESH_TOKEN_EXPIRY_DAYS` env var in `main.py` (hard fail if missing)
|
||||
- [x] Remove CORS (`flask-cors` from requirements.txt, `CORS(app)` from main.py)
|
||||
- [x] Add SSE authentication — `/events` endpoint uses `get_current_user_id()` from cookies instead of `user_id` query param
|
||||
- [x] Consolidate `admin_required` decorator into `utils.py` (removed duplicates from `admin_api.py` and `tracking_api.py`)
|
||||
- [x] Update cookie name from `token` to `access_token` in `utils.py`, `user_api.py`, `auth_api.py`
|
||||
- [x] Refactor `auth_api.py` login: issue 15-min access token + refresh token (new family)
|
||||
- [x] Add `POST /auth/refresh` endpoint with rotation and theft detection
|
||||
- [x] Refactor `auth_api.py` logout: delete refresh token from DB, clear both cookies
|
||||
- [x] Refactor `auth_api.py` reset-password: invalidate all refresh tokens + clear cookies
|
||||
- [x] Add expired token cleanup (opportunistic, on login/refresh)
|
||||
|
||||
## Backend Tests
|
||||
|
||||
- [x] All 14 test files updated: `SECRET_KEY` → `TEST_SECRET_KEY` from conftest, cookie `token` → `access_token`
|
||||
- [x] `test_login_with_correct_password`: asserts both `access_token` and `refresh_token` cookies
|
||||
- [x] `test_reset_password_invalidates_existing_jwt`: verifies refresh tokens deleted from DB
|
||||
- [x] `test_me_marked_for_deletion`: updated JWT payload with `token_version`
|
||||
- [x] `test_admin_api.py`: all `set_cookie('token')` → `set_cookie('access_token')`; `jwt.encode` uses `TEST_SECRET_KEY`
|
||||
- [x] All 258 backend tests pass
|
||||
|
||||
---
|
||||
|
||||
## Frontend Implementation
|
||||
|
||||
- [x] Update `api.ts` 401 interceptor: attempt `POST /api/auth/refresh` before logging out on 401
|
||||
- [x] Add refresh mutex: concurrent 401s only trigger one refresh call
|
||||
- [x] Skip refresh for auth endpoints (`/api/auth/refresh`, `/api/auth/login`)
|
||||
- [x] Retry original request after successful refresh
|
||||
- [x] Update `backendEvents.ts`: SSE URL changed from `/events?user_id=...` to `/api/events` (cookie-based auth)
|
||||
|
||||
## Frontend Tests
|
||||
|
||||
- [x] Interceptor tests rewritten: refresh-then-retry, refresh-fail-logout, auth-URL skip, concurrent mutex, non-401 passthrough (6 tests)
|
||||
- [x] backendEvents tests updated: URL assertions use `/api/events`
|
||||
- [x] All 287 frontend tests pass
|
||||
|
||||
---
|
||||
|
||||
## Security Hardening (included in this feature)
|
||||
|
||||
- [x] Secret key moved from hardcoded `'supersecretkey'` to required `SECRET_KEY` environment variable
|
||||
- [x] Hardcoded secret removed from `admin_required` decorators (was copy-pasted with literal string)
|
||||
- [x] SSE `/events` endpoint now requires authentication (was open to anyone with a user_id)
|
||||
- [x] CORS middleware removed (unnecessary behind nginx same-origin proxy)
|
||||
- [x] `admin_required` decorator consolidated into `utils.py` (was duplicated in `admin_api.py` and `tracking_api.py`)
|
||||
- [x] Refresh tokens stored as SHA-256 hashes (never raw)
|
||||
- [x] Token family tracking with automatic session kill on replay (theft detection)
|
||||
- [x] Refresh token cookie path restricted to `/auth` (not sent with every API call)
|
||||
|
||||
## Future Considerations
|
||||
|
||||
- Rate limiting on login, signup, and refresh endpoints
|
||||
- Configurable access token lifetime via env var
|
||||
- Background job for expired token cleanup (currently opportunistic)
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria (Definition of Done)
|
||||
|
||||
### Backend
|
||||
|
||||
- [x] Login returns two cookies: `access_token` (session, 15-min JWT) and `refresh_token` (persistent, configurable-day, path=/auth)
|
||||
- [x] `POST /auth/refresh` rotates refresh token and issues new access token
|
||||
- [x] Replay of rotated-out refresh token kills all user sessions (theft detection)
|
||||
- [x] Logout deletes refresh token from DB and clears both cookies
|
||||
- [x] Password reset invalidates all refresh tokens
|
||||
- [x] Secret key and refresh token expiry loaded from environment variables
|
||||
- [x] SSE requires authentication
|
||||
- [x] CORS removed
|
||||
- [x] All 258 backend tests pass
|
||||
|
||||
### Frontend
|
||||
|
||||
- [x] 401 interceptor attempts refresh before logging out
|
||||
- [x] Concurrent 401s trigger only one refresh call
|
||||
- [x] SSE connects without user_id query param (cookie auth)
|
||||
- [x] All 287 frontend tests pass
|
||||
36
.github/specs/archive/feat-nav-selector-icons.md
vendored
Normal file
36
.github/specs/archive/feat-nav-selector-icons.md
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
# Feature: Change navigation selector icons
|
||||
|
||||
## Overview
|
||||
|
||||
**Goal:** The current view navigation that is displayed in parent mode currently displays custom svg drawing for Children, Tasks, Rewards, and Notifications. I have new png image files that I'd like to replace them with.
|
||||
|
||||
**User Story:**
|
||||
As a user, when I am in parent mode, I should be able to use the navigation selector as usual but see the updated images in the buttons.
|
||||
|
||||
**Rules:**
|
||||
The size of the navigation view-selector should still remain the same for both desktop and mobile. The images should replace the svg drawings.
|
||||
This will most likely only affect ParentView.vue / ParentLayout.vue
|
||||
|
||||
**Drawing to image replacements:**
|
||||
aria-label: "Children" -> frontend/vue-app/public/nav/children.png
|
||||
aria-label: "Tasks" -> frontend/vue-app/public/nav/chores.png
|
||||
aria-label: "Rewards" -> frontend/vue-app/public/nav/rewards.png
|
||||
aria-label: "Notifications" -> frontend/vue-app/public/nav/notifications.png
|
||||
|
||||
---
|
||||
|
||||
## Frontend Implementation
|
||||
|
||||
- Replaced all four SVG drawings in the view-selector nav in `ParentLayout.vue` with `<img>` tags pointing to the corresponding PNG files under `/nav/`.
|
||||
- The Notifications button wraps its image in a positioned container so the existing unread badge dot continues to render correctly over the icon.
|
||||
- Added `.nav-icon` scoped styles: fixed 40×40px, `object-fit: contain`, and a reduced opacity (0.55) at rest that transitions to full opacity on active/hover.
|
||||
- Added `.nav-icon-wrap` scoped styles for the Notifications icon wrapper to keep the badge positioned correctly.
|
||||
- No changes to button sizing, layout, or responsive breakpoints — the selector dimensions are unchanged on both desktop and mobile.
|
||||
|
||||
### Frontend
|
||||
|
||||
- [x] Children, Tasks, Rewards, and Notifications nav buttons display PNG images instead of SVG drawings
|
||||
- [x] Images sourced from `frontend/vue-app/public/nav/` as specified
|
||||
- [x] Button and selector dimensions unchanged on desktop and mobile
|
||||
- [x] Notifications unread badge dot continues to render correctly over the icon
|
||||
- [x] Active and hover states visually reflected via opacity transition
|
||||
347
.github/specs/archive/feat-parent-chore-notifications.md
vendored
Normal file
347
.github/specs/archive/feat-parent-chore-notifications.md
vendored
Normal file
@@ -0,0 +1,347 @@
|
||||
# Feature: Notify parents of child activity requiring attention
|
||||
|
||||
## Overview
|
||||
|
||||
**Goal:** Alert parents in real-time when a child performs an action that requires their attention (completing a chore, or requesting an affordable reward), even when the parent does not have the app open in an active browser tab. Additionally, send a nightly email digest at 9 pm in the parent's local timezone summarising all still-unacknowledged items.
|
||||
|
||||
**User Story:**
|
||||
As a parent, I want to receive a notification when:
|
||||
|
||||
- My child completes a chore so I can review and approve or reject it promptly.
|
||||
- My child requests a reward they can afford so I can review and grant or deny it promptly.
|
||||
|
||||
I should receive these notifications without needing to have the app open.
|
||||
|
||||
Additionally, if any items are still unacknowledged at the end of the day, I want to receive a nightly summary email at 9 pm (my local time) that lists each pending chore and reward request with action links so I can approve or deny each item directly from the email without opening the app.
|
||||
|
||||
**Rules:**
|
||||
|
||||
- Follow `.github/copilot-instructions.md`
|
||||
- Notifications must be user-opt-in (browser permission request)
|
||||
- Every backend mutation must fire an SSE event (existing requirement)
|
||||
- Web Push is the primary notification channel; in-app SSE toast remains the secondary channel
|
||||
- Do not send email per event — email is reserved for account-level actions
|
||||
- A reward request notification is only sent when the child has enough points to afford the reward; do not notify for requests the child cannot afford
|
||||
- Tapping a notification must open (or focus) the app and navigate directly to the relevant child's `ParentView`, scrolling the pending item card into view within the corresponding section
|
||||
- Each notification must include an **Approve** and **Deny** action button so the parent can act directly from the OS notification tray without opening the app
|
||||
- Action button API calls from the Service Worker use short-lived signed action tokens (same `DigestActionToken` mechanism used for email digest links); tokens are generated server-side when the push payload is built and embedded in the push `data` — the SW never needs cookie access
|
||||
- Browsers that do not support notification `actions` (e.g. iOS Safari) fall back gracefully: tapping the notification body still opens the app as normal
|
||||
- The nightly digest email is sent at 9 pm in the parent's local timezone regardless of whether they have push notifications enabled
|
||||
- The digest is only sent if there is at least one unacknowledged pending item at send time
|
||||
- Approve/deny links in the email are protected by short-lived signed action tokens (valid for 24 hours); they do not rely on browser cookies
|
||||
- Action tokens must encode the `user_id`, `child_id`, `entity_id`, `entity_type`, and `action` and be signed with a server secret using HMAC-SHA256
|
||||
- Action tokens are single-use: once redeemed the backend marks them consumed so they cannot be replayed
|
||||
|
||||
---
|
||||
|
||||
## Data Model Changes
|
||||
|
||||
### Backend Model
|
||||
|
||||
New model: `PushSubscription`
|
||||
|
||||
- `id: str` — unique ID
|
||||
- `user_id: str` — the parent user this subscription belongs to
|
||||
- `endpoint: str` — browser-provided push endpoint URL
|
||||
- `keys: dict` — `{ p256dh: str, auth: str }` from the browser `PushSubscription`
|
||||
- `created_at: str` — ISO timestamp
|
||||
|
||||
A user may have **multiple** `PushSubscription` records (one per device/browser). The `user_id` + `endpoint` pair is the unique key; re-posting the same endpoint updates the existing record rather than creating a duplicate.
|
||||
|
||||
Existing model change: `User` — add two fields:
|
||||
|
||||
- `timezone: str | None` — IANA timezone string (e.g. `"America/New_York"`), updated whenever the frontend posts a push subscription. Used by the digest scheduler to determine the parent's local 9 pm. Defaults to `None` (falls back to UTC).
|
||||
- `email_digest_enabled: bool` — whether the user receives the nightly digest email. Defaults to `True`. Toggled via the User Profile page or via the digest email unsubscribe link.
|
||||
|
||||
New model: `DigestActionToken`
|
||||
|
||||
- `id: str` — unique token ID (also used as the URL token)
|
||||
- `user_id: str`
|
||||
- `child_id: str`
|
||||
- `entity_id: str`
|
||||
- `entity_type: str` — `"chore"` or `"reward"`
|
||||
- `action: str` — `"approve"` or `"deny"`
|
||||
- `expires_at: str` — ISO timestamp (24 hours from creation)
|
||||
- `used: bool` — set to `True` once redeemed
|
||||
- `signature: str` — HMAC-SHA256 of the above fields, keyed by `DIGEST_TOKEN_SECRET` env var
|
||||
|
||||
### Frontend Model
|
||||
|
||||
```ts
|
||||
interface PushSubscription {
|
||||
id: string;
|
||||
user_id: string;
|
||||
endpoint: string;
|
||||
keys: {
|
||||
p256dh: string;
|
||||
auth: string;
|
||||
};
|
||||
created_at: string;
|
||||
}
|
||||
```
|
||||
|
||||
Existing interface change: `User` — add:
|
||||
|
||||
```ts
|
||||
timezone: string | null;
|
||||
email_digest_enabled: boolean;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Backend Implementation
|
||||
|
||||
1. Add `pywebpush` and `py-vapid` to `requirements.txt`.
|
||||
2. Generate a VAPID key pair and store the public/private keys in environment config.
|
||||
3. New API file: `api/push_subscription_api.py`
|
||||
- `POST /push-subscription` — **upsert**: if a record with matching `user_id` + `endpoint` already exists, update its `keys`; otherwise insert a new `PushSubscription`. Also update `User.timezone` with the submitted IANA timezone string.
|
||||
- `DELETE /push-subscription` — accepts `{ endpoint }` in the request body and removes only that specific subscription for the authenticated user (not all subscriptions).
|
||||
4. New DB helper: `db/push_subscriptions.py` — CRUD for `PushSubscription` records. Must support multiple records per user. Key methods: `get_subscriptions_by_user(user_id)` (returns a list), `upsert_subscription(user_id, endpoint, keys)`, `delete_by_endpoint(user_id, endpoint)`.
|
||||
5. **Prerequisite — duplicate reward request guard:** In `request_reward()` in `child_api.py`, before creating a new `PendingConfirmation`, check whether one already exists for the same `child_id`, `entity_id` (`reward_id`), and `entity_type='reward'` with `status='pending'`. If so, return a `409 Conflict` with error code `DUPLICATE_REWARD_REQUEST`. This prevents the same reward from being requested twice.
|
||||
6. In the chore confirmation flow (where `send_event_for_current_user` is called for a completed/pending chore), also look up **all** of the parent's stored `PushSubscription` records and fire a web push to each. The push payload must contain: `user_id`, child name, chore name, `child_id`, `entity_id` (task ID), `entity_type: "chore"`, plus `approve_token` and `deny_token` (two freshly generated `DigestActionToken` IDs for the approve and deny actions). The frontend/SW will use these to construct the deep link `/parent/<child_id>?scrollTo=<entity_id>&entityType=chore`.
|
||||
7. In the reward request flow (`child_reward_request` with operation `REQUEST_CREATED`), check whether the child's current point balance is >= the reward's cost. If so, look up **all** of the parent's stored `PushSubscription` records and fire a web push to each. The push payload must contain: `user_id`, child name, reward name, reward cost, `child_id`, `entity_id` (reward ID), `entity_type: "reward"`, plus `approve_token` and `deny_token`. The frontend/SW constructs the deep link `/parent/<child_id>?scrollTo=<entity_id>&entityType=reward`. If the child cannot afford the reward, skip the push notification.
|
||||
8. Add a new endpoint `POST /child/<id>/deny-reward-request` (with body `{ reward_id }`) that a parent can call to reject a child's pending reward request. It should remove the `PendingConfirmation` record, fire a `CHILD_REWARD_REQUEST` SSE event with operation `REQUEST_CANCELLED`, and create a tracking event with action `denied`. This is the parent-facing counterpart to the child's `cancel-request-reward`. **Graceful handling:** If no pending request exists (e.g. the reward was already granted or cancelled), return `200` with `{ "message": "This reward request has already been resolved." }` instead of a 404.
|
||||
9. New utility: `utils/email_digest_scheduler.py`
|
||||
- Uses `APScheduler` (already in use for the account deletion scheduler — follow the same pattern) with a `BackgroundScheduler`.
|
||||
- **Important:** The scheduler job function must run inside `with app.app_context():` to access Flask extensions (Flask-Mail, config, TinyDB). Pass the Flask `app` instance to the scheduler setup function, and use `app.app_context()` as a context manager wrapping the job body.
|
||||
- Runs a job **every hour** on the server. Each run inspects all users where `user.verified == True` and `user.email_digest_enabled == True`. For each matching user, derive their current local hour from `User.timezone` (falling back to UTC if `timezone` is `None`), and send the digest to any user whose local hour is `21` (9 pm) and who has at least one pending `PendingConfirmation`.
|
||||
- For each pending item, generate a `DigestActionToken` for both `approve` and `deny` actions and persist them in TinyDB.
|
||||
- Call `send_digest_email()` (see step 10) with the assembled item list.
|
||||
- Skip sending if `DB_ENV == 'e2e'`.
|
||||
10. New function `send_digest_email(to_email, items)` in `utils/email_sender.py`. Each item in `items` is a dict with keys: `child_name`, `entity_name`, `entity_type`, `view_url`, `approve_token`, `deny_token`. The HTML email body must:
|
||||
- Open with a branded header ("Reward App — Daily Summary").
|
||||
- Contain one section per child, listing that child's pending items.
|
||||
- For each item show the item name and three links side by side:
|
||||
- **View** — deep link to `<FRONTEND_URL>/parent/<child_id>?scrollTo=<entity_id>&entityType=<entity_type>`
|
||||
- **Approve** — `<FRONTEND_URL>/api/digest-action/<approve_token>`
|
||||
- **Deny** — `<FRONTEND_URL>/api/digest-action/<deny_token>`
|
||||
- Use inline CSS styles consistent with the existing `send_pin_setup_email` format (no external stylesheets). Approve link styled green, Deny link styled red.
|
||||
- Close with a footer containing an unsubscribe link: "You are receiving this because you have the Reward App. [Unsubscribe from daily digest](<FRONTEND_URL>/api/digest-unsubscribe/<unsubscribe_token>)." The `unsubscribe_token` is a signed HMAC token encoding the `user_id` with a 30-day expiry.
|
||||
11. New API endpoint `GET /digest-action/<token>` in `api/digest_action_api.py`:
|
||||
- Validates the token exists in TinyDB, has not expired, has not been used, and the HMAC signature is valid.
|
||||
- If invalid/expired: return a plain 400 HTML error page (no redirect).
|
||||
- If valid: mark the token `used = True`, then:
|
||||
- `approve` + `chore` → call the same logic as `approve-chore`
|
||||
- `deny` + `chore` → call the same logic as `reject-chore`
|
||||
- `approve` + `reward` → call the same logic as `trigger-reward`
|
||||
- `deny` + `reward` → call the same logic as `deny-reward-request`
|
||||
- On success: **302 redirect** to `<FRONTEND_URL>/parent/<child_id>?scrollTo=<entity_id>&entityType=<entity_type>` so the parent lands on the correct view.
|
||||
- This endpoint is **unauthenticated** — the signed token is the credential.
|
||||
12. New API endpoint `GET /digest-unsubscribe/<token>` in `api/digest_action_api.py`:
|
||||
- Validates the unsubscribe token signature and 30-day expiry.
|
||||
- If valid: sets `User.email_digest_enabled = False` and returns a simple HTML confirmation page: "You have been unsubscribed from daily digest emails. To re-enable, visit your profile in the app."
|
||||
- If invalid/expired: returns a plain 400 HTML error page.
|
||||
13. Add `DIGEST_TOKEN_SECRET` to environment config. Document it in the README.
|
||||
14. Extend `PUT /user/profile` in `api/user_api.py` to also accept an optional `email_digest_enabled: bool` field in the request body. When present, update `User.email_digest_enabled`. Include `email_digest_enabled` in the `GET /user/profile` response.
|
||||
15. Register the new blueprints and start the digest scheduler in `main.py`.
|
||||
|
||||
## Backend Tests
|
||||
|
||||
- [x] `POST /push-subscription` saves a subscription for the current user
|
||||
- [x] `POST /push-subscription` upserts when the same endpoint is posted again (updates keys, no duplicate)
|
||||
- [x] `POST /push-subscription` supports multiple endpoints per user (one per device)
|
||||
- [x] `POST /push-subscription` updates `User.timezone` with the submitted IANA timezone string
|
||||
- [x] `POST /push-subscription` rejects unauthenticated requests
|
||||
- [x] `DELETE /push-subscription` removes only the specified endpoint for the current user
|
||||
- [x] Web push is fired to all stored subscriptions when a chore is marked complete
|
||||
- [x] Web push payload includes `user_id`, `approve_token`, and `deny_token`
|
||||
- [x] Web push is not fired when the parent has no stored subscription (no error raised)
|
||||
- [x] Web push is fired when a child requests a reward they can afford and the parent has a stored subscription
|
||||
- [x] Web push is NOT fired when a child requests a reward they cannot afford
|
||||
- [x] Web push is not fired for reward requests when the parent has no stored subscription (no error raised)
|
||||
- [x] `request_reward()` returns 409 Conflict for duplicate pending reward requests
|
||||
- [x] `POST /child/<id>/deny-reward-request` removes the pending confirmation and fires the `REQUEST_CANCELLED` SSE event
|
||||
- [x] `POST /child/<id>/deny-reward-request` returns 200 with message when reward already resolved (no pending request)
|
||||
- [x] `POST /child/<id>/deny-reward-request` rejects unauthenticated requests
|
||||
- [x] Digest scheduler identifies verified, digest-enabled users whose local time is 9 pm and who have pending items
|
||||
- [x] Digest scheduler skips users with no pending items
|
||||
- [x] Digest scheduler skips unverified users
|
||||
- [x] Digest scheduler skips users with `email_digest_enabled == False`
|
||||
- [x] Digest scheduler reads timezone from `User.timezone`, falling back to UTC
|
||||
- [x] Digest scheduler skips sending when `DB_ENV == 'e2e'`
|
||||
- [x] `DigestActionToken` is created with correct fields, expiry, and valid HMAC signature
|
||||
- [x] `GET /digest-action/<token>` executes the correct backend action for each combination of `entity_type` × `action`
|
||||
- [x] `GET /digest-action/<token>` redirects to the correct deep-link URL on success
|
||||
- [x] `GET /digest-action/<token>` returns 400 for expired tokens
|
||||
- [x] `GET /digest-action/<token>` returns 400 for already-used tokens
|
||||
- [x] `GET /digest-action/<token>` returns 400 for tampered/invalid signatures
|
||||
- [x] Digest email HTML contains per-child sections, item names, View / Approve / Deny links, and unsubscribe link
|
||||
- [x] `GET /digest-unsubscribe/<token>` sets `email_digest_enabled = False` and returns confirmation HTML
|
||||
- [x] `GET /digest-unsubscribe/<token>` returns 400 for expired or invalid tokens
|
||||
- [x] `GET /user/profile` response includes `email_digest_enabled`
|
||||
- [x] `PUT /user/profile` with `email_digest_enabled: false` updates the user and disables digest
|
||||
- [x] `PUT /user/profile` with `email_digest_enabled: true` re-enables digest
|
||||
|
||||
---
|
||||
|
||||
## Frontend Implementation
|
||||
|
||||
1. **PWA manifest:** Create `public/manifest.json` with `name`, `short_name`, `start_url: "/"`, `display: "standalone"`, `theme_color`, `background_color`, and an `icons` array. Add `<link rel="manifest" href="/manifest.json">` to `index.html`. This is required for iOS "Add to Home Screen" and for push notifications on iOS Safari.
|
||||
2. Register a Service Worker (`public/sw.js`) that listens for the `push` event and calls `self.registration.showNotification(...)` with:
|
||||
- `title`, `body`, and a `data` object containing `child_id`, `entity_id`, `entity_type`, `approve_token`, and `deny_token`
|
||||
- An `actions` array: `[{ action: 'approve', title: 'Approve' }, { action: 'deny', title: 'Deny' }]`
|
||||
- Note: the `actions` field is silently ignored by browsers that do not support it (e.g. iOS Safari); the notification body tap still works normally.
|
||||
3. In `LoginButton.vue` (`submit()`), after a successful PIN authentication, call `subscribeToPushWithResult()` (fire-and-forget, not awaited) — the PIN submit button click satisfies the browser's user-gesture requirement. If the parent has already granted permission, the browser returns the existing subscription silently and the call is idempotent. No floating opt-in banner is displayed anywhere in the app.
|
||||
4. Clean up: The **Push Notifications** toggle in `UserProfile.vue` handles explicit unsubscription — toggling off calls `unsubscribeFromPush()` which sends `DELETE /api/push-subscription` with `{ endpoint }` in the body, removing that specific subscription for the current user.
|
||||
5. The Service Worker's `notificationclick` handler must:
|
||||
- Close the notification with `event.notification.close()`
|
||||
- If `event.action === 'approve'`: `fetch('/api/digest-action/<approve_token>')` (GET, no credentials needed — the signed token IS the credential).
|
||||
- If `event.action === 'deny'`: `fetch('/api/digest-action/<deny_token>')` (GET, no credentials needed).
|
||||
- If no action (body tap): construct the deep-link URL `/parent/<child_id>?scrollTo=<entity_id>&entityType=<entity_type>`, check `clients.matchAll({ type: 'window' })` for an existing open window on the same origin — if found, call `client.focus()` and `client.navigate(url)`; otherwise call `clients.openWindow(url)`.
|
||||
6. `ParentView` uses vertically stacked `ScrollingList` sections (not tabs). The existing `scrollTo` + `entityType` query params call `scrollToItem()` on the correct list ref, which scrolls the card into view and centers it. After scrolling, apply a temporary pulse/highlight CSS animation (e.g. a 2-second `@keyframes highlight-pulse` using `--item-card-ready-shadow` or `--accent`) to draw the parent's eye to the target card. The animation class should be removed after it completes.
|
||||
7. **Parent mode timeout — return URL:** When the router guard redirects a non-parent-authenticated user away from a `/parent/...` deep link (e.g. from a notification body tap or email digest redirect), it saves the intended URL to `localStorage['parentReturnUrl']`. `LoginButton` checks for a pending return URL on mount and auto-opens the PIN modal. On successful PIN entry, `consumePendingReturnUrl()` is called and the user is redirected to the saved deep link instead of the default `/parent`. If the parent cancels the PIN modal, `clearPendingReturnUrl()` removes the saved URL so the prompt does not re-appear. The URL is validated to start with `/parent` before saving to prevent open redirect.
|
||||
8. Existing SSE in-app toast/badge behavior is unchanged — it remains the secondary channel when the tab is active.
|
||||
9. On `UserProfile.vue`, add a **Daily Digest** `ToggleField` and a **Push Notifications** `ToggleField` below the email field.
|
||||
- **Daily Digest**: When toggled, call `PUT /api/user/profile` with `{ email_digest_enabled: <value> }`. Initialize from `GET /api/user/profile` (`email_digest_enabled` field).
|
||||
- **Push Notifications**: When toggled on, call `subscribeToPushWithResult()` from `pushSubscription.ts`; on `permission_denied` show an inline error message. When toggled off, call `unsubscribeFromPush()`. Initialize by calling `isSubscribedToPush()` in `onMounted`. Disable the toggle (but keep it visible) when `getPushPermissionState() === 'denied'`.
|
||||
|
||||
## Frontend Tests
|
||||
|
||||
- [ ] Parent sees a browser notification when a chore is completed and the tab is backgrounded
|
||||
- [ ] Chore notification includes "Approve" and "Deny" action buttons (on supporting browsers)
|
||||
- [ ] Clicking "Approve" on a chore notification uses the signed action token URL (not cookie-authenticated fetch)
|
||||
- [ ] Clicking "Deny" on a chore notification uses the signed action token URL and dismisses the notification without opening the app
|
||||
- [ ] Tapping the chore notification body opens the app, scrolls to the pending chore within the tasks section of `ParentView`
|
||||
- [x] Scrolled-to card receives a temporary highlight/pulse animation
|
||||
- [ ] Parent sees a browser notification when a child requests an affordable reward and the tab is backgrounded
|
||||
- [ ] Reward notification includes "Approve" and "Deny" action buttons (on supporting browsers)
|
||||
- [ ] Clicking "Approve" on a reward notification uses the signed action token URL and dismisses the notification without opening the app
|
||||
- [ ] Clicking "Deny" on a reward notification uses the signed action token URL and dismisses the notification without opening the app
|
||||
- [ ] Tapping the reward notification body opens the app, scrolls to the pending reward within the rewards section of `ParentView`
|
||||
- [ ] If the app is already open in another tab, tapping the notification body focuses that tab and navigates it (no duplicate window opened)
|
||||
- [ ] No browser notification is shown for a reward request the child cannot afford
|
||||
- [x] Notification permission is requested on parent PIN entry (LoginButton.vue submit)
|
||||
- [x] If permission is denied, no subscription is posted to the backend
|
||||
- [x] Push subscription `POST` body includes the browser's IANA timezone string
|
||||
- [x] User Profile page shows an "Email Digest" toggle
|
||||
- [x] Toggling Email Digest off calls `PUT /api/user/profile` with `email_digest_enabled: false`
|
||||
- [x] Toggling Email Digest on calls `PUT /api/user/profile` with `email_digest_enabled: true`
|
||||
- [x] User Profile page shows a "Push Notifications" toggle
|
||||
- [x] Push Notifications toggle initialises to `true` when the browser already has an active push subscription
|
||||
- [x] Push Notifications toggle initialises to `false` when no subscription exists
|
||||
- [x] Toggling Push Notifications on triggers `subscribeToPushWithResult()` and posts subscription to the backend
|
||||
- [x] Toggling Push Notifications off calls `unsubscribeFromPush()` and removes the subscription from the backend
|
||||
- [x] Push Notifications toggle is disabled (not hidden) when browser notification permission is `"denied"`
|
||||
- [x] An inline error message is shown when enabling push notifications is blocked by the browser
|
||||
|
||||
---
|
||||
|
||||
## Future Considerations
|
||||
|
||||
- **iOS PWA caveat**: Web Push on iOS Safari requires the user to have added the app to their Home Screen (PWA mode). Until then, the in-app SSE toast is the only delivery mechanism for iOS Safari users in a regular browser tab. Consider prompting parents to install the PWA. Note that notification `actions` buttons are also not supported on iOS Safari — the body-tap deep-link is the only interaction available on that platform.
|
||||
- **Notification preferences**: Allow parents to toggle specific notification types (chore complete, reward request, etc.) per child.
|
||||
- **Configurable digest time**: Allow the parent to choose what time the daily digest is sent (default 9 pm).
|
||||
|
||||
---
|
||||
|
||||
## E2E Test Plan
|
||||
|
||||
A full Playwright E2E test plan has been produced and saved to:
|
||||
|
||||
`frontend/vue-app/e2e/plans/parent-notifications.plan.md`
|
||||
|
||||
The plan covers 10 scenario groups (56 automated test cases + 1 manual QA checklist):
|
||||
|
||||
| # | Group | Cases |
|
||||
| --- | ----------------------------------------- | ----- |
|
||||
| 1 | Push subscription registration | 3 |
|
||||
| 2 | Chore notification — approve flow | 8 |
|
||||
| 3 | Chore notification — reject flow | 5 |
|
||||
| 4 | Reward notification — grant flow | 7 |
|
||||
| 5 | Reward notification — deny flow | 6 |
|
||||
| 6 | ParentView deep-link navigation | 7 |
|
||||
| 7 | Digest action token — happy paths | 8 |
|
||||
| 8 | Digest action token — error paths | 5 |
|
||||
| 9 | Service Worker push — manual QA checklist | — |
|
||||
| 10 | User Profile notification toggles | 7 |
|
||||
|
||||
**Prerequisite noted in plan:** A backend test-only endpoint `POST /api/admin/test/digest-token` (active only when `DB_ENV=e2e`) is required before scenario groups 7 and 8 can run.
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria (Definition of Done)
|
||||
|
||||
### Backend
|
||||
|
||||
- [x] `PushSubscription` model and DB helper exist and follow existing patterns; supports multiple subscriptions per user (one per device)
|
||||
- [x] `POST /push-subscription` upserts by `user_id` + `endpoint` and also updates `User.timezone`
|
||||
- [x] `DELETE /push-subscription` removes only the specified endpoint for the authenticated user
|
||||
- [x] Web push is sent to **all** stored subscriptions when a child completes a chore
|
||||
- [x] Web push is sent to **all** stored subscriptions when a child requests an affordable reward
|
||||
- [x] Web push payload includes `user_id`, `approve_token`, and `deny_token`
|
||||
- [x] Web push is NOT sent when the child cannot afford the requested reward
|
||||
- [x] `request_reward()` rejects duplicate pending reward requests with 409 Conflict
|
||||
- [x] `POST /child/<id>/deny-reward-request` endpoint is implemented, authenticated, fires the correct SSE event, and creates a tracking event
|
||||
- [x] `POST /child/<id>/deny-reward-request` returns 200 with message when reward already resolved
|
||||
- [x] `utils/email_digest_scheduler.py` is implemented using APScheduler, runs hourly, uses `with app.app_context():`, and sends digests at 9 pm local time
|
||||
- [x] Digest scheduler reads timezone from `User.timezone` (falling back to UTC) and only processes verified users with `email_digest_enabled == True`
|
||||
- [x] `send_digest_email()` produces a well-formatted HTML email with per-child sections, View / Approve / Deny links, and an unsubscribe link
|
||||
- [x] `GET /digest-action/<token>` validates the token and performs the correct action
|
||||
- [x] `DigestActionToken` is single-use: redeeming it marks it consumed and subsequent requests with the same token return 400
|
||||
- [x] `GET /digest-unsubscribe/<token>` sets `email_digest_enabled = False` and returns confirmation HTML
|
||||
- [x] `GET /user/profile` includes `email_digest_enabled`; `PUT /user/profile` accepts `email_digest_enabled` toggle
|
||||
- [x] `DIGEST_TOKEN_SECRET` and VAPID keys are configurable via environment variables (not hardcoded)
|
||||
- [x] All backend tests pass
|
||||
|
||||
### Frontend
|
||||
|
||||
- [x] PWA manifest (`public/manifest.json`) is present and linked in `index.html`
|
||||
- [x] Service Worker is registered and handles `push` and `notificationclick` events
|
||||
- [x] Parent is prompted for notification permission on PIN entry (LoginButton.vue)
|
||||
- [x] Subscription is posted to the backend on PIN entry when permission is granted; removed on explicit toggle-off in UserProfile
|
||||
- [ ] Native notification appears when a chore is completed with the tab backgrounded, with "Approve" and "Deny" action buttons
|
||||
- [ ] Native notification appears when a child requests an affordable reward with the tab backgrounded, with "Approve" and "Deny" action buttons
|
||||
- [ ] No native notification appears for a reward request the child cannot afford
|
||||
- [ ] Clicking "Approve" on a chore notification uses the signed action token (no cookie auth) without opening the app
|
||||
- [ ] Clicking "Deny" on a chore notification uses the signed action token without opening the app
|
||||
- [ ] Clicking "Approve" on a reward notification uses the signed action token without opening the app
|
||||
- [ ] Clicking "Deny" on a reward notification uses the signed action token without opening the app
|
||||
- [ ] Tapping a chore notification body opens (or focuses) the app and scrolls to the pending chore within the tasks section
|
||||
- [ ] Tapping a reward notification body opens (or focuses) the app and scrolls to the pending reward within the rewards section
|
||||
- [x] Scrolled-to card receives a temporary highlight/pulse animation
|
||||
- [ ] If the app is already open, the existing tab is focused and navigated rather than opening a new window
|
||||
- [ ] In-app SSE toast behavior is unchanged
|
||||
- [x] Push subscription registration includes the browser's IANA timezone
|
||||
- [x] User Profile page includes an "Email Digest" toggle that calls `PUT /api/user/profile` with `email_digest_enabled`
|
||||
- [x] When a deep-link `/parent/...` is blocked by an expired parent session, the intended URL is saved to `localStorage` and the PIN modal is auto-opened; on successful PIN entry the parent is redirected to the saved URL
|
||||
- [x] Cancelling the PIN modal clears the saved return URL (no repeat prompt)
|
||||
- [ ] All frontend tests pass
|
||||
|
||||
### Email Digest
|
||||
|
||||
- [x] Digest email is sent at 9 pm in the parent's local timezone when pending items exist
|
||||
- [x] Digest is not sent if there are no pending items
|
||||
- [x] Email contains one section per child with pending items
|
||||
- [x] Each item row shows the item name and three links: View, Approve, Deny
|
||||
- [x] Approve link is visually styled green; Deny link is styled red
|
||||
- [ ] Clicking Approve in the email performs the approve action and redirects to the correct `ParentView` deep link
|
||||
- [ ] Clicking Deny in the email performs the deny action and redirects to the correct `ParentView` deep link
|
||||
- [ ] Clicking View in the email opens the app to the correct `ParentView` deep link
|
||||
- [x] Action tokens expire after 24 hours and return a 400 error page when used after expiry
|
||||
- [x] Action tokens are single-use: a second click on the same link returns a 400 error page
|
||||
- [x] Digest email includes an unsubscribe link in the footer
|
||||
- [x] Unsubscribe link sets `email_digest_enabled = False` and shows a confirmation page
|
||||
- [x] Digest is not sent to unverified users
|
||||
- [x] Digest is not sent to users who have unsubscribed (`email_digest_enabled == False`)
|
||||
- [x] Digest is not sent in `e2e` test environment
|
||||
|
||||
---
|
||||
|
||||
## Test Run Results
|
||||
|
||||
**Date:** Implementation complete
|
||||
|
||||
**Backend tests run:** `pytest tests/test_push_subscription_api.py tests/test_digest_token.py tests/test_digest_action_api.py -v`
|
||||
|
||||
**Results:** 36 passed, 0 failed
|
||||
|
||||
**Full suite:** `pytest tests/ -v` → 340 passed, 0 failed (no regressions)
|
||||
|
||||
**New test files:**
|
||||
|
||||
- `backend/tests/test_push_subscription_api.py` — 13 tests covering VAPID key endpoint, subscribe, upsert, timezone update, auth guard, unsubscribe
|
||||
- `backend/tests/test_digest_token.py` — 12 tests covering token creation, validate/consume, expiry, tampered signature, unsubscribe token lifecycle
|
||||
- `backend/tests/test_digest_action_api.py` — 11 tests covering approve/deny chore/reward, 302 redirect, invalid/used token 400, unsubscribe endpoint
|
||||
|
||||
- `backend/tests/test_web_push.py` — 6 tests covering push firing on chore confirm and reward request, payload fields, no-subscription no-error
|
||||
- `backend/tests/test_digest_scheduler.py` — 15 tests covering scheduler eligibility logic, e2e skip, timezone fallback, email HTML content (child sections, item names, View/Approve/Deny, unsubscribe, color styling)
|
||||
141
.github/specs/archive/feat-parent-mode-expire.md
vendored
Normal file
141
.github/specs/archive/feat-parent-mode-expire.md
vendored
Normal file
@@ -0,0 +1,141 @@
|
||||
# Feature: Persistent and non-persistent parent mode
|
||||
|
||||
## Overview
|
||||
|
||||
When a parent is prompted to input the parent PIN, a checkbox should also be available that asks if the parent wants to 'stay' in parent mode. If that is checked, the parent mode remains persistent on the device until child mode is entered or until an expiry time of 2 days.
|
||||
When the checkbox is not enabled (default) the parent authentication should expire in 1 minute or the next reload of the site.
|
||||
|
||||
**Goal:**
|
||||
A parent that has a dedicated device should stay in parent mode for a max of 2 days before having to re-enter the PIN, a device dedicated to the child should not stay in parent mode for more than a minute before reverting back to child mode.
|
||||
|
||||
**User Story:**
|
||||
As a parent, I want my personal device to be able to stay in parent mode until I enter child mode or 2 days expire.
|
||||
As a parent, on my child's device, I want to be able to enter parent mode to make a change or two and not have to worry about exiting parent mode.
|
||||
|
||||
**Rules:**
|
||||
Use .github/copilot-instructions.md
|
||||
|
||||
**Common files:**
|
||||
frontend\vue-app\src\components\shared\LoginButton.vue
|
||||
frontend\vue-app\src\stores\auth.ts
|
||||
frontend\vue-app\src\router\index.ts
|
||||
|
||||
---
|
||||
|
||||
## Data Model Changes
|
||||
|
||||
### Backend Model
|
||||
|
||||
No backend changes required. PIN validation is already handled server-side via `POST /user/check-pin`. Parent mode session duration is a purely client-side concern.
|
||||
|
||||
### Frontend Model
|
||||
|
||||
**`localStorage['parentAuth']`** (written only for persistent mode):
|
||||
|
||||
```json
|
||||
{ "expiresAt": 1234567890123 }
|
||||
```
|
||||
|
||||
- Present only when "Stay in parent mode" was checked at PIN entry.
|
||||
- Removed when the user clicks "Child Mode", on explicit logout, or when found expired on store init.
|
||||
|
||||
**Auth store state additions** (`frontend/vue-app/src/stores/auth.ts`):
|
||||
|
||||
- `parentAuthExpiresAt: Ref<number | null>` — epoch ms timestamp; `null` when not authenticated. Memory-only for non-persistent sessions, restored from `localStorage` for persistent ones.
|
||||
- `isParentPersistent: Ref<boolean>` — `true` when the current parent session was marked "stay".
|
||||
- `isParentAuthenticated: Ref<boolean>` — plain ref set to `true` by `authenticateParent()` and `false` by `logoutParent()`. Expiry is enforced by the 15-second background watcher and the router guard calling `enforceParentExpiry()`.
|
||||
|
||||
---
|
||||
|
||||
## Backend Implementation
|
||||
|
||||
No backend changes required.
|
||||
|
||||
---
|
||||
|
||||
## Backend Tests
|
||||
|
||||
- [x] No new backend tests required.
|
||||
|
||||
---
|
||||
|
||||
## Frontend Implementation
|
||||
|
||||
### 1. Refactor `auth.ts` — expiry-aware state
|
||||
|
||||
- Remove the plain `ref<boolean>` `isParentAuthenticated` and the `watch` that wrote `'true'/'false'` to `localStorage['isParentAuthenticated']`.
|
||||
- Add `parentAuthExpiresAt: ref<number | null>` (initialized to `null`).
|
||||
- Add `isParentPersistent: ref<boolean>` (initialized to `false`).
|
||||
- Keep `isParentAuthenticated` as a plain `ref<boolean>` — set explicitly by `authenticateParent()` and `logoutParent()`. A background watcher and router guard enforce expiry by calling `logoutParent()` when `Date.now() >= parentAuthExpiresAt.value`.
|
||||
- Update `authenticateParent(persistent: boolean)`:
|
||||
- Non-persistent: set `parentAuthExpiresAt.value = Date.now() + 60_000`, `isParentPersistent.value = false`. Write nothing to `localStorage`. State is lost on page reload naturally.
|
||||
- Persistent: set `parentAuthExpiresAt.value = Date.now() + 172_800_000` (2 days), `isParentPersistent.value = true`. Write `{ expiresAt }` to `localStorage['parentAuth']`.
|
||||
- Both: set `isParentAuthenticated.value = true`, call `startParentExpiryWatcher()`.
|
||||
- Update `logoutParent()`: clear all three refs (`null`/`false`/`false`), remove `localStorage['parentAuth']`, call `stopParentExpiryWatcher()`.
|
||||
- Update `loginUser()`: call `logoutParent()` internally (already resets parent state on fresh login).
|
||||
- On store initialization: read `localStorage['parentAuth']`; if present and `expiresAt > Date.now()`, restore as persistent auth; otherwise remove the stale key.
|
||||
|
||||
### 2. Add background expiry watcher to `auth.ts`
|
||||
|
||||
- Export `startParentExpiryWatcher()` and `stopParentExpiryWatcher()` that manage a 15-second `setInterval`.
|
||||
- The interval checks `Date.now() >= parentAuthExpiresAt.value`; if true, calls `logoutParent()` and navigates to `/child` via `window.location.href`. This enforces expiry even while a parent is mid-page on a `/parent` route.
|
||||
|
||||
### 3. Update router navigation guard — `router/index.ts`
|
||||
|
||||
- Import `logoutParent` and `enforceParentExpiry` from the auth store.
|
||||
- Before checking parent route access, call `enforceParentExpiry()` which evaluates `Date.now() >= parentAuthExpiresAt.value` directly and calls `logoutParent()` if expired.
|
||||
- If not authenticated after the check: call `logoutParent()` (cleanup) then redirect to `/child`.
|
||||
|
||||
### 4. Update PIN modal in `LoginButton.vue` — checkbox
|
||||
|
||||
- Add `stayInParentMode: ref<boolean>` (default `false`).
|
||||
- Add a checkbox below the PIN input, labelled **"Stay in parent mode on this device"**.
|
||||
- Style checkbox with `:root` CSS variables from `colors.css`.
|
||||
- Update `submit()` to call `authenticateParent(stayInParentMode.value)`.
|
||||
- Reset `stayInParentMode.value = false` when the modal closes.
|
||||
|
||||
### 5. Add lock badge to avatar button — `LoginButton.vue`
|
||||
|
||||
- Import `isParentPersistent` from the auth store.
|
||||
- Wrap the existing avatar button in a `position: relative` container.
|
||||
- When `isParentAuthenticated && isParentPersistent`, render a small `🔒` emoji element absolutely positioned at `bottom: -2px; left: -2px` with a font size of ~10px.
|
||||
- This badge disappears automatically when "Child Mode" is clicked (clears `isParentPersistent`).
|
||||
|
||||
---
|
||||
|
||||
## Frontend Tests
|
||||
|
||||
- [x] `auth.ts` — non-persistent: `authenticateParent(false)` sets expiry to `now + 60s`; `isParentAuthenticated` returns `false` after watcher fires past expiry (via fake timers).
|
||||
- [x] `auth.ts` — persistent: `authenticateParent(true)` sets `parentAuthExpiresAt` to `now + 2 days`; `isParentAuthenticated` returns `false` after watcher fires past 2-day expiry.
|
||||
- [x] `auth.ts` — `logoutParent()` clears refs, stops watcher.
|
||||
- [x] `auth.ts` — `loginUser()` calls `logoutParent()` clearing all parent auth state.
|
||||
- [x] `LoginButton.vue` — checkbox is unchecked by default; checking it and submitting calls `authenticateParent(true)`.
|
||||
- [x] `LoginButton.vue` — submitting without checkbox calls `authenticateParent(false)`.
|
||||
- [x] `LoginButton.vue` — lock badge `🔒` is visible only when `isParentAuthenticated && isParentPersistent`.
|
||||
|
||||
---
|
||||
|
||||
## Future Considerations
|
||||
|
||||
- Could offer a configurable expiry duration (e.g. 1 day, 3 days, 7 days) rather than a fixed 2-day cap.
|
||||
- Could show a "session expiring soon" warning for the persistent mode (e.g. banner appears 1 hour before the 2-day expiry).
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria (Definition of Done)
|
||||
|
||||
### Backend
|
||||
|
||||
- [x] No backend changes required; all work is frontend-only.
|
||||
|
||||
### Frontend
|
||||
|
||||
- [x] PIN modal includes an unchecked "Stay in parent mode on this device" checkbox.
|
||||
- [x] Non-persistent mode: parent auth is memory-only, expires after 1 minute, and is lost on page reload.
|
||||
- [x] Persistent mode: `localStorage['parentAuth']` is written with a 2-day `expiresAt` timestamp; auth survives page reload and new tabs.
|
||||
- [x] Router guard redirects silently to `/child` if parent mode has expired when navigating to any `/parent` route.
|
||||
- [x] Background 15-second interval also enforces expiry while the user is mid-page on a `/parent` route.
|
||||
- [x] "Child Mode" button clears both persistent and non-persistent auth state completely.
|
||||
- [x] A `🔒` emoji badge appears on the lower-left of the parent avatar button only when persistent mode is active.
|
||||
- [x] Opening a new tab while in persistent mode correctly restores parent mode from `localStorage`.
|
||||
- [x] All frontend tests listed above pass.
|
||||
242
.github/specs/archive/feat-schedule-enable-disable.md
vendored
Normal file
242
.github/specs/archive/feat-schedule-enable-disable.md
vendored
Normal file
@@ -0,0 +1,242 @@
|
||||
# Feature: Chore Schedule Enable/Disable Toggle
|
||||
|
||||
## Overview
|
||||
|
||||
**Goal:** Allow parents to pause and resume a chore schedule without deleting it. A paused schedule retains all its configuration (days, intervals, deadlines) but acts as if the chore had no schedule — it shows every day with no deadline or expiry — until re-enabled.
|
||||
|
||||
**User Story:**
|
||||
As a parent, I want to temporarily pause a chore's schedule so my child still sees it every day but it loses its deadline constraints, without losing the schedule configuration I've set up.
|
||||
|
||||
**Rules:**
|
||||
follow .github/copilot-instructions.md
|
||||
|
||||
---
|
||||
|
||||
## UI Design
|
||||
|
||||
### Toggle Placement
|
||||
|
||||
A dedicated row between the `ModalDialog` heading and the mode toggle ("Specific Days" / "Every X Days"):
|
||||
|
||||
```
|
||||
┌────────────────────────────────────┐
|
||||
│ 🛏️ Schedule Chore │ heading (unchanged)
|
||||
│ Make your bed │
|
||||
│ │
|
||||
│ Schedule ━━━━━━━━━━━━━━━● ON │ ← new toggle row
|
||||
│ │
|
||||
│ [Specific Days] [Every X Days] │ mode toggle (unchanged)
|
||||
│ ... │
|
||||
└────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- Adds ~44px of height; does not modify `ModalDialog.vue`.
|
||||
- Sits above all config, reflecting its role as the highest-priority decision ("Is this schedule even active?").
|
||||
|
||||
### Toggle Widget
|
||||
|
||||
Clean slider (no text inside the track) + external label:
|
||||
|
||||
- **ON:** Track = `var(--btn-primary)` (#667eea), label = "Enabled"
|
||||
- **OFF:** Track = `var(--form-input-border)` (#cbd5e1), label = "Paused"
|
||||
- **Thumb:** `#fff` with subtle box-shadow
|
||||
- **Dimensions:** 48×24px track, 20px circular thumb, 44px min-height row (mobile touch target)
|
||||
- Uses `role="switch"` and `aria-checked` for accessibility.
|
||||
|
||||
### Dim-on-Disable
|
||||
|
||||
When the toggle is OFF, the form body (mode toggle + all form fields) receives `opacity: 0.45; pointer-events: none`. The action buttons (Cancel / Save) remain fully interactive so the user can save the "paused" state.
|
||||
|
||||
### Color Rationale
|
||||
|
||||
Green (`--btn-green`) is reserved for "reward" semantics in this app. Brand blue (`--btn-primary`) is the correct choice for a generic on/off state, matching the mode toggle, day chips, and primary CTA button.
|
||||
|
||||
---
|
||||
|
||||
## Data Model Changes
|
||||
|
||||
### Backend Model — `backend/models/chore_schedule.py`
|
||||
|
||||
- [x] Add field `enabled: bool = True` to `ChoreSchedule` dataclass
|
||||
- [x] Add `enabled=d.get('enabled', True)` to `from_dict()`
|
||||
- [x] Add `'enabled': self.enabled` to `to_dict()`
|
||||
|
||||
### Frontend Model — `frontend/vue-app/src/common/models.ts`
|
||||
|
||||
- [x] Add `enabled?: boolean` to `ChoreSchedule` interface
|
||||
|
||||
---
|
||||
|
||||
## Backend Implementation
|
||||
|
||||
### API — `backend/api/chore_schedule_api.py`
|
||||
|
||||
- [x] Accept `enabled` field in the schedule create/update endpoint payload
|
||||
- [x] Persist `enabled` value through to the database
|
||||
|
||||
### Scheduler Logic
|
||||
|
||||
- [x] In `scheduleUtils.ts` (`isScheduledToday`), return `true` when `schedule.enabled === false` — a paused chore shows every day, like an unscheduled chore:
|
||||
```typescript
|
||||
if (schedule.enabled === false) return true; // paused = show always, like an unscheduled chore
|
||||
```
|
||||
- [x] In `scheduleUtils.ts` (`getDueTimeToday`), return `null` when `schedule.enabled === false` — a paused chore has no deadline or expiry:
|
||||
```typescript
|
||||
if (schedule.enabled === false) return null; // paused = no deadline, no expiry
|
||||
```
|
||||
> Note: Scheduling is evaluated client-side. The original spec referenced Python; this was updated to reflect the actual architecture.
|
||||
> Note: Behavior was revised from "hide chore" to "show always, no deadline" — paused acts like the chore has no schedule at all.
|
||||
|
||||
### SSE Events
|
||||
|
||||
- [x] Existing `chore_schedule_updated` event is sufficient (the payload includes the full schedule object, which will now contain `enabled`)
|
||||
|
||||
---
|
||||
|
||||
## Backend Tests
|
||||
|
||||
### Unit Tests — `backend/tests/test_chore_schedule_api.py`
|
||||
|
||||
- [x] **test_create_schedule_enabled_by_default**: Create a schedule without specifying `enabled`; verify the persisted schedule has `enabled=True`
|
||||
- [x] **test_create_schedule_with_enabled_false**: Create a schedule with `enabled=False`; verify it persists correctly
|
||||
- [x] **test_update_schedule_toggle_enabled**: Create a schedule (enabled), update it with `enabled=False`, verify the change persists; toggle back to `True`, verify again
|
||||
- [x] **test_enabled_field_in_get_response**: Fetch a schedule via GET; verify the `enabled` field is present in the JSON response
|
||||
- [x] **test_enabled_invalid_value_returns_400**: Pass a non-boolean `enabled` value; verify 400 response
|
||||
|
||||
### Model Tests
|
||||
|
||||
- [x] **test_chore_schedule_from_dict_defaults_enabled**: Call `ChoreSchedule.from_dict({...})` without `enabled`; assert `enabled is True`
|
||||
- [x] **test_chore_schedule_from_dict_enabled_false**: Call `ChoreSchedule.from_dict({..., 'enabled': False})`; assert `enabled is False`
|
||||
- [x] **test_chore_schedule_to_dict_includes_enabled**: Create a `ChoreSchedule` instance; call `to_dict()`; assert `'enabled'` key is present with correct value
|
||||
|
||||
---
|
||||
|
||||
## Frontend Implementation
|
||||
|
||||
### Component — `frontend/vue-app/src/components/shared/ScheduleModal.vue`
|
||||
|
||||
#### Template
|
||||
|
||||
- [x] Add toggle row between `ModalDialog` open and mode toggle
|
||||
- [x] Wrap mode toggle + form content in `<div class="schedule-body" :class="{ disabled: !scheduleEnabled }">`
|
||||
- [x] Keep action buttons (Cancel / Save) outside the dim wrapper
|
||||
|
||||
#### Script
|
||||
|
||||
- [x] Add `const scheduleEnabled = ref<boolean>(props.schedule?.enabled ?? true)`
|
||||
- [x] Add `const origEnabled = props.schedule?.enabled ?? true` for dirty tracking
|
||||
- [x] Add `if (scheduleEnabled.value !== origEnabled) return true` to `isDirty` computed
|
||||
- [x] Include `enabled: scheduleEnabled.value` in both `setChoreSchedule` payloads (days + interval modes)
|
||||
|
||||
#### CSS
|
||||
|
||||
- [x] Add toggle row styles (`.schedule-toggle-row`, `.toggle-label`)
|
||||
- [x] Add toggle track/thumb styles (`.toggle-track`, `.toggle-track.on`, `.toggle-thumb`)
|
||||
- [x] Add dim wrapper styles (`.schedule-body`, `.schedule-body.disabled`)
|
||||
|
||||
### Design Tokens — `frontend/vue-app/src/assets/colors.css` (optional)
|
||||
|
||||
- [ ] Optionally add reusable toggle tokens (not needed — existing tokens used directly)
|
||||
|
||||
---
|
||||
|
||||
## Frontend Tests
|
||||
|
||||
### scheduleUtils Tests — `frontend/vue-app/src/__tests__/scheduleUtils.spec.ts`
|
||||
|
||||
- [x] **returns true when enabled is false on a scheduled day (days mode)**: A paused schedule always returns `true` regardless of day
|
||||
- [x] **returns true when enabled is false on an unscheduled day (days mode)**: A paused schedule returns `true` even on days that would normally be skipped
|
||||
- [x] **returns true when enabled is false on a hit day (interval mode)**: A paused interval schedule always returns `true`
|
||||
- [x] **returns true when enabled is false on a non-hit day (interval mode)**: A paused interval schedule returns `true` even on non-hit days
|
||||
- [x] **getDueTimeToday returns null when paused**: A paused schedule's `getDueTimeToday` returns `null` — no deadline, no expiry
|
||||
- [x] **treats enabled=undefined as enabled (backward compat)**: Schedules without `enabled` field behave as enabled
|
||||
|
||||
---
|
||||
|
||||
## CSS Spec
|
||||
|
||||
```css
|
||||
/* ── Enable/Disable Toggle ────────────────────── */
|
||||
|
||||
.schedule-toggle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.6rem;
|
||||
margin-bottom: 1rem;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.toggle-label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--form-label, #444);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.toggle-track {
|
||||
position: relative;
|
||||
width: 48px;
|
||||
height: 24px;
|
||||
border-radius: 999px;
|
||||
border: none;
|
||||
background: var(--form-input-border, #cbd5e1);
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toggle-track.on {
|
||||
background: var(--btn-primary, #667eea);
|
||||
}
|
||||
|
||||
.toggle-thumb {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.18);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.toggle-track.on .toggle-thumb {
|
||||
transform: translateX(24px);
|
||||
}
|
||||
|
||||
/* ── Dim wrapper when paused ──────────────────── */
|
||||
|
||||
.schedule-body {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.schedule-body.disabled {
|
||||
opacity: 0.45;
|
||||
pointer-events: none;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria (Definition of Done)
|
||||
|
||||
### Backend
|
||||
|
||||
- [x] `ChoreSchedule` model has `enabled: bool` field, defaulting to `True`
|
||||
- [x] API accepts and persists `enabled` in create/update
|
||||
- [x] Client-side scheduler: `isScheduledToday` returns `true` for paused — chore shows every day
|
||||
- [x] Client-side scheduler: `getDueTimeToday` returns `null` for paused — no deadline or expiry
|
||||
- [x] All backend unit tests pass (28/28)
|
||||
|
||||
### Frontend
|
||||
|
||||
- [x] Toggle row renders in `ScheduleModal` between heading and mode toggle
|
||||
- [x] Toggle uses `--btn-primary` for ON and `--form-input-border` for OFF
|
||||
- [x] Form body dims with `opacity: 0.45` and `pointer-events: none` when paused
|
||||
- [x] `enabled` field is included in save payloads for both modes
|
||||
- [x] Dirty detection tracks `enabled` changes
|
||||
- [x] Toggle meets 44px minimum touch target
|
||||
- [x] All frontend scheduleUtils tests pass (38/38)
|
||||
103
.github/specs/archive/feat-state-expire.md
vendored
Normal file
103
.github/specs/archive/feat-state-expire.md
vendored
Normal file
@@ -0,0 +1,103 @@
|
||||
# Feature: Chore and Reward states expire at the end of the day.
|
||||
|
||||
## Overview
|
||||
|
||||
Chores and rewards can be pending, too late, etc. At the start of a new day, the state of chores and rewards should be reset. Notifications in the notification nav selector should be cleared out as well.
|
||||
|
||||
**Goal:** Chores and reward states get reset at the start of a new day.
|
||||
|
||||
**User Story:** As a parent, if I forget to approve/reject a pending chore or reward by the next day, then those items will have to be selected again by the child to go back to pending.
|
||||
As a parent, if a scheduled chore is too late, the next day that chore will reset, but still follow it's schedule.
|
||||
|
||||
**Rules:**
|
||||
Create frontend and backend unit tests
|
||||
|
||||
---
|
||||
|
||||
## Data Model Changes
|
||||
|
||||
### Backend Model
|
||||
|
||||
No model changes were required. `PendingConfirmation` already inherits `created_at: float` (Unix timestamp) from `BaseModel`, which is sufficient to determine whether a record is from a prior calendar day.
|
||||
|
||||
### Frontend Model
|
||||
|
||||
No model changes required. The `ChildChoreConfirmationPayload` and `ChildRewardRequestEventPayload` types are unchanged; the existing `RESET` and `CANCELLED` operations are reused.
|
||||
|
||||
---
|
||||
|
||||
## Backend Implementation
|
||||
|
||||
A new hourly scheduler (`backend/utils/state_expiry_scheduler.py`) runs idempotently to expire stale pending records:
|
||||
|
||||
- `_get_today_start_timestamp(tz_str)` computes the Unix timestamp for midnight today in the user's IANA timezone, falling back to UTC.
|
||||
- `expire_stale_pending_for_user(user_id, tz_str)` queries `pending_confirmations_db` for records where `status='pending'` and `created_at < today_start` for that user. Each matching record is deleted from the database, then an SSE event is fired: `CHILD_CHORE_CONFIRMATION(RESET)` for chores, `CHILD_REWARD_REQUEST(CANCELLED)` for rewards. Uses `send_event_to_user` directly since there is no HTTP request context.
|
||||
- `run_state_expiry_check(app)` iterates all verified users and calls `expire_stale_pending_for_user` for each. Skips entirely when `DB_ENV=e2e`.
|
||||
- `start_state_expiry_scheduler(app)` registers the hourly cron job via APScheduler and also runs an immediate check on startup so any records that expired while the server was down are cleared promptly.
|
||||
|
||||
The scheduler is registered in `backend/main.py` alongside the existing digest and deletion schedulers.
|
||||
|
||||
## Backend Tests
|
||||
|
||||
Tests live in `backend/tests/test_state_expiry_scheduler.py`. Coverage:
|
||||
|
||||
- `_get_today_start_timestamp` returns a float before now, falls back to UTC on `None` or invalid timezone, and shifts midnight correctly across timezones.
|
||||
- Pending chore from yesterday is deleted and fires a `CHILD_CHORE_CONFIRMATION(RESET)` SSE event with the correct `task_id`.
|
||||
- Pending reward from yesterday is deleted and fires a `CHILD_REWARD_REQUEST(CANCELLED)` SSE event with the correct `reward_id`.
|
||||
- Pending record created today is not deleted.
|
||||
- Record with `status='approved'` from yesterday is not deleted.
|
||||
- User with no pending records produces no errors and fires no SSE events.
|
||||
- Multiple stale records for one user are all expired in a single call.
|
||||
- `run_state_expiry_check` expires records for all verified users across multiple users.
|
||||
- Only stale records are expired; fresh records from today are preserved.
|
||||
- `DB_ENV=e2e` causes the scheduler to skip all processing.
|
||||
|
||||
---
|
||||
|
||||
## Frontend Implementation
|
||||
|
||||
Two files were updated to handle the `RESET` operation fired when chore pending states expire:
|
||||
|
||||
- `NotificationView.vue`: Added `'RESET'` to the `child_chore_confirmation` operation allow-list so the notification list refreshes when a pending chore confirmation is expired by the scheduler.
|
||||
- `ParentLayout.vue`: Added `'RESET'` to the `child_chore_confirmation` handler allow-list so the notification badge count is re-fetched when a pending chore confirmation is expired.
|
||||
|
||||
The child view (`ChildView.vue`) required no changes — it already refreshes on any `child_chore_confirmation` operation regardless of the operation value.
|
||||
|
||||
## Frontend Tests
|
||||
|
||||
Tests live in `src/components/__tests__/NotificationView.spec.ts` and `src/components/__tests__/ParentLayout.spec.ts`.
|
||||
|
||||
- `NotificationView.vue` increments `refreshKey` on each of `CONFIRMED`, `APPROVED`, `REJECTED`, `CANCELLED`, and `RESET` operations for `child_chore_confirmation`.
|
||||
- `NotificationView.vue` does not increment `refreshKey` on unknown operations.
|
||||
- `NotificationView.vue` increments `refreshKey` on `CREATED`, `CANCELLED`, and `GRANTED` operations for `child_reward_request`.
|
||||
- `ParentLayout.vue` re-fetches the notification count on each of `CONFIRMED`, `APPROVED`, `REJECTED`, `CANCELLED`, and `RESET` operations for `child_chore_confirmation`.
|
||||
- `ParentLayout.vue` does not re-fetch on unknown operations.
|
||||
- `ParentLayout.vue` re-fetches the notification count on `CREATED`, `CANCELLED`, and `GRANTED` operations for `child_reward_request`.
|
||||
|
||||
---
|
||||
|
||||
## Future Considerations
|
||||
|
||||
- Push notifications (Web Push) currently reference pending items; expired records will not trigger a push notification removal. A future improvement could send a silent push to dismiss stale items from the notification tray.
|
||||
- If digest emails are scheduled near midnight, the expiry scheduler may clear items before the digest runs. The digest scheduler already skips empty pending lists gracefully, so no action is needed, but the ordering could be made explicit.
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria (Definition of Done)
|
||||
|
||||
### Backend
|
||||
|
||||
- [x] Hourly scheduler deletes `PendingConfirmation` records with `status='pending'` created on a prior calendar day (per user timezone)
|
||||
- [x] `CHILD_CHORE_CONFIRMATION(RESET)` SSE event is fired for each expired chore record
|
||||
- [x] `CHILD_REWARD_REQUEST(CANCELLED)` SSE event is fired for each expired reward record
|
||||
- [x] Scheduler skips processing when `DB_ENV=e2e`
|
||||
- [x] Scheduler runs on startup to handle records that expired during downtime
|
||||
- [x] `status='approved'` records are not deleted
|
||||
- [x] Records created today are not deleted
|
||||
- [x] Backend unit tests pass (17 tests)
|
||||
|
||||
### Frontend
|
||||
|
||||
- [x] Notification badge in `ParentLayout.vue` re-fetches count on `RESET` operation
|
||||
- [x] Notification list in `NotificationView.vue` refreshes on `RESET` operation
|
||||
- [x] Frontend unit tests pass (18 tests)
|
||||
67
.github/specs/feat-child-actions.md
vendored
Normal file
67
.github/specs/feat-child-actions.md
vendored
Normal file
@@ -0,0 +1,67 @@
|
||||
# Feature: A new menu should be displayed when a child's card is clicked in ParentView. This menu will feature actions that a parent can perform on a child.
|
||||
|
||||
## Overview
|
||||
|
||||
**Goal:** When in parent mode, clicking on the child's card will bring up a menu that will have several items to issue actions on the child.
|
||||
|
||||
**User Story:**
|
||||
As a parent, when I click on the child's card in parent mode, I want to be presented with a menu that displays the following:
|
||||
|
||||
- Award Certificate
|
||||
- Award Badge
|
||||
|
||||
**Rules:**
|
||||
follow .github/copilot-instructions.md
|
||||
|
||||
---
|
||||
|
||||
## Data Model Changes
|
||||
|
||||
### Backend Model
|
||||
|
||||
### Frontend Model
|
||||
|
||||
---
|
||||
|
||||
## Backend Implementation
|
||||
|
||||
## Backend Tests
|
||||
|
||||
- [ ]
|
||||
|
||||
---
|
||||
|
||||
## Frontend Design
|
||||
|
||||
When the child card is pressed, the router should move to a page that presents the menu items. Or should it be a dropdown menu. it seems like having a seperate page with the options provides better UX.
|
||||
|
||||
When the menu item is clicked, we should stub out the action for now, Awarding achievements and badges will be done in a future spec.
|
||||
|
||||
## Frontend Implementation
|
||||
|
||||
## Frontend Tests
|
||||
|
||||
- [ ]
|
||||
|
||||
---
|
||||
|
||||
## Future Considerations
|
||||
|
||||
Eventually, there will be a new view that shows to create a certificate for the child. In this view, a parent can write information about the certificate. The certificate will show the next time a child clicks on themselves in child mode.
|
||||
Eventually, parents will be able to reward badges similar to certificates the the child.
|
||||
|
||||
## Questions
|
||||
|
||||
- Should badges be removable? For example, if there is a badge that represents most chores done in a week, that badge may move from one child to another. If removable, should that option also be presented under the new menu?
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria (Definition of Done)
|
||||
|
||||
### Backend
|
||||
|
||||
- [ ]
|
||||
|
||||
### Frontend
|
||||
|
||||
- [ ]
|
||||
148
.github/specs/feat-notify-ending-chore.md
vendored
Normal file
148
.github/specs/feat-notify-ending-chore.md
vendored
Normal file
@@ -0,0 +1,148 @@
|
||||
# Feature: Push notification for chores that are going to expire in the next hour.
|
||||
|
||||
## Overview
|
||||
|
||||
When a scheduled chore is going to expire within the next hour and it has not be marked completed or pending, a push notification should go out to tell the user that the scheduled chore needs to be finished soon.
|
||||
|
||||
**Goal:** When a chore is about to expire (within the hour) send a push notification to the user and allow the user to acknowlege it.
|
||||
|
||||
**User Story:**
|
||||
As a parent, I should receive a notification when a scheduled chore is going to expire soon. When I click on the notification, I should be brought to the page with the chore.
|
||||
|
||||
**Rules:**
|
||||
|
||||
1. If there are multiple chores that are going to expire, present them in the push notification as well.
|
||||
2. The notification should show which child the chore belongs to.
|
||||
3. If a chore is scheduled to expire between 9:00pm to 9:15pm, the notification for it should go out at 8:00pm. That should give the earlier hour expiration plenty of notice.
|
||||
|
||||
**Questions:**
|
||||
|
||||
1. If there are multiple chores in the push, should we say there are multiple chores ending next hour or should we list each by name?
|
||||
2. Should we section the notification if there are multiple children? [ex. Child1 chores: ... Child2 chores: ...]
|
||||
3. Should we reuse one of the other hourly schedulers or create a new one? should we refactor and have one hourly scheduler that does multiple operations?
|
||||
|
||||
---
|
||||
|
||||
## Data Model Changes
|
||||
|
||||
### Backend Model
|
||||
|
||||
No new models required. The feature reuses existing models:
|
||||
|
||||
- `ChoreSchedule` — provides schedule mode, day configs, deadline times, and enabled state
|
||||
- `PendingConfirmation` — queried to determine if a chore is already pending or approved (either status means the child has acted on it; no notification is needed)
|
||||
|
||||
### Frontend Model
|
||||
|
||||
No changes to TypeScript interfaces.
|
||||
|
||||
---
|
||||
|
||||
## Backend Implementation
|
||||
|
||||
**`backend/utils/schedule_utils.py`** (new)
|
||||
|
||||
A Python port of the three core functions from `frontend/vue-app/src/common/scheduleUtils.ts`. The frontend is the authoritative source for schedule math; this file mirrors that logic server-side so the scheduler can determine which chores are active and when they expire without making HTTP calls or duplicating logic ad-hoc.
|
||||
|
||||
- `interval_hits_today(anchor_date, interval_days, local_date)` — returns True if an interval-mode schedule fires on the given local date
|
||||
- `is_scheduled_today(schedule_dict, local_date)` — returns True for days-mode (weekday match) or interval-mode (anchor + interval math); paused schedules always return True
|
||||
- `get_due_time_today(schedule_dict, local_date)` — returns `(hour, minute)` or `None` (None means Anytime or paused — no expiry)
|
||||
|
||||
Note on weekday convention: the app uses JS/Python convention where Sunday = 0 and Saturday = 6 (same as `DayConfig.day`). Python's `date.weekday()` returns Monday = 0, so a conversion of `(weekday + 1) % 7` is applied when comparing.
|
||||
|
||||
**`backend/utils/chore_expiry_notification_scheduler.py`** (new)
|
||||
|
||||
- `get_expiring_chores_for_user(user_id, tz_str, now_dt)` — loads all children for the user, iterates their chore tasks, checks for an active schedule with a deadline falling in `(now, now + 75 min]`, and excludes any chore that already has a `PendingConfirmation` record (any status). Returns a list of dicts with child and task metadata.
|
||||
- `_build_push_payload(expiring)` — constructs the push payload. Single chore: title names the chore and deep-link fields are set for direct navigation. Multiple chores: generic title, `child_id` and `entity_id` are null, and the body lists chores grouped by child name (e.g. `Alex: Clean Room, Make Bed\nSam: Trash`).
|
||||
- `send_chore_expiry_notifications_for_user(user_id, tz_str)` — calls `get_expiring_chores_for_user`, builds the payload, and calls `send_push_to_user`.
|
||||
- `run_chore_expiry_check(app)` — skips the e2e environment, loops all verified users, skips users with `push_notifications_enabled=False`, and calls `send_chore_expiry_notifications_for_user` for each eligible user.
|
||||
- `start_chore_expiry_notification_scheduler(app)` — registers an APScheduler `cron` job at `minute=0` (top of every hour), consistent with the existing schedulers.
|
||||
|
||||
**Push payload type:** `chore_expiring_soon`. The absence of `approve_token` and `deny_token` fields is intentional and is what triggers the service worker to suppress action buttons.
|
||||
|
||||
**`backend/main.py`** (modified)
|
||||
|
||||
Import added and `start_chore_expiry_notification_scheduler(app)` called after the existing schedulers.
|
||||
|
||||
## Backend Tests
|
||||
|
||||
- [x] `get_expiring_chores_for_user`: chore in 75-min window → included
|
||||
- [x] `get_expiring_chores_for_user`: deadline in the past → excluded
|
||||
- [x] `get_expiring_chores_for_user`: deadline beyond 75-min window → excluded
|
||||
- [x] `get_expiring_chores_for_user`: Anytime schedule (no deadline) → excluded
|
||||
- [x] `get_expiring_chores_for_user`: chore not scheduled today (wrong weekday) → excluded
|
||||
- [x] `get_expiring_chores_for_user`: status=pending confirmation exists → excluded
|
||||
- [x] `get_expiring_chores_for_user`: status=approved confirmation exists → excluded
|
||||
- [x] `get_expiring_chores_for_user`: no schedule → excluded
|
||||
- [x] `get_expiring_chores_for_user`: paused schedule (enabled=False) → excluded
|
||||
- [x] `get_expiring_chores_for_user`: interval mode hits today → included
|
||||
- [x] `get_expiring_chores_for_user`: interval mode misses today → excluded
|
||||
- [x] `get_expiring_chores_for_user`: multiple children returns entries for both
|
||||
- [x] `_build_push_payload`: single chore → named title, deep-link fields set
|
||||
- [x] `_build_push_payload`: multiple chores → generic title, child_id/entity_id null
|
||||
- [x] `_build_push_payload`: body groups chore names by child
|
||||
- [x] `_build_push_payload`: no approve_token or reject_token in payload
|
||||
- [x] `send_chore_expiry_notifications_for_user`: calls send_push_to_user with correct payload
|
||||
- [x] `send_chore_expiry_notifications_for_user`: no push when no chores expiring
|
||||
- [x] `run_chore_expiry_check`: skips when DB_ENV=e2e
|
||||
- [x] `run_chore_expiry_check`: skips user with push_notifications_enabled=False
|
||||
- [x] `run_chore_expiry_check`: skips unverified user
|
||||
- [x] `run_chore_expiry_check`: sends push for eligible verified user with push enabled
|
||||
- [x] `schedule_utils`: interval_hits_today — same day as anchor hits
|
||||
- [x] `schedule_utils`: interval_hits_today — correct interval day hits
|
||||
- [x] `schedule_utils`: interval_hits_today — non-interval day misses
|
||||
- [x] `schedule_utils`: interval_hits_today — date before anchor misses
|
||||
- [x] `schedule_utils`: interval_hits_today — empty anchor hits today
|
||||
- [x] `schedule_utils`: is_scheduled_today — days mode matching weekday
|
||||
- [x] `schedule_utils`: is_scheduled_today — days mode non-matching weekday
|
||||
- [x] `schedule_utils`: is_scheduled_today — paused always true
|
||||
- [x] `schedule_utils`: is_scheduled_today — interval mode hit
|
||||
- [x] `schedule_utils`: is_scheduled_today — interval mode miss
|
||||
- [x] `schedule_utils`: get_due_time_today — days mode returns (hour, minute)
|
||||
- [x] `schedule_utils`: get_due_time_today — Anytime returns None
|
||||
- [x] `schedule_utils`: get_due_time_today — wrong day returns None
|
||||
- [x] `schedule_utils`: get_due_time_today — paused returns None
|
||||
- [x] `schedule_utils`: get_due_time_today — interval mode returns (hour, minute)
|
||||
- [x] `schedule_utils`: get_due_time_today — interval Anytime returns None
|
||||
|
||||
---
|
||||
|
||||
## Frontend Implementation
|
||||
|
||||
**`frontend/vue-app/public/sw.js`** (modified)
|
||||
|
||||
In the `push` event handler, the `actions` array passed to `showNotification` is now conditionally set: when `payload.type === 'chore_expiring_soon'` the array is empty (no buttons); otherwise Approve and Reject buttons are shown as before. The `notificationclick` handler requires no changes — tapping the notification body already navigates to `/parent/{child_id}?scrollTo={entity_id}` when those fields are present, and falls back to `/parent` when they are null (multi-chore case).
|
||||
|
||||
## Frontend Tests
|
||||
|
||||
No automated tests added. The `sw.js` change is a simple conditional and is covered by manual verification: triggering a `chore_expiring_soon` push and confirming no action buttons appear in the browser notification, and that tapping opens the correct URL.
|
||||
|
||||
---
|
||||
|
||||
## Future Considerations
|
||||
|
||||
- **75-minute window double-notification**: A chore deadline that falls in the overlap of two consecutive 75-minute windows (e.g. 9:10 pm appears in both the 8:00 pm run's window and the 9:00 pm run's window) will produce two notifications. This is a known and accepted trade-off; no deduplication is applied. A future improvement could track notified chores per day in a lightweight DB table to prevent repeats.
|
||||
- **Task extensions**: `TaskExtension` sets an `extension_date` that allows a chore to carry over to another day. Extended chores are treated identically to regular scheduled chores — the schedule deadline time is used as-is. A future enhancement could optionally adjust the deadline time for extended chores.
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria (Definition of Done)
|
||||
|
||||
### Backend
|
||||
|
||||
- [x] `backend/utils/schedule_utils.py` created — Python port of schedule math from `scheduleUtils.ts`
|
||||
- [x] `backend/utils/chore_expiry_notification_scheduler.py` created — hourly scheduler with 75-min look-ahead window
|
||||
- [x] Chores with `Anytime` schedule (no deadline) are not notified
|
||||
- [x] Chores with a `pending` or `approved` confirmation are not notified
|
||||
- [x] Chores on paused schedules are not notified
|
||||
- [x] Users with `push_notifications_enabled=False` are skipped
|
||||
- [x] Unverified users are skipped
|
||||
- [x] e2e environment is skipped
|
||||
- [x] Scheduler registered in `backend/main.py`
|
||||
- [x] All backend tests pass
|
||||
|
||||
### Frontend
|
||||
|
||||
- [x] `sw.js` updated — `chore_expiring_soon` notifications show no Approve/Reject action buttons
|
||||
- [x] Tapping a single-chore notification navigates to the child's chore page
|
||||
- [x] Tapping a multi-chore notification navigates to `/parent`
|
||||
70
.github/specs/plan-achievements.md
vendored
Normal file
70
.github/specs/plan-achievements.md
vendored
Normal file
@@ -0,0 +1,70 @@
|
||||
## Plan: Achievements Feature Design
|
||||
|
||||
Published to Gitea wiki page: Achievements
|
||||
|
||||
Design an achievements system that starts with low-risk, high-fun rules using existing tracking data, then expands to streak and behavior achievements. This approach gives fast player-visible value without major schema risk, while leaving room for deeper gamification.
|
||||
Status: Approved by user for implementation kickoff.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. Phase 1: Define achievement taxonomy and product goals
|
||||
2. Define families: points progression, consistency/streaks, reward habits, task diversity, check-in/engagement, social/funny outcomes.
|
||||
3. Define unlock semantics: one-time unlocks vs repeatable milestones, per-child scope, optional parent aggregate view. Depends on step 2.
|
||||
4. Define badge rarity tiers (common, rare, epic, legendary) and naming/tone guide so achievements feel playful, not punitive.
|
||||
5. Phase 2: MVP achievement set (no new raw telemetry)
|
||||
6. Use existing TrackingEvent and PendingConfirmation signals to launch 20-30 achievements immediately. Depends on phase 1.
|
||||
7. Include serious progression set: first points earned, 100/500/1000 lifetime points, best day records, no-miss week, kindness count milestones.
|
||||
8. Include funny set: zero points 7 days, last-minute legend (frequent late confirmations), window shopper (many reward requests without redemption), tiny spender (chooses low-cost rewards repeatedly).
|
||||
9. Include non-point set: reward loyalty (same reward redeemed N times), variety collector (N distinct chores completed), daily checker (opened/triggered chores on N consecutive days), comeback kid (returns after inactivity).
|
||||
10. Phase 3: Data model and evaluation architecture
|
||||
11. Add achievement definition catalog (static or DB-backed) and child achievement progress/unlock records. Depends on phase 2.
|
||||
12. Evaluate on mutation events (tracking event creation, chore confirmation, reward request/redeem), with idempotent unlock logic and timezone-aware daily boundaries.
|
||||
13. Emit achievement unlocked SSE events for real-time UI celebration and maintain API for achievement list/progress.
|
||||
14. Phase 4: UX and rollout
|
||||
15. Parent view: child achievement timeline, rarity filters, and progress-to-next badge.
|
||||
16. Child view: badge cabinet, streak flame, and lightweight celebration effects.
|
||||
17. Roll out behind feature flag, start with read-only earned badges, then add near-miss/progress indicators. Depends on phase 3.
|
||||
18. Phase 5: Validation and balancing
|
||||
19. Add unit tests for each rule family and timezone edge cases.
|
||||
20. Add end-to-end tests for unlock notifications and badge rendering.
|
||||
21. Run a balancing pass using anonymized historical events to ensure unlock pacing feels rewarding (not too sparse, not too noisy).
|
||||
22. Documentation deliverable: publish the final approved achievements plan to a new Gitea wiki page for the chore project as part of implementation handoff.
|
||||
|
||||
**Relevant files**
|
||||
|
||||
- /Users/ryan/Projects/chore/backend/models/tracking_event.py - existing event ledger for points/actions and occurred_at timestamps.
|
||||
- /Users/ryan/Projects/chore/backend/models/pending_confirmation.py - reward/chore confirmation states useful for request/approval achievements.
|
||||
- /Users/ryan/Projects/chore/backend/db/tracking.py - insertion/query leverage point for achievement evaluation trigger.
|
||||
- /Users/ryan/Projects/chore/backend/events/types/event_types.py - event taxonomy to extend with achievement unlock notifications.
|
||||
- /Users/ryan/Projects/chore/backend/api/child_api.py - existing child-facing data APIs to mirror for achievement endpoints.
|
||||
- /Users/ryan/Projects/chore/frontend/vue-app/src/common/models.ts - add Achievement interfaces (backend parity).
|
||||
- /Users/ryan/Projects/chore/frontend/vue-app/src/common/backendEvents.ts - frontend SSE subscriptions for unlock celebrations.
|
||||
- /Users/ryan/Projects/chore/frontend/vue-app/src/common/api.ts - API helpers for listing achievements and progress.
|
||||
|
||||
**Verification**
|
||||
|
||||
1. Rule correctness: seed deterministic tracking histories and verify each achievement unlock threshold exactly once.
|
||||
2. Timezone correctness: validate day/week achievements for users in at least three timezones and around midnight boundaries.
|
||||
3. Idempotency: replay the same event stream and verify no duplicate unlocks.
|
||||
4. UX behavior: confirm unlocked badges appear in parent and child views without manual refresh via SSE.
|
||||
5. Regression safety: run backend pytest suite and focused Playwright specs for parent/child dashboards.
|
||||
|
||||
**Decisions**
|
||||
|
||||
- Included in MVP: achievements derivable from existing signals (tracking events, pending confirmations, child points, reward history).
|
||||
- Data schema decision for wave 1: no new fields required on task/chore/reward models (no complexity/category requirement for initial launch).
|
||||
- Deliberately excluded from MVP: complex streak rules requiring schedule reinterpretation across every task mode; these are phase 2+ enhancements.
|
||||
- Scope model: per-child achievements first, optional parent-level achievements later.
|
||||
- Tone model: include playful/funny achievements but avoid shaming language; treat low-performance badges as light easter eggs.
|
||||
|
||||
**Further Considerations**
|
||||
|
||||
1. Modularity architecture recommendation: isolate all achievement logic into an Achievement Engine module with a rule interface (evaluate(event, context) -> unlocks) and a central registry so no existing chore/reward/business logic needs branching changes.
|
||||
2. Toggle recommendation: add a global feature flag plus optional per-user flag; when disabled, route through a no-op evaluator and suppress achievement APIs/SSE/UI sections without impacting core chore/reward flows.
|
||||
3. Editability recommendation: store initial achievement definitions in a versioned JSON or YAML catalog with schema validation at startup, stable ids, enabled flag, and threshold params so add/remove/edit is config-only for most rules.
|
||||
4. Progress UX recommendation: show top 3 near-unlock achievements to increase motivation without overwhelming the child dashboard.
|
||||
5. Seasonal/event achievements recommendation: add limited-time badges after baseline system proves stable.
|
||||
6. UI isolation recommendation: keep achievement state in a dedicated frontend store/module and render via isolated components; chore/reward screens should only publish domain events and never compute achievement rules directly.
|
||||
7. UI kill-switch behavior: when feature flag is off, hide achievement routes/cards/listeners behind one composition helper so no scattered conditional logic across pages.
|
||||
8. Backfill and migration recommendation: run one-time historical recompute job to award existing users fairly; mark job version so reruns are idempotent.
|
||||
9. Observability recommendation: emit achievement engine metrics (rules evaluated, unlock count, rule errors, evaluation latency) and add dead-letter logging for malformed catalog entries.
|
||||
231
.github/specs/plan-routinesFeature.prompt.md
vendored
Normal file
231
.github/specs/plan-routinesFeature.prompt.md
vendored
Normal file
@@ -0,0 +1,231 @@
|
||||
# Plan: Routines Feature
|
||||
|
||||
## Summary
|
||||
|
||||
A "Routines" system lets parents define a named checklist of simple items (e.g., "Morning Routine": Make Bed, Get Dressed, Eat Breakfast) worth X points. Children see routines in a scrollable list between Chores and Rewards in child mode. Tapping a routine opens a detail view showing the item list; a "Done" button submits it for parent approval. Points are awarded on parent approval. Supports scheduling (days/interval), deadlines, time extension, and per-child point overrides — all parallel to the existing chore system.
|
||||
|
||||
---
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
- **Routine items** are their own minimal entity (name + optional image, no points). Points belong to the routine.
|
||||
- **No per-item completion tracking** — detail screen is informational only. "Done" button submits the whole routine.
|
||||
- **Assignment**: per-child, same pattern as tasks (child.routines: list[str]).
|
||||
- **Parent approval**: approve/reject the whole routine (no item-level detail).
|
||||
- **Scheduling**: new RoutineSchedule model parallel to ChoreSchedule (same structure, routine_id instead of task_id).
|
||||
- **Point overrides**: reuse ChildOverride with entity_type='routine'.
|
||||
- **Pending confirmations**: extend PendingConfirmation entity_type to include 'routine'.
|
||||
- **Routine editor**: single-page with details section (name, points, image) + items sub-panel below.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Backend Models & DB
|
||||
|
||||
1. Create `backend/models/routine.py` — fields: name, points, image_id, user_id (inherits BaseModel). Mirror Task model.
|
||||
2. Create `backend/models/routine_item.py` — fields: routine_id, name, image_id, order (int). No points.
|
||||
3. Create `backend/models/routine_schedule.py` — copy of ChoreSchedule replacing task_id→routine_id. Same DayConfig, modes, deadline fields.
|
||||
4. Create `backend/models/routine_extension.py` — fields: child_id, routine_id, date (ISO). Mirror TaskExtension.
|
||||
5. Extend `backend/models/pending_confirmation.py` — add 'routine' to entity_type Literal.
|
||||
6. Extend `backend/models/child.py` — add `routines: list[str]` field (default=[]).
|
||||
7. Create DB layers (mirror existing patterns):
|
||||
- `backend/db/routines.py` — get/add/update/delete/list by user
|
||||
- `backend/db/routine_items.py` — get_items_for_routine/add/update/delete/delete_for_routine
|
||||
- `backend/db/routine_schedules.py` — get/upsert/delete/delete_for_child/delete_for_routine
|
||||
- `backend/db/routine_extensions.py` — get/add/delete_for_child_routine
|
||||
8. Extend `backend/db/child_overrides.py` entity_type to allow 'routine'.
|
||||
|
||||
## Phase 2: Backend SSE Events
|
||||
|
||||
9. Create event type files (mirror existing pattern):
|
||||
- `backend/events/types/routine_modified.py` — payload: routine_id, operation (ADD|EDIT|DELETE)
|
||||
- `backend/events/types/child_routines_set.py` — payload: child_id, routine_ids
|
||||
- `backend/events/types/routine_schedule_modified.py` — payload: child_id, routine_id, operation (SET|DELETED)
|
||||
- `backend/events/types/routine_time_extended.py` — payload: child_id, routine_id
|
||||
- `backend/events/types/child_routine_confirmation.py` — payload: child_id, routine_id, operation (PENDING|APPROVED|REJECTED|RESET)
|
||||
10. Update `backend/events/types/event_types.py` — add: ROUTINE_MODIFIED, CHILD_ROUTINES_SET, ROUTINE_SCHEDULE_MODIFIED, ROUTINE_TIME_EXTENDED, CHILD_ROUTINE_CONFIRMATION
|
||||
|
||||
## Phase 3: Backend API
|
||||
|
||||
11. Create `backend/api/routine_api.py`:
|
||||
- `PUT /routine/add` — create routine
|
||||
- `GET /routine/<id>` — fetch single
|
||||
- `GET /routine/list` — list by user
|
||||
- `PUT /routine/<id>/edit` — update
|
||||
- `DELETE /routine/<id>` — delete (cascade: remove from all children, delete items, schedules, overrides)
|
||||
12. Create `backend/api/routine_item_api.py`:
|
||||
- `PUT /routine/<routine_id>/item/add` — add item
|
||||
- `PUT /routine/<routine_id>/item/<item_id>/edit` — edit item name/image
|
||||
- `DELETE /routine/<routine_id>/item/<item_id>` — remove item
|
||||
- `GET /routine/<routine_id>/items` — list items
|
||||
13. Create `backend/api/child_routine_api.py`:
|
||||
- `POST /child/<id>/assign-routine` — add routine to child
|
||||
- `POST /child/<id>/remove-routine` — remove from child
|
||||
- `PUT /child/<id>/set-routines` — replace all assigned routines
|
||||
- `GET /child/<id>/list-routines` — list assigned routines with schedule, pending_status, extension_date, image_url, custom_value, and embedded items
|
||||
- `GET /child/<id>/list-assignable-routines` — routines not yet assigned
|
||||
- `POST /child/<id>/confirm-routine` — child submits routine as pending (creates PendingConfirmation entity_type='routine')
|
||||
- `POST /child/<id>/cancel-routine-confirmation` — cancel pending
|
||||
14. Create `backend/api/routine_schedule_api.py`:
|
||||
- `GET /child/<child_id>/routine/<routine_id>/schedule` — fetch
|
||||
- `PUT /child/<child_id>/routine/<routine_id>/schedule` — create/update
|
||||
- `DELETE /child/<child_id>/routine/<routine_id>/schedule` — delete
|
||||
- `POST /child/<child_id>/routine/<routine_id>/extend` — extend deadline
|
||||
15. Extend `backend/api/pending_confirmation.py`:
|
||||
- Add `GET /pending-confirmations?type=routine` support (or ensure existing endpoint includes routines)
|
||||
- Add `POST /child/<id>/approve-routine/<confirmation_id>` — approve (award points via override or routine.points)
|
||||
- Add `POST /child/<id>/reject-routine/<confirmation_id>` — reject
|
||||
- Add `POST /child/<id>/reset-routine/<confirmation_id>` — reset
|
||||
16. Update `backend/api/child_api.py` — cascade delete routines/schedules/extensions when child deleted.
|
||||
17. Update `backend/main.py` — register all new blueprints.
|
||||
|
||||
## Phase 4: TypeScript Models & API Helpers
|
||||
|
||||
18. Update `frontend/src/common/models.ts`:
|
||||
- Add `Routine` (id, name, points, image_id)
|
||||
- Add `RoutineItem` (id, routine_id, name, image_id, order)
|
||||
- Add `ChildRoutine` (extends Routine + custom_value, schedule, extension_date, pending_status, approved_at, items: RoutineItem[])
|
||||
- Add `RoutineSchedule` (mirror ChoreSchedule with routine_id)
|
||||
- Add new SSE payload types: RoutineModifiedEventPayload, ChildRoutinesSetEventPayload, RoutineScheduleModifiedPayload, RoutineTimeExtendedPayload, ChildRoutineConfirmationPayload
|
||||
19. Update `frontend/src/common/backendEvents.ts` — add new event type string constants.
|
||||
20. Update `frontend/src/common/api.ts` — add helpers: confirmRoutine(), cancelRoutineConfirmation(), setChildRoutineOverride().
|
||||
|
||||
## Phase 5: Frontend — Child Mode
|
||||
|
||||
21. Create `frontend/src/components/routine/RoutineDetailView.vue`:
|
||||
- Receives childId + routineId as route params
|
||||
- Fetches routine data (name, points, image, items list) via `GET /child/<id>/list-routines` (or dedicated detail endpoint)
|
||||
- Displays routine header (name, image, points)
|
||||
- Vertical card list of items (name + image, non-interactive)
|
||||
- "Done" button → RoutineConfirmDialog → calls confirmRoutine() → routine becomes 'pending'
|
||||
- If pending_status='pending': "Done" shows cancel dialog instead
|
||||
- If approved today: shows "COMPLETED" stamp (read-only)
|
||||
- If expired: shows "TOO LATE" (no action)
|
||||
- Back navigation to ChildView
|
||||
- Register SSE `child_routine_confirmation` to update status reactively
|
||||
22. Update `frontend/src/components/child/ChildView.vue`:
|
||||
- Add `routines: ref<string[]>` and `childRoutineListRef`
|
||||
- Add Routines `ScrollingList` between Chores list and Rewards list
|
||||
- fetchBaseUrl: `/api/child/${child.id}/list-routines`
|
||||
- itemKey: `'routines'`
|
||||
- filter-fn: filter by schedule (isScheduledToday equivalent for routines)
|
||||
- sort-fn: completed → pending → scheduled → general
|
||||
- getItemClass: similar chore-inactive logic for expired/completed
|
||||
- On routine click → navigate to RoutineDetailView route instead of triggering inline
|
||||
- Register new SSE handlers: routine_modified, child_routines_set, routine_schedule_modified, routine_time_extended, child_routine_confirmation
|
||||
- Expiry timer logic for routine deadlines (parallel to existing chore timer logic)
|
||||
23. Add routes:
|
||||
- `/child/:id/routine/:routineId` → RoutineDetailView
|
||||
|
||||
## Phase 6: Frontend — Parent Mode (Routine Library)
|
||||
|
||||
24. Create `frontend/src/components/routine/RoutineEditor.vue`:
|
||||
- Top section: EntityEditForm-style fields — name (text), points (number), image picker
|
||||
- Bottom section: Items sub-panel
|
||||
- List of existing items (name + image thumbnail + delete button)
|
||||
- "Add item" row: inline input for name + optional image picker + confirm button
|
||||
- Each item has edit-in-place or edit button
|
||||
- Save button submits both routine details and item changes
|
||||
- On edit mode: pre-populate all fields and items
|
||||
25. Create `frontend/src/views/parent/RoutinesView.vue`:
|
||||
- ItemList of all routines with add/edit/delete
|
||||
- Add → RoutineEditor (create mode)
|
||||
- Edit → RoutineEditor (edit mode)
|
||||
26. Add parent routes under TaskSubNav (4th tab — "Routines"):
|
||||
- `/parent/tasks/routines` — library list (RoutinesView)
|
||||
- `/parent/tasks/routines/add` — create (RoutineEditor)
|
||||
- `/parent/tasks/routines/:id/edit` — edit (RoutineEditor)
|
||||
27. Add tab to `frontend/src/components/task/TaskSubNav.vue` — "Routines" tab pointing to `/parent/tasks/routines`
|
||||
|
||||
## Phase 7: Frontend — Parent Mode (Per-Child Routine Management)
|
||||
|
||||
28. Extend ParentView (or child management component) to include a "Routines" section per child:
|
||||
- ItemList showing child's assigned routines
|
||||
- Assign button → RoutineAssignView (parallel to ChoreAssignView)
|
||||
- Kebab menu per routine item:
|
||||
- "Edit Routine" → navigate to `/parent/tasks/routines/:id/edit`
|
||||
- "Set Schedule" → open ScheduleModal with entityType='routine'
|
||||
- "Edit Points" → set ChildOverride with entity_type='routine'
|
||||
- "Extend Deadline" → call extend endpoint
|
||||
- Register SSE events to refresh list: routine_modified, child_routines_set, routine_schedule_modified, routine_time_extended, child_routine_confirmation, child_override_set
|
||||
29. Create `frontend/src/components/routine/RoutineAssignView.vue` — selectable ItemList of unassigned routines (parallel to ChoreAssignView).
|
||||
30. Extend pending confirmation approval UI — if entity_type='routine', fetch routine name and show in approval card. Approve → POST approve-routine endpoint. Reject → POST reject-routine.
|
||||
|
||||
## Phase 8: Push Notifications for Routine Pending
|
||||
|
||||
31. In `backend/api/child_routine_api.py` — after creating the PendingConfirmation for a routine, call `send_push_to_user(user_id, payload)` (mirror `child_api.py` chore confirmation pattern). Payload: `type='routine_confirmed'`, title = "Routine Pending", body = "{child_name} completed {routine_name}", include approve/reject action tokens.
|
||||
|
||||
## Phase 9: ScheduleModal entityType Refactor
|
||||
|
||||
32. Refactor `ScheduleModal.vue` — replace hard-coded `task_id` with `entityType: 'task' | 'routine'` + `entityId` props. All API calls to `.../schedule` and `.../extend` switch on `entityType` to route to either `/child/<childId>/task/<entityId>/...` or `/child/<childId>/routine/<entityId>/...`.
|
||||
33. Update all current ScheduleModal usages to explicitly pass `entityType='task'` so existing behavior is preserved.
|
||||
34. Routine kebab "Set Schedule" passes `entityType='routine'`.
|
||||
|
||||
---
|
||||
|
||||
## Relevant Files
|
||||
|
||||
**New backend:**
|
||||
|
||||
- `backend/models/routine.py`, `routine_item.py`, `routine_schedule.py`, `routine_extension.py`
|
||||
- `backend/db/routines.py`, `routine_items.py`, `routine_schedules.py`, `routine_extensions.py`
|
||||
- `backend/api/routine_api.py`, `routine_item_api.py`, `child_routine_api.py`, `routine_schedule_api.py`
|
||||
- `backend/events/types/routine_modified.py`, `child_routines_set.py`, `routine_schedule_modified.py`, `routine_time_extended.py`, `child_routine_confirmation.py`
|
||||
|
||||
**Modified backend:**
|
||||
|
||||
- `backend/models/child.py` — add routines field
|
||||
- `backend/models/pending_confirmation.py` — extend entity_type Literal
|
||||
- `backend/db/child_overrides.py` — allow 'routine' entity_type
|
||||
- `backend/api/child_api.py` — cascade deletes
|
||||
- `backend/api/pending_confirmation.py` — routine approval endpoints
|
||||
- `backend/events/types/event_types.py` — new constants
|
||||
- `backend/main.py` — register blueprints
|
||||
|
||||
**New frontend:**
|
||||
|
||||
- `frontend/src/components/routine/RoutineEditor.vue`
|
||||
- `frontend/src/components/routine/RoutineDetailView.vue`
|
||||
- `frontend/src/components/routine/RoutineAssignView.vue`
|
||||
- `frontend/src/views/parent/RoutinesView.vue`
|
||||
|
||||
**Modified frontend:**
|
||||
|
||||
- `frontend/src/common/models.ts` — new interfaces + SSE payload types
|
||||
- `frontend/src/common/backendEvents.ts` — new event constants
|
||||
- `frontend/src/common/api.ts` — new helpers
|
||||
- `frontend/src/components/child/ChildView.vue` — add routines list + navigation
|
||||
- `frontend/src/components/task/TaskSubNav.vue` — add Routines tab
|
||||
- `frontend/src/components/shared/ScheduleModal.vue` — entityType refactor
|
||||
- ParentView component — add routines section
|
||||
- Router — new routes
|
||||
|
||||
**Reference patterns:**
|
||||
|
||||
- `backend/api/chore_api.py` + `chore_schedule_api.py` — mirror for routine equivalents
|
||||
- `backend/models/chore_schedule.py` — copy for RoutineSchedule
|
||||
- `backend/models/task_extension.py` — copy for routine_extension.py
|
||||
- `frontend/src/components/shared/ScrollingList.vue` — reuse for routines in child view
|
||||
- `frontend/src/components/shared/EntityEditForm.vue` — reuse in RoutineEditor top section
|
||||
- `backend/utils/push_sender.py` + `child_api.py` chore confirm (~L1186) — push notification pattern
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
1. Create a routine with 2+ items via Tasks → Routines tab, assign to a child, verify it appears in child view
|
||||
2. Child submits "Done" → push notification fires → pending confirmation appears in parent view → approve → points credited
|
||||
3. Reject flow → no points awarded
|
||||
4. Schedule a routine for specific days → verify it only appears on those days
|
||||
5. Set a deadline → verify "TOO LATE" stamp after deadline passes
|
||||
6. Extend deadline via kebab → verify stamp removed for that day
|
||||
7. Set per-child point override → verify override value shown and applied on approval
|
||||
8. Delete routine → cascades (removed from children, items/schedules/extensions/overrides deleted)
|
||||
9. SSE: parent sees routine confirmation in notifications without page refresh
|
||||
10. ScheduleModal: existing chore schedule still works after entityType refactor
|
||||
|
||||
---
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Reordering routine items (order field exists but no drag-and-drop UI planned)
|
||||
- Per-item completion tracking
|
||||
49
.github/specs/template/feat-template.md
vendored
Normal file
49
.github/specs/template/feat-template.md
vendored
Normal file
@@ -0,0 +1,49 @@
|
||||
# Feature:
|
||||
|
||||
## Overview
|
||||
|
||||
**Goal:**
|
||||
|
||||
**User Story:**
|
||||
|
||||
**Rules:**
|
||||
|
||||
---
|
||||
|
||||
## Data Model Changes
|
||||
|
||||
### Backend Model
|
||||
|
||||
### Frontend Model
|
||||
|
||||
---
|
||||
|
||||
## Backend Implementation
|
||||
|
||||
## Backend Tests
|
||||
|
||||
- [ ]
|
||||
|
||||
---
|
||||
|
||||
## Frontend Implementation
|
||||
|
||||
## Frontend Tests
|
||||
|
||||
- [ ]
|
||||
|
||||
---
|
||||
|
||||
## Future Considerations
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria (Definition of Done)
|
||||
|
||||
### Backend
|
||||
|
||||
- [ ]
|
||||
|
||||
### Frontend
|
||||
|
||||
- [ ]
|
||||
34
.github/workflows/copilot-setup-steps.yml
vendored
Normal file
34
.github/workflows/copilot-setup-steps.yml
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
name: "Copilot Setup Steps"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
paths:
|
||||
- .github/workflows/copilot-setup-steps.yml
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/workflows/copilot-setup-steps.yml
|
||||
|
||||
jobs:
|
||||
copilot-setup-steps:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: npx playwright install --with-deps
|
||||
|
||||
# Customize this step as needed
|
||||
- name: Build application
|
||||
run: npx run build
|
||||
20
.gitignore
vendored
20
.gitignore
vendored
@@ -1,8 +1,12 @@
|
||||
backend/test_data/db/children.json
|
||||
backend/test_data/db/images.json
|
||||
backend/test_data/db/pending_rewards.json
|
||||
backend/test_data/db/rewards.json
|
||||
backend/test_data/db/tasks.json
|
||||
backend/test_data/db/users.json
|
||||
logs/account_deletion.log
|
||||
backend/test_data/db/tracking_events.json
|
||||
.env
|
||||
backend/test_data/
|
||||
logs/
|
||||
frontend/resources/
|
||||
frontend/playwright-report/
|
||||
frontend/test-results/
|
||||
backend/test-results/
|
||||
.vscode/keybindings.json
|
||||
.DS_Store
|
||||
**/.DS_Store
|
||||
frontend/cert.pem
|
||||
frontend/key.pem
|
||||
|
||||
5
.idea/.gitignore
generated
vendored
Normal file
5
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
15
.idea/Reward.iml
generated
Normal file
15
.idea/Reward.iml
generated
Normal 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
6
.idea/copilot.data.migration.agent.xml
generated
Normal 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>
|
||||
6
.idea/copilot.data.migration.ask2agent.xml
generated
Normal file
6
.idea/copilot.data.migration.ask2agent.xml
generated
Normal 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
6
.idea/copilot.data.migration.edit.xml
generated
Normal 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>
|
||||
10
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
10
.idea/inspectionProfiles/Project_Default.xml
generated
Normal 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>
|
||||
6
.idea/inspectionProfiles/profiles_settings.xml
generated
Normal file
6
.idea/inspectionProfiles/profiles_settings.xml
generated
Normal 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
4
.idea/misc.xml
generated
Normal 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
8
.idea/modules.xml
generated
Normal 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
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
54
.vscode/launch.json
vendored
54
.vscode/launch.json
vendored
@@ -3,13 +3,19 @@
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Python: Flask",
|
||||
"type": "debugpy",
|
||||
"type": "python",
|
||||
"request": "launch",
|
||||
"module": "flask",
|
||||
"python": "${command:python.interpreterPath}",
|
||||
"env": {
|
||||
"FLASK_APP": "backend/main.py",
|
||||
"FLASK_DEBUG": "1"
|
||||
"FLASK_DEBUG": "1",
|
||||
"SECRET_KEY": "dev-secret-key-change-in-production",
|
||||
"REFRESH_TOKEN_EXPIRY_DAYS": "90",
|
||||
"DIGEST_TOKEN_SECRET": "dev-digest-token",
|
||||
"VAPID_PUBLIC_KEY": "BNKkHdq45uLigohSG7c1TwlAo7ETncoRVLQK02LxHgu2P1DgSJD9njRMfbbzUsaTQGllvLBz7An1WiWsNYQhvKE",
|
||||
"VAPID_PRIVATE_KEY": "jNiZJT0UO4H861KmnCt874Fg6p5jDAyYKS4V2MZf8bQ",
|
||||
"FRONTEND_URL": "https://macbook:5173"
|
||||
},
|
||||
"args": [
|
||||
"run",
|
||||
@@ -17,7 +23,9 @@
|
||||
"--port=5000",
|
||||
"--no-debugger",
|
||||
"--no-reload"
|
||||
]
|
||||
],
|
||||
"cwd": "${workspaceFolder}/backend",
|
||||
"console": "integratedTerminal"
|
||||
},
|
||||
{
|
||||
"name": "Vue: Dev Server",
|
||||
@@ -28,21 +36,26 @@
|
||||
"run",
|
||||
"dev"
|
||||
],
|
||||
"cwd": "${workspaceFolder}/frontend/vue-app",
|
||||
"console": "integratedTerminal"
|
||||
"cwd": "${workspaceFolder}/frontend",
|
||||
"console": "integratedTerminal",
|
||||
"serverReadyAction": {
|
||||
"pattern": "Local:.*https://localhost:([0-9]+)",
|
||||
"uriFormat": "https://localhost:%s",
|
||||
"action": "debugWithChrome"
|
||||
}
|
||||
},
|
||||
{
|
||||
"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
|
||||
"webRoot": "${workspaceFolder}/frontend/vue-app"
|
||||
"url": "http://localhost:5173",
|
||||
"webRoot": "${workspaceFolder}/frontend"
|
||||
},
|
||||
{
|
||||
"name": "Python: Backend Tests",
|
||||
"type": "python",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/backend/.venv/Scripts/pytest.exe",
|
||||
"module": "pytest",
|
||||
"args": [
|
||||
"tests/"
|
||||
],
|
||||
@@ -56,21 +69,30 @@
|
||||
"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"
|
||||
"cwd": "${workspaceFolder}/frontend",
|
||||
"console": "integratedTerminal",
|
||||
"osx": {
|
||||
"env": {
|
||||
"PATH": "/opt/homebrew/bin:${env:PATH}"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"compounds": [
|
||||
{
|
||||
"name": "Full Stack (Backend + Frontend)",
|
||||
"stopAll": true,
|
||||
"configurations": [
|
||||
"Python: Flask",
|
||||
"Vue: Dev Server",
|
||||
"Chrome: Attach to Vue App"
|
||||
"Vue: Dev Server"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
77
.vscode/launch.json.bak
vendored
Normal file
77
.vscode/launch.json.bak
vendored
Normal 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",
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"console": "integratedTerminal"
|
||||
}
|
||||
],
|
||||
"compounds": [
|
||||
{
|
||||
"name": "Full Stack (Backend + Frontend)",
|
||||
"configurations": [
|
||||
"Python: Flask",
|
||||
"Vue: Dev Server",
|
||||
"Chrome: Attach to Vue App"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
28
.vscode/mcp.json
vendored
Normal file
28
.vscode/mcp.json
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"servers": {
|
||||
"playwright-test": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"playwright",
|
||||
"run-test-mcp-server",
|
||||
"--config=playwright.config.ts"
|
||||
],
|
||||
"cwd": "frontend"
|
||||
},
|
||||
"gitea": {
|
||||
"command": "docker",
|
||||
"args": [
|
||||
"run",
|
||||
"-i",
|
||||
"--rm",
|
||||
"-e",
|
||||
"GITEA_ACCESS_TOKEN=${env:GITEA_ACCESS_TOKEN}",
|
||||
"-e",
|
||||
"GITEA_HOST=https://git.ryankegel.com",
|
||||
"docker.gitea.com/gitea-mcp-server"
|
||||
],
|
||||
"type": "stdio"
|
||||
}
|
||||
}
|
||||
}
|
||||
25
.vscode/settings.json
vendored
25
.vscode/settings.json
vendored
@@ -1,4 +1,7 @@
|
||||
{
|
||||
"python.venvPath": "${workspaceFolder}/backend/.venv",
|
||||
"python.terminal.activateEnvironment": true,
|
||||
"python.terminal.shellIntegration.enabled": true,
|
||||
"explorer.fileNesting.enabled": true,
|
||||
"explorer.fileNesting.patterns": {
|
||||
"tsconfig.json": "tsconfig.*.json, env.d.ts",
|
||||
@@ -19,5 +22,25 @@
|
||||
},
|
||||
"chat.tools.terminal.autoApprove": {
|
||||
"&": true
|
||||
}
|
||||
},
|
||||
"terminal.integrated.automationProfile.windows": {
|
||||
"path": "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
|
||||
"args": []
|
||||
},
|
||||
"python-envs.defaultEnvManager": "ms-python.python:venv",
|
||||
"python-envs.pythonProjects": [],
|
||||
"editor.gotoLocation.multipleDeclarations": "gotoAndPeek",
|
||||
"editor.gotoLocation.multipleDefinitions": "gotoAndPeek",
|
||||
"editor.gotoLocation.multipleImplementations": "gotoAndPeek",
|
||||
"editor.gotoLocation.multipleReferences": "gotoAndPeek",
|
||||
"editor.gotoLocation.multipleTypeDefinitions": "gotoAndPeek",
|
||||
//"editor.fontFamily": "Consolas, 'Courier New', monospace",
|
||||
"editor.fontFamily": "JetBrains Mono",
|
||||
"editor.fontSize": 13,
|
||||
"editor.fontLigatures": true,
|
||||
"python.testing.pytestArgs": [
|
||||
"backend"
|
||||
],
|
||||
"python.testing.unittestEnabled": false,
|
||||
"python.testing.pytestEnabled": true,
|
||||
}
|
||||
146
.vscode/tasks.json
vendored
Normal file
146
.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,146 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "PW: Task Modification Tests",
|
||||
"type": "shell",
|
||||
"command": "cd \"${workspaceFolder}/frontend\" && npx playwright test e2e/mode_parent/task-modification --project=chromium-task-modification",
|
||||
"isBackground": false,
|
||||
"group": "test"
|
||||
},
|
||||
{
|
||||
"label": "PW: Task Modification Tests (PS)",
|
||||
"type": "shell",
|
||||
"command": "powershell -Command \"cd '$env:APPDATA/../../../d/Python Utilities/Reward/frontend'; npx playwright test e2e/mode_parent/task-modification --project=chromium-task-modification\"",
|
||||
"isBackground": false,
|
||||
"group": "test"
|
||||
},
|
||||
{
|
||||
"label": "PW: Task Modification Tests (cmd)",
|
||||
"type": "shell",
|
||||
"command": "cmd /c \"cd /d \\\"D:\\Python Utilities\\Reward\\frontend\\\" && npx playwright test e2e/mode_parent/task-modification --project=chromium-task-modification 2>&1\"",
|
||||
"isBackground": false,
|
||||
"group": "test"
|
||||
},
|
||||
{
|
||||
"label": "PW: User Profile Tests",
|
||||
"type": "shell",
|
||||
"command": "cd \"${workspaceFolder}/frontend\" && npx playwright test e2e/mode_parent/user-profile --project=chromium-user-profile --project=chromium-user-profile-pin --project=chromium-user-profile-delete",
|
||||
"isBackground": false,
|
||||
"group": "test"
|
||||
},
|
||||
{
|
||||
"label": "PW: User Profile Tests 2",
|
||||
"type": "shell",
|
||||
"command": "cd \"${workspaceFolder}/frontend\" && npx playwright test e2e/mode_parent/user-profile --project=chromium-user-profile --project=chromium-user-profile-pin --project=chromium-user-profile-delete",
|
||||
"isBackground": false,
|
||||
"group": "test"
|
||||
},
|
||||
{
|
||||
"label": "PW: User Profile Tests Final",
|
||||
"type": "shell",
|
||||
"command": "cd \"${workspaceFolder}/frontend\" && npx playwright test e2e/mode_parent/user-profile --project=chromium-user-profile --project=chromium-user-profile-pin --project=chromium-user-profile-delete",
|
||||
"isBackground": false,
|
||||
"group": "test"
|
||||
},
|
||||
{
|
||||
"label": "PW: Full Test Suite",
|
||||
"type": "shell",
|
||||
"command": "cd \"${workspaceFolder}/frontend\" && npx playwright test --reporter=line",
|
||||
"isBackground": false,
|
||||
"group": "test"
|
||||
},
|
||||
{
|
||||
"label": "PW: Full Suite",
|
||||
"type": "shell",
|
||||
"command": "cd \"${workspaceFolder}/frontend\" && npx playwright test --reporter=line",
|
||||
"isBackground": false,
|
||||
"group": "test"
|
||||
},
|
||||
{
|
||||
"label": "PW: Full Suite (Process)",
|
||||
"type": "process",
|
||||
"command": "powershell.exe",
|
||||
"args": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"cd 'D:\\Python Utilities\\Reward\\frontend'; npx playwright test --reporter=line 2>&1"
|
||||
],
|
||||
"group": "test",
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "shared"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "PW: Penalty Default Test (Process)",
|
||||
"type": "process",
|
||||
"command": "powershell.exe",
|
||||
"args": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"cd 'D:\\Python Utilities\\Reward\\frontend'; npx playwright test e2e/mode_parent/tasks/penalty-default.spec.ts --project=chromium-default-tasks --reporter=line 2>&1"
|
||||
],
|
||||
"group": "test",
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "shared"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "PW: User Profile Editing Test (Process)",
|
||||
"type": "process",
|
||||
"command": "powershell.exe",
|
||||
"args": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"cd 'D:\\Python Utilities\\Reward\\frontend'; npx playwright test e2e/mode_parent/user-profile/profile-editing.spec.ts --project=chromium-user-profile --reporter=line 2>&1"
|
||||
],
|
||||
"group": "test",
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "shared"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -24,7 +24,7 @@ python -m flask run --host=0.0.0.0 --port=5000
|
||||
### Frontend
|
||||
|
||||
```bash
|
||||
cd frontend/vue-app
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
@@ -114,7 +114,7 @@ pytest tests/
|
||||
### Frontend Tests
|
||||
|
||||
```bash
|
||||
cd frontend/vue-app
|
||||
cd frontend
|
||||
npm run test
|
||||
```
|
||||
|
||||
@@ -151,7 +151,6 @@ npm run test
|
||||
│ ├── tests/ # Backend tests
|
||||
│ └── utils/ # Utilities (scheduler, etc)
|
||||
├── frontend/
|
||||
│ └── vue-app/
|
||||
│ └── src/
|
||||
│ ├── common/ # Shared utilities
|
||||
│ ├── components/ # Vue components
|
||||
@@ -164,6 +163,10 @@ npm run test
|
||||
|
||||
For detailed development patterns and conventions, see [`.github/copilot-instructions.md`](.github/copilot-instructions.md).
|
||||
|
||||
## 📚 References
|
||||
|
||||
- Reset flow (token validation, JWT invalidation, cross-tab logout sync): [`docs/reset-password-reference.md`](docs/reset-password-reference.md)
|
||||
|
||||
## 📄 License
|
||||
|
||||
Private project - All rights reserved.
|
||||
|
||||
1
backend/.gitignore
vendored
1
backend/.gitignore
vendored
@@ -42,6 +42,7 @@ env/
|
||||
*.sqlite3
|
||||
data/db/*.json
|
||||
data/images/
|
||||
test_data/
|
||||
|
||||
# Flask
|
||||
instance/
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import os
|
||||
|
||||
from flask import Blueprint, request, jsonify
|
||||
from datetime import datetime, timedelta
|
||||
from tinydb import Query
|
||||
import jwt
|
||||
from functools import wraps
|
||||
|
||||
from db.db import users_db
|
||||
from models.user import User
|
||||
from api.utils import admin_required, get_validated_user_id
|
||||
from config.deletion_config import (
|
||||
ACCOUNT_DELETION_THRESHOLD_HOURS,
|
||||
MIN_THRESHOLD_HOURS,
|
||||
@@ -16,49 +17,6 @@ from utils.account_deletion_scheduler import trigger_deletion_manually
|
||||
|
||||
admin_api = Blueprint('admin_api', __name__)
|
||||
|
||||
def admin_required(f):
|
||||
"""
|
||||
Decorator to require admin role for endpoints.
|
||||
"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
# Get JWT token from cookie
|
||||
token = request.cookies.get('token')
|
||||
if not token:
|
||||
return jsonify({'error': 'Authentication required', 'code': 'AUTH_REQUIRED'}), 401
|
||||
|
||||
try:
|
||||
# Verify JWT token
|
||||
payload = jwt.decode(token, 'supersecretkey', algorithms=['HS256'])
|
||||
user_id = payload.get('user_id')
|
||||
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Invalid token', 'code': 'INVALID_TOKEN'}), 401
|
||||
|
||||
# Get user from database
|
||||
Query_ = Query()
|
||||
user_dict = users_db.get(Query_.id == user_id)
|
||||
|
||||
if not user_dict:
|
||||
return jsonify({'error': 'User not found', 'code': 'USER_NOT_FOUND'}), 404
|
||||
|
||||
user = User.from_dict(user_dict)
|
||||
|
||||
# Check if user has admin role
|
||||
if user.role != 'admin':
|
||||
return jsonify({'error': 'Admin access required', 'code': 'ADMIN_REQUIRED'}), 403
|
||||
|
||||
# Pass user to the endpoint
|
||||
request.current_user = user
|
||||
|
||||
except jwt.ExpiredSignatureError:
|
||||
return jsonify({'error': 'Token expired', 'code': 'TOKEN_EXPIRED'}), 401
|
||||
except jwt.InvalidTokenError:
|
||||
return jsonify({'error': 'Invalid token', 'code': 'INVALID_TOKEN'}), 401
|
||||
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
@admin_api.route('/admin/deletion-queue', methods=['GET'])
|
||||
@admin_required
|
||||
@@ -197,3 +155,133 @@ def trigger_deletion_queue():
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e), 'code': 'SERVER_ERROR'}), 500
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test-only endpoint — active ONLY when DB_ENV=e2e
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@admin_api.route('/admin/test/digest-token', methods=['POST'])
|
||||
def create_test_digest_token():
|
||||
"""Create a valid DigestActionToken for E2E tests.
|
||||
|
||||
Only active when DB_ENV=e2e. Requires authentication.
|
||||
"""
|
||||
if os.environ.get('DB_ENV') != 'e2e':
|
||||
return jsonify({'error': 'Not found', 'code': 'NOT_FOUND'}), 404
|
||||
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||
|
||||
data = request.get_json() or {}
|
||||
child_id = data.get('child_id')
|
||||
entity_id = data.get('entity_id')
|
||||
entity_type = data.get('entity_type')
|
||||
action = data.get('action')
|
||||
expires_in_hours = data.get('expires_in_hours', 24)
|
||||
|
||||
if not all([child_id, entity_id, entity_type, action]):
|
||||
return jsonify({'error': 'child_id, entity_id, entity_type, and action are required',
|
||||
'code': 'MISSING_FIELDS'}), 400
|
||||
|
||||
if entity_type not in ('chore', 'reward'):
|
||||
return jsonify({'error': 'entity_type must be "chore" or "reward"',
|
||||
'code': 'INVALID_ENTITY_TYPE'}), 400
|
||||
|
||||
if action not in ('approve', 'deny'):
|
||||
return jsonify({'error': 'action must be "approve" or "deny"',
|
||||
'code': 'INVALID_ACTION'}), 400
|
||||
|
||||
try:
|
||||
from utils.digest_token import create_action_token
|
||||
token = create_action_token(
|
||||
user_id=user_id,
|
||||
child_id=child_id,
|
||||
entity_id=entity_id,
|
||||
entity_type=entity_type,
|
||||
action=action,
|
||||
expiry_hours=int(expires_in_hours),
|
||||
)
|
||||
return jsonify({'token': token.id}), 200
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e), 'code': 'SERVER_ERROR'}), 500
|
||||
|
||||
|
||||
@admin_api.route('/admin/test/send-digest', methods=['POST'])
|
||||
def send_test_digest():
|
||||
"""Trigger a digest email for a specific user by email address.
|
||||
|
||||
Only active when DB_ENV is not 'production'. Requires admin authentication.
|
||||
Note: actual email delivery is skipped in e2e mode by email_sender.
|
||||
"""
|
||||
if os.environ.get('DB_ENV') == 'production':
|
||||
return jsonify({'error': 'Not found', 'code': 'NOT_FOUND'}), 404
|
||||
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||
|
||||
# Verify caller is admin
|
||||
caller_dict = users_db.get(Query().id == user_id)
|
||||
if not caller_dict or caller_dict.get('role') != 'admin':
|
||||
return jsonify({'error': 'Admin access required', 'code': 'ADMIN_REQUIRED'}), 403
|
||||
|
||||
data = request.get_json() or {}
|
||||
email = data.get('email', '').strip().lower()
|
||||
if not email:
|
||||
return jsonify({'error': 'email is required', 'code': 'MISSING_FIELDS'}), 400
|
||||
|
||||
target = users_db.get(Query().email == email)
|
||||
if not target:
|
||||
return jsonify({'error': 'User not found', 'code': 'USER_NOT_FOUND'}), 404
|
||||
|
||||
target_id = target.get('id')
|
||||
target_email = target.get('email')
|
||||
|
||||
try:
|
||||
from flask import current_app
|
||||
from utils.digest_scheduler import send_digest_for_user
|
||||
frontend_url = current_app.config.get('FRONTEND_URL', 'https://localhost:5173')
|
||||
items_sent = send_digest_for_user(target_id, target_email, frontend_url)
|
||||
return jsonify({'items_sent': items_sent}), 200
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e), 'code': 'SERVER_ERROR'}), 500
|
||||
|
||||
|
||||
@admin_api.route('/admin/test/trigger-chore-expiry', methods=['POST'])
|
||||
def trigger_test_chore_expiry():
|
||||
"""Trigger the chore expiry notification check for a specific user by email address.
|
||||
|
||||
Only active when DB_ENV is not 'production'. Requires admin authentication.
|
||||
Note: actual push delivery requires VAPID keys to be configured.
|
||||
"""
|
||||
if os.environ.get('DB_ENV') == 'production':
|
||||
return jsonify({'error': 'Not found', 'code': 'NOT_FOUND'}), 404
|
||||
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||
|
||||
caller_dict = users_db.get(Query().id == user_id)
|
||||
if not caller_dict or caller_dict.get('role') != 'admin':
|
||||
return jsonify({'error': 'Admin access required', 'code': 'ADMIN_REQUIRED'}), 403
|
||||
|
||||
data = request.get_json() or {}
|
||||
email = data.get('email', '').strip().lower()
|
||||
if not email:
|
||||
return jsonify({'error': 'email is required', 'code': 'MISSING_FIELDS'}), 400
|
||||
|
||||
target = users_db.get(Query().email == email)
|
||||
if not target:
|
||||
return jsonify({'error': 'User not found', 'code': 'USER_NOT_FOUND'}), 404
|
||||
|
||||
target_id = target.get('id')
|
||||
tz_str = target.get('timezone')
|
||||
|
||||
try:
|
||||
from utils.chore_expiry_notification_scheduler import send_chore_expiry_notifications_for_user
|
||||
chores_notified = send_chore_expiry_notifications_for_user(target_id, tz_str)
|
||||
return jsonify({'chores_notified': chores_notified}), 200
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e), 'code': 'SERVER_ERROR'}), 500
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import hashlib
|
||||
import logging
|
||||
import secrets, jwt
|
||||
import secrets
|
||||
import uuid
|
||||
import jwt
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from models.user import User
|
||||
from models.refresh_token import RefreshToken
|
||||
from flask import Blueprint, request, jsonify, current_app
|
||||
from tinydb import Query
|
||||
import os
|
||||
@@ -10,18 +14,44 @@ from werkzeug.security import generate_password_hash, check_password_hash
|
||||
|
||||
from api.utils import sanitize_email
|
||||
from config.paths import get_user_image_dir
|
||||
from events.sse import send_event_to_user
|
||||
from events.types.event import Event
|
||||
from events.types.event_types import EventType
|
||||
from events.types.payload import Payload
|
||||
|
||||
from api.error_codes import MISSING_FIELDS, EMAIL_EXISTS, MISSING_TOKEN, INVALID_TOKEN, TOKEN_TIMESTAMP_MISSING, \
|
||||
TOKEN_EXPIRED, ALREADY_VERIFIED, MISSING_EMAIL, USER_NOT_FOUND, MISSING_EMAIL_OR_PASSWORD, INVALID_CREDENTIALS, \
|
||||
NOT_VERIFIED, ACCOUNT_MARKED_FOR_DELETION
|
||||
from db.db import users_db
|
||||
from api.error_codes import (
|
||||
MISSING_FIELDS, EMAIL_EXISTS, MISSING_TOKEN, INVALID_TOKEN, TOKEN_TIMESTAMP_MISSING,
|
||||
TOKEN_EXPIRED, ALREADY_VERIFIED, MISSING_EMAIL, USER_NOT_FOUND, MISSING_EMAIL_OR_PASSWORD,
|
||||
INVALID_CREDENTIALS, NOT_VERIFIED, ACCOUNT_MARKED_FOR_DELETION,
|
||||
REFRESH_TOKEN_REUSE, REFRESH_TOKEN_EXPIRED, MISSING_REFRESH_TOKEN,
|
||||
)
|
||||
from db.db import (
|
||||
users_db, refresh_tokens_db, child_db, task_db, reward_db, image_db,
|
||||
pending_reward_db, pending_confirmations_db, tracking_events_db,
|
||||
child_overrides_db, chore_schedules_db, task_extensions_db,
|
||||
)
|
||||
from db.default import initializeImages, createDefaultTasks, createDefaultRewards
|
||||
from api.utils import normalize_email
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
auth_api = Blueprint('auth_api', __name__)
|
||||
UserQuery = Query()
|
||||
TOKEN_EXPIRY_MINUTES = 60*4
|
||||
TokenQuery = Query()
|
||||
TOKEN_EXPIRY_MINUTES = 60 * 4
|
||||
RESET_PASSWORD_TOKEN_EXPIRY_MINUTES = 10
|
||||
try:
|
||||
ACCESS_TOKEN_EXPIRY_MINUTES = int(os.environ.get('ACCESS_TOKEN_EXPIRY_MINUTES', '15'))
|
||||
except ValueError:
|
||||
ACCESS_TOKEN_EXPIRY_MINUTES = 15
|
||||
E2E_TEST_EMAIL = 'e2e@test.com'
|
||||
E2E_TEST_PASSWORD = 'E2eTestPass1!'
|
||||
E2E_TEST_PIN = '1234'
|
||||
E2E_DELETE_EMAIL = 'e2e-delete@test.com'
|
||||
E2E_DELETE_PASSWORD = 'E2eDeletePass1!'
|
||||
E2E_DELETE_PIN = '5678'
|
||||
E2E_CC_EMAIL = 'e2e-cc@test.com'
|
||||
E2E_CC_PASSWORD = 'E2eCCPass1!'
|
||||
E2E_CC_PIN = '3456'
|
||||
|
||||
|
||||
def send_verification_email(to_email, token):
|
||||
@@ -30,6 +60,81 @@ def send_verification_email(to_email, token):
|
||||
def send_reset_password_email(to_email, token):
|
||||
email_sender.send_reset_password_email(to_email, token)
|
||||
|
||||
|
||||
def _hash_token(raw_token: str) -> str:
|
||||
"""SHA-256 hash a raw refresh token for secure storage."""
|
||||
return hashlib.sha256(raw_token.encode('utf-8')).hexdigest()
|
||||
|
||||
|
||||
def _create_access_token(user: User) -> str:
|
||||
"""Create a short-lived JWT access token."""
|
||||
payload = {
|
||||
'email': user.email,
|
||||
'user_id': user.id,
|
||||
'token_version': user.token_version,
|
||||
'exp': datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRY_MINUTES),
|
||||
}
|
||||
return jwt.encode(payload, current_app.config['SECRET_KEY'], algorithm='HS256')
|
||||
|
||||
|
||||
def _create_refresh_token(user_id: str, token_family: str | None = None) -> tuple[str, RefreshToken]:
|
||||
"""
|
||||
Create a refresh token: returns (raw_token, RefreshToken record).
|
||||
If token_family is None, a new family is created (login).
|
||||
Otherwise, the existing family is reused (rotation).
|
||||
"""
|
||||
raw_token = secrets.token_urlsafe(32)
|
||||
expiry_days = current_app.config['REFRESH_TOKEN_EXPIRY_DAYS']
|
||||
expires_at = (datetime.now(timezone.utc) + timedelta(days=expiry_days)).isoformat()
|
||||
family = token_family or str(uuid.uuid4())
|
||||
|
||||
record = RefreshToken(
|
||||
user_id=user_id,
|
||||
token_hash=_hash_token(raw_token),
|
||||
token_family=family,
|
||||
expires_at=expires_at,
|
||||
is_used=False,
|
||||
)
|
||||
refresh_tokens_db.insert(record.to_dict())
|
||||
return raw_token, record
|
||||
|
||||
|
||||
def _set_auth_cookies(resp, access_token: str, raw_refresh_token: str):
|
||||
"""Set both access and refresh token cookies on a response."""
|
||||
expiry_days = current_app.config['REFRESH_TOKEN_EXPIRY_DAYS']
|
||||
resp.set_cookie(
|
||||
'access_token', access_token,
|
||||
httponly=True, secure=True, samesite='Lax',
|
||||
max_age=ACCESS_TOKEN_EXPIRY_MINUTES * 60,
|
||||
)
|
||||
resp.set_cookie(
|
||||
'refresh_token', raw_refresh_token,
|
||||
httponly=True, secure=True, samesite='Strict',
|
||||
max_age=expiry_days * 24 * 3600,
|
||||
path='/api/auth',
|
||||
)
|
||||
|
||||
|
||||
def _clear_auth_cookies(resp):
|
||||
"""Clear both access and refresh token cookies."""
|
||||
resp.set_cookie('access_token', '', expires=0, httponly=True, secure=True, samesite='Strict')
|
||||
resp.set_cookie('refresh_token', '', expires=0, httponly=True, secure=True, samesite='Strict', path='/api/auth')
|
||||
|
||||
|
||||
def _purge_expired_tokens(user_id: str):
|
||||
"""Remove expired refresh tokens for a user to prevent unbounded DB growth."""
|
||||
now = datetime.now(timezone.utc)
|
||||
all_tokens = refresh_tokens_db.search(TokenQuery.user_id == user_id)
|
||||
for t in all_tokens:
|
||||
try:
|
||||
exp = datetime.fromisoformat(t['expires_at'])
|
||||
if exp.tzinfo is None:
|
||||
exp = exp.replace(tzinfo=timezone.utc)
|
||||
if now > exp:
|
||||
refresh_tokens_db.remove(TokenQuery.id == t['id'])
|
||||
except (ValueError, KeyError):
|
||||
refresh_tokens_db.remove(TokenQuery.id == t['id'])
|
||||
|
||||
@auth_api.route('/signup', methods=['POST'])
|
||||
def signup():
|
||||
data = request.get_json()
|
||||
@@ -39,7 +144,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 +187,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:
|
||||
@@ -151,30 +264,37 @@ def login():
|
||||
if user.marked_for_deletion:
|
||||
return jsonify({'error': 'This account has been marked for deletion and cannot be accessed.', 'code': ACCOUNT_MARKED_FOR_DELETION}), 403
|
||||
|
||||
payload = {
|
||||
'email': norm_email,
|
||||
'user_id': user.id,
|
||||
'exp': datetime.utcnow() + timedelta(hours=24*7)
|
||||
}
|
||||
token = jwt.encode(payload, current_app.config['SECRET_KEY'], algorithm='HS256')
|
||||
# Purge expired refresh tokens for this user
|
||||
_purge_expired_tokens(user.id)
|
||||
|
||||
# Create access token (short-lived JWT)
|
||||
access_token = _create_access_token(user)
|
||||
|
||||
# Create refresh token (long-lived, new family for fresh login)
|
||||
raw_refresh, _ = _create_refresh_token(user.id)
|
||||
|
||||
resp = jsonify({'message': 'Login successful'})
|
||||
resp.set_cookie('token', token, httponly=True, secure=True, samesite='Strict')
|
||||
_set_auth_cookies(resp, access_token, raw_refresh)
|
||||
return resp, 200
|
||||
|
||||
@auth_api.route('/me', methods=['GET'])
|
||||
def me():
|
||||
token = request.cookies.get('token')
|
||||
token = request.cookies.get('access_token')
|
||||
if not token:
|
||||
return jsonify({'error': 'Missing token', 'code': MISSING_TOKEN}), 401
|
||||
|
||||
try:
|
||||
payload = jwt.decode(token, current_app.config['SECRET_KEY'], algorithms=['HS256'])
|
||||
user_id = payload.get('user_id', '')
|
||||
token_version = payload.get('token_version', 0)
|
||||
user_dict = users_db.get(UserQuery.id == user_id)
|
||||
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 token_version != user.token_version:
|
||||
return jsonify({'error': 'Invalid token', 'code': INVALID_TOKEN}), 401
|
||||
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 +321,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
|
||||
@@ -258,13 +378,191 @@ def reset_password():
|
||||
user.password = generate_password_hash(new_password)
|
||||
user.reset_token = None
|
||||
user.reset_token_created = None
|
||||
user.token_version += 1
|
||||
users_db.update(user.to_dict(), UserQuery.email == user.email)
|
||||
|
||||
return jsonify({'message': 'Password has been reset'}), 200
|
||||
# Invalidate ALL refresh tokens for this user
|
||||
refresh_tokens_db.remove(TokenQuery.user_id == user.id)
|
||||
|
||||
# Notify all active sessions (other tabs/devices) to sign out immediately
|
||||
send_event_to_user(user.id, Event(EventType.FORCE_LOGOUT.value, Payload({'reason': 'password_reset'})))
|
||||
|
||||
resp = jsonify({'message': 'Password has been reset'})
|
||||
_clear_auth_cookies(resp)
|
||||
return resp, 200
|
||||
|
||||
|
||||
@auth_api.route('/refresh', methods=['POST'])
|
||||
def refresh():
|
||||
raw_token = request.cookies.get('refresh_token')
|
||||
if not raw_token:
|
||||
return jsonify({'error': 'Missing refresh token', 'code': MISSING_REFRESH_TOKEN}), 401
|
||||
|
||||
token_hash = _hash_token(raw_token)
|
||||
token_dict = refresh_tokens_db.get(TokenQuery.token_hash == token_hash)
|
||||
|
||||
if not token_dict:
|
||||
# Token not found — could be invalid or already purged
|
||||
resp = jsonify({'error': 'Invalid token', 'code': INVALID_TOKEN})
|
||||
_clear_auth_cookies(resp)
|
||||
return resp, 401
|
||||
|
||||
token_record = RefreshToken.from_dict(token_dict)
|
||||
|
||||
# THEFT DETECTION: token was already used (rotated out) but replayed
|
||||
if token_record.is_used:
|
||||
logger.warning(
|
||||
'Refresh token reuse detected! user_id=%s, family=%s, ip=%s — killing all sessions',
|
||||
token_record.user_id, token_record.token_family, request.remote_addr,
|
||||
)
|
||||
# Nuke ALL refresh tokens for this user
|
||||
refresh_tokens_db.remove(TokenQuery.user_id == token_record.user_id)
|
||||
resp = jsonify({'error': 'Token reuse detected, all sessions invalidated', 'code': REFRESH_TOKEN_REUSE})
|
||||
_clear_auth_cookies(resp)
|
||||
return resp, 401
|
||||
|
||||
# Check expiry
|
||||
try:
|
||||
exp = datetime.fromisoformat(token_record.expires_at)
|
||||
if exp.tzinfo is None:
|
||||
exp = exp.replace(tzinfo=timezone.utc)
|
||||
if datetime.now(timezone.utc) > exp:
|
||||
refresh_tokens_db.remove(TokenQuery.id == token_record.id)
|
||||
resp = jsonify({'error': 'Refresh token expired', 'code': REFRESH_TOKEN_EXPIRED})
|
||||
_clear_auth_cookies(resp)
|
||||
return resp, 401
|
||||
except ValueError:
|
||||
refresh_tokens_db.remove(TokenQuery.id == token_record.id)
|
||||
resp = jsonify({'error': 'Invalid token', 'code': INVALID_TOKEN})
|
||||
_clear_auth_cookies(resp)
|
||||
return resp, 401
|
||||
|
||||
# Look up the user
|
||||
user_dict = users_db.get(UserQuery.id == token_record.user_id)
|
||||
user = User.from_dict(user_dict) if user_dict else None
|
||||
if not user:
|
||||
refresh_tokens_db.remove(TokenQuery.id == token_record.id)
|
||||
resp = jsonify({'error': 'User not found', 'code': USER_NOT_FOUND})
|
||||
_clear_auth_cookies(resp)
|
||||
return resp, 401
|
||||
|
||||
if user.marked_for_deletion:
|
||||
refresh_tokens_db.remove(TokenQuery.user_id == user.id)
|
||||
resp = jsonify({'error': 'Account marked for deletion', 'code': ACCOUNT_MARKED_FOR_DELETION})
|
||||
_clear_auth_cookies(resp)
|
||||
return resp, 403
|
||||
|
||||
# ROTATION: mark old token as used, create new one in same family
|
||||
refresh_tokens_db.update({'is_used': True}, TokenQuery.id == token_record.id)
|
||||
raw_new_refresh, _ = _create_refresh_token(user.id, token_family=token_record.token_family)
|
||||
|
||||
# Issue new access token
|
||||
access_token = _create_access_token(user)
|
||||
|
||||
resp = jsonify({
|
||||
'email': user.email,
|
||||
'id': user.id,
|
||||
'first_name': user.first_name,
|
||||
'last_name': user.last_name,
|
||||
'verified': user.verified,
|
||||
})
|
||||
_set_auth_cookies(resp, access_token, raw_new_refresh)
|
||||
return resp, 200
|
||||
|
||||
|
||||
@auth_api.route('/logout', methods=['POST'])
|
||||
def logout():
|
||||
# Delete the refresh token from DB if present
|
||||
raw_token = request.cookies.get('refresh_token')
|
||||
if raw_token:
|
||||
token_hash = _hash_token(raw_token)
|
||||
refresh_tokens_db.remove(TokenQuery.token_hash == token_hash)
|
||||
|
||||
resp = jsonify({'message': 'Logged out'})
|
||||
# Remove the token cookie by setting it to empty and expiring it
|
||||
resp.set_cookie('token', '', expires=0, httponly=True, secure=True, samesite='Strict')
|
||||
_clear_auth_cookies(resp)
|
||||
return resp, 200
|
||||
|
||||
|
||||
@auth_api.route('/e2e-create-delete-user', methods=['POST'])
|
||||
def e2e_create_delete_user():
|
||||
"""Create a secondary e2e test user for deletion testing. Only available outside production."""
|
||||
if os.environ.get('DB_ENV', 'prod') == 'prod':
|
||||
return jsonify({'error': 'Not available in production'}), 403
|
||||
|
||||
norm_email = normalize_email(E2E_DELETE_EMAIL)
|
||||
users_db.remove(UserQuery.email == norm_email)
|
||||
user = User(
|
||||
first_name='E2E',
|
||||
last_name='Delete',
|
||||
email=norm_email,
|
||||
password=generate_password_hash(E2E_DELETE_PASSWORD),
|
||||
verified=True,
|
||||
role='user',
|
||||
pin=E2E_DELETE_PIN,
|
||||
)
|
||||
users_db.insert(user.to_dict())
|
||||
return jsonify({'email': norm_email}), 201
|
||||
|
||||
|
||||
@auth_api.route('/e2e-create-cc-user', methods=['POST'])
|
||||
def e2e_create_cc_user():
|
||||
"""Create an isolated e2e test user for create-child tests. Only available outside production."""
|
||||
if os.environ.get('DB_ENV', 'prod') == 'prod':
|
||||
return jsonify({'error': 'Not available in production'}), 403
|
||||
|
||||
norm_email = normalize_email(E2E_CC_EMAIL)
|
||||
# Remove old user and all their children so deleteAllChildren() starts clean.
|
||||
existing = users_db.get(UserQuery.email == norm_email)
|
||||
if existing:
|
||||
child_db.remove(Query().user_id == existing.get('id'))
|
||||
users_db.remove(UserQuery.email == norm_email)
|
||||
user = User(
|
||||
first_name='E2E',
|
||||
last_name='CreateChild',
|
||||
email=norm_email,
|
||||
password=generate_password_hash(E2E_CC_PASSWORD),
|
||||
verified=True,
|
||||
role='user',
|
||||
pin=E2E_CC_PIN,
|
||||
)
|
||||
users_db.insert(user.to_dict())
|
||||
return jsonify({'email': norm_email}), 201
|
||||
|
||||
|
||||
@auth_api.route('/e2e-seed', methods=['POST'])
|
||||
def e2e_seed():
|
||||
"""Reset the database and insert a verified test user. Only available outside production."""
|
||||
if os.environ.get('DB_ENV', 'prod') == 'prod':
|
||||
return jsonify({'error': 'Not available in production'}), 403
|
||||
|
||||
child_db.truncate()
|
||||
task_db.truncate()
|
||||
reward_db.truncate()
|
||||
image_db.truncate()
|
||||
pending_reward_db.truncate()
|
||||
pending_confirmations_db.truncate()
|
||||
users_db.truncate()
|
||||
tracking_events_db.truncate()
|
||||
child_overrides_db.truncate()
|
||||
chore_schedules_db.truncate()
|
||||
task_extensions_db.truncate()
|
||||
refresh_tokens_db.truncate()
|
||||
|
||||
# Recreate only baseline defaults for e2e runs.
|
||||
initializeImages()
|
||||
createDefaultTasks()
|
||||
createDefaultRewards()
|
||||
|
||||
norm_email = normalize_email(E2E_TEST_EMAIL)
|
||||
user = User(
|
||||
first_name='E2E',
|
||||
last_name='Tester',
|
||||
email=norm_email,
|
||||
password=generate_password_hash(E2E_TEST_PASSWORD),
|
||||
verified=True,
|
||||
role='user',
|
||||
pin=E2E_TEST_PIN,
|
||||
)
|
||||
users_db.insert(user.to_dict())
|
||||
|
||||
return jsonify({'email': norm_email}), 201
|
||||
|
||||
273
backend/api/child_action_helpers.py
Normal file
273
backend/api/child_action_helpers.py
Normal file
@@ -0,0 +1,273 @@
|
||||
"""Shared business logic for chore confirmation and reward actions.
|
||||
|
||||
Called from both child_api.py (JWT-authenticated endpoints) and
|
||||
digest_action_api.py (token-authenticated endpoints). All functions take
|
||||
user_id explicitly rather than reading it from the Flask request context.
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from tinydb import Query
|
||||
|
||||
from db.db import child_db, task_db, reward_db, pending_confirmations_db
|
||||
from db.child_overrides import get_override
|
||||
from db.chore_schedules import get_schedule
|
||||
from db.tracking import insert_tracking_event
|
||||
from events.sse import send_event_to_user
|
||||
from events.types.child_chore_confirmation import ChildChoreConfirmation
|
||||
from events.types.child_reward_request import ChildRewardRequest
|
||||
from events.types.child_reward_triggered import ChildRewardTriggered
|
||||
from events.types.child_task_triggered import ChildTaskTriggered
|
||||
from events.types.tracking_event_created import TrackingEventCreated
|
||||
from events.types.event import Event
|
||||
from events.types.event_types import EventType
|
||||
from models.child import Child
|
||||
from models.reward import Reward
|
||||
from models.task import Task
|
||||
from models.tracking_event import TrackingEvent
|
||||
from utils.tracking_logger import log_tracking_event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def approve_chore(user_id: str, child_id: str, task_id: str) -> dict | None:
|
||||
"""Award points for a completed chore and mark the pending confirmation approved.
|
||||
|
||||
Returns a result dict on success, or None if the confirmation was already resolved.
|
||||
Raises ValueError if the child or task cannot be found.
|
||||
"""
|
||||
ChildQ = Query()
|
||||
child_result = child_db.get((ChildQ.id == child_id) & (ChildQ.user_id == user_id))
|
||||
if not child_result:
|
||||
raise ValueError(f'Child {child_id} not found for user {user_id}')
|
||||
child = Child.from_dict(child_result)
|
||||
|
||||
if task_id not in child.tasks:
|
||||
logger.info(f'Task {task_id} no longer assigned to child {child_id}; skipping approve')
|
||||
return None
|
||||
|
||||
PendingQ = Query()
|
||||
existing = pending_confirmations_db.get(
|
||||
(PendingQ.child_id == child_id) & (PendingQ.entity_id == task_id) &
|
||||
(PendingQ.entity_type == 'chore') & (PendingQ.status == 'pending') &
|
||||
(PendingQ.user_id == user_id)
|
||||
)
|
||||
if not existing:
|
||||
logger.info(f'No pending chore for child {child_id}, task {task_id} — already resolved')
|
||||
return None
|
||||
|
||||
TaskQ = Query()
|
||||
task_result = task_db.get(
|
||||
(TaskQ.id == task_id) & ((TaskQ.user_id == user_id) | (TaskQ.user_id == None))
|
||||
)
|
||||
if not task_result:
|
||||
raise ValueError(f'Task {task_id} not found')
|
||||
task = Task.from_dict(task_result)
|
||||
|
||||
override = get_override(child_id, task_id)
|
||||
points_value = override.custom_value if override else task.points
|
||||
points_before = child.points
|
||||
child.points += points_value
|
||||
child_db.update({'points': child.points}, ChildQ.id == child_id)
|
||||
|
||||
schedule = get_schedule(child_id, task_id)
|
||||
if schedule:
|
||||
now_str = datetime.now(timezone.utc).isoformat()
|
||||
pending_confirmations_db.update(
|
||||
{'status': 'approved', 'approved_at': now_str},
|
||||
(PendingQ.child_id == child_id) & (PendingQ.entity_id == task_id) &
|
||||
(PendingQ.entity_type == 'chore') & (PendingQ.user_id == user_id)
|
||||
)
|
||||
else:
|
||||
pending_confirmations_db.remove(
|
||||
(PendingQ.child_id == child_id) & (PendingQ.entity_id == task_id) &
|
||||
(PendingQ.entity_type == 'chore') & (PendingQ.user_id == user_id)
|
||||
)
|
||||
|
||||
tracking_metadata = {
|
||||
'task_name': task.name,
|
||||
'task_type': task.type,
|
||||
'default_points': task.points,
|
||||
}
|
||||
if override:
|
||||
tracking_metadata['custom_points'] = override.custom_value
|
||||
tracking_metadata['has_override'] = True
|
||||
|
||||
tracking_event = TrackingEvent.create_event(
|
||||
user_id=user_id, child_id=child_id, entity_type='chore', entity_id=task_id,
|
||||
action='approved', points_before=points_before, points_after=child.points,
|
||||
metadata=tracking_metadata,
|
||||
)
|
||||
insert_tracking_event(tracking_event)
|
||||
log_tracking_event(tracking_event)
|
||||
|
||||
send_event_to_user(user_id, Event(EventType.TRACKING_EVENT_CREATED.value,
|
||||
TrackingEventCreated(tracking_event.id, child_id, 'chore', 'approved')))
|
||||
send_event_to_user(user_id, Event(EventType.CHILD_CHORE_CONFIRMATION.value,
|
||||
ChildChoreConfirmation(child_id, task_id, ChildChoreConfirmation.OPERATION_APPROVED)))
|
||||
send_event_to_user(user_id, Event(EventType.CHILD_TASK_TRIGGERED.value,
|
||||
ChildTaskTriggered(task_id, child_id, child.points)))
|
||||
|
||||
return {'task_name': task.name, 'child_name': child.name, 'child_id': child_id, 'points': child.points}
|
||||
|
||||
|
||||
def reject_chore(user_id: str, child_id: str, task_id: str) -> None:
|
||||
"""Reject a pending chore confirmation. No-op if already resolved."""
|
||||
PendingQ = Query()
|
||||
existing = pending_confirmations_db.get(
|
||||
(PendingQ.child_id == child_id) & (PendingQ.entity_id == task_id) &
|
||||
(PendingQ.entity_type == 'chore') & (PendingQ.status == 'pending') &
|
||||
(PendingQ.user_id == user_id)
|
||||
)
|
||||
if not existing:
|
||||
logger.info(f'No pending chore for child {child_id}, task {task_id} — already resolved')
|
||||
return
|
||||
|
||||
pending_confirmations_db.remove(
|
||||
(PendingQ.child_id == child_id) & (PendingQ.entity_id == task_id) &
|
||||
(PendingQ.entity_type == 'chore') & (PendingQ.user_id == user_id)
|
||||
)
|
||||
|
||||
ChildQ = Query()
|
||||
child_result = child_db.get((ChildQ.id == child_id) & (ChildQ.user_id == user_id))
|
||||
if child_result:
|
||||
child = Child.from_dict(child_result)
|
||||
TaskQ = Query()
|
||||
task_result = task_db.get(
|
||||
(TaskQ.id == task_id) & ((TaskQ.user_id == user_id) | (TaskQ.user_id == None))
|
||||
)
|
||||
task_name = task_result.get('name') if task_result else 'Unknown'
|
||||
|
||||
tracking_event = TrackingEvent.create_event(
|
||||
user_id=user_id, child_id=child_id, entity_type='chore', entity_id=task_id,
|
||||
action='rejected', points_before=child.points, points_after=child.points,
|
||||
metadata={'task_name': task_name},
|
||||
)
|
||||
insert_tracking_event(tracking_event)
|
||||
log_tracking_event(tracking_event)
|
||||
|
||||
send_event_to_user(user_id, Event(EventType.TRACKING_EVENT_CREATED.value,
|
||||
TrackingEventCreated(tracking_event.id, child_id, 'chore', 'rejected')))
|
||||
|
||||
send_event_to_user(user_id, Event(EventType.CHILD_CHORE_CONFIRMATION.value,
|
||||
ChildChoreConfirmation(child_id, task_id, ChildChoreConfirmation.OPERATION_REJECTED)))
|
||||
|
||||
|
||||
def approve_reward_request(user_id: str, child_id: str, reward_id: str) -> dict:
|
||||
"""Approve a child's pending reward request: deduct points and fire SSE events.
|
||||
|
||||
Returns a result dict on success.
|
||||
Raises ValueError if child/reward not found or the child has insufficient points.
|
||||
"""
|
||||
ChildQ = Query()
|
||||
child_result = child_db.get((ChildQ.id == child_id) & (ChildQ.user_id == user_id))
|
||||
if not child_result:
|
||||
raise ValueError(f'Child {child_id} not found for user {user_id}')
|
||||
child = Child.from_dict(child_result)
|
||||
|
||||
if reward_id not in child.rewards:
|
||||
logger.info(f'Reward {reward_id} no longer assigned to child {child_id}; skipping approve')
|
||||
return None
|
||||
|
||||
RewardQ = Query()
|
||||
reward_result = reward_db.get(
|
||||
(RewardQ.id == reward_id) & ((RewardQ.user_id == user_id) | (RewardQ.user_id == None))
|
||||
)
|
||||
if not reward_result:
|
||||
raise ValueError(f'Reward {reward_id} not found')
|
||||
reward = Reward.from_dict(reward_result)
|
||||
|
||||
override = get_override(child_id, reward_id)
|
||||
cost_value = override.custom_value if override else reward.cost
|
||||
|
||||
if child.points < cost_value:
|
||||
raise ValueError(f'Child {child_id} has insufficient points for reward {reward_id}')
|
||||
|
||||
PendingQ = Query()
|
||||
existing = pending_confirmations_db.get(
|
||||
(PendingQ.child_id == child_id) & (PendingQ.entity_id == reward_id) &
|
||||
(PendingQ.entity_type == 'reward') & (PendingQ.status == 'pending') &
|
||||
(PendingQ.user_id == user_id)
|
||||
)
|
||||
if not existing:
|
||||
logger.info(f'No pending reward for child {child_id}, reward {reward_id} — already resolved')
|
||||
return None
|
||||
|
||||
pending_confirmations_db.remove(
|
||||
(PendingQ.child_id == child_id) & (PendingQ.entity_id == reward_id) &
|
||||
(PendingQ.entity_type == 'reward') & (PendingQ.user_id == user_id)
|
||||
)
|
||||
send_event_to_user(user_id, Event(EventType.CHILD_REWARD_REQUEST.value,
|
||||
ChildRewardRequest(child_id, reward_id, ChildRewardRequest.REQUEST_GRANTED)))
|
||||
|
||||
points_before = child.points
|
||||
child.points -= cost_value
|
||||
child_db.update({'points': child.points}, ChildQ.id == child_id)
|
||||
|
||||
tracking_metadata = {
|
||||
'reward_name': reward.name,
|
||||
'reward_cost': reward.cost,
|
||||
'default_cost': reward.cost,
|
||||
}
|
||||
if override:
|
||||
tracking_metadata['custom_cost'] = override.custom_value
|
||||
tracking_metadata['has_override'] = True
|
||||
|
||||
tracking_event = TrackingEvent.create_event(
|
||||
user_id=user_id, child_id=child_id, entity_type='reward', entity_id=reward_id,
|
||||
action='redeemed', points_before=points_before, points_after=child.points,
|
||||
metadata=tracking_metadata,
|
||||
)
|
||||
insert_tracking_event(tracking_event)
|
||||
log_tracking_event(tracking_event)
|
||||
|
||||
send_event_to_user(user_id, Event(EventType.TRACKING_EVENT_CREATED.value,
|
||||
TrackingEventCreated(tracking_event.id, child_id, 'reward', 'redeemed')))
|
||||
send_event_to_user(user_id, Event(EventType.CHILD_REWARD_TRIGGERED.value,
|
||||
ChildRewardTriggered(reward_id, child_id, child.points)))
|
||||
|
||||
return {'reward_name': reward.name, 'child_name': child.name, 'child_id': child_id, 'points': child.points}
|
||||
|
||||
|
||||
def deny_reward(user_id: str, child_id: str, reward_id: str) -> dict | None:
|
||||
"""Deny a child's pending reward request. No-op if already resolved.
|
||||
|
||||
Returns a result dict on success, or None if already resolved.
|
||||
"""
|
||||
PendingQ = Query()
|
||||
existing = pending_confirmations_db.get(
|
||||
(PendingQ.child_id == child_id) & (PendingQ.entity_id == reward_id) &
|
||||
(PendingQ.entity_type == 'reward') & (PendingQ.status == 'pending') &
|
||||
(PendingQ.user_id == user_id)
|
||||
)
|
||||
if not existing:
|
||||
logger.info(f'Reward request for child {child_id}, reward {reward_id} already resolved')
|
||||
return None
|
||||
|
||||
pending_confirmations_db.remove(
|
||||
(PendingQ.child_id == child_id) & (PendingQ.entity_id == reward_id) &
|
||||
(PendingQ.entity_type == 'reward') & (PendingQ.user_id == user_id)
|
||||
)
|
||||
|
||||
ChildQ = Query()
|
||||
child_result = child_db.get((ChildQ.id == child_id) & (ChildQ.user_id == user_id))
|
||||
child_name = 'Unknown'
|
||||
if child_result:
|
||||
child = Child.from_dict(child_result)
|
||||
child_name = child.name
|
||||
|
||||
tracking_event = TrackingEvent.create_event(
|
||||
user_id=user_id, child_id=child_id, entity_type='reward', entity_id=reward_id,
|
||||
action='denied', points_before=child.points, points_after=child.points,
|
||||
metadata={},
|
||||
)
|
||||
insert_tracking_event(tracking_event)
|
||||
log_tracking_event(tracking_event)
|
||||
|
||||
send_event_to_user(user_id, Event(EventType.TRACKING_EVENT_CREATED.value,
|
||||
TrackingEventCreated(tracking_event.id, child_id, 'reward', 'denied')))
|
||||
|
||||
send_event_to_user(user_id, Event(EventType.CHILD_REWARD_REQUEST.value,
|
||||
ChildRewardRequest(child_id, reward_id, ChildRewardRequest.REQUEST_CANCELLED)))
|
||||
|
||||
return {'child_name': child_name}
|
||||
@@ -1,16 +1,19 @@
|
||||
from time import sleep
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from flask import Blueprint, request, jsonify
|
||||
from tinydb import Query
|
||||
|
||||
from api.child_rewards import ChildReward
|
||||
from api.child_tasks import ChildTask
|
||||
from api.pending_reward import PendingReward as PendingRewardResponse
|
||||
from api.pending_confirmation import PendingConfirmationResponse
|
||||
from api.reward_status import RewardStatus
|
||||
from api.utils import send_event_for_current_user
|
||||
from db.db import child_db, task_db, reward_db, pending_reward_db
|
||||
from api.utils import send_event_for_current_user, get_validated_user_id
|
||||
import api.child_action_helpers as chore_actions
|
||||
from db.db import child_db, task_db, reward_db, pending_reward_db, pending_confirmations_db, users_db
|
||||
from db.tracking import insert_tracking_event
|
||||
from db.child_overrides import get_override, delete_override, delete_overrides_for_child
|
||||
from events.types.child_chore_confirmation import ChildChoreConfirmation
|
||||
from events.types.child_modified import ChildModified
|
||||
from events.types.child_reward_request import ChildRewardRequest
|
||||
from events.types.child_reward_triggered import ChildRewardTriggered
|
||||
@@ -21,13 +24,17 @@ from events.types.tracking_event_created import TrackingEventCreated
|
||||
from events.types.event import Event
|
||||
from events.types.event_types import EventType
|
||||
from models.child import Child
|
||||
from models.pending_confirmation import PendingConfirmation
|
||||
from models.pending_reward import PendingReward
|
||||
from models.reward import Reward
|
||||
from models.task import Task
|
||||
from models.tracking_event import TrackingEvent
|
||||
from api.utils import get_validated_user_id
|
||||
from utils.tracking_logger import log_tracking_event
|
||||
from utils.push_sender import send_push_to_user
|
||||
from utils.digest_token import create_action_token
|
||||
from collections import defaultdict
|
||||
from db.chore_schedules import get_schedule
|
||||
from db.task_extensions import get_extension_for_child_task
|
||||
import logging
|
||||
|
||||
child_api = Blueprint('child_api', __name__)
|
||||
@@ -95,18 +102,22 @@ def edit_child(id):
|
||||
# Check if points changed and handle pending rewards
|
||||
if points is not None:
|
||||
PendingQuery = Query()
|
||||
pending_rewards = pending_reward_db.search((PendingQuery.child_id == id) & (PendingQuery.user_id == user_id))
|
||||
pending_rewards = pending_confirmations_db.search(
|
||||
(PendingQuery.child_id == id) & (PendingQuery.user_id == user_id) &
|
||||
(PendingQuery.entity_type == 'reward') & (PendingQuery.status == 'pending')
|
||||
)
|
||||
|
||||
RewardQuery = Query()
|
||||
for pr in pending_rewards:
|
||||
pending = PendingReward.from_dict(pr)
|
||||
reward_result = reward_db.get((RewardQuery.id == pending.reward_id) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None)))
|
||||
pending = PendingConfirmation.from_dict(pr)
|
||||
reward_result = reward_db.get((RewardQuery.id == pending.entity_id) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None)))
|
||||
if reward_result:
|
||||
reward = Reward.from_dict(reward_result)
|
||||
# If child can no longer afford the reward, remove the pending request
|
||||
if child.points < reward.cost:
|
||||
pending_reward_db.remove(
|
||||
(PendingQuery.child_id == id) & (PendingQuery.reward_id == reward.id) & (PendingQuery.user_id == user_id)
|
||||
pending_confirmations_db.remove(
|
||||
(PendingQuery.child_id == id) & (PendingQuery.entity_id == reward.id) &
|
||||
(PendingQuery.entity_type == 'reward') & (PendingQuery.user_id == user_id)
|
||||
)
|
||||
resp = send_event_for_current_user(
|
||||
Event(EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest(id, reward.id, ChildRewardRequest.REQUEST_CANCELLED)))
|
||||
@@ -166,7 +177,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'])
|
||||
@@ -177,11 +188,10 @@ def set_child_tasks(id):
|
||||
data = request.get_json() or {}
|
||||
task_ids = data.get('task_ids')
|
||||
if 'type' not in data:
|
||||
return jsonify({'error': 'type is required (good or bad)'}), 400
|
||||
task_type = data.get('type', 'good')
|
||||
if task_type not in ['good', 'bad']:
|
||||
return jsonify({'error': 'type must be either good or bad'}), 400
|
||||
is_good = task_type == 'good'
|
||||
return jsonify({'error': 'type is required (chore, kindness, or penalty)'}), 400
|
||||
task_type = data.get('type')
|
||||
if task_type not in ['chore', 'kindness', 'penalty']:
|
||||
return jsonify({'error': 'type must be chore, kindness, or penalty', 'code': 'INVALID_TASK_TYPE'}), 400
|
||||
if not isinstance(task_ids, list):
|
||||
return jsonify({'error': 'task_ids must be a list'}), 400
|
||||
|
||||
@@ -192,15 +202,16 @@ def set_child_tasks(id):
|
||||
child = Child.from_dict(result[0])
|
||||
new_task_ids = set(task_ids)
|
||||
|
||||
# Add all existing child tasks of the opposite type
|
||||
for task in task_db.all():
|
||||
if task['id'] in child.tasks and task['is_good'] != is_good:
|
||||
new_task_ids.add(task['id'])
|
||||
# Add all existing child tasks of other types
|
||||
for task_record in task_db.all():
|
||||
task_obj = Task.from_dict(task_record)
|
||||
if task_obj.id in child.tasks and task_obj.type != task_type:
|
||||
new_task_ids.add(task_obj.id)
|
||||
|
||||
# Convert back to list if needed
|
||||
new_tasks = list(new_task_ids)
|
||||
|
||||
# Identify unassigned tasks and delete their overrides
|
||||
# Identify unassigned tasks and delete their overrides and pending confirmations
|
||||
old_task_ids = set(child.tasks)
|
||||
unassigned_task_ids = old_task_ids - new_task_ids
|
||||
for task_id in unassigned_task_ids:
|
||||
@@ -209,6 +220,12 @@ def set_child_tasks(id):
|
||||
if override and override.entity_type == 'task':
|
||||
delete_override(id, task_id)
|
||||
logger.info(f"Deleted override for unassigned task: child={id}, task={task_id}")
|
||||
# Clear any pending chore confirmation
|
||||
PendingQ = Query()
|
||||
pending_confirmations_db.remove(
|
||||
(PendingQ.child_id == id) & (PendingQ.entity_id == task_id) &
|
||||
(PendingQ.entity_type == 'chore') & (PendingQ.user_id == user_id)
|
||||
)
|
||||
|
||||
# Replace tasks with validated IDs
|
||||
child_db.update({'tasks': new_tasks}, ChildQuery.id == id)
|
||||
@@ -242,6 +259,12 @@ def remove_task_from_child(id):
|
||||
if task_id in child.get('tasks', []):
|
||||
child['tasks'].remove(task_id)
|
||||
child_db.update({'tasks': child['tasks']}, ChildQuery.id == id)
|
||||
# Clear any pending chore confirmation for this task
|
||||
PendingQ = Query()
|
||||
pending_confirmations_db.remove(
|
||||
(PendingQ.child_id == id) & (PendingQ.entity_id == task_id) &
|
||||
(PendingQ.entity_type == 'chore') & (PendingQ.user_id == user_id)
|
||||
)
|
||||
return jsonify({'message': f'Task {task_id} removed from {child["name"]}.'}), 200
|
||||
return jsonify({'error': 'Task not assigned to child'}), 400
|
||||
|
||||
@@ -265,14 +288,42 @@ def list_child_tasks(id):
|
||||
if not task:
|
||||
continue
|
||||
|
||||
task_obj = Task.from_dict(task)
|
||||
|
||||
# Check for override
|
||||
override = get_override(id, tid)
|
||||
custom_value = override.custom_value if override else None
|
||||
|
||||
ct = ChildTask(task.get('name'), task.get('is_good'), task.get('points'), task.get('image_id'), task.get('id'))
|
||||
ct = ChildTask(task_obj.name, task_obj.type, task_obj.points, task_obj.image_id, task_obj.id)
|
||||
ct_dict = ct.to_dict()
|
||||
if custom_value is not None:
|
||||
ct_dict['custom_value'] = custom_value
|
||||
|
||||
# Attach schedule and most recent extension_date for chores (client does all time math)
|
||||
if task_obj.type == 'chore':
|
||||
schedule = get_schedule(id, tid)
|
||||
ct_dict['schedule'] = schedule.to_dict() if schedule else None
|
||||
ext = get_extension_for_child_task(id, tid)
|
||||
ct_dict['extension_date'] = ext.date if ext else None
|
||||
|
||||
# Attach pending confirmation status for chores
|
||||
PendingQuery = Query()
|
||||
pending = pending_confirmations_db.get(
|
||||
(PendingQuery.child_id == id) & (PendingQuery.entity_id == tid) &
|
||||
(PendingQuery.entity_type == 'chore') & (PendingQuery.user_id == user_id)
|
||||
)
|
||||
if pending:
|
||||
ct_dict['pending_status'] = pending.get('status')
|
||||
ct_dict['approved_at'] = pending.get('approved_at')
|
||||
else:
|
||||
ct_dict['pending_status'] = None
|
||||
ct_dict['approved_at'] = None
|
||||
else:
|
||||
ct_dict['schedule'] = None
|
||||
ct_dict['extension_date'] = None
|
||||
ct_dict['pending_status'] = None
|
||||
ct_dict['approved_at'] = None
|
||||
|
||||
child_tasks.append(ct_dict)
|
||||
|
||||
return jsonify({'tasks': child_tasks}), 200
|
||||
@@ -294,7 +345,6 @@ def list_assignable_tasks(id):
|
||||
all_tasks = [t for t in task_db.all() if t and t.get('id') and t.get('id') not in assigned_ids]
|
||||
|
||||
# Group by name
|
||||
from collections import defaultdict
|
||||
name_to_tasks = defaultdict(list)
|
||||
for t in all_tasks:
|
||||
name_to_tasks[t.get('name')].append(t)
|
||||
@@ -313,7 +363,7 @@ def list_assignable_tasks(id):
|
||||
filtered_tasks.extend(user_tasks)
|
||||
|
||||
# Wrap in ChildTask and return
|
||||
assignable_tasks = [ChildTask(t.get('name'), t.get('is_good'), t.get('points'), t.get('image_id'), t.get('id')).to_dict() for t in filtered_tasks]
|
||||
assignable_tasks = [ChildTask(t.get('name'), Task.from_dict(t).type, t.get('points'), t.get('image_id'), t.get('id')).to_dict() for t in filtered_tasks]
|
||||
return jsonify({'tasks': assignable_tasks, 'count': len(assignable_tasks)}), 200
|
||||
|
||||
|
||||
@@ -327,9 +377,9 @@ def list_all_tasks(id):
|
||||
if not result:
|
||||
return jsonify({'error': 'Child not found'}), 404
|
||||
has_type = "type" in request.args
|
||||
if has_type and request.args.get('type') not in ['good', 'bad']:
|
||||
return jsonify({'error': 'type must be either good or bad'}), 400
|
||||
good = request.args.get('type', False) == 'good'
|
||||
if has_type and request.args.get('type') not in ['chore', 'kindness', 'penalty']:
|
||||
return jsonify({'error': 'type must be chore, kindness, or penalty'}), 400
|
||||
filter_type = request.args.get('type', None) if has_type else None
|
||||
|
||||
child = result[0]
|
||||
assigned_ids = set(child.get('tasks', []))
|
||||
@@ -353,14 +403,15 @@ def list_all_tasks(id):
|
||||
|
||||
result_tasks = []
|
||||
for t in filtered_tasks:
|
||||
if has_type and t.get('is_good') != good:
|
||||
task_obj = Task.from_dict(t)
|
||||
if has_type and task_obj.type != filter_type:
|
||||
continue
|
||||
ct = ChildTask(
|
||||
t.get('name'),
|
||||
t.get('is_good'),
|
||||
t.get('points'),
|
||||
t.get('image_id'),
|
||||
t.get('id')
|
||||
task_obj.name,
|
||||
task_obj.type,
|
||||
task_obj.points,
|
||||
task_obj.image_id,
|
||||
task_obj.id
|
||||
)
|
||||
task_dict = ct.to_dict()
|
||||
task_dict.update({'assigned': t.get('id') in assigned_ids})
|
||||
@@ -412,11 +463,31 @@ def trigger_child_task(id):
|
||||
# update the child in the database
|
||||
child_db.update({'points': child.points}, ChildQuery.id == id)
|
||||
|
||||
# For chores, create an approved PendingConfirmation so child view shows COMPLETED
|
||||
# Only persist for scheduled chores; general chores reset to normal after trigger
|
||||
if task.type == 'chore':
|
||||
PendingQuery = Query()
|
||||
# Remove any existing pending confirmation for this chore
|
||||
pending_confirmations_db.remove(
|
||||
(PendingQuery.child_id == id) & (PendingQuery.entity_id == task_id) &
|
||||
(PendingQuery.entity_type == 'chore') & (PendingQuery.user_id == user_id)
|
||||
)
|
||||
schedule = get_schedule(id, task_id)
|
||||
if schedule:
|
||||
confirmation = PendingConfirmation(
|
||||
child_id=id, entity_id=task_id, entity_type='chore',
|
||||
user_id=user_id, status='approved',
|
||||
approved_at=datetime.now(timezone.utc).isoformat()
|
||||
)
|
||||
pending_confirmations_db.insert(confirmation.to_dict())
|
||||
send_event_for_current_user(Event(EventType.CHILD_CHORE_CONFIRMATION.value,
|
||||
ChildChoreConfirmation(id, task_id, ChildChoreConfirmation.OPERATION_APPROVED)))
|
||||
|
||||
# Create tracking event
|
||||
entity_type = 'penalty' if not task.is_good else 'task'
|
||||
entity_type = task.type
|
||||
tracking_metadata = {
|
||||
'task_name': task.name,
|
||||
'is_good': task.is_good,
|
||||
'task_type': task.type,
|
||||
'default_points': task.points
|
||||
}
|
||||
if override:
|
||||
@@ -482,7 +553,6 @@ def list_all_rewards(id):
|
||||
ChildRewardQuery = Query()
|
||||
all_rewards = reward_db.search((ChildRewardQuery.user_id == user_id) | (ChildRewardQuery.user_id == None))
|
||||
|
||||
from collections import defaultdict
|
||||
name_to_rewards = defaultdict(list)
|
||||
for r in all_rewards:
|
||||
name_to_rewards[r.get('name')].append(r)
|
||||
@@ -544,7 +614,7 @@ def set_child_rewards(id):
|
||||
if reward_db.get((RewardQuery.id == rid) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None))):
|
||||
valid_reward_ids.append(rid)
|
||||
|
||||
# Identify unassigned rewards and delete their overrides
|
||||
# Identify unassigned rewards and delete their overrides and pending confirmations
|
||||
new_reward_ids_set = set(valid_reward_ids)
|
||||
unassigned_reward_ids = old_reward_ids - new_reward_ids_set
|
||||
for reward_id in unassigned_reward_ids:
|
||||
@@ -552,6 +622,12 @@ def set_child_rewards(id):
|
||||
if override and override.entity_type == 'reward':
|
||||
delete_override(id, reward_id)
|
||||
logger.info(f"Deleted override for unassigned reward: child={id}, reward={reward_id}")
|
||||
# Clear any pending reward confirmation
|
||||
PendingQ = Query()
|
||||
pending_confirmations_db.remove(
|
||||
(PendingQ.child_id == id) & (PendingQ.entity_id == reward_id) &
|
||||
(PendingQ.entity_type == 'reward') & (PendingQ.user_id == user_id)
|
||||
)
|
||||
|
||||
# Replace rewards with validated IDs
|
||||
child_db.update({'rewards': valid_reward_ids}, ChildQuery.id == id)
|
||||
@@ -582,6 +658,12 @@ def remove_reward_from_child(id):
|
||||
if reward_id in child.get('rewards', []):
|
||||
child['rewards'].remove(reward_id)
|
||||
child_db.update({'rewards': child['rewards']}, ChildQuery.id == id)
|
||||
# Clear any pending reward confirmation for this reward
|
||||
PendingQ = Query()
|
||||
pending_confirmations_db.remove(
|
||||
(PendingQ.child_id == id) & (PendingQ.entity_id == reward_id) &
|
||||
(PendingQ.entity_type == 'reward') & (PendingQ.user_id == user_id)
|
||||
)
|
||||
return jsonify({'message': f'Reward {reward_id} removed from {child["name"]}.'}), 200
|
||||
return jsonify({'error': 'Reward not assigned to child'}), 400
|
||||
|
||||
@@ -634,7 +716,6 @@ def list_assignable_rewards(id):
|
||||
all_rewards = [r for r in reward_db.all() if r and r.get('id') and r.get('id') not in assigned_ids]
|
||||
|
||||
# Group by name
|
||||
from collections import defaultdict
|
||||
name_to_rewards = defaultdict(list)
|
||||
for r in all_rewards:
|
||||
name_to_rewards[r.get('name')].append(r)
|
||||
@@ -694,8 +775,9 @@ def trigger_child_reward(id):
|
||||
|
||||
# Remove matching pending reward requests for this child and reward
|
||||
PendingQuery = Query()
|
||||
removed = pending_reward_db.remove(
|
||||
(PendingQuery.child_id == child.id) & (PendingQuery.reward_id == reward.id)
|
||||
removed = pending_confirmations_db.remove(
|
||||
(PendingQuery.child_id == child.id) & (PendingQuery.entity_id == reward.id) &
|
||||
(PendingQuery.entity_type == 'reward')
|
||||
)
|
||||
if removed:
|
||||
send_event_for_current_user(Event(EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest(reward.id, child.id, ChildRewardRequest.REQUEST_GRANTED)))
|
||||
@@ -784,9 +866,12 @@ def reward_status(id):
|
||||
cost_value = override.custom_value if override else reward.cost
|
||||
points_needed = max(0, cost_value - points)
|
||||
|
||||
#check to see if this reward id and child id is in the pending rewards db if so set its redeeming flag to true
|
||||
#check to see if this reward id and child id is in the pending confirmations db
|
||||
pending_query = Query()
|
||||
pending = pending_reward_db.get((pending_query.child_id == child.id) & (pending_query.reward_id == reward.id) & (pending_query.user_id == user_id))
|
||||
pending = pending_confirmations_db.get(
|
||||
(pending_query.child_id == child.id) & (pending_query.entity_id == reward.id) &
|
||||
(pending_query.entity_type == 'reward') & (pending_query.user_id == user_id)
|
||||
)
|
||||
status = RewardStatus(reward.id, reward.name, points_needed, cost_value, pending is not None, reward.image_id)
|
||||
status_dict = status.to_dict()
|
||||
if override:
|
||||
@@ -834,8 +919,18 @@ def request_reward(id):
|
||||
'reward_cost': reward.cost
|
||||
}), 400
|
||||
|
||||
pending = PendingReward(child_id=child.id, reward_id=reward.id, user_id=user_id)
|
||||
pending_reward_db.insert(pending.to_dict())
|
||||
# Check for duplicate pending request
|
||||
DupQuery = Query()
|
||||
duplicate = pending_confirmations_db.get(
|
||||
(DupQuery.child_id == child.id) & (DupQuery.entity_id == reward.id) &
|
||||
(DupQuery.entity_type == 'reward') & (DupQuery.status == 'pending') &
|
||||
(DupQuery.user_id == user_id)
|
||||
)
|
||||
if duplicate:
|
||||
return jsonify({'error': 'Reward request already pending', 'code': 'DUPLICATE_REWARD_REQUEST'}), 409
|
||||
|
||||
pending = PendingConfirmation(child_id=child.id, entity_id=reward.id, entity_type='reward', user_id=user_id)
|
||||
pending_confirmations_db.insert(pending.to_dict())
|
||||
logger.info(f'Pending reward request created for child {child.name} for reward {reward.name}')
|
||||
|
||||
# Create tracking event (no points change on request)
|
||||
@@ -856,6 +951,30 @@ def request_reward(id):
|
||||
send_event_for_current_user(Event(EventType.TRACKING_EVENT_CREATED.value, TrackingEventCreated(tracking_event.id, child.id, 'reward', 'requested')))
|
||||
|
||||
send_event_for_current_user(Event(EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest(child.id, reward.id, ChildRewardRequest.REQUEST_CREATED)))
|
||||
|
||||
# Fire web push notification to all parent subscriptions
|
||||
_push_user = users_db.get(Query().id == user_id)
|
||||
if _push_user and _push_user.get('push_notifications_enabled', True):
|
||||
try:
|
||||
approve_token = create_action_token(user_id, child.id, reward.id, 'reward', 'approve')
|
||||
deny_token = create_action_token(user_id, child.id, reward.id, 'reward', 'deny')
|
||||
push_payload = {
|
||||
'type': 'reward_requested',
|
||||
'title': f'{child.name} wants a reward',
|
||||
'body': f'{reward.name} costs {reward.cost} points.',
|
||||
'user_id': user_id,
|
||||
'child_id': child.id,
|
||||
'child_name': child.name,
|
||||
'entity_id': reward.id,
|
||||
'entity_type': 'reward',
|
||||
'entity_name': reward.name,
|
||||
'approve_token': approve_token.id,
|
||||
'deny_token': deny_token.id,
|
||||
}
|
||||
send_push_to_user(user_id, push_payload)
|
||||
except Exception as _push_err:
|
||||
logger.warning(f'Push notification failed for reward request: {_push_err}')
|
||||
|
||||
return jsonify({
|
||||
'message': f'Reward request for {reward.name} submitted for {child.name}.',
|
||||
'reward_id': reward.id,
|
||||
@@ -890,8 +1009,9 @@ def cancel_request_reward(id):
|
||||
|
||||
# Remove matching pending reward request
|
||||
PendingQuery = Query()
|
||||
removed = pending_reward_db.remove(
|
||||
(PendingQuery.child_id == child.id) & (PendingQuery.reward_id == reward_id) & (PendingQuery.user_id == user_id)
|
||||
removed = pending_confirmations_db.remove(
|
||||
(PendingQuery.child_id == child.id) & (PendingQuery.entity_id == reward_id) &
|
||||
(PendingQuery.entity_type == 'reward') & (PendingQuery.user_id == user_id)
|
||||
)
|
||||
|
||||
if not removed:
|
||||
@@ -927,26 +1047,23 @@ def cancel_request_reward(id):
|
||||
|
||||
|
||||
|
||||
@child_api.route('/pending-rewards', methods=['GET'])
|
||||
def list_pending_rewards():
|
||||
@child_api.route('/pending-confirmations', methods=['GET'])
|
||||
def list_pending_confirmations():
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||
PendingQuery = Query()
|
||||
pending_rewards = pending_reward_db.search(PendingQuery.user_id == user_id)
|
||||
reward_responses = []
|
||||
pending_items = pending_confirmations_db.search(
|
||||
(PendingQuery.user_id == user_id) & (PendingQuery.status == 'pending')
|
||||
)
|
||||
responses = []
|
||||
|
||||
RewardQuery = Query()
|
||||
TaskQuery = Query()
|
||||
ChildQuery = Query()
|
||||
|
||||
for pr in pending_rewards:
|
||||
pending = PendingReward.from_dict(pr)
|
||||
|
||||
# Look up reward details
|
||||
reward_result = reward_db.get((RewardQuery.id == pending.reward_id) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None)))
|
||||
if not reward_result:
|
||||
continue
|
||||
reward = Reward.from_dict(reward_result)
|
||||
for pr in pending_items:
|
||||
pending = PendingConfirmation.from_dict(pr)
|
||||
|
||||
# Look up child details
|
||||
child_result = child_db.get(ChildQuery.id == pending.child_id)
|
||||
@@ -954,17 +1071,297 @@ def list_pending_rewards():
|
||||
continue
|
||||
child = Child.from_dict(child_result)
|
||||
|
||||
# Create response object
|
||||
response = PendingRewardResponse(
|
||||
# Look up entity details based on type
|
||||
if pending.entity_type == 'reward':
|
||||
entity_result = reward_db.get((RewardQuery.id == pending.entity_id) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None)))
|
||||
else:
|
||||
entity_result = task_db.get((TaskQuery.id == pending.entity_id) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)))
|
||||
|
||||
if not entity_result:
|
||||
continue
|
||||
|
||||
response = PendingConfirmationResponse(
|
||||
_id=pending.id,
|
||||
child_id=child.id,
|
||||
child_name=child.name,
|
||||
child_image_id=child.image_id,
|
||||
reward_id=reward.id,
|
||||
reward_name=reward.name,
|
||||
reward_image_id=reward.image_id
|
||||
entity_id=pending.entity_id,
|
||||
entity_type=pending.entity_type,
|
||||
entity_name=entity_result.get('name'),
|
||||
entity_image_id=entity_result.get('image_id'),
|
||||
status=pending.status,
|
||||
approved_at=pending.approved_at
|
||||
)
|
||||
reward_responses.append(response.to_dict())
|
||||
responses.append(response.to_dict())
|
||||
|
||||
return jsonify({'confirmations': responses, 'count': len(responses), 'list_type': 'notification'}), 200
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chore Confirmation Endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@child_api.route('/child/<id>/confirm-chore', methods=['POST'])
|
||||
def confirm_chore(id):
|
||||
"""Child confirms they completed a chore. Creates a pending confirmation."""
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||
data = request.get_json()
|
||||
task_id = data.get('task_id')
|
||||
if not task_id:
|
||||
return jsonify({'error': 'task_id is required'}), 400
|
||||
|
||||
ChildQuery = Query()
|
||||
result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id))
|
||||
if not result:
|
||||
return jsonify({'error': 'Child not found'}), 404
|
||||
|
||||
child = Child.from_dict(result[0])
|
||||
if task_id not in child.tasks:
|
||||
return jsonify({'error': 'Task not assigned to child', 'code': 'ENTITY_NOT_ASSIGNED'}), 400
|
||||
|
||||
TaskQuery = Query()
|
||||
task_result = task_db.get((TaskQuery.id == task_id) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)))
|
||||
if not task_result:
|
||||
return jsonify({'error': 'Task not found', 'code': 'TASK_NOT_FOUND'}), 404
|
||||
|
||||
task = Task.from_dict(task_result)
|
||||
if task.type != 'chore':
|
||||
return jsonify({'error': 'Only chores can be confirmed', 'code': 'INVALID_TASK_TYPE'}), 400
|
||||
|
||||
# Check if already pending or completed today
|
||||
PendingQuery = Query()
|
||||
existing = pending_confirmations_db.get(
|
||||
(PendingQuery.child_id == id) & (PendingQuery.entity_id == task_id) &
|
||||
(PendingQuery.entity_type == 'chore') & (PendingQuery.user_id == user_id)
|
||||
)
|
||||
if existing:
|
||||
if existing.get('status') == 'pending':
|
||||
return jsonify({'error': 'Chore already pending confirmation', 'code': 'CHORE_ALREADY_PENDING'}), 400
|
||||
if existing.get('status') == 'approved':
|
||||
approved_at = existing.get('approved_at', '')
|
||||
today_utc = datetime.now(timezone.utc).strftime('%Y-%m-%d')
|
||||
if approved_at and approved_at[:10] == today_utc:
|
||||
return jsonify({'error': 'Chore already completed today', 'code': 'CHORE_ALREADY_COMPLETED'}), 400
|
||||
|
||||
confirmation = PendingConfirmation(
|
||||
child_id=id, entity_id=task_id, entity_type='chore', user_id=user_id
|
||||
)
|
||||
pending_confirmations_db.insert(confirmation.to_dict())
|
||||
|
||||
# Create tracking event
|
||||
tracking_event = TrackingEvent.create_event(
|
||||
user_id=user_id, child_id=id, entity_type='chore', entity_id=task_id,
|
||||
action='confirmed', points_before=child.points, points_after=child.points,
|
||||
metadata={'task_name': task.name, 'task_type': task.type}
|
||||
)
|
||||
insert_tracking_event(tracking_event)
|
||||
log_tracking_event(tracking_event)
|
||||
|
||||
send_event_for_current_user(Event(EventType.TRACKING_EVENT_CREATED.value,
|
||||
TrackingEventCreated(tracking_event.id, id, 'chore', 'confirmed')))
|
||||
send_event_for_current_user(Event(EventType.CHILD_CHORE_CONFIRMATION.value,
|
||||
ChildChoreConfirmation(id, task_id, ChildChoreConfirmation.OPERATION_CONFIRMED)))
|
||||
|
||||
# Fire web push notification to all parent subscriptions
|
||||
_push_user = users_db.get(Query().id == user_id)
|
||||
if _push_user and _push_user.get('push_notifications_enabled', True):
|
||||
try:
|
||||
approve_token = create_action_token(user_id, id, task_id, 'chore', 'approve')
|
||||
deny_token = create_action_token(user_id, id, task_id, 'chore', 'deny')
|
||||
push_payload = {
|
||||
'type': 'chore_confirmed',
|
||||
'title': f'{child.name} completed a chore',
|
||||
'body': f'{task.name} is waiting for your approval.',
|
||||
'user_id': user_id,
|
||||
'child_id': id,
|
||||
'child_name': child.name,
|
||||
'entity_id': task_id,
|
||||
'entity_type': 'chore',
|
||||
'entity_name': task.name,
|
||||
'approve_token': approve_token.id,
|
||||
'deny_token': deny_token.id,
|
||||
}
|
||||
send_push_to_user(user_id, push_payload)
|
||||
except Exception as _push_err:
|
||||
logger.warning(f'Push notification failed for chore confirmation: {_push_err}')
|
||||
|
||||
return jsonify({'message': f'Chore {task.name} confirmed by {child.name}.', 'confirmation_id': confirmation.id}), 200
|
||||
|
||||
|
||||
@child_api.route('/child/<id>/cancel-confirm-chore', methods=['POST'])
|
||||
def cancel_confirm_chore(id):
|
||||
"""Child cancels their pending chore confirmation."""
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||
data = request.get_json()
|
||||
task_id = data.get('task_id')
|
||||
if not task_id:
|
||||
return jsonify({'error': 'task_id is required'}), 400
|
||||
|
||||
ChildQuery = Query()
|
||||
result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id))
|
||||
if not result:
|
||||
return jsonify({'error': 'Child not found'}), 404
|
||||
child = Child.from_dict(result[0])
|
||||
|
||||
PendingQuery = Query()
|
||||
existing = pending_confirmations_db.get(
|
||||
(PendingQuery.child_id == id) & (PendingQuery.entity_id == task_id) &
|
||||
(PendingQuery.entity_type == 'chore') & (PendingQuery.status == 'pending') &
|
||||
(PendingQuery.user_id == user_id)
|
||||
)
|
||||
if not existing:
|
||||
return jsonify({'error': 'No pending confirmation found', 'code': 'PENDING_NOT_FOUND'}), 400
|
||||
|
||||
pending_confirmations_db.remove(
|
||||
(PendingQuery.child_id == id) & (PendingQuery.entity_id == task_id) &
|
||||
(PendingQuery.entity_type == 'chore') & (PendingQuery.status == 'pending') &
|
||||
(PendingQuery.user_id == user_id)
|
||||
)
|
||||
|
||||
# Fetch task name for tracking
|
||||
TaskQuery = Query()
|
||||
task_result = task_db.get((TaskQuery.id == task_id) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)))
|
||||
task_name = task_result.get('name') if task_result else 'Unknown'
|
||||
|
||||
tracking_event = TrackingEvent.create_event(
|
||||
user_id=user_id, child_id=id, entity_type='chore', entity_id=task_id,
|
||||
action='cancelled', points_before=child.points, points_after=child.points,
|
||||
metadata={'task_name': task_name}
|
||||
)
|
||||
insert_tracking_event(tracking_event)
|
||||
log_tracking_event(tracking_event)
|
||||
|
||||
send_event_for_current_user(Event(EventType.TRACKING_EVENT_CREATED.value,
|
||||
TrackingEventCreated(tracking_event.id, id, 'chore', 'cancelled')))
|
||||
send_event_for_current_user(Event(EventType.CHILD_CHORE_CONFIRMATION.value,
|
||||
ChildChoreConfirmation(id, task_id, ChildChoreConfirmation.OPERATION_CANCELLED)))
|
||||
return jsonify({'message': 'Chore confirmation cancelled.'}), 200
|
||||
|
||||
|
||||
@child_api.route('/child/<id>/approve-chore', methods=['POST'])
|
||||
def approve_chore(id):
|
||||
"""Parent approves a pending chore confirmation, awarding points."""
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||
data = request.get_json()
|
||||
task_id = data.get('task_id')
|
||||
if not task_id:
|
||||
return jsonify({'error': 'task_id is required'}), 400
|
||||
|
||||
try:
|
||||
result = chore_actions.approve_chore(user_id, id, task_id)
|
||||
except ValueError:
|
||||
return jsonify({'error': 'Child or task not found'}), 404
|
||||
|
||||
if result is None:
|
||||
return jsonify({'error': 'No pending confirmation found', 'code': 'PENDING_NOT_FOUND'}), 400
|
||||
|
||||
return jsonify({
|
||||
'message': f'Chore {result["task_name"]} approved for {result["child_name"]}.',
|
||||
'points': result['points'],
|
||||
'id': result['child_id']
|
||||
}), 200
|
||||
|
||||
|
||||
@child_api.route('/child/<id>/reject-chore', methods=['POST'])
|
||||
def reject_chore(id):
|
||||
"""Parent rejects a pending chore confirmation."""
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||
data = request.get_json()
|
||||
task_id = data.get('task_id')
|
||||
if not task_id:
|
||||
return jsonify({'error': 'task_id is required'}), 400
|
||||
|
||||
ChildQuery = Query()
|
||||
result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id))
|
||||
if not result:
|
||||
return jsonify({'error': 'Child not found'}), 404
|
||||
|
||||
PendingQuery = Query()
|
||||
existing = pending_confirmations_db.get(
|
||||
(PendingQuery.child_id == id) & (PendingQuery.entity_id == task_id) &
|
||||
(PendingQuery.entity_type == 'chore') & (PendingQuery.status == 'pending') &
|
||||
(PendingQuery.user_id == user_id)
|
||||
)
|
||||
if not existing:
|
||||
return jsonify({'error': 'No pending confirmation found', 'code': 'PENDING_NOT_FOUND'}), 400
|
||||
|
||||
chore_actions.reject_chore(user_id, id, task_id)
|
||||
return jsonify({'message': 'Chore confirmation rejected.'}), 200
|
||||
|
||||
|
||||
@child_api.route('/child/<id>/reset-chore', methods=['POST'])
|
||||
def reset_chore(id):
|
||||
"""Parent resets a completed chore so the child can confirm again."""
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||
data = request.get_json()
|
||||
task_id = data.get('task_id')
|
||||
if not task_id:
|
||||
return jsonify({'error': 'task_id is required'}), 400
|
||||
|
||||
ChildQuery = Query()
|
||||
result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id))
|
||||
if not result:
|
||||
return jsonify({'error': 'Child not found'}), 404
|
||||
child = Child.from_dict(result[0])
|
||||
|
||||
PendingQuery = Query()
|
||||
existing = pending_confirmations_db.get(
|
||||
(PendingQuery.child_id == id) & (PendingQuery.entity_id == task_id) &
|
||||
(PendingQuery.entity_type == 'chore') & (PendingQuery.status == 'approved') &
|
||||
(PendingQuery.user_id == user_id)
|
||||
)
|
||||
if not existing:
|
||||
return jsonify({'error': 'No completed confirmation found to reset', 'code': 'PENDING_NOT_FOUND'}), 400
|
||||
|
||||
pending_confirmations_db.remove(
|
||||
(PendingQuery.child_id == id) & (PendingQuery.entity_id == task_id) &
|
||||
(PendingQuery.entity_type == 'chore') & (PendingQuery.user_id == user_id)
|
||||
)
|
||||
|
||||
TaskQuery = Query()
|
||||
task_result = task_db.get((TaskQuery.id == task_id) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)))
|
||||
task_name = task_result.get('name') if task_result else 'Unknown'
|
||||
|
||||
tracking_event = TrackingEvent.create_event(
|
||||
user_id=user_id, child_id=id, entity_type='chore', entity_id=task_id,
|
||||
action='reset', points_before=child.points, points_after=child.points,
|
||||
metadata={'task_name': task_name}
|
||||
)
|
||||
insert_tracking_event(tracking_event)
|
||||
log_tracking_event(tracking_event)
|
||||
|
||||
send_event_for_current_user(Event(EventType.TRACKING_EVENT_CREATED.value,
|
||||
TrackingEventCreated(tracking_event.id, id, 'chore', 'reset')))
|
||||
send_event_for_current_user(Event(EventType.CHILD_CHORE_CONFIRMATION.value,
|
||||
ChildChoreConfirmation(id, task_id, ChildChoreConfirmation.OPERATION_RESET)))
|
||||
return jsonify({'message': 'Chore reset to available.'}), 200
|
||||
|
||||
|
||||
@child_api.route('/child/<id>/deny-reward-request', methods=['POST'])
|
||||
def deny_reward_request(id):
|
||||
"""Parent denies a child's pending reward request."""
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||
data = request.get_json()
|
||||
reward_id = data.get('reward_id')
|
||||
if not reward_id:
|
||||
return jsonify({'error': 'reward_id is required'}), 400
|
||||
|
||||
result = chore_actions.deny_reward(user_id, id, reward_id)
|
||||
if result is None:
|
||||
return jsonify({'message': 'This reward request has already been resolved.'}), 200
|
||||
|
||||
return jsonify({'message': f'Reward request denied for {result["child_name"]}.'}), 200
|
||||
|
||||
return jsonify({'rewards': reward_responses, 'count': len(reward_responses), 'list_type': 'notification'}), 200
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
class ChildTask:
|
||||
def __init__(self, name, is_good, points, image_id, id):
|
||||
def __init__(self, name, task_type, points, image_id, id):
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.is_good = is_good
|
||||
self.type = task_type
|
||||
self.points = points
|
||||
self.image_id = image_id
|
||||
|
||||
@@ -10,7 +10,7 @@ class ChildTask:
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'is_good': self.is_good,
|
||||
'type': self.type,
|
||||
'points': self.points,
|
||||
'image_id': self.image_id
|
||||
}
|
||||
165
backend/api/chore_api.py
Normal file
165
backend/api/chore_api.py
Normal file
@@ -0,0 +1,165 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from tinydb import Query
|
||||
|
||||
from api.utils import send_event_for_current_user, get_validated_user_id
|
||||
from events.types.child_tasks_set import ChildTasksSet
|
||||
from db.db import task_db, child_db
|
||||
from db.child_overrides import delete_overrides_for_entity
|
||||
from events.types.event import Event
|
||||
from events.types.event_types import EventType
|
||||
from events.types.task_modified import TaskModified
|
||||
from models.task import Task
|
||||
|
||||
chore_api = Blueprint('chore_api', __name__)
|
||||
|
||||
TASK_TYPE = 'chore'
|
||||
|
||||
|
||||
@chore_api.route('/chore/add', methods=['PUT'])
|
||||
def add_chore():
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||
data = request.get_json()
|
||||
name = data.get('name')
|
||||
points = data.get('points')
|
||||
image = data.get('image_id', '')
|
||||
if not name or points is None:
|
||||
return jsonify({'error': 'Name and points are required'}), 400
|
||||
task = Task(name=name, points=points, type=TASK_TYPE, image_id=image, user_id=user_id)
|
||||
task_db.insert(task.to_dict())
|
||||
send_event_for_current_user(Event(EventType.TASK_MODIFIED.value,
|
||||
TaskModified(task.id, TaskModified.OPERATION_ADD)))
|
||||
return jsonify({'message': f'Chore {name} added.'}), 201
|
||||
|
||||
|
||||
@chore_api.route('/chore/<id>', methods=['GET'])
|
||||
def get_chore(id):
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||
TaskQuery = Query()
|
||||
result = task_db.search(
|
||||
(TaskQuery.id == id) &
|
||||
((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)) &
|
||||
(TaskQuery.type == TASK_TYPE)
|
||||
)
|
||||
if not result:
|
||||
return jsonify({'error': 'Chore not found'}), 404
|
||||
return jsonify(result[0]), 200
|
||||
|
||||
|
||||
@chore_api.route('/chore/list', methods=['GET'])
|
||||
def list_chores():
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||
TaskQuery = Query()
|
||||
tasks = task_db.search(
|
||||
((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)) &
|
||||
(TaskQuery.type == TASK_TYPE)
|
||||
)
|
||||
|
||||
user_tasks = {t['name'].strip().lower(): t for t in tasks if t.get('user_id') == user_id}
|
||||
filtered_tasks = []
|
||||
for t in tasks:
|
||||
if t.get('user_id') is None and t['name'].strip().lower() in user_tasks:
|
||||
continue
|
||||
filtered_tasks.append(t)
|
||||
|
||||
def sort_user_then_default(tasks_group):
|
||||
user_created = sorted(
|
||||
[t for t in tasks_group if t.get('user_id') == user_id],
|
||||
key=lambda x: x['name'].lower(),
|
||||
)
|
||||
default_items = sorted(
|
||||
[t for t in tasks_group if t.get('user_id') is None],
|
||||
key=lambda x: x['name'].lower(),
|
||||
)
|
||||
return user_created + default_items
|
||||
|
||||
sorted_tasks = sort_user_then_default(filtered_tasks)
|
||||
return jsonify({'tasks': sorted_tasks}), 200
|
||||
|
||||
|
||||
@chore_api.route('/chore/<id>', methods=['DELETE'])
|
||||
def delete_chore(id):
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||
TaskQuery = Query()
|
||||
task = task_db.get((TaskQuery.id == id) & (TaskQuery.type == TASK_TYPE))
|
||||
if not task:
|
||||
return jsonify({'error': 'Chore not found'}), 404
|
||||
if task.get('user_id') is None:
|
||||
import logging
|
||||
logging.warning(f"Forbidden delete attempt on system chore: id={id}, by user_id={user_id}")
|
||||
return jsonify({'error': 'System chores cannot be deleted.'}), 403
|
||||
removed = task_db.remove((TaskQuery.id == id) & (TaskQuery.user_id == user_id))
|
||||
if removed:
|
||||
deleted_count = delete_overrides_for_entity(id)
|
||||
if deleted_count > 0:
|
||||
import logging
|
||||
logging.info(f"Cascade deleted {deleted_count} overrides for chore {id}")
|
||||
ChildQuery = Query()
|
||||
for child in child_db.all():
|
||||
child_tasks = child.get('tasks', [])
|
||||
if id in child_tasks:
|
||||
child_tasks.remove(id)
|
||||
child_db.update({'tasks': child_tasks}, ChildQuery.id == child.get('id'))
|
||||
send_event_for_current_user(Event(EventType.CHILD_TASKS_SET.value, ChildTasksSet(id, child_tasks)))
|
||||
send_event_for_current_user(Event(EventType.TASK_MODIFIED.value, TaskModified(id, TaskModified.OPERATION_DELETE)))
|
||||
return jsonify({'message': f'Chore {id} deleted.'}), 200
|
||||
return jsonify({'error': 'Chore not found'}), 404
|
||||
|
||||
|
||||
@chore_api.route('/chore/<id>/edit', methods=['PUT'])
|
||||
def edit_chore(id):
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||
TaskQuery = Query()
|
||||
existing = task_db.get(
|
||||
(TaskQuery.id == id) &
|
||||
((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)) &
|
||||
(TaskQuery.type == TASK_TYPE)
|
||||
)
|
||||
if not existing:
|
||||
return jsonify({'error': 'Chore not found'}), 404
|
||||
|
||||
task = Task.from_dict(existing)
|
||||
is_dirty = False
|
||||
data = request.get_json(force=True) or {}
|
||||
|
||||
if 'name' in data:
|
||||
name = data.get('name', '').strip()
|
||||
if not name:
|
||||
return jsonify({'error': 'Name cannot be empty'}), 400
|
||||
task.name = name
|
||||
is_dirty = True
|
||||
|
||||
if 'points' in data:
|
||||
points = data.get('points')
|
||||
if not isinstance(points, int) or points <= 0:
|
||||
return jsonify({'error': 'Points must be a positive integer'}), 400
|
||||
task.points = points
|
||||
is_dirty = True
|
||||
|
||||
if 'image_id' in data:
|
||||
task.image_id = data.get('image_id', '')
|
||||
is_dirty = True
|
||||
|
||||
if not is_dirty:
|
||||
return jsonify({'error': 'No valid fields to update'}), 400
|
||||
|
||||
if task.user_id is None:
|
||||
new_task = Task(name=task.name, points=task.points, type=TASK_TYPE, image_id=task.image_id, user_id=user_id)
|
||||
task_db.insert(new_task.to_dict())
|
||||
send_event_for_current_user(Event(EventType.TASK_MODIFIED.value,
|
||||
TaskModified(new_task.id, TaskModified.OPERATION_ADD)))
|
||||
return jsonify(new_task.to_dict()), 200
|
||||
|
||||
task_db.update(task.to_dict(), (TaskQuery.id == id) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)))
|
||||
send_event_for_current_user(Event(EventType.TASK_MODIFIED.value,
|
||||
TaskModified(id, TaskModified.OPERATION_EDIT)))
|
||||
return jsonify(task.to_dict()), 200
|
||||
179
backend/api/chore_schedule_api.py
Normal file
179
backend/api/chore_schedule_api.py
Normal file
@@ -0,0 +1,179 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from tinydb import Query
|
||||
from api.utils import get_validated_user_id, send_event_for_current_user
|
||||
from api.error_codes import ErrorCodes
|
||||
from db.db import child_db, pending_confirmations_db
|
||||
from db.chore_schedules import get_schedule, upsert_schedule, delete_schedule
|
||||
from db.task_extensions import get_extension, add_extension, delete_extension_for_child_task
|
||||
from models.chore_schedule import ChoreSchedule
|
||||
from models.task_extension import TaskExtension
|
||||
from events.types.event import Event
|
||||
from events.types.event_types import EventType
|
||||
from events.types.chore_schedule_modified import ChoreScheduleModified
|
||||
from events.types.chore_time_extended import ChoreTimeExtended
|
||||
from events.types.child_chore_confirmation import ChildChoreConfirmation
|
||||
import logging
|
||||
|
||||
chore_schedule_api = Blueprint('chore_schedule_api', __name__)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _validate_child(child_id: str, user_id: str):
|
||||
"""Return child dict if found and owned by user, else None."""
|
||||
ChildQuery = Query()
|
||||
result = child_db.search((ChildQuery.id == child_id) & (ChildQuery.user_id == user_id))
|
||||
return result[0] if result else None
|
||||
|
||||
|
||||
@chore_schedule_api.route('/child/<child_id>/task/<task_id>/schedule', methods=['GET'])
|
||||
def get_chore_schedule(child_id, task_id):
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
|
||||
|
||||
if not _validate_child(child_id, user_id):
|
||||
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
|
||||
|
||||
schedule = get_schedule(child_id, task_id)
|
||||
if not schedule:
|
||||
return jsonify({'error': 'Schedule not found'}), 404
|
||||
|
||||
return jsonify(schedule.to_dict()), 200
|
||||
|
||||
|
||||
@chore_schedule_api.route('/child/<child_id>/task/<task_id>/schedule', methods=['PUT'])
|
||||
def set_chore_schedule(child_id, task_id):
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
|
||||
|
||||
if not _validate_child(child_id, user_id):
|
||||
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
|
||||
|
||||
data = request.get_json() or {}
|
||||
mode = data.get('mode')
|
||||
if mode not in ('days', 'interval'):
|
||||
return jsonify({'error': 'mode must be "days" or "interval"', 'code': ErrorCodes.INVALID_VALUE}), 400
|
||||
|
||||
enabled = data.get('enabled', True)
|
||||
if not isinstance(enabled, bool):
|
||||
return jsonify({'error': 'enabled must be a boolean', 'code': ErrorCodes.INVALID_VALUE}), 400
|
||||
|
||||
if mode == 'days':
|
||||
day_configs = data.get('day_configs', [])
|
||||
if not isinstance(day_configs, list):
|
||||
return jsonify({'error': 'day_configs must be a list', 'code': ErrorCodes.INVALID_VALUE}), 400
|
||||
default_hour = data.get('default_hour', 8)
|
||||
default_minute = data.get('default_minute', 0)
|
||||
default_has_deadline = data.get('default_has_deadline', True)
|
||||
schedule = ChoreSchedule(
|
||||
child_id=child_id,
|
||||
task_id=task_id,
|
||||
mode='days',
|
||||
day_configs=day_configs,
|
||||
default_hour=default_hour,
|
||||
default_minute=default_minute,
|
||||
default_has_deadline=default_has_deadline,
|
||||
enabled=enabled,
|
||||
)
|
||||
else:
|
||||
interval_days = data.get('interval_days', 2)
|
||||
anchor_date = data.get('anchor_date', '')
|
||||
interval_has_deadline = data.get('interval_has_deadline', True)
|
||||
interval_hour = data.get('interval_hour', 0)
|
||||
interval_minute = data.get('interval_minute', 0)
|
||||
if not isinstance(interval_days, int) or not (1 <= interval_days <= 7):
|
||||
return jsonify({'error': 'interval_days must be an integer between 1 and 7', 'code': ErrorCodes.INVALID_VALUE}), 400
|
||||
if not isinstance(anchor_date, str):
|
||||
return jsonify({'error': 'anchor_date must be a string', 'code': ErrorCodes.INVALID_VALUE}), 400
|
||||
if not isinstance(interval_has_deadline, bool):
|
||||
return jsonify({'error': 'interval_has_deadline must be a boolean', 'code': ErrorCodes.INVALID_VALUE}), 400
|
||||
schedule = ChoreSchedule(
|
||||
child_id=child_id,
|
||||
task_id=task_id,
|
||||
mode='interval',
|
||||
interval_days=interval_days,
|
||||
anchor_date=anchor_date,
|
||||
interval_has_deadline=interval_has_deadline,
|
||||
interval_hour=interval_hour,
|
||||
interval_minute=interval_minute,
|
||||
enabled=enabled,
|
||||
)
|
||||
|
||||
delete_extension_for_child_task(child_id, task_id)
|
||||
upsert_schedule(schedule)
|
||||
|
||||
# Reset pending chore confirmations when schedule changes (completed chores stay)
|
||||
PendingQuery = Query()
|
||||
pending_chores = pending_confirmations_db.search(
|
||||
(PendingQuery.child_id == child_id) & (PendingQuery.entity_id == task_id) &
|
||||
(PendingQuery.entity_type == 'chore') & (PendingQuery.status == 'pending')
|
||||
)
|
||||
for pc in pending_chores:
|
||||
pending_confirmations_db.remove(
|
||||
(PendingQuery.child_id == child_id) & (PendingQuery.entity_id == task_id) &
|
||||
(PendingQuery.entity_type == 'chore') & (PendingQuery.status == 'pending')
|
||||
)
|
||||
send_event_for_current_user(Event(EventType.CHILD_CHORE_CONFIRMATION.value,
|
||||
ChildChoreConfirmation(child_id, task_id, ChildChoreConfirmation.OPERATION_RESET)))
|
||||
|
||||
send_event_for_current_user(Event(
|
||||
EventType.CHORE_SCHEDULE_MODIFIED.value,
|
||||
ChoreScheduleModified(child_id, task_id, ChoreScheduleModified.OPERATION_SET)
|
||||
))
|
||||
|
||||
return jsonify(schedule.to_dict()), 200
|
||||
|
||||
|
||||
@chore_schedule_api.route('/child/<child_id>/task/<task_id>/schedule', methods=['DELETE'])
|
||||
def delete_chore_schedule(child_id, task_id):
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
|
||||
|
||||
if not _validate_child(child_id, user_id):
|
||||
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
|
||||
|
||||
removed = delete_schedule(child_id, task_id)
|
||||
if not removed:
|
||||
return jsonify({'error': 'Schedule not found'}), 404
|
||||
|
||||
send_event_for_current_user(Event(
|
||||
EventType.CHORE_SCHEDULE_MODIFIED.value,
|
||||
ChoreScheduleModified(child_id, task_id, ChoreScheduleModified.OPERATION_DELETED)
|
||||
))
|
||||
|
||||
return jsonify({'message': 'Schedule deleted'}), 200
|
||||
|
||||
|
||||
@chore_schedule_api.route('/child/<child_id>/task/<task_id>/extend', methods=['POST'])
|
||||
def extend_chore_time(child_id, task_id):
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
|
||||
|
||||
if not _validate_child(child_id, user_id):
|
||||
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
|
||||
|
||||
data = request.get_json() or {}
|
||||
date = data.get('date')
|
||||
if not date or not isinstance(date, str):
|
||||
return jsonify({'error': 'date is required (ISO date string)', 'code': ErrorCodes.MISSING_FIELD}), 400
|
||||
|
||||
# 409 if already extended for this exact date
|
||||
existing = get_extension(child_id, task_id, date)
|
||||
if existing:
|
||||
return jsonify({'error': 'Chore already extended for this date', 'code': 'ALREADY_EXTENDED'}), 409
|
||||
|
||||
# Clear any prior extension for this child+task before inserting the new one
|
||||
# so stale records from previous dates do not accumulate.
|
||||
delete_extension_for_child_task(child_id, task_id)
|
||||
extension = TaskExtension(child_id=child_id, task_id=task_id, date=date)
|
||||
add_extension(extension)
|
||||
|
||||
send_event_for_current_user(Event(
|
||||
EventType.CHORE_TIME_EXTENDED.value,
|
||||
ChoreTimeExtended(child_id, task_id)
|
||||
))
|
||||
|
||||
return jsonify(extension.to_dict()), 200
|
||||
109
backend/api/digest_action_api.py
Normal file
109
backend/api/digest_action_api.py
Normal file
@@ -0,0 +1,109 @@
|
||||
import logging
|
||||
|
||||
from flask import Blueprint, redirect, make_response, jsonify, request
|
||||
from tinydb import Query
|
||||
|
||||
from utils.digest_token import validate_and_consume_token, validate_unsubscribe_token, peek_token
|
||||
from db.db import users_db
|
||||
from api.child_action_helpers import approve_chore, reject_chore, approve_reward_request, deny_reward
|
||||
from api.utils import get_validated_user_id
|
||||
|
||||
digest_action_api = Blueprint('digest_action_api', __name__)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_ERROR_HTML = """<!DOCTYPE html>
|
||||
<html><head><title>Link Error</title></head>
|
||||
<body style="font-family:sans-serif;text-align:center;margin-top:60px;">
|
||||
<h2>This link is invalid or has expired.</h2>
|
||||
<p>Action links expire after 24 hours and can only be used once.</p>
|
||||
</body></html>"""
|
||||
|
||||
_UNSUB_HTML = """<!DOCTYPE html>
|
||||
<html><head><title>Unsubscribed</title></head>
|
||||
<body style="font-family:sans-serif;text-align:center;margin-top:60px;">
|
||||
<h2>You have been unsubscribed from daily digest emails.</h2>
|
||||
<p>To re-enable, visit your profile in the app.</p>
|
||||
</body></html>"""
|
||||
|
||||
_UNSUB_ERROR_HTML = """<!DOCTYPE html>
|
||||
<html><head><title>Link Error</title></head>
|
||||
<body style="font-family:sans-serif;text-align:center;margin-top:60px;">
|
||||
<h2>This unsubscribe link is invalid or has expired.</h2>
|
||||
</body></html>"""
|
||||
|
||||
|
||||
@digest_action_api.route('/digest-action/<token_id>', methods=['GET'])
|
||||
def handle_digest_action(token_id: str):
|
||||
"""
|
||||
Validate a digest action token (without consuming it) and redirect to the
|
||||
frontend ParentView with the token embedded so the action executes only
|
||||
after the user authenticates as a parent.
|
||||
"""
|
||||
from flask import current_app
|
||||
frontend_url = current_app.config.get('FRONTEND_URL', 'https://localhost:5173')
|
||||
|
||||
token = peek_token(token_id)
|
||||
if not token:
|
||||
return make_response(_ERROR_HTML, 400)
|
||||
|
||||
deep_link = (
|
||||
f"{frontend_url}/parent/{token.child_id}"
|
||||
f"?digestToken={token_id}&scrollTo={token.entity_id}&entityType={token.entity_type}"
|
||||
)
|
||||
return redirect(deep_link, 302)
|
||||
|
||||
|
||||
@digest_action_api.route('/digest-action/<token_id>', methods=['POST'])
|
||||
def execute_digest_action(token_id: str):
|
||||
"""
|
||||
Execute a digest action. Requires the user to be authenticated (JWT cookie).
|
||||
Validates and consumes the token, then performs the approve/deny action.
|
||||
"""
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||
|
||||
token = validate_and_consume_token(token_id)
|
||||
if not token:
|
||||
return jsonify({'error': 'This link is invalid or has expired.', 'code': 'INVALID_TOKEN'}), 400
|
||||
|
||||
if token.user_id != user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 403
|
||||
|
||||
try:
|
||||
if token.entity_type == 'chore' and token.action == 'approve':
|
||||
approve_chore(user_id, token.child_id, token.entity_id)
|
||||
elif token.entity_type == 'chore' and token.action == 'deny':
|
||||
reject_chore(user_id, token.child_id, token.entity_id)
|
||||
elif token.entity_type == 'reward' and token.action == 'approve':
|
||||
approve_reward_request(user_id, token.child_id, token.entity_id)
|
||||
elif token.entity_type == 'reward' and token.action == 'deny':
|
||||
deny_reward(user_id, token.child_id, token.entity_id)
|
||||
else:
|
||||
return jsonify({'error': 'Unknown action', 'code': 'INVALID_ACTION'}), 400
|
||||
except Exception as e:
|
||||
logger.error(f'Error executing digest action {token.action}/{token.entity_type}: {e}')
|
||||
return jsonify({'error': 'Failed to execute action', 'code': 'ACTION_FAILED'}), 400
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'child_id': token.child_id,
|
||||
'entity_id': token.entity_id,
|
||||
'entity_type': token.entity_type,
|
||||
'action': token.action,
|
||||
}), 200
|
||||
|
||||
|
||||
@digest_action_api.route('/digest-unsubscribe/<token>', methods=['GET'])
|
||||
def handle_digest_unsubscribe(token: str):
|
||||
user_id = validate_unsubscribe_token(token)
|
||||
if not user_id:
|
||||
return make_response(_UNSUB_ERROR_HTML, 400)
|
||||
|
||||
UserQ = Query()
|
||||
users_db.update({'email_digest_enabled': False}, UserQ.id == user_id)
|
||||
logger.info(f'User {user_id} unsubscribed from digest via email link')
|
||||
return make_response(_UNSUB_HTML, 200)
|
||||
|
||||
|
||||
|
||||
@@ -12,6 +12,9 @@ INVALID_CREDENTIALS = "INVALID_CREDENTIALS"
|
||||
NOT_VERIFIED = "NOT_VERIFIED"
|
||||
ACCOUNT_MARKED_FOR_DELETION = "ACCOUNT_MARKED_FOR_DELETION"
|
||||
ALREADY_MARKED = "ALREADY_MARKED"
|
||||
REFRESH_TOKEN_REUSE = "REFRESH_TOKEN_REUSE"
|
||||
REFRESH_TOKEN_EXPIRED = "REFRESH_TOKEN_EXPIRED"
|
||||
MISSING_REFRESH_TOKEN = "MISSING_REFRESH_TOKEN"
|
||||
|
||||
|
||||
class ErrorCodes:
|
||||
@@ -26,3 +29,10 @@ class ErrorCodes:
|
||||
INVALID_VALUE = "INVALID_VALUE"
|
||||
VALIDATION_ERROR = "VALIDATION_ERROR"
|
||||
INTERNAL_ERROR = "INTERNAL_ERROR"
|
||||
CHORE_EXPIRED = "CHORE_EXPIRED"
|
||||
CHORE_ALREADY_PENDING = "CHORE_ALREADY_PENDING"
|
||||
CHORE_ALREADY_COMPLETED = "CHORE_ALREADY_COMPLETED"
|
||||
PENDING_NOT_FOUND = "PENDING_NOT_FOUND"
|
||||
INSUFFICIENT_POINTS = "INSUFFICIENT_POINTS"
|
||||
INVALID_TASK_TYPE = "INVALID_TASK_TYPE"
|
||||
DUPLICATE_REWARD_REQUEST = "DUPLICATE_REWARD_REQUEST"
|
||||
|
||||
165
backend/api/kindness_api.py
Normal file
165
backend/api/kindness_api.py
Normal file
@@ -0,0 +1,165 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from tinydb import Query
|
||||
|
||||
from api.utils import send_event_for_current_user, get_validated_user_id
|
||||
from events.types.child_tasks_set import ChildTasksSet
|
||||
from db.db import task_db, child_db
|
||||
from db.child_overrides import delete_overrides_for_entity
|
||||
from events.types.event import Event
|
||||
from events.types.event_types import EventType
|
||||
from events.types.task_modified import TaskModified
|
||||
from models.task import Task
|
||||
|
||||
kindness_api = Blueprint('kindness_api', __name__)
|
||||
|
||||
TASK_TYPE = 'kindness'
|
||||
|
||||
|
||||
@kindness_api.route('/kindness/add', methods=['PUT'])
|
||||
def add_kindness():
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||
data = request.get_json()
|
||||
name = data.get('name')
|
||||
points = data.get('points')
|
||||
image = data.get('image_id', '')
|
||||
if not name or points is None:
|
||||
return jsonify({'error': 'Name and points are required'}), 400
|
||||
task = Task(name=name, points=points, type=TASK_TYPE, image_id=image, user_id=user_id)
|
||||
task_db.insert(task.to_dict())
|
||||
send_event_for_current_user(Event(EventType.TASK_MODIFIED.value,
|
||||
TaskModified(task.id, TaskModified.OPERATION_ADD)))
|
||||
return jsonify({'message': f'Kindness {name} added.'}), 201
|
||||
|
||||
|
||||
@kindness_api.route('/kindness/<id>', methods=['GET'])
|
||||
def get_kindness(id):
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||
TaskQuery = Query()
|
||||
result = task_db.search(
|
||||
(TaskQuery.id == id) &
|
||||
((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)) &
|
||||
(TaskQuery.type == TASK_TYPE)
|
||||
)
|
||||
if not result:
|
||||
return jsonify({'error': 'Kindness act not found'}), 404
|
||||
return jsonify(result[0]), 200
|
||||
|
||||
|
||||
@kindness_api.route('/kindness/list', methods=['GET'])
|
||||
def list_kindness():
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||
TaskQuery = Query()
|
||||
tasks = task_db.search(
|
||||
((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)) &
|
||||
(TaskQuery.type == TASK_TYPE)
|
||||
)
|
||||
|
||||
user_tasks = {t['name'].strip().lower(): t for t in tasks if t.get('user_id') == user_id}
|
||||
filtered_tasks = []
|
||||
for t in tasks:
|
||||
if t.get('user_id') is None and t['name'].strip().lower() in user_tasks:
|
||||
continue
|
||||
filtered_tasks.append(t)
|
||||
|
||||
def sort_user_then_default(tasks_group):
|
||||
user_created = sorted(
|
||||
[t for t in tasks_group if t.get('user_id') == user_id],
|
||||
key=lambda x: x['name'].lower(),
|
||||
)
|
||||
default_items = sorted(
|
||||
[t for t in tasks_group if t.get('user_id') is None],
|
||||
key=lambda x: x['name'].lower(),
|
||||
)
|
||||
return user_created + default_items
|
||||
|
||||
sorted_tasks = sort_user_then_default(filtered_tasks)
|
||||
return jsonify({'tasks': sorted_tasks}), 200
|
||||
|
||||
|
||||
@kindness_api.route('/kindness/<id>', methods=['DELETE'])
|
||||
def delete_kindness(id):
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||
TaskQuery = Query()
|
||||
task = task_db.get((TaskQuery.id == id) & (TaskQuery.type == TASK_TYPE))
|
||||
if not task:
|
||||
return jsonify({'error': 'Kindness act not found'}), 404
|
||||
if task.get('user_id') is None:
|
||||
import logging
|
||||
logging.warning(f"Forbidden delete attempt on system kindness: id={id}, by user_id={user_id}")
|
||||
return jsonify({'error': 'System kindness acts cannot be deleted.'}), 403
|
||||
removed = task_db.remove((TaskQuery.id == id) & (TaskQuery.user_id == user_id))
|
||||
if removed:
|
||||
deleted_count = delete_overrides_for_entity(id)
|
||||
if deleted_count > 0:
|
||||
import logging
|
||||
logging.info(f"Cascade deleted {deleted_count} overrides for kindness {id}")
|
||||
ChildQuery = Query()
|
||||
for child in child_db.all():
|
||||
child_tasks = child.get('tasks', [])
|
||||
if id in child_tasks:
|
||||
child_tasks.remove(id)
|
||||
child_db.update({'tasks': child_tasks}, ChildQuery.id == child.get('id'))
|
||||
send_event_for_current_user(Event(EventType.CHILD_TASKS_SET.value, ChildTasksSet(id, child_tasks)))
|
||||
send_event_for_current_user(Event(EventType.TASK_MODIFIED.value, TaskModified(id, TaskModified.OPERATION_DELETE)))
|
||||
return jsonify({'message': f'Kindness {id} deleted.'}), 200
|
||||
return jsonify({'error': 'Kindness act not found'}), 404
|
||||
|
||||
|
||||
@kindness_api.route('/kindness/<id>/edit', methods=['PUT'])
|
||||
def edit_kindness(id):
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||
TaskQuery = Query()
|
||||
existing = task_db.get(
|
||||
(TaskQuery.id == id) &
|
||||
((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)) &
|
||||
(TaskQuery.type == TASK_TYPE)
|
||||
)
|
||||
if not existing:
|
||||
return jsonify({'error': 'Kindness act not found'}), 404
|
||||
|
||||
task = Task.from_dict(existing)
|
||||
is_dirty = False
|
||||
data = request.get_json(force=True) or {}
|
||||
|
||||
if 'name' in data:
|
||||
name = data.get('name', '').strip()
|
||||
if not name:
|
||||
return jsonify({'error': 'Name cannot be empty'}), 400
|
||||
task.name = name
|
||||
is_dirty = True
|
||||
|
||||
if 'points' in data:
|
||||
points = data.get('points')
|
||||
if not isinstance(points, int) or points <= 0:
|
||||
return jsonify({'error': 'Points must be a positive integer'}), 400
|
||||
task.points = points
|
||||
is_dirty = True
|
||||
|
||||
if 'image_id' in data:
|
||||
task.image_id = data.get('image_id', '')
|
||||
is_dirty = True
|
||||
|
||||
if not is_dirty:
|
||||
return jsonify({'error': 'No valid fields to update'}), 400
|
||||
|
||||
if task.user_id is None:
|
||||
new_task = Task(name=task.name, points=task.points, type=TASK_TYPE, image_id=task.image_id, user_id=user_id)
|
||||
task_db.insert(new_task.to_dict())
|
||||
send_event_for_current_user(Event(EventType.TASK_MODIFIED.value,
|
||||
TaskModified(new_task.id, TaskModified.OPERATION_ADD)))
|
||||
return jsonify(new_task.to_dict()), 200
|
||||
|
||||
task_db.update(task.to_dict(), (TaskQuery.id == id) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)))
|
||||
send_event_for_current_user(Event(EventType.TASK_MODIFIED.value,
|
||||
TaskModified(id, TaskModified.OPERATION_EDIT)))
|
||||
return jsonify(task.to_dict()), 200
|
||||
165
backend/api/penalty_api.py
Normal file
165
backend/api/penalty_api.py
Normal file
@@ -0,0 +1,165 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from tinydb import Query
|
||||
|
||||
from api.utils import send_event_for_current_user, get_validated_user_id
|
||||
from events.types.child_tasks_set import ChildTasksSet
|
||||
from db.db import task_db, child_db
|
||||
from db.child_overrides import delete_overrides_for_entity
|
||||
from events.types.event import Event
|
||||
from events.types.event_types import EventType
|
||||
from events.types.task_modified import TaskModified
|
||||
from models.task import Task
|
||||
|
||||
penalty_api = Blueprint('penalty_api', __name__)
|
||||
|
||||
TASK_TYPE = 'penalty'
|
||||
|
||||
|
||||
@penalty_api.route('/penalty/add', methods=['PUT'])
|
||||
def add_penalty():
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||
data = request.get_json()
|
||||
name = data.get('name')
|
||||
points = data.get('points')
|
||||
image = data.get('image_id', '')
|
||||
if not name or points is None:
|
||||
return jsonify({'error': 'Name and points are required'}), 400
|
||||
task = Task(name=name, points=points, type=TASK_TYPE, image_id=image, user_id=user_id)
|
||||
task_db.insert(task.to_dict())
|
||||
send_event_for_current_user(Event(EventType.TASK_MODIFIED.value,
|
||||
TaskModified(task.id, TaskModified.OPERATION_ADD)))
|
||||
return jsonify({'message': f'Penalty {name} added.'}), 201
|
||||
|
||||
|
||||
@penalty_api.route('/penalty/<id>', methods=['GET'])
|
||||
def get_penalty(id):
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||
TaskQuery = Query()
|
||||
result = task_db.search(
|
||||
(TaskQuery.id == id) &
|
||||
((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)) &
|
||||
(TaskQuery.type == TASK_TYPE)
|
||||
)
|
||||
if not result:
|
||||
return jsonify({'error': 'Penalty not found'}), 404
|
||||
return jsonify(result[0]), 200
|
||||
|
||||
|
||||
@penalty_api.route('/penalty/list', methods=['GET'])
|
||||
def list_penalties():
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||
TaskQuery = Query()
|
||||
tasks = task_db.search(
|
||||
((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)) &
|
||||
(TaskQuery.type == TASK_TYPE)
|
||||
)
|
||||
|
||||
user_tasks = {t['name'].strip().lower(): t for t in tasks if t.get('user_id') == user_id}
|
||||
filtered_tasks = []
|
||||
for t in tasks:
|
||||
if t.get('user_id') is None and t['name'].strip().lower() in user_tasks:
|
||||
continue
|
||||
filtered_tasks.append(t)
|
||||
|
||||
def sort_user_then_default(tasks_group):
|
||||
user_created = sorted(
|
||||
[t for t in tasks_group if t.get('user_id') == user_id],
|
||||
key=lambda x: x['name'].lower(),
|
||||
)
|
||||
default_items = sorted(
|
||||
[t for t in tasks_group if t.get('user_id') is None],
|
||||
key=lambda x: x['name'].lower(),
|
||||
)
|
||||
return user_created + default_items
|
||||
|
||||
sorted_tasks = sort_user_then_default(filtered_tasks)
|
||||
return jsonify({'tasks': sorted_tasks}), 200
|
||||
|
||||
|
||||
@penalty_api.route('/penalty/<id>', methods=['DELETE'])
|
||||
def delete_penalty(id):
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||
TaskQuery = Query()
|
||||
task = task_db.get((TaskQuery.id == id) & (TaskQuery.type == TASK_TYPE))
|
||||
if not task:
|
||||
return jsonify({'error': 'Penalty not found'}), 404
|
||||
if task.get('user_id') is None:
|
||||
import logging
|
||||
logging.warning(f"Forbidden delete attempt on system penalty: id={id}, by user_id={user_id}")
|
||||
return jsonify({'error': 'System penalties cannot be deleted.'}), 403
|
||||
removed = task_db.remove((TaskQuery.id == id) & (TaskQuery.user_id == user_id))
|
||||
if removed:
|
||||
deleted_count = delete_overrides_for_entity(id)
|
||||
if deleted_count > 0:
|
||||
import logging
|
||||
logging.info(f"Cascade deleted {deleted_count} overrides for penalty {id}")
|
||||
ChildQuery = Query()
|
||||
for child in child_db.all():
|
||||
child_tasks = child.get('tasks', [])
|
||||
if id in child_tasks:
|
||||
child_tasks.remove(id)
|
||||
child_db.update({'tasks': child_tasks}, ChildQuery.id == child.get('id'))
|
||||
send_event_for_current_user(Event(EventType.CHILD_TASKS_SET.value, ChildTasksSet(id, child_tasks)))
|
||||
send_event_for_current_user(Event(EventType.TASK_MODIFIED.value, TaskModified(id, TaskModified.OPERATION_DELETE)))
|
||||
return jsonify({'message': f'Penalty {id} deleted.'}), 200
|
||||
return jsonify({'error': 'Penalty not found'}), 404
|
||||
|
||||
|
||||
@penalty_api.route('/penalty/<id>/edit', methods=['PUT'])
|
||||
def edit_penalty(id):
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||
TaskQuery = Query()
|
||||
existing = task_db.get(
|
||||
(TaskQuery.id == id) &
|
||||
((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)) &
|
||||
(TaskQuery.type == TASK_TYPE)
|
||||
)
|
||||
if not existing:
|
||||
return jsonify({'error': 'Penalty not found'}), 404
|
||||
|
||||
task = Task.from_dict(existing)
|
||||
is_dirty = False
|
||||
data = request.get_json(force=True) or {}
|
||||
|
||||
if 'name' in data:
|
||||
name = data.get('name', '').strip()
|
||||
if not name:
|
||||
return jsonify({'error': 'Name cannot be empty'}), 400
|
||||
task.name = name
|
||||
is_dirty = True
|
||||
|
||||
if 'points' in data:
|
||||
points = data.get('points')
|
||||
if not isinstance(points, int) or points <= 0:
|
||||
return jsonify({'error': 'Points must be a positive integer'}), 400
|
||||
task.points = points
|
||||
is_dirty = True
|
||||
|
||||
if 'image_id' in data:
|
||||
task.image_id = data.get('image_id', '')
|
||||
is_dirty = True
|
||||
|
||||
if not is_dirty:
|
||||
return jsonify({'error': 'No valid fields to update'}), 400
|
||||
|
||||
if task.user_id is None:
|
||||
new_task = Task(name=task.name, points=task.points, type=TASK_TYPE, image_id=task.image_id, user_id=user_id)
|
||||
task_db.insert(new_task.to_dict())
|
||||
send_event_for_current_user(Event(EventType.TASK_MODIFIED.value,
|
||||
TaskModified(new_task.id, TaskModified.OPERATION_ADD)))
|
||||
return jsonify(new_task.to_dict()), 200
|
||||
|
||||
task_db.update(task.to_dict(), (TaskQuery.id == id) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)))
|
||||
send_event_for_current_user(Event(EventType.TASK_MODIFIED.value,
|
||||
TaskModified(id, TaskModified.OPERATION_EDIT)))
|
||||
return jsonify(task.to_dict()), 200
|
||||
29
backend/api/pending_confirmation.py
Normal file
29
backend/api/pending_confirmation.py
Normal file
@@ -0,0 +1,29 @@
|
||||
class PendingConfirmationResponse:
|
||||
"""Response DTO for hydrated pending confirmation data."""
|
||||
def __init__(self, _id, child_id, child_name, child_image_id,
|
||||
entity_id, entity_type, entity_name, entity_image_id,
|
||||
status='pending', approved_at=None):
|
||||
self.id = _id
|
||||
self.child_id = child_id
|
||||
self.child_name = child_name
|
||||
self.child_image_id = child_image_id
|
||||
self.entity_id = entity_id
|
||||
self.entity_type = entity_type
|
||||
self.entity_name = entity_name
|
||||
self.entity_image_id = entity_image_id
|
||||
self.status = status
|
||||
self.approved_at = approved_at
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'child_id': self.child_id,
|
||||
'child_name': self.child_name,
|
||||
'child_image_id': self.child_image_id,
|
||||
'entity_id': self.entity_id,
|
||||
'entity_type': self.entity_type,
|
||||
'entity_name': self.entity_name,
|
||||
'entity_image_id': self.entity_image_id,
|
||||
'status': self.status,
|
||||
'approved_at': self.approved_at
|
||||
}
|
||||
59
backend/api/push_subscription_api.py
Normal file
59
backend/api/push_subscription_api.py
Normal file
@@ -0,0 +1,59 @@
|
||||
from flask import Blueprint, request, jsonify, current_app
|
||||
from tinydb import Query
|
||||
|
||||
from api.utils import get_validated_user_id
|
||||
from db.push_subscriptions import upsert_subscription, delete_by_endpoint
|
||||
from db.db import users_db
|
||||
|
||||
push_subscription_api = Blueprint('push_subscription_api', __name__)
|
||||
|
||||
|
||||
@push_subscription_api.route('/push-vapid-key', methods=['GET'])
|
||||
def get_vapid_public_key():
|
||||
"""Return the VAPID public key for the frontend to use when subscribing."""
|
||||
public_key = current_app.config.get('VAPID_PUBLIC_KEY', '')
|
||||
return jsonify({'public_key': public_key}), 200
|
||||
|
||||
|
||||
@push_subscription_api.route('/push-subscription', methods=['POST'])
|
||||
def subscribe():
|
||||
"""Upsert a push subscription for the current user."""
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||
|
||||
data = request.get_json() or {}
|
||||
endpoint = data.get('endpoint')
|
||||
keys = data.get('keys')
|
||||
timezone_str = data.get('timezone')
|
||||
|
||||
if not endpoint or not isinstance(keys, dict):
|
||||
return jsonify({'error': 'endpoint and keys are required', 'code': 'MISSING_FIELDS'}), 400
|
||||
if 'p256dh' not in keys or 'auth' not in keys:
|
||||
return jsonify({'error': 'keys must contain p256dh and auth', 'code': 'MISSING_FIELDS'}), 400
|
||||
|
||||
sub = upsert_subscription(user_id=user_id, endpoint=endpoint, keys=keys)
|
||||
|
||||
# Update user timezone if provided
|
||||
if timezone_str:
|
||||
UserQ = Query()
|
||||
users_db.update({'timezone': timezone_str}, UserQ.id == user_id)
|
||||
|
||||
return jsonify({'message': 'Subscription saved', 'id': sub.id}), 200
|
||||
|
||||
|
||||
@push_subscription_api.route('/push-subscription', methods=['DELETE'])
|
||||
def unsubscribe():
|
||||
"""Remove a push subscription for the current user by endpoint."""
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||
|
||||
data = request.get_json() or {}
|
||||
endpoint = data.get('endpoint')
|
||||
|
||||
if not endpoint:
|
||||
return jsonify({'error': 'endpoint is required', 'code': 'MISSING_FIELDS'}), 400
|
||||
|
||||
removed = delete_by_endpoint(user_id=user_id, endpoint=endpoint)
|
||||
return jsonify({'message': 'Subscription removed', 'removed': removed}), 200
|
||||
@@ -3,11 +3,12 @@ from tinydb import Query
|
||||
|
||||
from api.utils import send_event_for_current_user, get_validated_user_id
|
||||
from events.types.child_rewards_set import ChildRewardsSet
|
||||
from db.db import reward_db, child_db
|
||||
from db.db import reward_db, child_db, pending_confirmations_db
|
||||
from db.child_overrides import delete_overrides_for_entity
|
||||
from events.types.event import Event
|
||||
from events.types.event_types import EventType
|
||||
from events.types.reward_modified import RewardModified
|
||||
from events.types.child_reward_request import ChildRewardRequest
|
||||
from models.reward import Reward
|
||||
|
||||
reward_api = Blueprint('reward_api', __name__)
|
||||
@@ -65,7 +66,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):
|
||||
@@ -122,14 +129,19 @@ def edit_reward(id):
|
||||
is_dirty = True
|
||||
|
||||
if 'description' in data:
|
||||
desc = (data.get('description') or '').strip()
|
||||
if not desc:
|
||||
return jsonify({'error': 'Description cannot be empty'}), 400
|
||||
reward.description = desc
|
||||
# allow empty description (same behavior as add_reward)
|
||||
# note: front-end often submits an empty string, so don't block edits
|
||||
reward.description = data.get('description') or ''
|
||||
is_dirty = True
|
||||
|
||||
if 'cost' in data:
|
||||
cost = data.get('cost')
|
||||
# allow numeric strings as well
|
||||
if isinstance(cost, str):
|
||||
try:
|
||||
cost = int(cost)
|
||||
except ValueError:
|
||||
return jsonify({'error': 'Cost must be an integer'}), 400
|
||||
if not isinstance(cost, int):
|
||||
return jsonify({'error': 'Cost must be an integer'}), 400
|
||||
if cost <= 0:
|
||||
@@ -144,6 +156,21 @@ def edit_reward(id):
|
||||
if not is_dirty:
|
||||
return jsonify({'error': 'No valid fields to update'}), 400
|
||||
|
||||
# Reset pending reward requests when cost changes
|
||||
if 'cost' in data:
|
||||
PendingQuery = Query()
|
||||
pending_rewards = pending_confirmations_db.search(
|
||||
(PendingQuery.entity_id == id) & (PendingQuery.entity_type == 'reward') &
|
||||
(PendingQuery.status == 'pending')
|
||||
)
|
||||
for pr in pending_rewards:
|
||||
pending_confirmations_db.remove(
|
||||
(PendingQuery.child_id == pr['child_id']) & (PendingQuery.entity_id == id) &
|
||||
(PendingQuery.entity_type == 'reward') & (PendingQuery.status == 'pending')
|
||||
)
|
||||
send_event_for_current_user(Event(EventType.CHILD_REWARD_REQUEST.value,
|
||||
ChildRewardRequest(pr['child_id'], id, ChildRewardRequest.REQUEST_CANCELLED)))
|
||||
|
||||
if reward.user_id is None: # public reward
|
||||
new_reward = Reward(name=reward.name, description=reward.description, cost=reward.cost, image_id=reward.image_id, user_id=user_id)
|
||||
reward_db.insert(new_reward.to_dict())
|
||||
|
||||
@@ -3,11 +3,12 @@ from tinydb import Query
|
||||
|
||||
from api.utils import send_event_for_current_user, get_validated_user_id
|
||||
from events.types.child_tasks_set import ChildTasksSet
|
||||
from db.db import task_db, child_db
|
||||
from db.db import task_db, child_db, pending_confirmations_db
|
||||
from db.child_overrides import delete_overrides_for_entity
|
||||
from events.types.event import Event
|
||||
from events.types.event_types import EventType
|
||||
from events.types.task_modified import TaskModified
|
||||
from events.types.child_chore_confirmation import ChildChoreConfirmation
|
||||
from models.task import Task
|
||||
|
||||
task_api = Blueprint('task_api', __name__)
|
||||
@@ -21,11 +22,16 @@ def add_task():
|
||||
data = request.get_json()
|
||||
name = data.get('name')
|
||||
points = data.get('points')
|
||||
is_good = data.get('is_good')
|
||||
task_type = data.get('type')
|
||||
# Support legacy is_good field
|
||||
if task_type is None and 'is_good' in data:
|
||||
task_type = 'chore' if data['is_good'] else 'penalty'
|
||||
image = data.get('image_id', '')
|
||||
if not name or points is None or is_good is None:
|
||||
return jsonify({'error': 'Name, points, and is_good are required'}), 400
|
||||
task = Task(name=name, points=points, is_good=is_good, image_id=image, user_id=user_id)
|
||||
if not name or points is None or task_type is None:
|
||||
return jsonify({'error': 'Name, points, and type are required'}), 400
|
||||
if task_type not in ['chore', 'kindness', 'penalty']:
|
||||
return jsonify({'error': 'type must be chore, kindness, or penalty'}), 400
|
||||
task = Task(name=name, points=points, type=task_type, image_id=image, user_id=user_id)
|
||||
task_db.insert(task.to_dict())
|
||||
send_event_for_current_user(Event(EventType.TASK_MODIFIED.value,
|
||||
TaskModified(task.id, TaskModified.OPERATION_ADD)))
|
||||
@@ -63,7 +69,27 @@ 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 order:
|
||||
# 1) chore/kindness first, then penalties
|
||||
# 2) within each group: user-created items first (by name), then default items (by name)
|
||||
good_tasks = [t for t in filtered_tasks if Task.from_dict(t).type != 'penalty']
|
||||
not_good_tasks = [t for t in filtered_tasks if Task.from_dict(t).type == 'penalty']
|
||||
|
||||
def sort_user_then_default(tasks_group):
|
||||
user_created = sorted(
|
||||
[t for t in tasks_group if t.get('user_id') == user_id],
|
||||
key=lambda x: x['name'].lower(),
|
||||
)
|
||||
default_items = sorted(
|
||||
[t for t in tasks_group if t.get('user_id') is None],
|
||||
key=lambda x: x['name'].lower(),
|
||||
)
|
||||
return user_created + default_items
|
||||
|
||||
sorted_tasks = sort_user_then_default(good_tasks) + sort_user_then_default(not_good_tasks)
|
||||
|
||||
return jsonify({'tasks': sorted_tasks}), 200
|
||||
|
||||
@task_api.route('/task/<id>', methods=['DELETE'])
|
||||
def delete_task(id):
|
||||
@@ -134,7 +160,15 @@ def edit_task(id):
|
||||
is_good = data.get('is_good')
|
||||
if not isinstance(is_good, bool):
|
||||
return jsonify({'error': 'is_good must be a boolean'}), 400
|
||||
task.is_good = is_good
|
||||
# Convert to type
|
||||
task.type = 'chore' if is_good else 'penalty'
|
||||
is_dirty = True
|
||||
|
||||
if 'type' in data:
|
||||
task_type = data.get('type')
|
||||
if task_type not in ['chore', 'kindness', 'penalty']:
|
||||
return jsonify({'error': 'type must be chore, kindness, or penalty'}), 400
|
||||
task.type = task_type
|
||||
is_dirty = True
|
||||
|
||||
if 'image_id' in data:
|
||||
@@ -144,8 +178,24 @@ def edit_task(id):
|
||||
if not is_dirty:
|
||||
return jsonify({'error': 'No valid fields to update'}), 400
|
||||
|
||||
# Reset pending chore confirmations when task is modified (points, type, etc.)
|
||||
# Completed (approved) chores stay in completed state
|
||||
if 'points' in data or 'type' in data:
|
||||
PendingQuery = Query()
|
||||
pending_chores = pending_confirmations_db.search(
|
||||
(PendingQuery.entity_id == id) & (PendingQuery.entity_type == 'chore') &
|
||||
(PendingQuery.status == 'pending')
|
||||
)
|
||||
for pc in pending_chores:
|
||||
pending_confirmations_db.remove(
|
||||
(PendingQuery.child_id == pc['child_id']) & (PendingQuery.entity_id == id) &
|
||||
(PendingQuery.entity_type == 'chore') & (PendingQuery.status == 'pending')
|
||||
)
|
||||
send_event_for_current_user(Event(EventType.CHILD_CHORE_CONFIRMATION.value,
|
||||
ChildChoreConfirmation(pc['child_id'], id, ChildChoreConfirmation.OPERATION_RESET)))
|
||||
|
||||
if task.user_id is None: # public task
|
||||
new_task = Task(name=task.name, points=task.points, is_good=task.is_good, image_id=task.image_id, user_id=user_id)
|
||||
new_task = Task(name=task.name, points=task.points, type=task.type, image_id=task.image_id, user_id=user_id)
|
||||
task_db.insert(new_task.to_dict())
|
||||
send_event_for_current_user(Event(EventType.TASK_MODIFIED.value,
|
||||
TaskModified(new_task.id, TaskModified.OPERATION_ADD)))
|
||||
|
||||
@@ -1,61 +1,12 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from api.utils import get_validated_user_id
|
||||
from api.utils import get_validated_user_id, admin_required
|
||||
from db.tracking import get_tracking_events_by_child, get_tracking_events_by_user
|
||||
from models.tracking_event import TrackingEvent
|
||||
from functools import wraps
|
||||
import jwt
|
||||
from tinydb import Query
|
||||
from db.db import users_db
|
||||
from models.user import User
|
||||
|
||||
|
||||
tracking_api = Blueprint('tracking_api', __name__)
|
||||
|
||||
|
||||
def admin_required(f):
|
||||
"""
|
||||
Decorator to require admin role for endpoints.
|
||||
"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
# Get JWT token from cookie
|
||||
token = request.cookies.get('token')
|
||||
if not token:
|
||||
return jsonify({'error': 'Authentication required', 'code': 'AUTH_REQUIRED'}), 401
|
||||
|
||||
try:
|
||||
# Verify JWT token
|
||||
payload = jwt.decode(token, 'supersecretkey', algorithms=['HS256'])
|
||||
user_id = payload.get('user_id')
|
||||
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Invalid token', 'code': 'INVALID_TOKEN'}), 401
|
||||
|
||||
# Get user from database
|
||||
Query_ = Query()
|
||||
user_dict = users_db.get(Query_.id == user_id)
|
||||
|
||||
if not user_dict:
|
||||
return jsonify({'error': 'User not found', 'code': 'USER_NOT_FOUND'}), 404
|
||||
|
||||
user = User.from_dict(user_dict)
|
||||
|
||||
# Check if user has admin role
|
||||
if user.role != 'admin':
|
||||
return jsonify({'error': 'Admin access required', 'code': 'ADMIN_REQUIRED'}), 403
|
||||
|
||||
# Store user_id in request context
|
||||
request.admin_user_id = user_id
|
||||
return f(*args, **kwargs)
|
||||
|
||||
except jwt.ExpiredSignatureError:
|
||||
return jsonify({'error': 'Token expired', 'code': 'TOKEN_EXPIRED'}), 401
|
||||
except jwt.InvalidTokenError:
|
||||
return jsonify({'error': 'Invalid token', 'code': 'INVALID_TOKEN'}), 401
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
@tracking_api.route('/admin/tracking', methods=['GET'])
|
||||
@admin_required
|
||||
def get_tracking():
|
||||
|
||||
@@ -9,15 +9,21 @@ import string
|
||||
import utils.email_sender as email_sender
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from api.utils import get_validated_user_id, normalize_email, send_event_for_current_user
|
||||
from events.sse import send_event_to_user
|
||||
from events.types.payload import Payload
|
||||
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()
|
||||
|
||||
def get_current_user():
|
||||
token = request.cookies.get('token')
|
||||
token = request.cookies.get('access_token')
|
||||
if not token:
|
||||
return None
|
||||
try:
|
||||
@@ -40,7 +46,9 @@ def get_profile():
|
||||
'first_name': user.first_name,
|
||||
'last_name': user.last_name,
|
||||
'email': user.email,
|
||||
'image_id': user.image_id
|
||||
'image_id': user.image_id,
|
||||
'email_digest_enabled': user.email_digest_enabled,
|
||||
'push_notifications_enabled': user.push_notifications_enabled,
|
||||
}), 200
|
||||
|
||||
@user_api.route('/user/profile', methods=['PUT'])
|
||||
@@ -52,17 +60,53 @@ def update_profile():
|
||||
if not user:
|
||||
return jsonify({'error': 'Unauthorized'}), 401
|
||||
data = request.get_json()
|
||||
# Only allow first_name, last_name, image_id to be updated
|
||||
# Only allow first_name, last_name, image_id, email_digest_enabled, push_notifications_enabled to be updated
|
||||
first_name = data.get('first_name')
|
||||
last_name = data.get('last_name')
|
||||
image_id = data.get('image_id')
|
||||
email_digest_enabled = data.get('email_digest_enabled')
|
||||
push_notifications_enabled = data.get('push_notifications_enabled')
|
||||
if first_name is not None:
|
||||
user.first_name = first_name
|
||||
if last_name is not None:
|
||||
user.last_name = last_name
|
||||
if image_id is not None:
|
||||
user.image_id = image_id
|
||||
if email_digest_enabled is not None:
|
||||
user.email_digest_enabled = bool(email_digest_enabled)
|
||||
if push_notifications_enabled is not None:
|
||||
user.push_notifications_enabled = bool(push_notifications_enabled)
|
||||
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
|
||||
if email_digest_enabled is not None:
|
||||
metadata['email_digest_enabled_updated'] = True
|
||||
if push_notifications_enabled is not None:
|
||||
metadata['push_notifications_enabled_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,9 +245,34 @@ 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
|
||||
send_event_for_current_user(Event(EventType.USER_MARKED_FOR_DELETION.value, UserModified(user.id, UserModified.OPERATION_DELETE)))
|
||||
|
||||
# Notify all other active sessions to sign out and go to landing page
|
||||
send_event_to_user(user.id, Event(EventType.FORCE_LOGOUT.value, Payload({'reason': 'account_deleted'})))
|
||||
|
||||
return jsonify({'success': True}), 200
|
||||
|
||||
|
||||
@user_api.route('/user/e2e-get-pin-code', methods=['GET'])
|
||||
def e2e_get_pin_code():
|
||||
"""Return the current user's pin_setup_code for e2e testing. Never available in production."""
|
||||
import os
|
||||
if os.environ.get('DB_ENV', 'prod') == 'prod':
|
||||
return jsonify({'error': 'Not available in production'}), 403
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized'}), 401
|
||||
user = get_current_user()
|
||||
if not user:
|
||||
return jsonify({'error': 'Unauthorized'}), 401
|
||||
return jsonify({'code': user.pin_setup_code or ''}), 200
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import jwt
|
||||
import re
|
||||
from functools import wraps
|
||||
from db.db import users_db
|
||||
from tinydb import Query
|
||||
from flask import request, current_app, jsonify
|
||||
|
||||
from events.sse import send_event_to_user
|
||||
from models.user import User
|
||||
|
||||
|
||||
def normalize_email(email: str) -> str:
|
||||
@@ -21,7 +23,7 @@ def sanitize_email(email):
|
||||
return email.replace('@', '_at_').replace('.', '_dot_')
|
||||
|
||||
def get_current_user_id():
|
||||
token = request.cookies.get('token')
|
||||
token = request.cookies.get('access_token')
|
||||
if not token:
|
||||
return None
|
||||
try:
|
||||
@@ -29,6 +31,12 @@ def get_current_user_id():
|
||||
user_id = payload.get('user_id')
|
||||
if not user_id:
|
||||
return None
|
||||
token_version = payload.get('token_version', 0)
|
||||
user = users_db.get(Query().id == user_id)
|
||||
if not user:
|
||||
return None
|
||||
if token_version != user.get('token_version', 0):
|
||||
return None
|
||||
return user_id
|
||||
except jwt.InvalidTokenError:
|
||||
return None
|
||||
@@ -45,3 +53,45 @@ def send_event_for_current_user(event):
|
||||
return jsonify({'error': 'Unauthorized'}), 401
|
||||
send_event_to_user(user_id, event)
|
||||
return None
|
||||
|
||||
|
||||
def admin_required(f):
|
||||
"""
|
||||
Decorator to require admin role for endpoints.
|
||||
Validates JWT from access_token cookie and checks admin role.
|
||||
"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
token = request.cookies.get('access_token')
|
||||
if not token:
|
||||
return jsonify({'error': 'Authentication required', 'code': 'AUTH_REQUIRED'}), 401
|
||||
|
||||
try:
|
||||
payload = jwt.decode(token, current_app.config['SECRET_KEY'], algorithms=['HS256'])
|
||||
user_id = payload.get('user_id')
|
||||
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Invalid token', 'code': 'INVALID_TOKEN'}), 401
|
||||
|
||||
user_dict = users_db.get(Query().id == user_id)
|
||||
|
||||
if not user_dict:
|
||||
return jsonify({'error': 'User not found', 'code': 'USER_NOT_FOUND'}), 404
|
||||
|
||||
user = User.from_dict(user_dict)
|
||||
|
||||
if user.role != 'admin':
|
||||
return jsonify({'error': 'Admin access required', 'code': 'ADMIN_REQUIRED'}), 403
|
||||
|
||||
# Store user info in request context for the endpoint
|
||||
request.current_user = user
|
||||
request.admin_user_id = user_id
|
||||
|
||||
except jwt.ExpiredSignatureError:
|
||||
return jsonify({'error': 'Token expired', 'code': 'TOKEN_EXPIRED'}), 401
|
||||
except jwt.InvalidTokenError:
|
||||
return jsonify({'error': 'Invalid token', 'code': 'INVALID_TOKEN'}), 401
|
||||
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
@@ -26,6 +26,12 @@ def get_database_dir(db_env: str | None = None) -> str:
|
||||
env = (db_env or os.environ.get('DB_ENV', 'prod')).lower()
|
||||
return os.path.join(PROJECT_ROOT, get_base_data_dir(env), 'db')
|
||||
|
||||
def get_images_dir() -> str:
|
||||
"""
|
||||
Return the absolute directory path for storing images.
|
||||
"""
|
||||
return os.path.join(PROJECT_ROOT, get_base_data_dir(), 'images')
|
||||
|
||||
def get_user_image_dir(username: str | None) -> str:
|
||||
"""
|
||||
Return the absolute directory path for storing images for a specific user.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# file: config/version.py
|
||||
import os
|
||||
|
||||
BASE_VERSION = "1.0.4" # update manually when releasing features
|
||||
BASE_VERSION = "1.0.13" # update manually when releasing features
|
||||
|
||||
def get_full_version() -> str:
|
||||
"""
|
||||
|
||||
39
backend/db/chore_schedules.py
Normal file
39
backend/db/chore_schedules.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from db.db import chore_schedules_db
|
||||
from models.chore_schedule import ChoreSchedule
|
||||
from tinydb import Query
|
||||
|
||||
|
||||
def get_schedule(child_id: str, task_id: str) -> ChoreSchedule | None:
|
||||
q = Query()
|
||||
result = chore_schedules_db.search((q.child_id == child_id) & (q.task_id == task_id))
|
||||
if not result:
|
||||
return None
|
||||
return ChoreSchedule.from_dict(result[0])
|
||||
|
||||
|
||||
def upsert_schedule(schedule: ChoreSchedule) -> None:
|
||||
q = Query()
|
||||
existing = chore_schedules_db.get((q.child_id == schedule.child_id) & (q.task_id == schedule.task_id))
|
||||
if existing:
|
||||
chore_schedules_db.update(schedule.to_dict(), (q.child_id == schedule.child_id) & (q.task_id == schedule.task_id))
|
||||
else:
|
||||
chore_schedules_db.insert(schedule.to_dict())
|
||||
|
||||
|
||||
def delete_schedule(child_id: str, task_id: str) -> bool:
|
||||
q = Query()
|
||||
existing = chore_schedules_db.get((q.child_id == child_id) & (q.task_id == task_id))
|
||||
if not existing:
|
||||
return False
|
||||
chore_schedules_db.remove((q.child_id == child_id) & (q.task_id == task_id))
|
||||
return True
|
||||
|
||||
|
||||
def delete_schedules_for_child(child_id: str) -> None:
|
||||
q = Query()
|
||||
chore_schedules_db.remove(q.child_id == child_id)
|
||||
|
||||
|
||||
def delete_schedules_for_task(task_id: str) -> None:
|
||||
q = Query()
|
||||
chore_schedules_db.remove(q.task_id == task_id)
|
||||
@@ -72,9 +72,15 @@ task_path = os.path.join(base_dir, 'tasks.json')
|
||||
reward_path = os.path.join(base_dir, 'rewards.json')
|
||||
image_path = os.path.join(base_dir, 'images.json')
|
||||
pending_reward_path = os.path.join(base_dir, 'pending_rewards.json')
|
||||
pending_confirmations_path = os.path.join(base_dir, 'pending_confirmations.json')
|
||||
users_path = os.path.join(base_dir, 'users.json')
|
||||
tracking_events_path = os.path.join(base_dir, 'tracking_events.json')
|
||||
child_overrides_path = os.path.join(base_dir, 'child_overrides.json')
|
||||
chore_schedules_path = os.path.join(base_dir, 'chore_schedules.json')
|
||||
task_extensions_path = os.path.join(base_dir, 'task_extensions.json')
|
||||
refresh_tokens_path = os.path.join(base_dir, 'refresh_tokens.json')
|
||||
push_subscriptions_path = os.path.join(base_dir, 'push_subscriptions.json')
|
||||
digest_action_tokens_path = os.path.join(base_dir, 'digest_action_tokens.json')
|
||||
|
||||
# Use separate TinyDB instances/files for each collection
|
||||
_child_db = TinyDB(child_path, indent=2)
|
||||
@@ -82,9 +88,15 @@ _task_db = TinyDB(task_path, indent=2)
|
||||
_reward_db = TinyDB(reward_path, indent=2)
|
||||
_image_db = TinyDB(image_path, indent=2)
|
||||
_pending_rewards_db = TinyDB(pending_reward_path, indent=2)
|
||||
_pending_confirmations_db = TinyDB(pending_confirmations_path, indent=2)
|
||||
_users_db = TinyDB(users_path, indent=2)
|
||||
_tracking_events_db = TinyDB(tracking_events_path, indent=2)
|
||||
_child_overrides_db = TinyDB(child_overrides_path, indent=2)
|
||||
_chore_schedules_db = TinyDB(chore_schedules_path, indent=2)
|
||||
_task_extensions_db = TinyDB(task_extensions_path, indent=2)
|
||||
_refresh_tokens_db = TinyDB(refresh_tokens_path, indent=2)
|
||||
_push_subscriptions_db = TinyDB(push_subscriptions_path, indent=2)
|
||||
_digest_action_tokens_db = TinyDB(digest_action_tokens_path, indent=2)
|
||||
|
||||
# Expose table objects wrapped with locking
|
||||
child_db = LockedTable(_child_db)
|
||||
@@ -92,9 +104,15 @@ task_db = LockedTable(_task_db)
|
||||
reward_db = LockedTable(_reward_db)
|
||||
image_db = LockedTable(_image_db)
|
||||
pending_reward_db = LockedTable(_pending_rewards_db)
|
||||
pending_confirmations_db = LockedTable(_pending_confirmations_db)
|
||||
users_db = LockedTable(_users_db)
|
||||
tracking_events_db = LockedTable(_tracking_events_db)
|
||||
child_overrides_db = LockedTable(_child_overrides_db)
|
||||
chore_schedules_db = LockedTable(_chore_schedules_db)
|
||||
task_extensions_db = LockedTable(_task_extensions_db)
|
||||
refresh_tokens_db = LockedTable(_refresh_tokens_db)
|
||||
push_subscriptions_db = LockedTable(_push_subscriptions_db)
|
||||
digest_action_tokens_db = LockedTable(_digest_action_tokens_db)
|
||||
|
||||
if os.environ.get('DB_ENV', 'prod') == 'test':
|
||||
child_db.truncate()
|
||||
@@ -102,7 +120,13 @@ if os.environ.get('DB_ENV', 'prod') == 'test':
|
||||
reward_db.truncate()
|
||||
image_db.truncate()
|
||||
pending_reward_db.truncate()
|
||||
pending_confirmations_db.truncate()
|
||||
users_db.truncate()
|
||||
tracking_events_db.truncate()
|
||||
child_overrides_db.truncate()
|
||||
chore_schedules_db.truncate()
|
||||
task_extensions_db.truncate()
|
||||
refresh_tokens_db.truncate()
|
||||
push_subscriptions_db.truncate()
|
||||
digest_action_tokens_db.truncate()
|
||||
|
||||
|
||||
@@ -6,25 +6,29 @@ import os
|
||||
import shutil
|
||||
|
||||
from api.image_api import IMAGE_TYPE_ICON, IMAGE_TYPE_PROFILE
|
||||
from config.paths import get_images_dir
|
||||
from db.db import task_db, reward_db, image_db
|
||||
from models.image import Image
|
||||
from models.reward import Reward
|
||||
from models.task import Task
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def populate_default_data():
|
||||
# Create tasks
|
||||
task_defs = [
|
||||
('default_001', "Be Respectful", 2, True, ''),
|
||||
('default_002', "Brush Teeth", 2, True, ''),
|
||||
('default_003', "Go To Bed", 2, True, ''),
|
||||
('default_004', "Do What You Are Told", 2, True, ''),
|
||||
('default_005', "Make Your Bed", 2, True, ''),
|
||||
('default_006', "Do Homework", 2, True, ''),
|
||||
('default_001', "Be Respectful", 2, 'chore', ''),
|
||||
('default_002', "Brush Teeth", 2, 'chore', ''),
|
||||
('default_003', "Go To Bed", 2, 'chore', ''),
|
||||
('default_004', "Do What You Are Told", 2, 'chore', ''),
|
||||
('default_005', "Make Your Bed", 2, 'chore', ''),
|
||||
('default_006', "Do Homework", 2, 'chore', ''),
|
||||
]
|
||||
tasks = []
|
||||
for _id, name, points, is_good, image in task_defs:
|
||||
t = Task(name=name, points=points, is_good=is_good, image_id=image, id=_id)
|
||||
for _id, name, points, task_type, image in task_defs:
|
||||
t = Task(name=name, points=points, type=task_type, image_id=image, id=_id)
|
||||
tq = Query()
|
||||
_result = task_db.search(tq.id == _id)
|
||||
if not _result:
|
||||
@@ -88,18 +92,18 @@ def createDefaultTasks():
|
||||
"""Create default tasks if none exist."""
|
||||
if len(task_db.all()) == 0:
|
||||
default_tasks = [
|
||||
Task(name="Take out trash", points=20, is_good=True, image_id="trash-can"),
|
||||
Task(name="Make your bed", points=25, is_good=True, image_id="make-the-bed"),
|
||||
Task(name="Sweep and clean kitchen", points=15, is_good=True, image_id="vacuum"),
|
||||
Task(name="Do homework early", points=30, is_good=True, image_id="homework"),
|
||||
Task(name="Be good for the day", points=15, is_good=True, image_id="good"),
|
||||
Task(name="Clean your mess", points=20, is_good=True, image_id="broom"),
|
||||
Task(name="Take out trash", points=20, type='chore', image_id="trash-can"),
|
||||
Task(name="Make your bed", points=25, type='chore', image_id="make-the-bed"),
|
||||
Task(name="Sweep and clean kitchen", points=15, type='chore', image_id="vacuum"),
|
||||
Task(name="Do homework early", points=30, type='chore', image_id="homework"),
|
||||
Task(name="Be good for the day", points=15, type='kindness', image_id="good"),
|
||||
Task(name="Clean your mess", points=20, type='chore', image_id="broom"),
|
||||
|
||||
Task(name="Fighting", points=10, is_good=False, image_id="fighting"),
|
||||
Task(name="Yelling at parents", points=10, is_good=False, image_id="yelling"),
|
||||
Task(name="Lying", points=10, is_good=False, image_id="lying"),
|
||||
Task(name="Not doing what told", points=5, is_good=False, image_id="ignore"),
|
||||
Task(name="Not flushing toilet", points=5, is_good=False, image_id="toilet"),
|
||||
Task(name="Fighting", points=10, type='penalty', image_id="fighting"),
|
||||
Task(name="Yelling at parents", points=10, type='penalty', image_id="yelling"),
|
||||
Task(name="Lying", points=10, type='penalty', image_id="lying"),
|
||||
Task(name="Not doing what told", points=5, type='penalty', image_id="ignore"),
|
||||
Task(name="Not flushing toilet", points=5, type='penalty', image_id="toilet"),
|
||||
]
|
||||
for task in default_tasks:
|
||||
task_db.insert(task.to_dict())
|
||||
@@ -124,7 +128,8 @@ def initializeImages():
|
||||
|
||||
"""Initialize the image database with default images if empty, and copy images to data/images/default."""
|
||||
# Step 1: Create data/images/default directory if it doesn't exist
|
||||
default_img_dir = os.path.join(os.path.dirname(__file__), '../data/images/default')
|
||||
default_img_dir = os.path.join(get_images_dir(), 'default')
|
||||
logger.info(f"Initializing images. Ensuring directory exists: {default_img_dir}")
|
||||
os.makedirs(default_img_dir, exist_ok=True)
|
||||
|
||||
# Step 2: Copy all image files from resources/images/ to data/images/default
|
||||
|
||||
18
backend/db/digest_action_tokens.py
Normal file
18
backend/db/digest_action_tokens.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from tinydb import Query
|
||||
from db.db import digest_action_tokens_db
|
||||
from models.digest_action_token import DigestActionToken
|
||||
|
||||
|
||||
def insert_token(token: DigestActionToken) -> None:
|
||||
digest_action_tokens_db.insert(token.to_dict())
|
||||
|
||||
|
||||
def get_token_by_id(token_id: str) -> DigestActionToken | None:
|
||||
Q = Query()
|
||||
result = digest_action_tokens_db.get(Q.id == token_id)
|
||||
return DigestActionToken.from_dict(result) if result else None
|
||||
|
||||
|
||||
def mark_token_used(token_id: str) -> None:
|
||||
Q = Query()
|
||||
digest_action_tokens_db.update({'used': True}, Q.id == token_id)
|
||||
38
backend/db/push_subscriptions.py
Normal file
38
backend/db/push_subscriptions.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from tinydb import Query
|
||||
from db.db import push_subscriptions_db
|
||||
from models.push_subscription import PushSubscription
|
||||
|
||||
|
||||
def get_subscriptions_by_user(user_id: str) -> list[PushSubscription]:
|
||||
"""Return all push subscriptions for a user."""
|
||||
Q = Query()
|
||||
results = push_subscriptions_db.search(Q.user_id == user_id)
|
||||
return [PushSubscription.from_dict(r) for r in results]
|
||||
|
||||
|
||||
def upsert_subscription(user_id: str, endpoint: str, keys: dict) -> PushSubscription:
|
||||
"""Insert or update a subscription for the given user+endpoint pair."""
|
||||
Q = Query()
|
||||
existing = push_subscriptions_db.get((Q.user_id == user_id) & (Q.endpoint == endpoint))
|
||||
if existing:
|
||||
sub = PushSubscription.from_dict(existing)
|
||||
sub.keys = keys
|
||||
sub.touch()
|
||||
push_subscriptions_db.update(sub.to_dict(), (Q.user_id == user_id) & (Q.endpoint == endpoint))
|
||||
return sub
|
||||
sub = PushSubscription(user_id=user_id, endpoint=endpoint, keys=keys)
|
||||
push_subscriptions_db.insert(sub.to_dict())
|
||||
return sub
|
||||
|
||||
|
||||
def delete_by_endpoint(user_id: str, endpoint: str) -> int:
|
||||
"""Remove the subscription with the given endpoint for this user. Returns count removed."""
|
||||
Q = Query()
|
||||
removed = push_subscriptions_db.remove((Q.user_id == user_id) & (Q.endpoint == endpoint))
|
||||
return len(removed)
|
||||
|
||||
|
||||
def delete_subscription_by_id(subscription_id: str) -> None:
|
||||
"""Remove a subscription by its ID (used when push delivery fails)."""
|
||||
Q = Query()
|
||||
push_subscriptions_db.remove(Q.id == subscription_id)
|
||||
49
backend/db/task_extensions.py
Normal file
49
backend/db/task_extensions.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from db.db import task_extensions_db
|
||||
from models.task_extension import TaskExtension
|
||||
from tinydb import Query
|
||||
|
||||
|
||||
def get_extension(child_id: str, task_id: str, date: str) -> TaskExtension | None:
|
||||
q = Query()
|
||||
result = task_extensions_db.search(
|
||||
(q.child_id == child_id) & (q.task_id == task_id) & (q.date == date)
|
||||
)
|
||||
if not result:
|
||||
return None
|
||||
return TaskExtension.from_dict(result[0])
|
||||
|
||||
|
||||
def add_extension(extension: TaskExtension) -> None:
|
||||
task_extensions_db.insert(extension.to_dict())
|
||||
|
||||
|
||||
def delete_extensions_for_child(child_id: str) -> None:
|
||||
q = Query()
|
||||
task_extensions_db.remove(q.child_id == child_id)
|
||||
|
||||
|
||||
def delete_extensions_for_task(task_id: str) -> None:
|
||||
q = Query()
|
||||
task_extensions_db.remove(q.task_id == task_id)
|
||||
|
||||
|
||||
def delete_extension_for_child_task(child_id: str, task_id: str) -> None:
|
||||
q = Query()
|
||||
task_extensions_db.remove((q.child_id == child_id) & (q.task_id == task_id))
|
||||
|
||||
|
||||
def get_extension_for_child_task(child_id: str, task_id: str) -> TaskExtension | None:
|
||||
"""Return the extension for a child+task without filtering by date.
|
||||
|
||||
Avoids timezone mismatches between the server (UTC) and the client's local
|
||||
date. The caller (or the frontend) is responsible for deciding whether the
|
||||
returned extension_date is still applicable.
|
||||
"""
|
||||
q = Query()
|
||||
results = task_extensions_db.search(
|
||||
(q.child_id == child_id) & (q.task_id == task_id)
|
||||
)
|
||||
if not results:
|
||||
return None
|
||||
latest = max(results, key=lambda r: r.get('date', ''))
|
||||
return TaskExtension.from_dict(latest)
|
||||
@@ -59,9 +59,15 @@ def sse_response_for_user(user_id: str):
|
||||
def generate():
|
||||
try:
|
||||
while True:
|
||||
# Get message from queue (blocks until available)
|
||||
message = user_queue.get()
|
||||
try:
|
||||
# Use a timeout so the thread yields periodically and keepalives are sent.
|
||||
# This prevents Werkzeug's dev server from starving other connections.
|
||||
message = user_queue.get(timeout=15)
|
||||
yield message
|
||||
logger.debug(f"Sent message to {user_id} connection {connection_id}")
|
||||
except queue.Empty:
|
||||
# Send an SSE comment as a keepalive ping to maintain the connection.
|
||||
yield b': ping\n\n'
|
||||
except GeneratorExit:
|
||||
# Clean up when client disconnects
|
||||
if user_id in user_queues and connection_id in user_queues[user_id]:
|
||||
|
||||
28
backend/events/types/child_chore_confirmation.py
Normal file
28
backend/events/types/child_chore_confirmation.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from events.types.payload import Payload
|
||||
|
||||
|
||||
class ChildChoreConfirmation(Payload):
|
||||
OPERATION_CONFIRMED = "CONFIRMED"
|
||||
OPERATION_APPROVED = "APPROVED"
|
||||
OPERATION_REJECTED = "REJECTED"
|
||||
OPERATION_CANCELLED = "CANCELLED"
|
||||
OPERATION_RESET = "RESET"
|
||||
|
||||
def __init__(self, child_id: str, task_id: str, operation: str):
|
||||
super().__init__({
|
||||
'child_id': child_id,
|
||||
'task_id': task_id,
|
||||
'operation': operation
|
||||
})
|
||||
|
||||
@property
|
||||
def child_id(self) -> str:
|
||||
return self.get("child_id")
|
||||
|
||||
@property
|
||||
def task_id(self) -> str:
|
||||
return self.get("task_id")
|
||||
|
||||
@property
|
||||
def operation(self) -> str:
|
||||
return self.get("operation")
|
||||
25
backend/events/types/chore_schedule_modified.py
Normal file
25
backend/events/types/chore_schedule_modified.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from events.types.payload import Payload
|
||||
|
||||
|
||||
class ChoreScheduleModified(Payload):
|
||||
OPERATION_SET = 'SET'
|
||||
OPERATION_DELETED = 'DELETED'
|
||||
|
||||
def __init__(self, child_id: str, task_id: str, operation: str):
|
||||
super().__init__({
|
||||
'child_id': child_id,
|
||||
'task_id': task_id,
|
||||
'operation': operation,
|
||||
})
|
||||
|
||||
@property
|
||||
def child_id(self) -> str:
|
||||
return self.get('child_id')
|
||||
|
||||
@property
|
||||
def task_id(self) -> str:
|
||||
return self.get('task_id')
|
||||
|
||||
@property
|
||||
def operation(self) -> str:
|
||||
return self.get('operation')
|
||||
17
backend/events/types/chore_time_extended.py
Normal file
17
backend/events/types/chore_time_extended.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from events.types.payload import Payload
|
||||
|
||||
|
||||
class ChoreTimeExtended(Payload):
|
||||
def __init__(self, child_id: str, task_id: str):
|
||||
super().__init__({
|
||||
'child_id': child_id,
|
||||
'task_id': task_id,
|
||||
})
|
||||
|
||||
@property
|
||||
def child_id(self) -> str:
|
||||
return self.get('child_id')
|
||||
|
||||
@property
|
||||
def task_id(self) -> str:
|
||||
return self.get('task_id')
|
||||
@@ -21,3 +21,11 @@ class EventType(Enum):
|
||||
|
||||
CHILD_OVERRIDE_SET = "child_override_set"
|
||||
CHILD_OVERRIDE_DELETED = "child_override_deleted"
|
||||
|
||||
PROFILE_UPDATED = "profile_updated"
|
||||
|
||||
CHORE_SCHEDULE_MODIFIED = "chore_schedule_modified"
|
||||
CHORE_TIME_EXTENDED = "chore_time_extended"
|
||||
CHILD_CHORE_CONFIRMATION = "child_chore_confirmation"
|
||||
|
||||
FORCE_LOGOUT = "force_logout"
|
||||
|
||||
12
backend/events/types/profile_updated.py
Normal file
12
backend/events/types/profile_updated.py
Normal 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")
|
||||
@@ -1,24 +1,34 @@
|
||||
import logging
|
||||
import sys
|
||||
import os
|
||||
|
||||
from flask import Flask, request, jsonify
|
||||
from flask_cors import CORS
|
||||
|
||||
from api.admin_api import admin_api
|
||||
from api.auth_api import auth_api
|
||||
from api.child_api import child_api
|
||||
from api.child_override_api import child_override_api
|
||||
from api.chore_api import chore_api
|
||||
from api.chore_schedule_api import chore_schedule_api
|
||||
from api.image_api import image_api
|
||||
from api.kindness_api import kindness_api
|
||||
from api.penalty_api import penalty_api
|
||||
from api.reward_api import reward_api
|
||||
from api.task_api import task_api
|
||||
from api.tracking_api import tracking_api
|
||||
from api.user_api import user_api
|
||||
from api.push_subscription_api import push_subscription_api
|
||||
from api.digest_action_api import digest_action_api
|
||||
from config.version import get_full_version
|
||||
|
||||
from db.default import initializeImages, createDefaultTasks, createDefaultRewards
|
||||
from events.broadcaster import Broadcaster
|
||||
from events.sse import sse_response_for_user, send_to_user
|
||||
from api.utils import get_current_user_id
|
||||
from utils.account_deletion_scheduler import start_deletion_scheduler
|
||||
from utils.chore_expiry_notification_scheduler import start_chore_expiry_notification_scheduler
|
||||
from utils.digest_scheduler import start_digest_scheduler
|
||||
from utils.state_expiry_scheduler import start_state_expiry_scheduler
|
||||
|
||||
# Configure logging once at application startup
|
||||
logging.basicConfig(
|
||||
@@ -30,17 +40,27 @@ logging.basicConfig(
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
logging.getLogger("werkzeug").setLevel(logging.WARNING)
|
||||
logging.getLogger("events.sse").setLevel(logging.WARNING)
|
||||
|
||||
app = Flask(__name__)
|
||||
#CORS(app, resources={r"/api/*": {"origins": ["http://localhost:3000", "http://localhost:5173"]}})
|
||||
#Todo - add prefix to all these routes instead of in each blueprint
|
||||
app.register_blueprint(admin_api)
|
||||
app.register_blueprint(child_api)
|
||||
app.register_blueprint(child_override_api)
|
||||
app.register_blueprint(chore_api)
|
||||
app.register_blueprint(chore_schedule_api)
|
||||
app.register_blueprint(kindness_api)
|
||||
app.register_blueprint(penalty_api)
|
||||
app.register_blueprint(reward_api)
|
||||
app.register_blueprint(task_api)
|
||||
app.register_blueprint(image_api)
|
||||
app.register_blueprint(auth_api)
|
||||
app.register_blueprint(auth_api, url_prefix='/auth')
|
||||
app.register_blueprint(user_api)
|
||||
app.register_blueprint(tracking_api)
|
||||
app.register_blueprint(push_subscription_api)
|
||||
app.register_blueprint(digest_action_api)
|
||||
|
||||
app.config.update(
|
||||
MAIL_SERVER='smtp.gmail.com',
|
||||
@@ -49,11 +69,43 @@ 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
|
||||
SECRET_KEY='supersecretkey' # Replace with a secure key in production
|
||||
FRONTEND_URL=os.environ.get('FRONTEND_URL', 'https://localhost:5173'), # Dynamic via env var, defaults to localhost
|
||||
VAPID_CLAIMS_EMAIL=os.environ.get('VAPID_CLAIMS_EMAIL', 'admin@reward-app.local'),
|
||||
)
|
||||
|
||||
CORS(app)
|
||||
# Security: require SECRET_KEY and REFRESH_TOKEN_EXPIRY_DAYS from environment
|
||||
_secret_key = os.environ.get('SECRET_KEY')
|
||||
if not _secret_key:
|
||||
raise RuntimeError(
|
||||
'SECRET_KEY environment variable is required. '
|
||||
'Set it to a random string (e.g. python -c "import secrets; print(secrets.token_urlsafe(64))")')
|
||||
app.config['SECRET_KEY'] = _secret_key
|
||||
|
||||
_refresh_expiry = os.environ.get('REFRESH_TOKEN_EXPIRY_DAYS')
|
||||
if not _refresh_expiry:
|
||||
raise RuntimeError('REFRESH_TOKEN_EXPIRY_DAYS environment variable is required (e.g. 90).')
|
||||
try:
|
||||
app.config['REFRESH_TOKEN_EXPIRY_DAYS'] = int(_refresh_expiry)
|
||||
except ValueError:
|
||||
raise RuntimeError('REFRESH_TOKEN_EXPIRY_DAYS must be an integer.')
|
||||
|
||||
_digest_token_secret = os.environ.get('DIGEST_TOKEN_SECRET')
|
||||
if not _digest_token_secret:
|
||||
raise RuntimeError(
|
||||
'DIGEST_TOKEN_SECRET environment variable is required. '
|
||||
'Set it to a random string (e.g. python -c "import secrets; print(secrets.token_urlsafe(64))")')
|
||||
app.config['DIGEST_TOKEN_SECRET'] = _digest_token_secret
|
||||
|
||||
_vapid_public_key = os.environ.get('VAPID_PUBLIC_KEY')
|
||||
_vapid_private_key = os.environ.get('VAPID_PRIVATE_KEY')
|
||||
if not _vapid_public_key or not _vapid_private_key:
|
||||
raise RuntimeError(
|
||||
'VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY environment variables are required. '
|
||||
'Generate a key pair with: python -c "from py_vapid import Vapid; v=Vapid(); '
|
||||
'v.generate_keys(); print(v.public_key.public_bytes_raw().hex(), v.private_key.private_bytes_raw().hex())"'
|
||||
)
|
||||
app.config['VAPID_PUBLIC_KEY'] = _vapid_public_key
|
||||
app.config['VAPID_PRIVATE_KEY'] = _vapid_private_key
|
||||
|
||||
@app.route("/version")
|
||||
def api_version():
|
||||
@@ -61,11 +113,9 @@ def api_version():
|
||||
|
||||
@app.route("/events")
|
||||
def events():
|
||||
# Authenticate user or read a token
|
||||
user_id = request.args.get("user_id")
|
||||
user_id = get_current_user_id()
|
||||
if not user_id:
|
||||
return {"error": "Missing user_id"}, 400
|
||||
|
||||
return {"error": "Authentication required"}, 401
|
||||
|
||||
return sse_response_for_user(user_id)
|
||||
|
||||
@@ -91,6 +141,9 @@ createDefaultTasks()
|
||||
createDefaultRewards()
|
||||
start_background_threads()
|
||||
start_deletion_scheduler()
|
||||
start_digest_scheduler(app)
|
||||
start_state_expiry_scheduler(app)
|
||||
start_chore_expiry_notification_scheduler(app)
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(debug=False, host='0.0.0.0', port=5000, threaded=True)
|
||||
@@ -16,15 +16,15 @@ class ChildOverride(BaseModel):
|
||||
"""
|
||||
child_id: str
|
||||
entity_id: str
|
||||
entity_type: Literal['task', 'reward']
|
||||
entity_type: Literal['task', 'reward', 'chore', 'kindness', 'penalty']
|
||||
custom_value: int
|
||||
|
||||
def __post_init__(self):
|
||||
"""Validate custom_value range and entity_type."""
|
||||
if self.custom_value < 0 or self.custom_value > 10000:
|
||||
raise ValueError("custom_value must be between 0 and 10000")
|
||||
if self.entity_type not in ['task', 'reward']:
|
||||
raise ValueError("entity_type must be 'task' or 'reward'")
|
||||
if self.entity_type not in ['task', 'reward', 'chore', 'kindness', 'penalty']:
|
||||
raise ValueError("entity_type must be 'task', 'reward', 'chore', 'kindness', or 'penalty'")
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict):
|
||||
@@ -52,7 +52,7 @@ class ChildOverride(BaseModel):
|
||||
def create_override(
|
||||
child_id: str,
|
||||
entity_id: str,
|
||||
entity_type: Literal['task', 'reward'],
|
||||
entity_type: Literal['task', 'reward', 'chore', 'kindness', 'penalty'],
|
||||
custom_value: int
|
||||
) -> 'ChildOverride':
|
||||
"""Factory method to create a new override."""
|
||||
|
||||
87
backend/models/chore_schedule.py
Normal file
87
backend/models/chore_schedule.py
Normal file
@@ -0,0 +1,87 @@
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Literal
|
||||
from models.base import BaseModel
|
||||
|
||||
|
||||
@dataclass
|
||||
class DayConfig:
|
||||
day: int # 0=Sun, 1=Mon, ..., 6=Sat
|
||||
hour: int # 0–23 (24h)
|
||||
minute: int # 0, 15, 30, or 45
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
'day': self.day,
|
||||
'hour': self.hour,
|
||||
'minute': self.minute,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> 'DayConfig':
|
||||
return cls(
|
||||
day=d.get('day', 0),
|
||||
hour=d.get('hour', 0),
|
||||
minute=d.get('minute', 0),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChoreSchedule(BaseModel):
|
||||
child_id: str
|
||||
task_id: str
|
||||
mode: Literal['days', 'interval']
|
||||
|
||||
# mode='days' fields
|
||||
day_configs: list = field(default_factory=list) # list of DayConfig dicts
|
||||
default_hour: int = 8 # master deadline hour for 'days' mode
|
||||
default_minute: int = 0 # master deadline minute for 'days' mode
|
||||
default_has_deadline: bool = True # False = 'Anytime', no expiry for 'days' mode
|
||||
|
||||
# mode='interval' fields
|
||||
interval_days: int = 2 # 1–7
|
||||
anchor_date: str = "" # ISO date string e.g. "2026-02-25"; "" = use today
|
||||
interval_has_deadline: bool = True # False = "Anytime" (no deadline)
|
||||
interval_hour: int = 0
|
||||
interval_minute: int = 0
|
||||
|
||||
enabled: bool = True # False = schedule paused; chore won't appear for child
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> 'ChoreSchedule':
|
||||
return cls(
|
||||
child_id=d.get('child_id'),
|
||||
task_id=d.get('task_id'),
|
||||
mode=d.get('mode', 'days'),
|
||||
day_configs=d.get('day_configs', []),
|
||||
default_hour=d.get('default_hour', 8),
|
||||
default_minute=d.get('default_minute', 0),
|
||||
default_has_deadline=d.get('default_has_deadline', True),
|
||||
interval_days=d.get('interval_days', 2),
|
||||
anchor_date=d.get('anchor_date', ''),
|
||||
interval_has_deadline=d.get('interval_has_deadline', True),
|
||||
interval_hour=d.get('interval_hour', 0),
|
||||
interval_minute=d.get('interval_minute', 0),
|
||||
enabled=d.get('enabled', True),
|
||||
id=d.get('id'),
|
||||
created_at=d.get('created_at'),
|
||||
updated_at=d.get('updated_at'),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
base = super().to_dict()
|
||||
base.update({
|
||||
'child_id': self.child_id,
|
||||
'task_id': self.task_id,
|
||||
'mode': self.mode,
|
||||
'day_configs': self.day_configs,
|
||||
'default_hour': self.default_hour,
|
||||
'default_minute': self.default_minute,
|
||||
'default_has_deadline': self.default_has_deadline,
|
||||
'interval_days': self.interval_days,
|
||||
'anchor_date': self.anchor_date,
|
||||
'interval_has_deadline': self.interval_has_deadline,
|
||||
'interval_hour': self.interval_hour,
|
||||
'interval_minute': self.interval_minute,
|
||||
'enabled': self.enabled,
|
||||
})
|
||||
return base
|
||||
44
backend/models/digest_action_token.py
Normal file
44
backend/models/digest_action_token.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from dataclasses import dataclass
|
||||
from models.base import BaseModel
|
||||
|
||||
|
||||
@dataclass
|
||||
class DigestActionToken(BaseModel):
|
||||
user_id: str
|
||||
child_id: str
|
||||
entity_id: str
|
||||
entity_type: str # 'chore' or 'reward'
|
||||
action: str # 'approve' or 'deny'
|
||||
expires_at: str # ISO timestamp
|
||||
used: bool = False
|
||||
signature: str = ''
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> 'DigestActionToken':
|
||||
return cls(
|
||||
user_id=d.get('user_id'),
|
||||
child_id=d.get('child_id'),
|
||||
entity_id=d.get('entity_id'),
|
||||
entity_type=d.get('entity_type'),
|
||||
action=d.get('action'),
|
||||
expires_at=d.get('expires_at'),
|
||||
used=d.get('used', False),
|
||||
signature=d.get('signature', ''),
|
||||
id=d.get('id'),
|
||||
created_at=d.get('created_at'),
|
||||
updated_at=d.get('updated_at'),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
base = super().to_dict()
|
||||
base.update({
|
||||
'user_id': self.user_id,
|
||||
'child_id': self.child_id,
|
||||
'entity_id': self.entity_id,
|
||||
'entity_type': self.entity_type,
|
||||
'action': self.action,
|
||||
'expires_at': self.expires_at,
|
||||
'used': self.used,
|
||||
'signature': self.signature,
|
||||
})
|
||||
return base
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user