Compare commits
147 Commits
3b7798369f
...
next
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 29563eeb83 | |||
| fc364621e3 | |||
| dffa4824fb | |||
| 28166842f1 | |||
| 484c7f0052 | |||
| 682e01bbf1 | |||
| 917ad25f7f | |||
| 26f90a4d1f | |||
| 73b5d831ed | |||
| 401c21ad82 | |||
| 3dee8b80a2 | |||
| 27f02224ab | |||
| 060b2953fa | |||
| 04f50c32ae | |||
| 0d651129cb | |||
| 47541afbbf | |||
| fd70eca0c9 | |||
| 99d3aeb068 | |||
| 5351932194 | |||
| e42c6c1ef2 | |||
| f14de28daa | |||
| 6f5b61de7f | |||
| 3066d7d356 | |||
| cd9070ec99 | |||
| 74d6f5819c | |||
| 63769fbe32 | |||
| a0a059472b | |||
| a47df7171c | |||
| 59b480621e | |||
| 904185e5c8 | |||
| dcac2742e9 | |||
| c7c3cce76d | |||
| 7de7047a4d | |||
| 49c175c01d | |||
| 35c4fcb9bb | |||
| 3b1e1eae6d | |||
| 6cec6bdb50 | |||
| cc436798d1 | |||
| cd34d27f76 | |||
| 92020e68ce | |||
| 7b91d2c8a4 | |||
| 696683cf30 | |||
| 96ccc1b04c | |||
| 007187020b | |||
| 39eea3ed07 | |||
| eac6f4b848 | |||
| caa28a3a2b | |||
| fd5a828084 | |||
| 76091ff06c | |||
| 4b1b3cedd1 | |||
| 40a835cfd2 | |||
| a6936ce609 | |||
| 0fd9c2618d | |||
| 3091c5ca97 | |||
| ee903f8bd6 | |||
| c4713dd9ef | |||
| a89d3d7313 | |||
| 9a6fbced15 | |||
| 5b0fe2adc2 | |||
| fd1057662f | |||
| d7fc3c0cab | |||
| 1900667328 | |||
| 03356d813f | |||
| f65d97a50a | |||
| 46af0fb959 |
1
.env.example
Normal file
1
.env.example
Normal file
@@ -0,0 +1 @@
|
||||
FRONTEND_URL=https://yourdomain.com
|
||||
168
.gitea/workflows/build.yaml
Normal file
168
.gitea/workflows/build.yaml
Normal file
@@ -0,0 +1,168 @@
|
||||
name: Chore App Build, Test, and Push Docker Images
|
||||
run-name: ${{ gitea.actor }} is building Chores [${{ gitea.ref_name }}@${{ gitea.sha }}] 🚀
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- next
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Determine Image Tag
|
||||
id: vars
|
||||
run: |
|
||||
version=$(python -c "import sys; sys.path.append('./backend'); from config.version import BASE_VERSION; print(BASE_VERSION)")
|
||||
current_date=$(date +%Y%m%d)
|
||||
if [ "${{ gitea.ref }}" == "refs/heads/master" ]; then
|
||||
echo "tag=$version" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "tag=next-$version-$current_date" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Set up Python for backend tests
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install backend dependencies
|
||||
run: |
|
||||
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/vue-app/package-lock.json
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: npm ci
|
||||
working-directory: frontend/vue-app
|
||||
|
||||
- name: Run frontend unit tests
|
||||
run: npm run test:unit --if-present
|
||||
working-directory: frontend/vue-app
|
||||
|
||||
- name: Build Backend Docker Image
|
||||
run: |
|
||||
docker build -t git.ryankegel.com:3000/kegel/chores/backend:${{ steps.vars.outputs.tag }} ./backend
|
||||
|
||||
- name: Build Frontend Docker Image
|
||||
run: |
|
||||
docker build -t git.ryankegel.com:3000/kegel/chores/frontend:${{ steps.vars.outputs.tag }} ./frontend/vue-app
|
||||
|
||||
- name: Log in to Registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: git.ryankegel.com:3000
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Push Backend Image to Gitea Registry
|
||||
run: |
|
||||
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 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: |
|
||||
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
|
||||
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
|
||||
|
||||
- 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)
|
||||
41
.github/agents/playwright-implement.agent.md
vendored
Normal file
41
.github/agents/playwright-implement.agent.md
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
---
|
||||
name: playwright-implementation
|
||||
|
||||
description: Converts plans into code and performs self-healing verification.
|
||||
tools:
|
||||
[
|
||||
"read",
|
||||
"write",
|
||||
"terminal/*",
|
||||
"playwright/*",
|
||||
"edit",
|
||||
"execute",
|
||||
"vscode",
|
||||
"web",
|
||||
"search",
|
||||
"todo",
|
||||
]
|
||||
---
|
||||
|
||||
# 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.
|
||||
87
.github/agents/playwright-test-generator.agent.md
vendored
Normal file
87
.github/agents/playwright-test-generator.agent.md
vendored
Normal file
@@ -0,0 +1,87 @@
|
||||
---
|
||||
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
|
||||
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
|
||||
81
.github/agents/playwright-test-planner.agent.md
vendored
Normal file
81
.github/agents/playwright-test-planner.agent.md
vendored
Normal file
@@ -0,0 +1,81 @@
|
||||
---
|
||||
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
|
||||
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.
|
||||
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
|
||||
64
.github/copilot-instructions.md
vendored
Normal file
64
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,64 @@
|
||||
# Reward Project: AI Coding Agent Instructions
|
||||
|
||||
## 🏗️ Architecture & Data Flow
|
||||
|
||||
- **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`).
|
||||
- **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.
|
||||
- **Specs**: If specs have a checklist, all items must be completed and marked done.
|
||||
|
||||
## 🧩 Key Patterns & Conventions
|
||||
|
||||
- **Frontend Styling**: Use only `:root` CSS variables from `colors.css` for all colors, spacing, and tokens. Example: `--btn-primary`, `--list-item-bg-good`.
|
||||
- **Scoped Styles**: All `.vue` files must use `<style scoped>`. Reference global variables for theme consistency.
|
||||
- **API Error Handling**: Backend returns JSON with `error` and `code` (see `backend/api/error_codes.py`). Frontend extracts `{ msg, code }` using `parseErrorResponse(res)` from `api.ts`.
|
||||
- **JWT Auth**: Tokens are stored in HttpOnly, Secure, SameSite=Strict cookies.
|
||||
- **Code Style**:
|
||||
1. Follow PEP 8 for Python, and standard TypeScript conventions.
|
||||
2. Use type annotations everywhere in Python.
|
||||
3. Place all imports at the top of the file.
|
||||
4. Vue files should specifically place `<template>`, `<script>`, then `<style>` in that order. Make sure to put ts code in `<script>` only.
|
||||
|
||||
## 🚦 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`.
|
||||
- **Layout Hierarchy**: Use `ParentLayout` for admin/management, `ChildLayout` for dashboard/focus views.
|
||||
|
||||
## ⚖️ Business Logic & Safeguards
|
||||
|
||||
- **Token Expiry**: Verification tokens expire in 4 hours; password reset tokens in 10 minutes.
|
||||
- **Image Assets**: Models use `image_id` for storage; frontend resolves to `image_url` for rendering.
|
||||
|
||||
## 🛠️ Developer Workflows
|
||||
|
||||
- **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__/`. E2E tests: `npx playwright test` from `frontend/vue-app/` — requires both servers running (use the `flask-backend` and `vue-frontend` skills).
|
||||
- **E2E Setup**: Playwright config is at `frontend/vue-app/playwright.config.ts`. Tests live in `frontend/vue-app/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
|
||||
|
||||
- `backend/api/` — Flask API endpoints (one file per entity)
|
||||
- `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
|
||||
|
||||
## 🧠 Integration & Cross-Component Patterns
|
||||
|
||||
- **Every backend mutation must trigger an SSE event** for the current user.
|
||||
- **Frontend state is event-driven**: always listen for and react to SSE events for real-time updates.
|
||||
- **Model changes require updating both Python and TypeScript definitions** to maintain parity.
|
||||
|
||||
---
|
||||
|
||||
For any unclear or missing conventions, review the referenced files or ask for clarification. Keep this document concise and actionable for AI agents.
|
||||
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.
|
||||
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/vue-app`.
|
||||
- _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/vue-app`.
|
||||
- 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.
|
||||
19
.github/specs/archive/bug-both-system-and-user-items-shown.md
vendored
Normal file
19
.github/specs/archive/bug-both-system-and-user-items-shown.md
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
# Bug: When a user task or reward exists with the same name as a system user or task, both are shown in the assign list.
|
||||
|
||||
## The Problem
|
||||
|
||||
- **Actual:** When the user creates a task/reward from a system task/reward (copy on edit), and then goes to assign the task/reward, both the system and user task/reward are shown and can be assigned.
|
||||
- **Expected:** When a user task/reward is created from a system (or even if it has the same name) - show the user item instead in the assign views.
|
||||
|
||||
## Investigation Notes
|
||||
|
||||
- When a copy on edit happens of a 'good' task and it is changed to 'bad', I can see the 'good' task when assigning tasks and the 'bad' penalty when assigning the penalty
|
||||
- The backend will have to change to probably check if the names are the same on tasks/rewards and if so, choose to return the user items instead.
|
||||
- In the case of two items having the same name AND having different user_ids that are not null, then we should show both items.
|
||||
- The task view and reward view correctly hides the system item. However, the Task Assign View and RewardAssignView are still showing both items.
|
||||
|
||||
## The "Red" Tests
|
||||
|
||||
- [x] Create a test that performs a copy on edit and then makes sure only that item shows instead of the system item
|
||||
- [x] Create a test that performs has 2 user items with the same name as a system item. Verify that the user items are shown, but not the system item.
|
||||
- [x] Create a test where if a system and identically named user task exist that the user tasks is the only one shown in the task assign view and reward assign view.
|
||||
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
|
||||
318
.github/specs/archive/feat-account-delete-scheduler.md
vendored
Normal file
318
.github/specs/archive/feat-account-delete-scheduler.md
vendored
Normal file
@@ -0,0 +1,318 @@
|
||||
# Feature: Account Deletion Scheduler
|
||||
|
||||
## Overview
|
||||
|
||||
**Goal:** Implement a scheduler in the backend that will delete accounts that are marked for deletion after a period of time.
|
||||
|
||||
**User Story:**
|
||||
As an administrator, I want accounts that are marked for deletion to be deleted around X amount of hours after they were marked. I want the time to be adjustable.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
- `ACCOUNT_DELETION_THRESHOLD_HOURS`: Hours to wait before deleting marked accounts (default: 720 hours / 30 days)
|
||||
- **Minimum:** 24 hours (enforced for safety)
|
||||
- **Maximum:** 720 hours (30 days)
|
||||
- Configurable via environment variable with validation on startup
|
||||
|
||||
### Scheduler Settings
|
||||
|
||||
- **Check Interval:** Every 1 hour
|
||||
- **Implementation:** APScheduler (BackgroundScheduler)
|
||||
- **Restart Handling:** On app restart, scheduler checks for users with `deletion_in_progress = True` and retries them
|
||||
- **Retry Logic:** Maximum 3 attempts per user; tracked via `deletion_attempted_at` timestamp
|
||||
|
||||
---
|
||||
|
||||
## Data Model Changes
|
||||
|
||||
### User Model (`backend/models/user.py`)
|
||||
|
||||
Add two new fields to the `User` dataclass:
|
||||
|
||||
- `deletion_in_progress: bool` - Default `False`. Set to `True` when deletion is actively running
|
||||
- `deletion_attempted_at: datetime | None` - Default `None`. Timestamp of last deletion attempt
|
||||
|
||||
**Serialization:**
|
||||
|
||||
- Both fields must be included in `to_dict()` and `from_dict()` methods
|
||||
|
||||
---
|
||||
|
||||
## Deletion Process & Order
|
||||
|
||||
When a user is due for deletion (current time >= `marked_for_deletion_at` + threshold), the scheduler performs deletion in this order:
|
||||
|
||||
1. **Set Flag:** `deletion_in_progress = True` (prevents concurrent deletion)
|
||||
2. **Pending Rewards:** Remove all pending rewards for user's children
|
||||
3. **Children:** Remove all children belonging to the user
|
||||
4. **Tasks:** Remove all user-created tasks (where `user_id` matches)
|
||||
5. **Rewards:** Remove all user-created rewards (where `user_id` matches)
|
||||
6. **Images (Database):** Remove user's uploaded images from `image_db`
|
||||
7. **Images (Filesystem):** Delete `data/images/[user_id]` directory and all contents
|
||||
8. **User Record:** Remove the user from `users_db`
|
||||
9. **Clear Flag:** `deletion_in_progress = False` (only if deletion failed; otherwise user is deleted)
|
||||
10. **Update Timestamp:** Set `deletion_attempted_at` to current time (if deletion failed)
|
||||
|
||||
### Error Handling
|
||||
|
||||
- If any step fails, log the error and continue to next step
|
||||
- If deletion fails completely, update `deletion_attempted_at` and set `deletion_in_progress = False`
|
||||
- If a user has 3 failed attempts, log a critical error but continue processing other users
|
||||
- Missing directories or empty tables are not considered errors
|
||||
|
||||
---
|
||||
|
||||
## Admin API Endpoints
|
||||
|
||||
### New Blueprint: `backend/api/admin_api.py`
|
||||
|
||||
All endpoints require JWT authentication and admin privileges.
|
||||
|
||||
**Note:** Endpoint paths below are as defined in Flask (without `/api` prefix). Frontend accesses them via nginx proxy at `/api/admin/*`.
|
||||
|
||||
#### `GET /admin/deletion-queue`
|
||||
|
||||
Returns list of users pending deletion.
|
||||
|
||||
**Response:** JSON with `count` and `users` array containing user objects with fields: `id`, `email`, `marked_for_deletion_at`, `deletion_due_at`, `deletion_in_progress`, `deletion_attempted_at`
|
||||
|
||||
#### `GET /admin/deletion-threshold`
|
||||
|
||||
Returns current deletion threshold configuration.
|
||||
|
||||
**Response:** JSON with `threshold_hours`, `threshold_min`, and `threshold_max` fields
|
||||
|
||||
#### `PUT /admin/deletion-threshold`
|
||||
|
||||
Updates deletion threshold (requires admin auth).
|
||||
|
||||
**Request:** JSON with `threshold_hours` field
|
||||
|
||||
**Response:** JSON with `message` and updated `threshold_hours`
|
||||
|
||||
**Validation:**
|
||||
|
||||
- Must be between 24 and 720 hours
|
||||
- Returns 400 error if out of range
|
||||
|
||||
#### `POST /admin/deletion-queue/trigger`
|
||||
|
||||
Manually triggers the deletion scheduler (processes entire queue immediately).
|
||||
|
||||
**Response:** JSON with `message`, `processed`, `deleted`, and `failed` counts
|
||||
|
||||
---
|
||||
|
||||
## SSE Event
|
||||
|
||||
### New Event Type: `USER_DELETED`
|
||||
|
||||
**File:** `backend/events/types/user_deleted.py`
|
||||
|
||||
**Payload fields:**
|
||||
|
||||
- `user_id: str` - ID of deleted user
|
||||
- `email: str` - Email of deleted user
|
||||
- `deleted_at: str` - ISO format timestamp of deletion
|
||||
|
||||
**Broadcasting:**
|
||||
|
||||
- Event is sent only to **admin users** (not broadcast to all users)
|
||||
- Triggered immediately after successful user deletion
|
||||
- Frontend admin clients can listen to this event to update UI
|
||||
|
||||
---
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### File Structure
|
||||
|
||||
- `backend/config/deletion_config.py` - Configuration with env variable
|
||||
- `backend/utils/account_deletion_scheduler.py` - Scheduler logic
|
||||
- `backend/api/admin_api.py` - New admin endpoints
|
||||
- `backend/events/types/user_deleted.py` - New SSE event
|
||||
|
||||
### Scheduler Startup
|
||||
|
||||
In `backend/main.py`, import and call `start_deletion_scheduler()` after Flask app setup
|
||||
|
||||
### Logging Strategy
|
||||
|
||||
**Configuration:**
|
||||
|
||||
- Use dedicated logger: `account_deletion_scheduler`
|
||||
- Log to both stdout (for Docker/dev) and rotating file (for persistence)
|
||||
- File: `logs/account_deletion.log`
|
||||
- Rotation: 10MB max file size, keep 5 backups
|
||||
- Format: `%(asctime)s - %(name)s - %(levelname)s - %(message)s`
|
||||
|
||||
**Log Levels:**
|
||||
|
||||
- **INFO:** Each deletion step (e.g., "Deleted 5 children for user {user_id}")
|
||||
- **INFO:** Summary after each run (e.g., "Deletion scheduler run: 3 users processed, 2 deleted, 1 failed")
|
||||
- **ERROR:** Individual step failures (e.g., "Failed to delete images for user {user_id}: {error}")
|
||||
- **CRITICAL:** User with 3+ failed attempts (e.g., "User {user_id} has failed deletion 3 times")
|
||||
- **WARNING:** Threshold set below 168 hours (7 days)
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria (Definition of Done)
|
||||
|
||||
### Data Model
|
||||
|
||||
- [x] Add `deletion_in_progress` field to User model
|
||||
- [x] Add `deletion_attempted_at` field to User model
|
||||
- [x] Update `to_dict()` and `from_dict()` methods for serialization
|
||||
- [x] Update TypeScript User interface in frontend
|
||||
|
||||
### Configuration
|
||||
|
||||
- [x] Create `backend/config/deletion_config.py` with `ACCOUNT_DELETION_THRESHOLD_HOURS`
|
||||
- [x] Add environment variable support with default (720 hours)
|
||||
- [x] Enforce minimum threshold of 24 hours
|
||||
- [x] Enforce maximum threshold of 720 hours
|
||||
- [x] Log warning if threshold is less than 168 hours
|
||||
|
||||
### Backend Implementation
|
||||
|
||||
- [x] Create `backend/utils/account_deletion_scheduler.py`
|
||||
- [x] Implement APScheduler with 1-hour check interval
|
||||
- [x] Implement deletion logic in correct order (pending_rewards → children → tasks → rewards → images → directory → user)
|
||||
- [x] Add comprehensive error handling (log and continue)
|
||||
- [x] Add restart handling (check `deletion_in_progress` flag on startup)
|
||||
- [x] Add retry logic (max 3 attempts per user)
|
||||
- [x] Integrate scheduler into `backend/main.py` startup
|
||||
|
||||
### Admin API
|
||||
|
||||
- [x] Create `backend/api/admin_api.py` blueprint
|
||||
- [x] Implement `GET /admin/deletion-queue` endpoint
|
||||
- [x] Implement `GET /admin/deletion-threshold` endpoint
|
||||
- [x] Implement `PUT /admin/deletion-threshold` endpoint
|
||||
- [x] Implement `POST /admin/deletion-queue/trigger` endpoint
|
||||
- [x] Add JWT authentication checks for all admin endpoints
|
||||
- [x] Add admin role validation
|
||||
|
||||
### SSE Event
|
||||
|
||||
- [x] Create `backend/events/types/user_deleted.py`
|
||||
- [x] Add `USER_DELETED` to `event_types.py`
|
||||
- [x] Implement admin-only event broadcasting
|
||||
- [x] Trigger event after successful deletion
|
||||
|
||||
### Backend Unit Tests
|
||||
|
||||
#### Configuration Tests
|
||||
|
||||
- [x] Test default threshold value (720 hours)
|
||||
- [x] Test environment variable override
|
||||
- [x] Test minimum threshold enforcement (24 hours)
|
||||
- [x] Test maximum threshold enforcement (720 hours)
|
||||
- [x] Test invalid threshold values (negative, non-numeric)
|
||||
|
||||
#### Scheduler Tests
|
||||
|
||||
- [x] Test scheduler identifies users ready for deletion (past threshold)
|
||||
- [x] Test scheduler ignores users not yet due for deletion
|
||||
- [x] Test scheduler handles empty database
|
||||
- [x] Test scheduler runs at correct interval (1 hour)
|
||||
- [x] Test scheduler handles restart with `deletion_in_progress = True`
|
||||
- [x] Test scheduler respects retry limit (max 3 attempts)
|
||||
|
||||
#### Deletion Process Tests
|
||||
|
||||
- [x] Test deletion removes pending_rewards for user's children
|
||||
- [x] Test deletion removes children for user
|
||||
- [x] Test deletion removes user's tasks (not system tasks)
|
||||
- [x] Test deletion removes user's rewards (not system rewards)
|
||||
- [x] Test deletion removes user's images from database
|
||||
- [x] Test deletion removes user directory from filesystem
|
||||
- [x] Test deletion removes user record from database
|
||||
- [x] Test deletion handles missing directory gracefully
|
||||
- [x] Test deletion order is correct (children before user, etc.)
|
||||
- [x] Test `deletion_in_progress` flag is set during deletion
|
||||
- [x] Test `deletion_attempted_at` is updated on failure
|
||||
|
||||
#### Edge Cases
|
||||
|
||||
- [x] Test deletion with user who has no children
|
||||
- [x] Test deletion with user who has no custom tasks/rewards
|
||||
- [x] Test deletion with user who has no uploaded images
|
||||
- [x] Test partial deletion failure (continue with other users)
|
||||
- [x] Test concurrent deletion attempts (flag prevents double-deletion)
|
||||
- [x] Test user with exactly 3 failed attempts (logs critical, no retry)
|
||||
|
||||
#### Admin API Tests
|
||||
|
||||
- [x] Test `GET /admin/deletion-queue` returns correct users
|
||||
- [x] Test `GET /admin/deletion-queue` requires authentication
|
||||
- [x] Test `GET /admin/deletion-threshold` returns current threshold
|
||||
- [x] Test `PUT /admin/deletion-threshold` updates threshold
|
||||
- [x] Test `PUT /admin/deletion-threshold` validates min/max
|
||||
- [x] Test `PUT /admin/deletion-threshold` requires admin role
|
||||
- [x] Test `POST /admin/deletion-queue/trigger` triggers scheduler
|
||||
- [x] Test `POST /admin/deletion-queue/trigger` returns summary
|
||||
|
||||
#### Integration Tests
|
||||
|
||||
- [x] Test full deletion flow from marking to deletion
|
||||
- [x] Test multiple users deleted in same scheduler run
|
||||
- [x] Test deletion with restart midway (recovery)
|
||||
|
||||
### Logging & Monitoring
|
||||
|
||||
- [x] Configure dedicated scheduler logger with rotating file handler
|
||||
- [x] Create `logs/` directory for log files
|
||||
- [x] Log each deletion step with INFO level
|
||||
- [x] Log summary after each scheduler run (users processed, deleted, failed)
|
||||
- [x] Log errors with user ID for debugging
|
||||
- [x] Log critical error for users with 3+ failed attempts
|
||||
- [x] Log warning if threshold is set below 168 hours
|
||||
|
||||
### Documentation
|
||||
|
||||
- [x] Create `README.md` at project root
|
||||
- [x] Document scheduler feature and behavior
|
||||
- [x] Document environment variable `ACCOUNT_DELETION_THRESHOLD_HOURS`
|
||||
- [x] Document deletion process and order
|
||||
- [x] Document admin API endpoints
|
||||
- [x] Document restart/retry behavior
|
||||
|
||||
---
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
All tests should use `DB_ENV=test` and operate on test databases in `backend/test_data/`.
|
||||
|
||||
### Unit Test Files
|
||||
|
||||
- `backend/tests/test_deletion_config.py` - Configuration validation
|
||||
- `backend/tests/test_deletion_scheduler.py` - Scheduler logic
|
||||
- `backend/tests/test_admin_api.py` - Admin endpoints
|
||||
|
||||
### Test Fixtures
|
||||
|
||||
- Create users with various `marked_for_deletion_at` timestamps
|
||||
- Create users with children, tasks, rewards, images
|
||||
- Create users with `deletion_in_progress = True` (for restart tests)
|
||||
|
||||
### Assertions
|
||||
|
||||
- Database records are removed in correct order
|
||||
- Filesystem directories are deleted
|
||||
- Flags and timestamps are updated correctly
|
||||
- Error handling works (log and continue)
|
||||
- Admin API responses match expected format
|
||||
|
||||
---
|
||||
|
||||
## Future Considerations
|
||||
|
||||
- Archive deleted accounts instead of hard deletion
|
||||
- Email notification to admin when deletion completes
|
||||
- Configurable retry count (currently hardcoded to 3)
|
||||
- Soft delete with recovery option (within grace period)
|
||||
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
|
||||
149
.github/specs/archive/feat-dynamic-points/IMPLEMENTATION_SUMMARY.md
vendored
Normal file
149
.github/specs/archive/feat-dynamic-points/IMPLEMENTATION_SUMMARY.md
vendored
Normal file
@@ -0,0 +1,149 @@
|
||||
# Tracking Feature Implementation Summary
|
||||
|
||||
## ✅ Implementation Complete
|
||||
|
||||
All acceptance criteria from [feat-tracking.md](.github/specs/active/feat-dynamic-points/feat-tracking.md) have been implemented and tested.
|
||||
|
||||
---
|
||||
|
||||
## 📦 What Was Delivered
|
||||
|
||||
### Backend
|
||||
|
||||
1. **Data Model** ([tracking_event.py](backend/models/tracking_event.py))
|
||||
- `TrackingEvent` dataclass with full type safety
|
||||
- Factory method `create_event()` for server-side timestamp generation
|
||||
- Delta invariant validation (`delta == points_after - points_before`)
|
||||
|
||||
2. **Database Layer** ([tracking.py](backend/db/tracking.py))
|
||||
- New TinyDB table: `tracking_events.json`
|
||||
- Helper functions: `insert_tracking_event`, `get_tracking_events_by_child`, `get_tracking_events_by_user`, `anonymize_tracking_events_for_user`
|
||||
- Offset-based pagination with sorting by `occurred_at` (desc)
|
||||
|
||||
3. **Audit Logging** ([tracking_logger.py](backend/utils/tracking_logger.py))
|
||||
- Per-user rotating file handlers (`logs/tracking_user_<user_id>.log`)
|
||||
- 10MB max file size, 5 backups
|
||||
- Structured log format with all event metadata
|
||||
|
||||
4. **API Integration** ([child_api.py](backend/api/child_api.py))
|
||||
- Tracking added to:
|
||||
- `POST /child/<id>/trigger-task` → action: `activated`
|
||||
- `POST /child/<id>/request-reward` → action: `requested`
|
||||
- `POST /child/<id>/trigger-reward` → action: `redeemed`
|
||||
- `POST /child/<id>/cancel-request-reward` → action: `cancelled`
|
||||
|
||||
5. **Admin API** ([tracking_api.py](backend/api/tracking_api.py))
|
||||
- `GET /admin/tracking` with filters:
|
||||
- `child_id` (required if no `user_id`)
|
||||
- `user_id` (admin only)
|
||||
- `entity_type` (task|reward|penalty)
|
||||
- `action` (activated|requested|redeemed|cancelled)
|
||||
- `limit` (default 50, max 500)
|
||||
- `offset` (default 0)
|
||||
- Returns total count for future pagination UI
|
||||
|
||||
6. **SSE Events** ([event_types.py](backend/events/types/event_types.py), [tracking_event_created.py](backend/events/types/tracking_event_created.py))
|
||||
- New event type: `TRACKING_EVENT_CREATED`
|
||||
- Payload: `tracking_event_id`, `child_id`, `entity_type`, `action`
|
||||
- Emitted on every tracking event creation
|
||||
|
||||
---
|
||||
|
||||
### Frontend
|
||||
|
||||
1. **TypeScript Models** ([models.ts](frontend/vue-app/src/common/models.ts))
|
||||
- `TrackingEvent` interface (1:1 parity with Python)
|
||||
- Type aliases: `EntityType`, `ActionType`
|
||||
- `TrackingEventCreatedPayload` for SSE events
|
||||
|
||||
2. **API Helpers** ([api.ts](frontend/vue-app/src/common/api.ts))
|
||||
- `getTrackingEventsForChild()` function with all filter params
|
||||
|
||||
3. **SSE Registration**
|
||||
- Event type registered in type union
|
||||
- Ready for future UI components
|
||||
|
||||
---
|
||||
|
||||
### Tests
|
||||
|
||||
**Backend Unit Tests** ([test_tracking.py](backend/tests/test_tracking.py)):
|
||||
|
||||
- ✅ Tracking event creation with factory method
|
||||
- ✅ Delta invariant validation
|
||||
- ✅ Insert and query tracking events
|
||||
- ✅ Filtering by `entity_type` and `action`
|
||||
- ✅ Offset-based pagination
|
||||
- ✅ User anonymization on deletion
|
||||
- ✅ Points change correctness (positive/negative/zero delta)
|
||||
- ✅ No points change for request/cancel actions
|
||||
|
||||
---
|
||||
|
||||
## 🔑 Key Design Decisions
|
||||
|
||||
1. **Append-only tracking table** - No deletions, only anonymization on user deletion
|
||||
2. **Server timestamps** - `occurred_at` always uses server time (UTC) to avoid client clock drift
|
||||
3. **Separate logging** - Per-user audit logs independent of database
|
||||
4. **Offset pagination** - Simpler than cursors, sufficient for expected scale
|
||||
5. **No UI (yet)** - API/models/SSE only; UI deferred to future phase
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Usage Examples
|
||||
|
||||
### Backend: Create a tracking event
|
||||
|
||||
```python
|
||||
from models.tracking_event import TrackingEvent
|
||||
from db.tracking import insert_tracking_event
|
||||
from utils.tracking_logger import log_tracking_event
|
||||
|
||||
event = TrackingEvent.create_event(
|
||||
user_id='user123',
|
||||
child_id='child456',
|
||||
entity_type='task',
|
||||
entity_id='task789',
|
||||
action='activated',
|
||||
points_before=50,
|
||||
points_after=60,
|
||||
metadata={'task_name': 'Homework'}
|
||||
)
|
||||
|
||||
insert_tracking_event(event)
|
||||
log_tracking_event(event)
|
||||
```
|
||||
|
||||
### Frontend: Query tracking events
|
||||
|
||||
```typescript
|
||||
import { getTrackingEventsForChild } from "@/common/api";
|
||||
|
||||
const res = await getTrackingEventsForChild({
|
||||
childId: "child456",
|
||||
entityType: "task",
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
// { tracking_events: [...], total: 42, count: 20, limit: 20, offset: 0 }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 Migration Notes
|
||||
|
||||
1. **New database file**: `backend/data/db/tracking_events.json` will be created automatically on first tracking event.
|
||||
2. **New log directory**: `backend/logs/tracking_user_<user_id>.log` files will be created per user.
|
||||
3. **No breaking changes** to existing APIs or data models.
|
||||
|
||||
---
|
||||
|
||||
## 🔮 Future Enhancements (Not in This Phase)
|
||||
|
||||
- Admin/parent UI for viewing tracking history
|
||||
- Badges and certificates based on tracking data
|
||||
- Analytics and reporting dashboards
|
||||
- Export tracking data (CSV, JSON)
|
||||
- Time-based filters (date range queries)
|
||||
BIN
.github/specs/archive/feat-dynamic-points/feat-dynamic-points-after.png
vendored
Normal file
BIN
.github/specs/archive/feat-dynamic-points/feat-dynamic-points-after.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
.github/specs/archive/feat-dynamic-points/feat-dynamic-points-before.png
vendored
Normal file
BIN
.github/specs/archive/feat-dynamic-points/feat-dynamic-points-before.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
BIN
.github/specs/archive/feat-dynamic-points/feat-dynamic-points-edit-modal.png
vendored
Normal file
BIN
.github/specs/archive/feat-dynamic-points/feat-dynamic-points-edit-modal.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
519
.github/specs/archive/feat-dynamic-points/feat-dynamic-points.md
vendored
Normal file
519
.github/specs/archive/feat-dynamic-points/feat-dynamic-points.md
vendored
Normal file
@@ -0,0 +1,519 @@
|
||||
# Feature: Dynamic Point and Cost Customization
|
||||
|
||||
## Overview
|
||||
|
||||
**Goal:** Allow parents to customize the point value of tasks/penalties and the cost of rewards on a per-child basis after assignment.
|
||||
|
||||
**User Story:**
|
||||
As a parent, I want to assign different point values to the same task for different children, so I can tailor rewards to each child's needs and motivations. For example, "Clean Room" might be worth 10 points for one child but 5 points for another.
|
||||
|
||||
**Process:**
|
||||
|
||||
1. **Assignment First**: Tasks, penalties, and rewards must be assigned to a child before their points/cost can be customized.
|
||||
2. **Edit Button Access**: After the first click on an item in ScrollingList (when it centers), an edit button appears in the corner (34x34px, using `edit.png` icon).
|
||||
3. **Modal Customization**: Clicking the edit button opens a modal with a number input field allowing values from **0 to 10000**.
|
||||
4. **Default Values**: The field defaults to the last user-set value or the entity's default points/cost if never customized.
|
||||
5. **Visual Indicator**: Items with custom values show a ✏️ emoji badge next to the points/cost number.
|
||||
6. **Activation Behavior**: The second click on an item activates it (triggers task/reward), not the first click.
|
||||
|
||||
**Architecture Decisions:**
|
||||
|
||||
- **Storage**: Use a separate `child_overrides.json` table (not embedded in child model) to store per-child customizations.
|
||||
- **Lifecycle**: Overrides reset to default when a child is unassigned from a task/reward. Overrides are deleted when the entity or child is deleted (cascade).
|
||||
- **Validation**: Allow 0 points/cost (not minimum 1). Disable save button on invalid input (empty, negative, >10000).
|
||||
- **UI Flow**: First click centers item and shows edit button. Second click activates entity. Edit button opens modal for customization.
|
||||
|
||||
**UI:**
|
||||
|
||||
- Before first click: [feat-dynamic-points-before.png](feat-dynamic-points-before.png)
|
||||
- After first click: [feat-dynamic-points-after.png](feat-dynamic-points-after.png)
|
||||
- Edit button icon: `frontend/vue-app/public/edit.png` (34x34px)
|
||||
- Button position: Corner of ScrollingList item, not interfering with text
|
||||
- Badge: ✏️ emoji displayed next to points/cost number when override exists
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
**No new configuration required.** Range validation (0-10000) is hardcoded per requirements.
|
||||
|
||||
---
|
||||
|
||||
## Data Model Changes
|
||||
|
||||
### New Model: `ChildOverride`
|
||||
|
||||
**Python** (`backend/models/child_override.py`):
|
||||
|
||||
Create a dataclass that inherits from `BaseModel` with the following fields:
|
||||
|
||||
- `child_id` (str): ID of the child this override applies to
|
||||
- `entity_id` (str): ID of the task/penalty/reward being customized
|
||||
- `entity_type` (Literal['task', 'reward']): Type of entity
|
||||
- `custom_value` (int): Custom points or cost value
|
||||
|
||||
Validation requirements:
|
||||
|
||||
- `custom_value` must be between 0 and 10000 (inclusive)
|
||||
- `entity_type` must be either 'task' or 'reward'
|
||||
- Include `__post_init__` method to enforce these validations
|
||||
- Include static factory method `create_override()` that accepts the four main fields and returns a new instance
|
||||
|
||||
**TypeScript** (`frontend/vue-app/src/common/models.ts`):
|
||||
|
||||
Create an interface with 1:1 parity to the Python model:
|
||||
|
||||
- Define `EntityType` as a union type: 'task' | 'reward'
|
||||
- Include all fields: `id`, `child_id`, `entity_id`, `entity_type`, `custom_value`, `created_at`, `updated_at`
|
||||
- All string fields except `custom_value` which is number
|
||||
|
||||
### Database Table
|
||||
|
||||
**New Table**: `child_overrides.json`
|
||||
|
||||
**Indexes**:
|
||||
|
||||
- `child_id` (for lookup by child)
|
||||
- `entity_id` (for lookup by task/reward)
|
||||
- Composite `(child_id, entity_id)` (for uniqueness constraint)
|
||||
|
||||
**Database Helper** (`backend/db/child_overrides.py`):
|
||||
|
||||
Create database helper functions using TinyDB and the `child_overrides_db` table:
|
||||
|
||||
- `insert_override(override)`: Insert or update (upsert) based on composite key (child_id, entity_id). Only one override allowed per child-entity pair.
|
||||
- `get_override(child_id, entity_id)`: Return Optional[ChildOverride] for a specific child and entity combination
|
||||
- `get_overrides_for_child(child_id)`: Return List[ChildOverride] for all overrides belonging to a child
|
||||
- `delete_override(child_id, entity_id)`: Delete specific override, return bool indicating success
|
||||
- `delete_overrides_for_child(child_id)`: Delete all overrides for a child, return count deleted
|
||||
- `delete_overrides_for_entity(entity_id)`: Delete all overrides for an entity, return count deleted
|
||||
|
||||
All functions should use `from_dict()` and `to_dict()` for model serialization.
|
||||
|
||||
---
|
||||
|
||||
## SSE Events
|
||||
|
||||
### 1. `child_override_set`
|
||||
|
||||
**Emitted When**: A parent sets or updates a custom value for a task/reward.
|
||||
|
||||
**Payload** (`backend/events/types/child_override_set.py`):
|
||||
|
||||
Create a dataclass `ChildOverrideSetPayload` that inherits from `EventPayload` with a single field:
|
||||
|
||||
- `override` (ChildOverride): The override object that was set
|
||||
|
||||
**TypeScript** (`frontend/vue-app/src/common/backendEvents.ts`):
|
||||
|
||||
Create an interface `ChildOverrideSetPayload` with:
|
||||
|
||||
- `override` (ChildOverride): The override object that was set
|
||||
|
||||
### 2. `child_override_deleted`
|
||||
|
||||
**Emitted When**: An override is deleted (manual reset, unassignment, or cascade).
|
||||
|
||||
**Payload** (`backend/events/types/child_override_deleted.py`):
|
||||
|
||||
Create a dataclass `ChildOverrideDeletedPayload` that inherits from `EventPayload` with three fields:
|
||||
|
||||
- `child_id` (str): ID of the child
|
||||
- `entity_id` (str): ID of the entity
|
||||
- `entity_type` (str): Type of entity ('task' or 'reward')
|
||||
|
||||
**TypeScript** (`frontend/vue-app/src/common/backendEvents.ts`):
|
||||
|
||||
Create an interface `ChildOverrideDeletedPayload` with:
|
||||
|
||||
- `child_id` (string): ID of the child
|
||||
- `entity_id` (string): ID of the entity
|
||||
- `entity_type` (string): Type of entity
|
||||
|
||||
---
|
||||
|
||||
## API Design
|
||||
|
||||
### 1. **PUT** `/child/<child_id>/override`
|
||||
|
||||
**Purpose**: Set or update a custom value for a task/reward.
|
||||
|
||||
**Auth**: User must own the child.
|
||||
|
||||
**Request Body**:
|
||||
|
||||
JSON object with three required fields:
|
||||
|
||||
- `entity_id` (string): UUID of the task or reward
|
||||
- `entity_type` (string): Either "task" or "reward"
|
||||
- `custom_value` (number): Integer between 0 and 10000
|
||||
|
||||
**Validation**:
|
||||
|
||||
- `entity_type` must be "task" or "reward"
|
||||
- `custom_value` must be 0-10000
|
||||
- Entity must be assigned to child
|
||||
- Child must exist and belong to user
|
||||
|
||||
**Response**:
|
||||
|
||||
JSON object with a single key `override` containing the complete ChildOverride object with all fields (id, child_id, entity_id, entity_type, custom_value, created_at, updated_at in ISO format).
|
||||
|
||||
**Errors**:
|
||||
|
||||
- 404: Child not found or not owned
|
||||
- 404: Entity not assigned to child
|
||||
- 400: Invalid entity_type
|
||||
- 400: custom_value out of range
|
||||
|
||||
**SSE**: Emits `child_override_set` to user.
|
||||
|
||||
### 2. **GET** `/child/<child_id>/overrides`
|
||||
|
||||
**Purpose**: Get all overrides for a child.
|
||||
|
||||
**Auth**: User must own the child.
|
||||
|
||||
**Response**:
|
||||
|
||||
JSON object with a single key `overrides` containing an array of ChildOverride objects. Each object includes all standard fields (id, child_id, entity_id, entity_type, custom_value, created_at, updated_at).
|
||||
|
||||
**Errors**:
|
||||
|
||||
- 404: Child not found or not owned
|
||||
|
||||
### 3. **DELETE** `/child/<child_id>/override/<entity_id>`
|
||||
|
||||
**Purpose**: Delete an override (reset to default).
|
||||
|
||||
**Auth**: User must own the child.
|
||||
|
||||
**Response**:
|
||||
|
||||
JSON object with `message` field set to "Override deleted".
|
||||
|
||||
**Errors**:
|
||||
|
||||
- 404: Child not found or not owned
|
||||
- 404: Override not found
|
||||
|
||||
**SSE**: Emits `child_override_deleted` to user.
|
||||
|
||||
### Modified Endpoints
|
||||
|
||||
Update these existing endpoints to include override information:
|
||||
|
||||
1. **GET** `/child/<child_id>/list-tasks` - Include `custom_value` in task objects if override exists
|
||||
2. **GET** `/child/<child_id>/list-rewards` - Include `custom_value` in reward objects if override exists
|
||||
3. **POST** `/child/<child_id>/trigger-task` - Use `custom_value` if override exists when awarding points
|
||||
4. **POST** `/child/<child_id>/trigger-reward` - Use `custom_value` if override exists when deducting points
|
||||
5. **PUT** `/child/<child_id>/set-tasks` - Delete overrides for unassigned tasks
|
||||
6. **PUT** `/child/<child_id>/set-rewards` - Delete overrides for unassigned rewards
|
||||
|
||||
---
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### File Structure
|
||||
|
||||
**Backend**:
|
||||
|
||||
- `backend/models/child_override.py` - ChildOverride model
|
||||
- `backend/db/child_overrides.py` - Database helpers
|
||||
- `backend/api/child_override_api.py` - New API endpoints (PUT, GET, DELETE)
|
||||
- `backend/events/types/child_override_set.py` - SSE event payload
|
||||
- `backend/events/types/child_override_deleted.py` - SSE event payload
|
||||
- `backend/events/types/event_types.py` - Add CHILD_OVERRIDE_SET, CHILD_OVERRIDE_DELETED enums
|
||||
- `backend/tests/test_child_override_api.py` - Unit tests
|
||||
|
||||
**Frontend**:
|
||||
|
||||
- `frontend/vue-app/src/common/models.ts` - Add ChildOverride interface
|
||||
- `frontend/vue-app/src/common/api.ts` - Add setChildOverride(), getChildOverrides(), deleteChildOverride()
|
||||
- `frontend/vue-app/src/common/backendEvents.ts` - Add event types
|
||||
- `frontend/vue-app/src/components/OverrideEditModal.vue` - New modal component
|
||||
- `frontend/vue-app/src/components/ScrollingList.vue` - Add edit button and ✏️ badge
|
||||
- `frontend/vue-app/components/__tests__/OverrideEditModal.spec.ts` - Component tests
|
||||
|
||||
### Logging Strategy
|
||||
|
||||
**Backend**: Log override operations to per-user rotating log files (same pattern as tracking).
|
||||
|
||||
**Log Messages**:
|
||||
|
||||
- `Override set: child={child_id}, entity={entity_id}, type={entity_type}, value={custom_value}`
|
||||
- `Override deleted: child={child_id}, entity={entity_id}`
|
||||
- `Overrides cascade deleted for child: child_id={child_id}, count={count}`
|
||||
- `Overrides cascade deleted for entity: entity_id={entity_id}, count={count}`
|
||||
|
||||
**Frontend**: No additional logging beyond standard error handling.
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria (Definition of Done)
|
||||
|
||||
### Data Model
|
||||
|
||||
- [x] `ChildOverride` Python dataclass created with validation (0-10000 range, entity_type literal)
|
||||
- [x] `ChildOverride` TypeScript interface created (1:1 parity with Python)
|
||||
- [x] `child_overrides.json` TinyDB table created in `backend/db/db.py`
|
||||
- [x] Database helper functions created (insert, get, delete by child, delete by entity)
|
||||
- [x] Composite uniqueness constraint enforced (child_id, entity_id)
|
||||
|
||||
### Backend Implementation
|
||||
|
||||
- [x] PUT `/child/<child_id>/override` endpoint created with validation
|
||||
- [x] GET `/child/<child_id>/overrides` endpoint created
|
||||
- [x] DELETE `/child/<child_id>/override/<entity_id>` endpoint created
|
||||
- [x] GET `/child/<child_id>/list-tasks` modified to include `custom_value` when override exists
|
||||
- [x] GET `/child/<child_id>/list-rewards` modified to include `custom_value` when override exists
|
||||
- [x] POST `/child/<child_id>/trigger-task` modified to use override value
|
||||
- [x] POST `/child/<child_id>/trigger-reward` modified to use override value
|
||||
- [x] PUT `/child/<child_id>/set-tasks` modified to delete overrides for unassigned tasks
|
||||
- [x] PUT `/child/<child_id>/set-rewards` modified to delete overrides for unassigned rewards
|
||||
- [x] Cascade delete implemented: deleting child removes all its overrides
|
||||
- [x] Cascade delete implemented: deleting task/reward removes all its overrides
|
||||
- [x] Authorization checks: user must own child to access overrides
|
||||
- [x] Validation: entity must be assigned to child before override can be set
|
||||
|
||||
### SSE Events
|
||||
|
||||
- [x] `child_override_set` event type added to event_types.py
|
||||
- [x] `child_override_deleted` event type added to event_types.py
|
||||
- [x] `ChildOverrideSetPayload` class created (Python)
|
||||
- [x] `ChildOverrideDeletedPayload` class created (Python)
|
||||
- [x] PUT endpoint emits `child_override_set` event
|
||||
- [x] DELETE endpoint emits `child_override_deleted` event
|
||||
- [x] Frontend TypeScript interfaces for event payloads created
|
||||
|
||||
### Frontend Implementation
|
||||
|
||||
- [x] `OverrideEditModal.vue` component created
|
||||
- [x] Modal has number input field with 0-10000 validation
|
||||
- [x] Modal disables save button on invalid input (empty, negative, >10000)
|
||||
- [x] Modal defaults to current override value or entity default
|
||||
- [x] Modal calls PUT `/child/<id>/override` API on save
|
||||
- [x] Edit button (34x34px) added to ScrollingList items
|
||||
- [x] Edit button only appears after first click (when item is centered)
|
||||
- [x] Edit button uses `edit.png` icon from public folder
|
||||
- [x] ✏️ emoji badge displayed next to points/cost when override exists
|
||||
- [x] Badge only shows for items with active overrides
|
||||
- [x] Second click on item activates entity (not first click)
|
||||
- [x] SSE listeners registered for `child_override_set` and `child_override_deleted`
|
||||
- [x] Real-time UI updates when override events received
|
||||
|
||||
### Backend Unit Tests
|
||||
|
||||
#### API Tests (`backend/tests/test_child_override_api.py`)
|
||||
|
||||
- [x] Test PUT creates new override with valid data
|
||||
- [x] Test PUT updates existing override
|
||||
- [x] Test PUT returns 400 for custom_value < 0
|
||||
- [x] Test PUT returns 400 for custom_value > 10000
|
||||
- [x] Test PUT returns 400 for invalid entity_type
|
||||
- [ ] Test PUT returns 404 for non-existent child
|
||||
- [ ] Test PUT returns 404 for unassigned entity
|
||||
- [ ] Test PUT returns 403 for child not owned by user
|
||||
- [ ] Test PUT emits child_override_set event
|
||||
- [x] Test GET returns all overrides for child
|
||||
- [ ] Test GET returns empty array when no overrides
|
||||
- [ ] Test GET returns 404 for non-existent child
|
||||
- [ ] Test GET returns 403 for child not owned by user
|
||||
- [x] Test DELETE removes override
|
||||
- [ ] Test DELETE returns 404 when override doesn't exist
|
||||
- [ ] Test DELETE returns 404 for non-existent child
|
||||
- [ ] Test DELETE returns 403 for child not owned by user
|
||||
- [ ] Test DELETE emits child_override_deleted event
|
||||
|
||||
#### Integration Tests
|
||||
|
||||
- [ ] Test list-tasks includes custom_value for overridden tasks
|
||||
- [ ] Test list-tasks shows default points for non-overridden tasks
|
||||
- [ ] Test list-rewards includes custom_value for overridden rewards
|
||||
- [ ] Test trigger-task uses custom_value when awarding points
|
||||
- [ ] Test trigger-task uses default points when no override
|
||||
- [ ] Test trigger-reward uses custom_value when deducting points
|
||||
- [ ] Test trigger-reward uses default cost when no override
|
||||
- [ ] Test set-tasks deletes overrides for unassigned tasks
|
||||
- [ ] Test set-tasks preserves overrides for still-assigned tasks
|
||||
- [ ] Test set-rewards deletes overrides for unassigned rewards
|
||||
- [ ] Test set-rewards preserves overrides for still-assigned rewards
|
||||
|
||||
#### Cascade Delete Tests
|
||||
|
||||
- [x] Test deleting child removes all its overrides
|
||||
- [x] Test deleting task removes all overrides for that task
|
||||
- [x] Test deleting reward removes all overrides for that reward
|
||||
- [x] Test unassigning task from child deletes override
|
||||
- [x] Test reassigning task to child resets override (not preserved)
|
||||
|
||||
#### Edge Cases
|
||||
|
||||
- [x] Test custom_value = 0 is allowed
|
||||
- [x] Test custom_value = 10000 is allowed
|
||||
- [ ] Test cannot set override for entity not assigned to child
|
||||
- [ ] Test cannot set override for non-existent entity
|
||||
- [ ] Test multiple children can have different overrides for same entity
|
||||
|
||||
### Frontend Unit Tests
|
||||
|
||||
#### Component Tests (`components/__tests__/OverrideEditModal.spec.ts`)
|
||||
|
||||
- [x] Test modal renders with default value
|
||||
- [x] Test modal renders with existing override value
|
||||
- [x] Test save button disabled when input is empty
|
||||
- [x] Test save button disabled when value < 0
|
||||
- [x] Test save button disabled when value > 10000
|
||||
- [x] Test save button enabled when value is 0-10000
|
||||
- [x] Test modal calls API with correct parameters on save
|
||||
- [x] Test modal emits close event after successful save
|
||||
- [x] Test modal shows error message on API failure
|
||||
- [x] Test cancel button closes modal without saving
|
||||
|
||||
#### Component Tests (`components/__tests__/ScrollingList.spec.ts`)
|
||||
|
||||
- [ ] Test edit button hidden before first click
|
||||
- [ ] Test edit button appears after first click (when centered)
|
||||
- [ ] Test edit button opens OverrideEditModal
|
||||
- [ ] Test ✏️ badge displayed when override exists
|
||||
- [ ] Test ✏️ badge hidden when no override exists
|
||||
- [ ] Test second click activates entity (not first click)
|
||||
- [ ] Test edit button positioned correctly (34x34px, corner)
|
||||
- [ ] Test edit button doesn't interfere with text
|
||||
|
||||
#### Integration Tests
|
||||
|
||||
- [ ] Test SSE event updates UI when override is set
|
||||
- [ ] Test SSE event updates UI when override is deleted
|
||||
- [ ] Test override value displayed in task/reward list
|
||||
- [ ] Test points calculation uses override when triggering task
|
||||
- [ ] Test cost calculation uses override when triggering reward
|
||||
|
||||
#### Edge Cases
|
||||
|
||||
- [ ] Test 0 points/cost displays correctly
|
||||
- [ ] Test 10000 points/cost displays correctly
|
||||
- [ ] Test badge updates immediately after setting override
|
||||
- [ ] Test badge disappears immediately after deleting override
|
||||
|
||||
### Logging & Monitoring
|
||||
|
||||
- [ ] Override set operations logged to per-user log files
|
||||
- [ ] Override delete operations logged
|
||||
- [ ] Cascade delete operations logged with count
|
||||
- [ ] Log messages include child_id, entity_id, entity_type, custom_value
|
||||
|
||||
### Documentation
|
||||
|
||||
- [ ] API endpoints documented in this spec
|
||||
- [ ] Data model documented in this spec
|
||||
- [ ] SSE events documented in this spec
|
||||
- [ ] UI behavior documented in this spec
|
||||
- [ ] Edge cases and validation rules documented
|
||||
|
||||
---
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Test Files
|
||||
|
||||
**Backend** (`backend/tests/test_child_override_api.py`):
|
||||
|
||||
Create six test classes:
|
||||
|
||||
1. **TestChildOverrideModel**: Test model validation (6 tests)
|
||||
- Valid override creation
|
||||
- Negative custom_value raises ValueError
|
||||
- custom_value > 10000 raises ValueError
|
||||
- custom_value = 0 is allowed
|
||||
- custom_value = 10000 is allowed
|
||||
- Invalid entity_type raises ValueError
|
||||
|
||||
2. **TestChildOverrideDB**: Test database operations (8 tests)
|
||||
- Insert new override
|
||||
- Insert updates existing (upsert behavior)
|
||||
- Get existing override returns object
|
||||
- Get nonexistent override returns None
|
||||
- Get all overrides for a child
|
||||
- Delete specific override
|
||||
- Delete all overrides for a child (returns count)
|
||||
- Delete all overrides for an entity (returns count)
|
||||
|
||||
3. **TestChildOverrideAPI**: Test all three API endpoints (18 tests)
|
||||
- PUT creates new override
|
||||
- PUT updates existing override
|
||||
- PUT returns 400 for negative value
|
||||
- PUT returns 400 for value > 10000
|
||||
- PUT returns 400 for invalid entity_type
|
||||
- PUT returns 404 for nonexistent child
|
||||
- PUT returns 404 for unassigned entity
|
||||
- PUT returns 403 for child not owned by user
|
||||
- PUT emits child_override_set event
|
||||
- GET returns all overrides for child
|
||||
- GET returns empty array when no overrides
|
||||
- GET returns 404 for nonexistent child
|
||||
- GET returns 403 for child not owned
|
||||
- DELETE removes override successfully
|
||||
- DELETE returns 404 when override doesn't exist
|
||||
- DELETE returns 404 for nonexistent child
|
||||
- DELETE returns 403 for child not owned
|
||||
- DELETE emits child_override_deleted event
|
||||
|
||||
4. **TestIntegration**: Test override integration with existing endpoints (11 tests)
|
||||
- list-tasks includes custom_value for overridden tasks
|
||||
- list-tasks shows default points for non-overridden tasks
|
||||
- list-rewards includes custom_value for overridden rewards
|
||||
- trigger-task uses custom_value when awarding points
|
||||
- trigger-task uses default points when no override
|
||||
- trigger-reward uses custom_value when deducting points
|
||||
- trigger-reward uses default cost when no override
|
||||
- set-tasks deletes overrides for unassigned tasks
|
||||
- set-tasks preserves overrides for still-assigned tasks
|
||||
- set-rewards deletes overrides for unassigned rewards
|
||||
- set-rewards preserves overrides for still-assigned rewards
|
||||
|
||||
5. **TestCascadeDelete**: Test cascade deletion behavior (5 tests)
|
||||
- Deleting child removes all its overrides
|
||||
- Deleting task removes all overrides for that task
|
||||
- Deleting reward removes all overrides for that reward
|
||||
- Unassigning task deletes override
|
||||
- Reassigning task resets override (not preserved)
|
||||
|
||||
6. **TestEdgeCases**: Test boundary conditions (5 tests)
|
||||
- custom_value = 0 is allowed
|
||||
- custom_value = 10000 is allowed
|
||||
- Cannot set override for unassigned entity
|
||||
- Cannot set override for nonexistent entity
|
||||
- Multiple children can have different overrides for same entity
|
||||
|
||||
### Test Fixtures
|
||||
|
||||
Create pytest fixtures for common test scenarios:
|
||||
|
||||
- `child_with_task`: Uses existing `child` and `task` fixtures, calls set-tasks endpoint to assign task to child, asserts 200 response, returns child dict
|
||||
- `child_with_task_override`: Builds on `child_with_task`, calls PUT override endpoint to set custom_value=15 for the task, asserts 200 response, returns child dict
|
||||
- Similar fixtures for rewards: `child_with_reward`, `child_with_reward_override`
|
||||
- `child_with_overrides`: Child with multiple overrides for testing bulk operations
|
||||
|
||||
### Assertions
|
||||
|
||||
Test assertions should verify three main areas:
|
||||
|
||||
1. **API Response Correctness**: Check status code (200, 400, 403, 404), verify returned override object has correct values for all fields (custom_value, child_id, entity_id, etc.)
|
||||
|
||||
2. **SSE Event Emission**: Use mock_sse fixture to assert `send_event_for_current_user` was called exactly once with the correct EventType (CHILD_OVERRIDE_SET or CHILD_OVERRIDE_DELETED)
|
||||
|
||||
3. **Points Calculation**: After triggering tasks/rewards, verify the child's points reflect the custom_value (not the default). For example, if default is 10 but override is 15, child.points should increase by 15.
|
||||
|
||||
---
|
||||
|
||||
## Future Considerations
|
||||
|
||||
1. **Bulk Override Management**: Add endpoint to set/get/delete multiple overrides at once for performance.
|
||||
2. **Override History**: Track changes to override values over time for analytics.
|
||||
3. **Copy Overrides**: Allow copying overrides from one child to another.
|
||||
4. **Override Templates**: Save common override patterns as reusable templates.
|
||||
5. **Percentage-Based Overrides**: Allow setting overrides as percentage of default (e.g., "150% of default").
|
||||
6. **Override Expiration**: Add optional expiration dates for temporary adjustments.
|
||||
7. **Undo Override**: Add "Restore Default" button in UI that deletes override with one click.
|
||||
8. **Admin Dashboard**: Show overview of all overrides across all children for analysis.
|
||||
112
.github/specs/archive/feat-dynamic-points/feat-tracking.md
vendored
Normal file
112
.github/specs/archive/feat-dynamic-points/feat-tracking.md
vendored
Normal file
@@ -0,0 +1,112 @@
|
||||
# Feature: Task and Reward Tracking
|
||||
|
||||
## Overview
|
||||
|
||||
**Goal:** Tasks, Penalties, and Rewards should be recorded when completed (activated), requested, redeemed, and cancelled. A record of the date and time should also be kept for these actions. A log file shall be produced that shows the child's points before and after the action happened.
|
||||
|
||||
**User Story:**
|
||||
As an administrator, I want to know what kind and when a task, penalty, or reward was activated.
|
||||
As an administrator, I want a log created detailing when a task, penalty, or reward was activated and how points for the affected child has changed.
|
||||
As a user (parent), when I activate a task or penalty, I want to record the time and what task or penalty was activated.
|
||||
As a user (parent), when I redeem a reward, I want to record the time and what reward was redeeemed.
|
||||
As a user (parent/child), when I cancel a reward, I want to record the time and what reward was cancelled.
|
||||
As a user (child), when I request a reward, I want to record the time and what reward was requested.
|
||||
|
||||
**Questions:**
|
||||
|
||||
- Tasks/Penalty, rewards should be tracked per child. Should the tracking be recorded in the child database, or should a new database be used linking the tracking to the child?
|
||||
- If using a new database, should tracking also be linking to user in case of account deletion?
|
||||
- Does there need to be any frontend changes for now?
|
||||
|
||||
**Decisions:**
|
||||
|
||||
- Use a **new TinyDB table** (`tracking_events.json`) for tracking records (append-only). Do **not** embed tracking in `child` to avoid large child docs and preserve audit history. Each record includes `child_id` and `user_id`.
|
||||
- Track events for: task/penalty activated, reward requested, reward redeemed, reward cancelled.
|
||||
- Store timestamps in **UTC ISO 8601** with timezone (e.g. `2026-02-09T18:42:15Z`). Always use **server time** for `occurred_at` to avoid client clock drift.
|
||||
- On user deletion: **anonymize** tracking records by setting `user_id` to `null`, preserving child activity history for compliance/audit.
|
||||
- Keep an **audit log file per user** (e.g. `tracking_user_<user_id>.log`) with points before/after and event metadata. Use rotating file handler.
|
||||
- Use **offset-based pagination** for tracking queries (simpler with TinyDB, sufficient for expected scale).
|
||||
- **Frontend changes deferred**: Ship backend API, models, and SSE events only. No UI components in this phase.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
## Acceptance Criteria (Definition of Done)
|
||||
|
||||
### Data Model
|
||||
|
||||
- [x] Add `TrackingEvent` model in `backend/models/` with `from_dict()`/`to_dict()` and 1:1 TS interface in [frontend/vue-app/src/common/models.ts](frontend/vue-app/src/common/models.ts)
|
||||
- [x] `TrackingEvent` fields include: `id`, `user_id`, `child_id`, `entity_type` (task|reward|penalty), `entity_id`, `action` (activated|requested|redeemed|cancelled), `points_before`, `points_after`, `delta`, `occurred_at`, `created_at`, `metadata` (optional dict)
|
||||
- [x] Ensure `delta == points_after - points_before` invariant
|
||||
|
||||
### Backend Implementation
|
||||
|
||||
- [x] Create TinyDB table (e.g., `tracking_events.json`) with helper functions in `backend/db/`
|
||||
- [x] Add tracking write in all mutation endpoints:
|
||||
- task/penalty activation
|
||||
- reward request
|
||||
- reward redeem
|
||||
- reward cancel
|
||||
- [x] Build `TrackingEvent` instances from models (no raw dict writes)
|
||||
- [x] Add server-side validation for required fields and action/entity enums
|
||||
- [x] Add `send_event_for_current_user` calls for tracking mutations
|
||||
|
||||
### Frontend Implementation
|
||||
|
||||
- [x] Add `TrackingEvent` interface and enums to [frontend/vue-app/src/common/models.ts](frontend/vue-app/src/common/models.ts)
|
||||
- [x] Add API helpers for tracking (list per child, optional filters) in [frontend/vue-app/src/common/api.ts](frontend/vue-app/src/common/api.ts)
|
||||
- [x] Register SSE event type `tracking_event_created` in [frontend/vue-app/src/common/backendEvents.ts](frontend/vue-app/src/common/backendEvents.ts)
|
||||
- [x] **No UI components** — deferred to future phase
|
||||
|
||||
### Admin API
|
||||
|
||||
- [x] Add admin endpoint to query tracking by `child_id`, date range, and `entity_type` (e.g. `GET /admin/tracking`)
|
||||
- [x] Add offset-based pagination parameters (`limit`, `offset`) with sensible defaults (e.g. limit=50, max=500)
|
||||
- [x] Return total count for pagination UI (future)
|
||||
|
||||
### SSE Event
|
||||
|
||||
- [x] Add event type `tracking_event_created` with payload containing `tracking_event_id` and minimal denormalized info
|
||||
- [x] Update [backend/events/types/event_types.py](backend/events/types/event_types.py) and [frontend/vue-app/src/common/backendEvents.ts](frontend/vue-app/src/common/backendEvents.ts)
|
||||
|
||||
### Backend Unit Tests
|
||||
|
||||
- [x] Create tests for tracking creation on each mutation endpoint (task/penalty activated, reward requested/redeemed/cancelled)
|
||||
- [x] Validate `points_before/after` and `delta` are correct
|
||||
- [x] Ensure tracking write does not block core mutation (failure behavior defined)
|
||||
|
||||
### Frontend Unit Tests
|
||||
|
||||
- [x] Test API helper functions for tracking queries
|
||||
- [x] Test TypeScript interface matches backend model (type safety)
|
||||
|
||||
#### Edge Cases
|
||||
|
||||
- [x] Reward cancel after redeem should not create duplicate inconsistent entries
|
||||
- [x] Multiple activations in rapid sequence must be ordered by `occurred_at` then `created_at`
|
||||
- [x] Child deleted: tracking records retained and still queryable by admin (archive mode)
|
||||
- [x] User deleted: anonymize tracking by setting `user_id` to `null`, retain all other fields for audit history
|
||||
|
||||
#### Integration Tests
|
||||
|
||||
- [x] End-to-end: activate task -> tracking created -> SSE event emitted -> audit log written
|
||||
- [x] Verify user deletion anonymizes tracking records without breaking queries
|
||||
|
||||
### Logging & Monitoring
|
||||
|
||||
- [x] Add dedicated tracking logger with **per-user rotating file handler** (e.g. `logs/tracking_user_<user_id>.log`)
|
||||
- [x] Log one line per tracking event with `user_id`, `child_id`, `entity_type`, `entity_id`, `action`, `points_before`, `points_after`, `delta`, `occurred_at`
|
||||
- [x] Configure max file size and backup count (e.g. 10MB, 5 backups)
|
||||
|
||||
### Documentation
|
||||
|
||||
- [x] Update README or docs to include tracking endpoints, schema, and sample responses
|
||||
- [x] Add migration note for new `tracking_events.json`
|
||||
|
||||
---
|
||||
|
||||
## Future Considerations
|
||||
|
||||
- Reward tracking will be used to determine child ranking (badges and certificates!)
|
||||
- is_good vs not is_good in task tracking can be used to show the child their balance in good vs not good
|
||||
87
.github/specs/archive/feat-hashed-passwords.md
vendored
Normal file
87
.github/specs/archive/feat-hashed-passwords.md
vendored
Normal file
@@ -0,0 +1,87 @@
|
||||
# Feature: Hash passwords in database
|
||||
|
||||
## Overview
|
||||
|
||||
**Goal:** Currently passwords for users are stored in the database as plain text. They need to be hashed using a secure algorithm to prevent exposure in case of a data breach.
|
||||
|
||||
**User Story:**
|
||||
As a user, when I create an account with a password, the password needs to be hashed in the database.
|
||||
As an admin, I would like a script that will convert the current user database passwords into a hash.
|
||||
|
||||
---
|
||||
|
||||
## Data Model Changes
|
||||
|
||||
### Backend Model (`backend/models/user.py`)
|
||||
|
||||
No changes required to the `User` dataclass fields. Passwords will remain as strings, but they will now be hashed values instead of plain text.
|
||||
|
||||
### Frontend Model (`frontend/vue-app/src/common/models.ts`)
|
||||
|
||||
No changes required. The `User` interface does not expose passwords.
|
||||
|
||||
---
|
||||
|
||||
## Backend Implementation
|
||||
|
||||
### Password Hashing
|
||||
|
||||
- Use `werkzeug.security.generate_password_hash()` with default settings (PBKDF2 with SHA256, salt, and iterations) for hashing new passwords.
|
||||
- Use `werkzeug.security.check_password_hash()` for verification during login and password reset.
|
||||
- Update the following endpoints to hash passwords on input and verify hashes on output:
|
||||
- `POST /signup` (hash password before storing; existing length/complexity checks apply).
|
||||
- `POST /login` (verify hash against input).
|
||||
- `POST /reset-password` (hash new password before storing; existing length/complexity checks apply).
|
||||
|
||||
### Migration Script (`backend/scripts/hash_passwords.py`)
|
||||
|
||||
Create a new script to hash existing plain text passwords in the database:
|
||||
|
||||
- Read all users from `users_db`.
|
||||
- For each user, check if the password is already hashed (starts with `scrypt:` or `$pbkdf2-sha256$`); if so, skip.
|
||||
- For plain text passwords, hash using `generate_password_hash()`.
|
||||
- Update the user record in the database.
|
||||
- Log the number of users updated.
|
||||
- Run this script once after deployment to migrate existing data.
|
||||
|
||||
**Usage:** `python backend/scripts/hash_passwords.py`
|
||||
|
||||
**Security Notes:**
|
||||
|
||||
- The script should only be run in a secure environment (e.g., admin access).
|
||||
- After migration, verify a few users can log in.
|
||||
- Delete or secure the script post-migration to avoid reuse.
|
||||
|
||||
### Error Handling
|
||||
|
||||
No new error codes needed. Existing authentication errors (e.g., invalid credentials) remain unchanged.
|
||||
|
||||
---
|
||||
|
||||
### Backend Tests (`backend/tests/test_auth_api.py`)
|
||||
|
||||
- [x] Test signup with password hashing: Verify stored password is hashed (starts with `scrypt:`).
|
||||
- [x] Test login with correct password: Succeeds.
|
||||
- [x] Test login with incorrect password: Fails with appropriate error.
|
||||
- [x] Test password reset: New password is hashed.
|
||||
- [x] Test migration script: Hashes existing plain text passwords without data loss; skips already-hashed passwords.
|
||||
|
||||
---
|
||||
|
||||
## Future Considerations
|
||||
|
||||
- Monitor for deprecated hashing algorithms and plan upgrades (e.g., to Argon2 if needed).
|
||||
- Implement password strength requirements on signup/reset if not already present.
|
||||
- Consider rate limiting on login attempts to prevent brute-force attacks.
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria (Definition of Done)
|
||||
|
||||
### Backend
|
||||
|
||||
- [x] Update `/signup` to hash passwords using `werkzeug.security.generate_password_hash()`.
|
||||
- [x] Update `/login` to verify passwords using `werkzeug.security.check_password_hash()`.
|
||||
- [x] Update `/reset-password` to hash new passwords.
|
||||
- [x] Create `backend/scripts/hash_passwords.py` script for migrating existing plain text passwords.
|
||||
- [x] All backend tests pass, including new hashing tests.
|
||||
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
|
||||
26
.github/specs/archive/feat-no-delete-system-tasks-and-rewards.md
vendored
Normal file
26
.github/specs/archive/feat-no-delete-system-tasks-and-rewards.md
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
# Feature: Do Not Allow System Tasks or System Rewards To Be Deleted
|
||||
|
||||
## Context:
|
||||
|
||||
- **Goal:** In Task List view and Reward List view, do not allow items to be deleted by the user if they are system tasks.
|
||||
- **User Story:** As a [user], I want to only be able to press the delete button on a task or reward if that item is not a system task or reward so that shared system tasks are not deleted for other users.
|
||||
|
||||
## Technical Requirements
|
||||
|
||||
- **File Affected:** ItemList.vue, TaskView.vue, RewardView.vue, task_api.py, reward_api.py
|
||||
- **Logic:**
|
||||
1. Starting with ItemList.vue, we should check to see if any item in the list has an "user_id" property and if that property is null.
|
||||
2. If the property is null, that means the item is not owned by a user, so do no display a delete button.
|
||||
3. If the ItemList has it's deletable property as false, don't bother checking each item for user_id as the delete button will not display.
|
||||
4. As a safeguard, on the backend, the DELETE api requests should check to see if the "user_id" property of the requested task or reward is null. This is done by requesting the item from the database. The request provides the item's id. If the item is a system item, return 403. Let the return tell the requestor that the item is a system item and cannot be deleted.
|
||||
5. As a safeguard, make PUT/PATCH operations perform a copy-on-edit of the item. This is already implemented.
|
||||
6. Bulk deletion is not possible, don't make changes for this.
|
||||
7. For any item in the frontend or backend that does not have a "user_id" property, treat that as a system item (user_id=null)
|
||||
8. For both task and reward api create an application level constraint on the database that checks for user_id before mutation logic.
|
||||
|
||||
## Acceptance Criteria (The "Definition of Done")
|
||||
|
||||
- [x] Logic: Task or Reward does not display the delete button when props.deletable is true and a list item is a system item.
|
||||
- [x] UI: Doesn't show delete button for system items.
|
||||
- [x] Backend Tests: Unit tests cover a delete API request for a system task or reward and returns a 403.
|
||||
- [x] Frontend Tests: Add vitest for this feature in the frontend to make sure the delete button hidden or shown.
|
||||
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.
|
||||
251
.github/specs/archive/feat-profile-mark-remove-account.md
vendored
Normal file
251
.github/specs/archive/feat-profile-mark-remove-account.md
vendored
Normal file
@@ -0,0 +1,251 @@
|
||||
# Feature: Account Deletion (Mark for Removal)
|
||||
|
||||
## Overview
|
||||
|
||||
**Goal:** Allow users to mark their account for deletion from the Profile page.
|
||||
|
||||
**User Story:**
|
||||
As a user, I want to delete my account from the Profile page. When I click "Delete My Account", I want a confirmation dialog that warns me about data loss. After confirming by entering my email, my account will be marked for deletion, I will be signed out, and I will not be able to log in again.
|
||||
|
||||
---
|
||||
|
||||
## Data Model Changes
|
||||
|
||||
### Backend Model (`backend/models/user.py`)
|
||||
|
||||
Add the following fields to the `User` class:
|
||||
|
||||
```python
|
||||
marked_for_deletion: bool = False
|
||||
marked_for_deletion_at: datetime | None = None
|
||||
```
|
||||
|
||||
- Update `to_dict()` and `from_dict()` methods to serialize these fields.
|
||||
- Import `datetime` from Python standard library if not already imported.
|
||||
|
||||
### Frontend Model (`frontend/vue-app/src/common/models.ts`)
|
||||
|
||||
Add matching fields to the `User` interface:
|
||||
|
||||
```typescript
|
||||
marked_for_deletion: boolean;
|
||||
marked_for_deletion_at: string | null;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Backend Implementation
|
||||
|
||||
### New Error Codes (`backend/api/error_codes.py`)
|
||||
|
||||
Add the following error code:
|
||||
|
||||
```python
|
||||
ACCOUNT_MARKED_FOR_DELETION = 'ACCOUNT_MARKED_FOR_DELETION'
|
||||
ALREADY_MARKED = 'ALREADY_MARKED'
|
||||
```
|
||||
|
||||
### New API Endpoint (`backend/api/user_api.py`)
|
||||
|
||||
**Endpoint:** `POST /api/user/mark-for-deletion`
|
||||
|
||||
**Authentication:** Requires valid JWT (authenticated user).
|
||||
|
||||
**Request:**
|
||||
|
||||
```json
|
||||
{}
|
||||
```
|
||||
|
||||
(Empty body; user is identified from JWT token)
|
||||
|
||||
**Response:**
|
||||
|
||||
- **Success (200):**
|
||||
```json
|
||||
{ "success": true }
|
||||
```
|
||||
- **Error (400/401/403):**
|
||||
```json
|
||||
{ "error": "Error message", "code": "INVALID_USER" | "ALREADY_MARKED" }
|
||||
```
|
||||
|
||||
**Logic:**
|
||||
|
||||
1. Extract current user from JWT token.
|
||||
2. Validate user exists in database.
|
||||
3. Check if already marked for deletion:
|
||||
- If `marked_for_deletion == True`, return error with code `ALREADY_MARKED` (or make idempotent and return success).
|
||||
4. Set `marked_for_deletion = True` and `marked_for_deletion_at = datetime.now(timezone.utc)`.
|
||||
5. Save user to database using `users_db.update()`.
|
||||
6. Trigger SSE event: `send_event_for_current_user('user_marked_for_deletion', { 'user_id': user.id })`.
|
||||
7. Return success response.
|
||||
|
||||
### Login Blocking (`backend/api/auth_api.py`)
|
||||
|
||||
In the `/api/login` endpoint, after validating credentials:
|
||||
|
||||
1. Check if `user.marked_for_deletion == True`.
|
||||
2. If yes, return:
|
||||
```json
|
||||
{
|
||||
"error": "This account has been marked for deletion and cannot be accessed.",
|
||||
"code": "ACCOUNT_MARKED_FOR_DELETION"
|
||||
}
|
||||
```
|
||||
with HTTP status `403`.
|
||||
|
||||
### Password Reset Blocking (`backend/api/user_api.py`)
|
||||
|
||||
In the `/api/user/request-reset` endpoint:
|
||||
|
||||
1. After finding the user by email, check if `user.marked_for_deletion == True`.
|
||||
2. If yes, **silently ignore the request**:
|
||||
- Do not send an email.
|
||||
- Return success response (to avoid leaking account status).
|
||||
|
||||
### SSE Event (`backend/events/types/event_types.py`)
|
||||
|
||||
Add new event type:
|
||||
|
||||
```python
|
||||
USER_MARKED_FOR_DELETION = 'user_marked_for_deletion'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Frontend Implementation
|
||||
|
||||
### Files Affected
|
||||
|
||||
- `frontend/vue-app/src/components/parent/UserProfile.vue`
|
||||
- `frontend/vue-app/src/common/models.ts`
|
||||
- `frontend/vue-app/src/common/errorCodes.ts`
|
||||
|
||||
### Error Codes (`frontend/vue-app/src/common/errorCodes.ts`)
|
||||
|
||||
Add:
|
||||
|
||||
```typescript
|
||||
export const ACCOUNT_MARKED_FOR_DELETION = "ACCOUNT_MARKED_FOR_DELETION";
|
||||
export const ALREADY_MARKED = "ALREADY_MARKED";
|
||||
```
|
||||
|
||||
### UI Components (`UserProfile.vue`)
|
||||
|
||||
#### 1. Delete Account Button
|
||||
|
||||
- **Label:** "Delete My Account"
|
||||
- **Style:** `.btn-danger-link` (use `--danger` color from `colors.css`)
|
||||
- **Placement:** Below "Change Password" link, with `24px` margin-top
|
||||
- **Behavior:** Opens warning modal on click
|
||||
|
||||
#### 2. Warning Modal (uses `ModalDialog.vue`)
|
||||
|
||||
- **Title:** "Delete Your Account?"
|
||||
- **Body:**
|
||||
"This will permanently delete your account and all associated data. This action cannot be undone."
|
||||
- **Email Confirmation Input:**
|
||||
- Require user to type their email address to confirm.
|
||||
- Display message: "Type your email address to confirm:"
|
||||
- Input field with `v-model` bound to `confirmEmail` ref.
|
||||
- **Buttons:**
|
||||
- **"Cancel"** (`.btn-secondary`) — closes modal
|
||||
- **"Delete My Account"** (`.btn-danger`) — disabled until `confirmEmail` matches user email, triggers API call
|
||||
|
||||
#### 3. Loading State
|
||||
|
||||
- Disable "Delete My Account" button during API call.
|
||||
- Show loading spinner or "Deleting..." text.
|
||||
|
||||
#### 4. Success Modal
|
||||
|
||||
- **Title:** "Account Deleted"
|
||||
- **Body:**
|
||||
"Your account has been marked for deletion. You will now be signed out."
|
||||
- **Button:** "OK" (closes modal, triggers `logoutUser()` and redirects to `/auth/login`)
|
||||
|
||||
#### 5. Error Modal
|
||||
|
||||
- **Title:** "Error"
|
||||
- **Body:** Display error message from API using `parseErrorResponse(res).msg`.
|
||||
- **Button:** "Close"
|
||||
|
||||
### Frontend Logic
|
||||
|
||||
1. User clicks "Delete My Account" button.
|
||||
2. Warning modal opens with email confirmation input.
|
||||
3. User types email and clicks "Delete My Account".
|
||||
4. Frontend calls `POST /api/user/mark-for-deletion`.
|
||||
5. On success:
|
||||
- Close warning modal.
|
||||
- Show success modal.
|
||||
- On "OK" click: call `logoutUser()` from `stores/auth.ts`, redirect to `/auth/login`.
|
||||
6. On error:
|
||||
- Close warning modal.
|
||||
- Show error modal with message from API.
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
### Backend Tests (`backend/tests/test_user_api.py`)
|
||||
|
||||
- [x] Test marking a valid user account (200, `marked_for_deletion = True`, `marked_for_deletion_at` is set).
|
||||
- [x] Test marking an already-marked account (return error with `ALREADY_MARKED` or be idempotent).
|
||||
- [x] Test marking with invalid JWT (401).
|
||||
- [x] Test marking with missing JWT (401).
|
||||
- [x] Test login attempt by marked user (403, `ACCOUNT_MARKED_FOR_DELETION`).
|
||||
- [x] Test password reset request by marked user (silently ignored, returns 200 but no email sent).
|
||||
- [x] Test SSE event is triggered after marking.
|
||||
|
||||
### Frontend Tests (`frontend/vue-app/src/components/__tests__/UserProfile.spec.ts`)
|
||||
|
||||
- [x] Test "Delete My Account" button renders.
|
||||
- [x] Test warning modal opens on button click.
|
||||
- [x] Test "Delete My Account" button in modal is disabled until email matches.
|
||||
- [x] Test API call is made when user confirms with correct email.
|
||||
- [x] Test success modal shows after successful API response.
|
||||
- [x] Test error modal shows on API failure (with error message).
|
||||
- [x] Test user is signed out after success (calls `logoutUser()`).
|
||||
- [x] Test redirect to login page after sign-out.
|
||||
- [x] Test button is disabled during loading.
|
||||
|
||||
---
|
||||
|
||||
## Future Considerations
|
||||
|
||||
- A background scheduler will be implemented to physically delete marked accounts after a grace period (e.g., 30 days).
|
||||
- Admin panel to view and manage marked accounts.
|
||||
- Email notification to user when account is marked for deletion (with grace period details).
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria (Definition of Done)
|
||||
|
||||
### Data Model
|
||||
|
||||
- [x] Add `marked_for_deletion` and `marked_for_deletion_at` fields to `User` model (backend).
|
||||
- [x] Add matching fields to `User` interface (frontend).
|
||||
- [x] Update `to_dict()` and `from_dict()` methods in `User` model.
|
||||
|
||||
### Backend
|
||||
|
||||
- [x] Create `POST /api/user/mark-for-deletion` endpoint.
|
||||
- [x] Add `ACCOUNT_MARKED_FOR_DELETION` and `ALREADY_MARKED` error codes.
|
||||
- [x] Block login for marked users in `/api/login`.
|
||||
- [x] Block password reset for marked users in `/api/user/request-reset`.
|
||||
- [x] Trigger `user_marked_for_deletion` SSE event after marking.
|
||||
- [x] All backend tests pass.
|
||||
|
||||
### Frontend
|
||||
|
||||
- [x] Add "Delete My Account" button to `UserProfile.vue` below "Change Password".
|
||||
- [x] Implement warning modal with email confirmation.
|
||||
- [x] Implement success modal.
|
||||
- [x] Implement error modal.
|
||||
- [x] Implement loading state during API call.
|
||||
- [x] Sign out user after successful account marking.
|
||||
- [x] Redirect to login page after sign-out.
|
||||
- [x] Add `ACCOUNT_MARKED_FOR_DELETION` and `ALREADY_MARKED` to `errorCodes.ts`.
|
||||
- [x] All frontend tests pass.
|
||||
65
.github/specs/archive/profile-button-menu/feat-profile-icon-button-menu.md
vendored
Normal file
65
.github/specs/archive/profile-button-menu/feat-profile-icon-button-menu.md
vendored
Normal file
@@ -0,0 +1,65 @@
|
||||
# Feature: Replace the text-based "Parent" button with an image icon and modernize the dropdown menu
|
||||
|
||||
## Visual Reference:
|
||||
|
||||
- **Sample Design:** #mockup.png
|
||||
- **Design:**
|
||||
1. Dropdown header colors need to match color theme inside #colors.css
|
||||
2. The icon button shall be circular and use all the space of it's container. It should be centered in it's container.
|
||||
3. The three dropdown items should be "Profile", "Child Mode", and "Sign out"
|
||||
4. Currently, the dropdown shows "Log out" for "Child Mode", that should be changed to "Child Mode"
|
||||
|
||||
## Context:
|
||||
|
||||
- **Goal:** I want a user image icon to display in place of the current "Parent" button
|
||||
- **User Story:** As a [user], I want to see the image assigned in my profile as an icon button at the top right of the screen. When I click the button I want to see a dropdown appear if I'm in 'parent mode.' I to have the options to see/edit my profile, go back to child mode, or sign out. In child mode, I want the button to trigger the parent pin modal if clicked.
|
||||
|
||||
## Technical Requirements
|
||||
|
||||
- **File Affected:** LoginButton.vue, ParentLayout.vue, ChildLayout.vue, AuthLayout.vue
|
||||
- **Backend:** When LoginButton loads, it should query the backend for the current user data (/user/profile) The returned data will provide the image_id and first_name of the user.
|
||||
- **Navigation:**
|
||||
1. When the avatar button is focused, pressing Enter or Space opens the dropdown.
|
||||
2. When the dropdown is open:
|
||||
- Up/Down arrow keys move focus between menu items.
|
||||
- Enter or Space activates the focused menu item.
|
||||
- Esc closes the dropdown and returns focus to the avatar button.
|
||||
3. Tabbing away from the dropdown closes it.
|
||||
- **ARIA:**
|
||||
1. The avatar button must have aria-haspopup="menu" and aria-expanded reflecting the dropdown state.
|
||||
2. The dropdown menu must use role="menu", and each item must use role="menuitem".
|
||||
3. The currently focused menu item should have aria-selected="true".
|
||||
- **Focus Ring:** All interactive elements (avatar button and dropdown menu items) must display a visible focus ring when focused via keyboard navigation. The focus ring color should use a theme variable from colors.css and meet accessibility contrast guidelines.
|
||||
- **Mobile & Layout:**
|
||||
1. The avatar icon button must always be positioned at the top right of the screen, regardless of device size.
|
||||
2. The icon must never exceed 44px in width or height.
|
||||
3. On mobile, ensure the button is at least 44x44px for touch accessibility.
|
||||
- **Avatar Fallback:** If user.first_name does not exist, display a ? as the fallback initial.
|
||||
- **Dropdown Placement and Animation:**
|
||||
1. The dropdown menu must always appear directly below the avatar icon, right-aligned to the screen edge.
|
||||
2. Use a slide down/up animation for showing/hiding the dropdown.
|
||||
- **State Requirements:**
|
||||
- Collapsed: Button shows the user.image_id or a fallback icon with the initial of the user.first_name
|
||||
- Expanded: Shows the dropdown with the three menu options shown in the #mockup.png. -**Menu Item Icons:**: For now, use a stub element or placeholder for each menu item icon, to be replaced with real icons later.
|
||||
- **Logic:**
|
||||
1. Clicking an item in the dropdown should already be implemented. Do not change this.
|
||||
2. When clicking a menu item or clicking outside the menu, collapse the menu.
|
||||
3. When in 'child mode' (parent not authenticated), show the parent PIN modal or create PIN view (/parent/pin-setup) if user.pin doesn't exist or is empty. (this is already implemented)
|
||||
|
||||
## UI Acceptance Criteria (The "Definition of Done")
|
||||
|
||||
- [x] UI: Swap the "Parent" button with the user's avatar image.
|
||||
- [x] UI: Refactor #LoginButton.vue to use new CSS generated from #mockup.png
|
||||
- [x] Logic: Make sure the dropdown does not show when in child mode.
|
||||
- [x] Logic: Make sure the parent PIN modal shows when the button is pressed in child mode.
|
||||
- [x] Logic: Make sure the parent PIN creation view shows when the button is pressed in child mode if no user.pin doesn't exist or is empty.
|
||||
- [x] Frontend Tests: Add vitest for this feature in the frontend to make sure the logic for button clicking in parent mode and child mode act correctly.
|
||||
1. [x] Avatar button renders image, initial, or ? as fallback
|
||||
2. [x] Dropdown opens/closes via click, Enter, Space, Esc, and outside click.
|
||||
3. [x] Dropdown is positioned and animated correctly.
|
||||
4. [x] Keyboard navigation (Up/Down, Enter, Space, Esc) works as specified.
|
||||
5. [x] ARIA attributes and roles are set correctly.
|
||||
6. [x] Focus ring is visible and uses theme color.
|
||||
7. [x] Avatar button meets size and position requirements on all devices.
|
||||
8. [x] Menu logic for parent/child mode is correct.
|
||||
9. [x] Stub icons are rendered for menu items.
|
||||
BIN
.github/specs/archive/profile-button-menu/mockup.png
vendored
Normal file
BIN
.github/specs/archive/profile-button-menu/mockup.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
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
|
||||
|
||||
- [ ]
|
||||
125
.github/specs/feat-landing-page.md
vendored
Normal file
125
.github/specs/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 `/`.
|
||||
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
|
||||
126
.gitignore
vendored
126
.gitignore
vendored
@@ -1,78 +1,50 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
PIPFILE.lock
|
||||
|
||||
# Virtual Environment
|
||||
venv/
|
||||
ENV/
|
||||
env/
|
||||
.venv
|
||||
|
||||
# PyCharm
|
||||
.idea/
|
||||
*.iml
|
||||
*.iws
|
||||
.idea_modules/
|
||||
|
||||
# VS Code
|
||||
.vscode/
|
||||
|
||||
# Database files
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
data/db/*.json
|
||||
|
||||
# Flask
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Node.js / Vue (web directory)
|
||||
web/node_modules/
|
||||
web/npm-debug.log*
|
||||
web/yarn-debug.log*
|
||||
web/yarn-error.log*
|
||||
web/dist/
|
||||
web/.nuxt/
|
||||
web/.cache/
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Testing
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.tox/
|
||||
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
|
||||
resources/
|
||||
frontend/vue-app/playwright-report/index.html
|
||||
frontend/vue-app/playwright-report/data/7d9e8e8c2aa259dc9ad2c4715b0cf596bf05b8ad.webm
|
||||
frontend/vue-app/playwright-report/data/53a9d25a05f8605aaf31a0831a88d4d108e65031.png
|
||||
frontend/vue-app/playwright-report/data/97a02a9005e1cf49de3250991c2dfc24e1845eda.zip
|
||||
frontend/vue-app/playwright-report/data/278ccf1fa441cc9997d89650beac0252c6bd72c7.zip
|
||||
frontend/vue-app/playwright-report/data/831e7a25fc01d2aea65ef4b9590141615157afa6.webm
|
||||
frontend/vue-app/playwright-report/data/12526c507e79af2d09d56299c0bf7a147d27f0c3.md
|
||||
frontend/vue-app/playwright-report/data/a0602f28b9051f5191f9f9c04caf6e2be1fcf939.zip
|
||||
frontend/vue-app/playwright-report/data/e8146067f1903cc173e1cc4f5c59b4796fcbb901.zip
|
||||
frontend/vue-app/playwright-report/trace/codeMirrorModule.DYBRYzYX.css
|
||||
frontend/vue-app/playwright-report/trace/codicon.DCmgc-ay.ttf
|
||||
frontend/vue-app/playwright-report/trace/defaultSettingsView.7ch9cixO.css
|
||||
frontend/vue-app/playwright-report/trace/index.BDwrLSGN.js
|
||||
frontend/vue-app/playwright-report/trace/index.BVu7tZDe.css
|
||||
frontend/vue-app/playwright-report/trace/index.html
|
||||
frontend/vue-app/playwright-report/trace/manifest.webmanifest
|
||||
frontend/vue-app/playwright-report/trace/playwright-logo.svg
|
||||
frontend/vue-app/playwright-report/trace/snapshot.html
|
||||
frontend/vue-app/playwright-report/trace/sw.bundle.js
|
||||
frontend/vue-app/playwright-report/trace/uiMode.Btcz36p_.css
|
||||
frontend/vue-app/playwright-report/trace/uiMode.CQJ9SCIQ.js
|
||||
frontend/vue-app/playwright-report/trace/uiMode.html
|
||||
frontend/vue-app/playwright-report/trace/xtermModule.DYP7pi_n.css
|
||||
frontend/vue-app/playwright-report/trace/assets/codeMirrorModule-a5XoALAZ.js
|
||||
frontend/vue-app/playwright-report/trace/assets/defaultSettingsView-CJSZINFr.js
|
||||
frontend/vue-app/test-results/.last-run.json
|
||||
frontend/vue-app/test-results/chores-create.smoke-Chores-127ec-re-form-and-validate-fields-smoke/error-context.md
|
||||
frontend/vue-app/test-results/chores-create.smoke-Chores-127ec-re-form-and-validate-fields-smoke/test-failed-1.png
|
||||
frontend/vue-app/test-results/chores-create.smoke-Chores-127ec-re-form-and-validate-fields-smoke/trace.zip
|
||||
frontend/vue-app/test-results/chores-create.smoke-Chores-127ec-re-form-and-validate-fields-smoke-retry1/error-context.md
|
||||
frontend/vue-app/test-results/chores-create.smoke-Chores-127ec-re-form-and-validate-fields-smoke-retry1/test-failed-1.png
|
||||
frontend/vue-app/test-results/chores-create.smoke-Chores-127ec-re-form-and-validate-fields-smoke-retry1/trace.zip
|
||||
frontend/vue-app/test-results/chores-create.smoke-Chores-127ec-re-form-and-validate-fields-smoke-retry1/video.webm
|
||||
frontend/vue-app/test-results/chores-create.smoke-Chores-f8e2f-hould-cancel-chore-creation-smoke/error-context.md
|
||||
frontend/vue-app/test-results/chores-create.smoke-Chores-f8e2f-hould-cancel-chore-creation-smoke/test-failed-1.png
|
||||
frontend/vue-app/test-results/chores-create.smoke-Chores-f8e2f-hould-cancel-chore-creation-smoke/trace.zip
|
||||
frontend/vue-app/test-results/chores-create.smoke-Chores-f8e2f-hould-cancel-chore-creation-smoke-retry1/error-context.md
|
||||
frontend/vue-app/test-results/chores-create.smoke-Chores-f8e2f-hould-cancel-chore-creation-smoke-retry1/test-failed-1.png
|
||||
frontend/vue-app/test-results/chores-create.smoke-Chores-f8e2f-hould-cancel-chore-creation-smoke-retry1/trace.zip
|
||||
frontend/vue-app/test-results/chores-create.smoke-Chores-f8e2f-hould-cancel-chore-creation-smoke-retry1/video.webm
|
||||
|
||||
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>
|
||||
9
.vscode/extensions.json
vendored
Normal file
9
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"Vue.volar",
|
||||
"vitest.explorer",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"EditorConfig.EditorConfig",
|
||||
"esbenp.prettier-vscode"
|
||||
]
|
||||
}
|
||||
90
.vscode/launch.json
vendored
Normal file
90
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Python: Flask",
|
||||
"type": "python",
|
||||
"request": "launch",
|
||||
"module": "flask",
|
||||
"python": "${command:python.interpreterPath}",
|
||||
"env": {
|
||||
"FLASK_APP": "backend/main.py",
|
||||
"FLASK_DEBUG": "1",
|
||||
"SECRET_KEY": "dev-secret-key-change-in-production",
|
||||
"REFRESH_TOKEN_EXPIRY_DAYS": "90"
|
||||
},
|
||||
"args": [
|
||||
"run",
|
||||
"--host=0.0.0.0",
|
||||
"--port=5000",
|
||||
"--no-debugger",
|
||||
"--no-reload"
|
||||
],
|
||||
"cwd": "${workspaceFolder}/backend",
|
||||
"console": "integratedTerminal"
|
||||
},
|
||||
{
|
||||
"name": "Vue: Dev Server",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": [
|
||||
"run",
|
||||
"dev"
|
||||
],
|
||||
"cwd": "${workspaceFolder}/frontend/vue-app",
|
||||
"console": "integratedTerminal"
|
||||
},
|
||||
{
|
||||
"name": "Chrome: Launch (Vue App)",
|
||||
"type": "pwa-chrome",
|
||||
"request": "launch",
|
||||
"url": "http://localhost:5173",
|
||||
"webRoot": "${workspaceFolder}/frontend/vue-app"
|
||||
},
|
||||
{
|
||||
"name": "Python: Backend Tests",
|
||||
"type": "python",
|
||||
"request": "launch",
|
||||
"module": "pytest",
|
||||
"args": [
|
||||
"tests/"
|
||||
],
|
||||
"cwd": "${workspaceFolder}/backend",
|
||||
"console": "integratedTerminal",
|
||||
"env": {
|
||||
"PYTHONPATH": "${workspaceFolder}/backend"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Vue: Frontend Tests",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"runtimeExecutable": "npm",
|
||||
"windows": {
|
||||
"runtimeExecutable": "npm.cmd"
|
||||
},
|
||||
"runtimeArgs": [
|
||||
"run",
|
||||
"test:unit"
|
||||
],
|
||||
"cwd": "${workspaceFolder}/frontend/vue-app",
|
||||
"console": "integratedTerminal",
|
||||
"osx": {
|
||||
"env": {
|
||||
"PATH": "/opt/homebrew/bin:${env:PATH}"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"compounds": [
|
||||
{
|
||||
"name": "Full Stack (Backend + Frontend)",
|
||||
"configurations": [
|
||||
"Python: Flask",
|
||||
"Vue: Dev Server",
|
||||
"Chrome: Launch (Vue App)"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
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/vue-app",
|
||||
"console": "integratedTerminal"
|
||||
},
|
||||
{
|
||||
"name": "Chrome: Attach to Vue App",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"url": "https://localhost:5173", // or your Vite dev server port
|
||||
"webRoot": "${workspaceFolder}/frontend/vue-app"
|
||||
},
|
||||
{
|
||||
"name": "Python: Backend Tests",
|
||||
"type": "python",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/backend/.venv/Scripts/pytest.exe",
|
||||
"args": [
|
||||
"tests/"
|
||||
],
|
||||
"cwd": "${workspaceFolder}/backend",
|
||||
"console": "integratedTerminal",
|
||||
"env": {
|
||||
"PYTHONPATH": "${workspaceFolder}/backend"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Vue: Frontend Tests",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"runtimeExecutable": "npx",
|
||||
"runtimeArgs": [
|
||||
"vitest"
|
||||
],
|
||||
"cwd": "${workspaceFolder}/frontend/vue-app",
|
||||
"console": "integratedTerminal"
|
||||
}
|
||||
],
|
||||
"compounds": [
|
||||
{
|
||||
"name": "Full Stack (Backend + Frontend)",
|
||||
"configurations": [
|
||||
"Python: Flask",
|
||||
"Vue: Dev Server",
|
||||
"Chrome: Attach to Vue App"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
13
.vscode/mcp.json
vendored
Normal file
13
.vscode/mcp.json
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"servers": {
|
||||
"playwright-test": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"playwright",
|
||||
"run-test-mcp-server"
|
||||
]
|
||||
}
|
||||
},
|
||||
"inputs": []
|
||||
}
|
||||
28
.vscode/settings.json
vendored
Normal file
28
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"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",
|
||||
"vite.config.*": "jsconfig*, vitest.config.*, cypress.config.*, playwright.config.*",
|
||||
"package.json": "package-lock.json, pnpm*, .yarnrc*, yarn*, .eslint*, eslint*, .oxlint*, oxlint*, .prettier*, prettier*, .editorconfig"
|
||||
},
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"[json]": {
|
||||
"editor.tabSize": 2,
|
||||
"editor.insertSpaces": true
|
||||
},
|
||||
"[jsonc]": {
|
||||
"editor.defaultFormatter": "vscode.json-language-features"
|
||||
},
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
},
|
||||
"chat.tools.terminal.autoApprove": {
|
||||
"&": true
|
||||
},
|
||||
"python-envs.defaultEnvManager": "ms-python.python:venv",
|
||||
"python-envs.pythonProjects": []
|
||||
}
|
||||
45
.vscode/tasks.json
vendored
Normal file
45
.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "Git: Save WIP",
|
||||
"type": "shell",
|
||||
"command": "git save-wip",
|
||||
"group": "build",
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "shared"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Git: Load WIP",
|
||||
"type": "shell",
|
||||
"command": "git load-wip",
|
||||
"group": "build",
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "shared"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Git: Reset Cloud WIP",
|
||||
"type": "shell",
|
||||
"command": "git push origin --delete wip-sync",
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "shared"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Git: Abort WIP (Reset Local)",
|
||||
"type": "shell",
|
||||
"command": "git abort-wip",
|
||||
"group": "none",
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "shared",
|
||||
"echo": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
173
README.md
Normal file
173
README.md
Normal file
@@ -0,0 +1,173 @@
|
||||
# Reward - Chore & Reward Management System
|
||||
|
||||
A family-friendly application for managing chores, tasks, and rewards for children.
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
- **Backend**: Flask (Python) with TinyDB for data persistence
|
||||
- **Frontend**: Vue 3 (TypeScript) with real-time SSE updates
|
||||
- **Deployment**: Docker with nginx reverse proxy
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
### Backend
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
python -m venv .venv
|
||||
.venv\Scripts\activate # Windows
|
||||
source .venv/bin/activate # Linux/Mac
|
||||
pip install -r requirements.txt
|
||||
python -m flask run --host=0.0.0.0 --port=5000
|
||||
```
|
||||
|
||||
### Frontend
|
||||
|
||||
```bash
|
||||
cd frontend/vue-app
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## 🔧 Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
| ---------------------------------- | --------------------------------------------- | ------------- |
|
||||
| `ACCOUNT_DELETION_THRESHOLD_HOURS` | Hours to wait before deleting marked accounts | 720 (30 days) |
|
||||
| `DB_ENV` | Database environment (`prod` or `test`) | `prod` |
|
||||
| `DATA_ENV` | Data directory environment (`prod` or `test`) | `prod` |
|
||||
|
||||
### Account Deletion Scheduler
|
||||
|
||||
The application includes an automated account deletion scheduler that removes user accounts marked for deletion after a configurable threshold period.
|
||||
|
||||
**Key Features:**
|
||||
|
||||
- Runs every hour checking for accounts due for deletion
|
||||
- Configurable threshold between 24 hours (minimum) and 720 hours (maximum)
|
||||
- Automatic retry on failure (max 3 attempts)
|
||||
- Restart-safe: recovers from interruptions during deletion
|
||||
|
||||
**Deletion Process:**
|
||||
When an account is marked for deletion, the scheduler will automatically:
|
||||
|
||||
1. Remove all pending rewards for the user's children
|
||||
2. Remove all children belonging to the user
|
||||
3. Remove all user-created tasks
|
||||
4. Remove all user-created rewards
|
||||
5. Remove uploaded images from database
|
||||
6. Delete user's image directory from filesystem
|
||||
7. Remove the user account
|
||||
|
||||
**Configuration:**
|
||||
Set the deletion threshold via environment variable:
|
||||
|
||||
```bash
|
||||
export ACCOUNT_DELETION_THRESHOLD_HOURS=168 # 7 days
|
||||
```
|
||||
|
||||
**Monitoring:**
|
||||
|
||||
- Logs are written to `logs/account_deletion.log` with rotation (10MB max, 5 backups)
|
||||
- Check logs for deletion summaries and any errors
|
||||
|
||||
## 🔌 API Endpoints
|
||||
|
||||
### Admin Endpoints
|
||||
|
||||
All admin endpoints require JWT authentication and **admin role**.
|
||||
|
||||
**Note:** Admin users must be created manually or via the provided script (`backend/scripts/create_admin.py`). The admin role cannot be assigned through the signup API for security reasons.
|
||||
|
||||
**Creating an Admin User:**
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
python scripts/create_admin.py
|
||||
```
|
||||
|
||||
#### Account Deletion Management
|
||||
|
||||
- `GET /api/admin/deletion-queue` - View users pending deletion
|
||||
- `GET /api/admin/deletion-threshold` - Get current deletion threshold
|
||||
- `PUT /api/admin/deletion-threshold` - Update deletion threshold (24-720 hours)
|
||||
- `POST /api/admin/deletion-queue/trigger` - Manually trigger deletion scheduler
|
||||
|
||||
### User Endpoints
|
||||
|
||||
- `POST /api/user/mark-for-deletion` - Mark current user's account for deletion
|
||||
- `GET /api/me` - Get current user info
|
||||
- `POST /api/login` - User login
|
||||
- `POST /api/logout` - User logout
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
### Backend Tests
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
pytest tests/
|
||||
```
|
||||
|
||||
### Frontend Tests
|
||||
|
||||
```bash
|
||||
cd frontend/vue-app
|
||||
npm run test
|
||||
```
|
||||
|
||||
## 📝 Features
|
||||
|
||||
- ✅ User authentication with JWT tokens
|
||||
- ✅ Child profile management
|
||||
- ✅ Task assignment and tracking
|
||||
- ✅ Reward system
|
||||
- ✅ Real-time updates via SSE
|
||||
- ✅ Image upload and management
|
||||
- ✅ Account deletion with grace period
|
||||
- ✅ Automated cleanup scheduler
|
||||
|
||||
## 🔒 Security
|
||||
|
||||
- JWT tokens stored in HttpOnly, Secure, SameSite=Strict cookies
|
||||
- **Role-Based Access Control (RBAC)**: Admin endpoints protected by admin role validation
|
||||
- Admin users can only be created via direct database manipulation or provided script
|
||||
- Regular users cannot escalate privileges to admin
|
||||
- Account deletion requires email confirmation
|
||||
- Marked accounts blocked from login immediately
|
||||
|
||||
## 📁 Project Structure
|
||||
|
||||
```
|
||||
.
|
||||
├── backend/
|
||||
│ ├── api/ # REST API endpoints
|
||||
│ ├── config/ # Configuration files
|
||||
│ ├── db/ # TinyDB setup
|
||||
│ ├── events/ # SSE event system
|
||||
│ ├── models/ # Data models
|
||||
│ ├── tests/ # Backend tests
|
||||
│ └── utils/ # Utilities (scheduler, etc)
|
||||
├── frontend/
|
||||
│ └── vue-app/
|
||||
│ └── src/
|
||||
│ ├── common/ # Shared utilities
|
||||
│ ├── components/ # Vue components
|
||||
│ └── layout/ # Layout components
|
||||
└── .github/
|
||||
└── specs/ # Feature specifications
|
||||
```
|
||||
|
||||
## 🛠️ Development
|
||||
|
||||
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.
|
||||
671
api/child_api.py
671
api/child_api.py
@@ -1,671 +0,0 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from tinydb import Query
|
||||
from db.db import child_db, task_db, reward_db, pending_reward_db
|
||||
from api.reward_status import RewardStatus
|
||||
from api.child_tasks import ChildTask
|
||||
from api.child_rewards import ChildReward
|
||||
from events.sse import send_event_to_user
|
||||
from events.types.child_modified import ChildModified
|
||||
from events.types.child_reward_request import ChildRewardRequest
|
||||
from events.types.child_reward_triggered import ChildRewardTriggered
|
||||
from events.types.child_rewards_set import ChildRewardsSet
|
||||
from events.types.child_task_triggered import ChildTaskTriggered
|
||||
from events.types.child_tasks_set import ChildTasksSet
|
||||
from events.types.event import Event
|
||||
from events.types.event_types import EventType
|
||||
from api.pending_reward import PendingReward as PendingRewardResponse
|
||||
|
||||
from models.child import Child
|
||||
from models.pending_reward import PendingReward
|
||||
from models.task import Task
|
||||
from models.reward import Reward
|
||||
|
||||
child_api = Blueprint('child_api', __name__)
|
||||
|
||||
@child_api.route('/child/<name>', methods=['GET'])
|
||||
@child_api.route('/child/<id>', methods=['GET'])
|
||||
def get_child(id):
|
||||
ChildQuery = Query()
|
||||
result = child_db.search(ChildQuery.id == id)
|
||||
if not result:
|
||||
return jsonify({'error': 'Child not found'}), 404
|
||||
return jsonify(Child.from_dict(result[0]).to_dict()), 200
|
||||
|
||||
@child_api.route('/child/add', methods=['PUT'])
|
||||
def add_child():
|
||||
data = request.get_json()
|
||||
name = data.get('name')
|
||||
age = data.get('age')
|
||||
image = data.get('image_id', None)
|
||||
if not name:
|
||||
return jsonify({'error': 'Name is required'}), 400
|
||||
if not image:
|
||||
image = 'boy01'
|
||||
|
||||
child = Child(name=name, age=age, image_id=image)
|
||||
child_db.insert(child.to_dict())
|
||||
send_event_to_user("user123", Event(EventType.CHILD_MODIFIED.value, ChildModified(child.id, ChildModified.OPERATION_ADD)))
|
||||
return jsonify({'message': f'Child {name} added.'}), 201
|
||||
|
||||
@child_api.route('/child/<id>/edit', methods=['PUT'])
|
||||
def edit_child(id):
|
||||
data = request.get_json()
|
||||
name = data.get('name', None)
|
||||
age = data.get('age', None)
|
||||
points = data.get('points', None)
|
||||
image = data.get('image_id', None)
|
||||
ChildQuery = Query()
|
||||
result = child_db.search(ChildQuery.id == id)
|
||||
if not result:
|
||||
return jsonify({'error': 'Child not found'}), 404
|
||||
child = Child.from_dict(result[0])
|
||||
|
||||
if name is not None:
|
||||
child.name = name
|
||||
if age is not None:
|
||||
child.age = age
|
||||
if points is not None:
|
||||
child.points = points
|
||||
if image is not None:
|
||||
child.image_id = image
|
||||
|
||||
# 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)
|
||||
|
||||
RewardQuery = Query()
|
||||
for pr in pending_rewards:
|
||||
pending = PendingReward.from_dict(pr)
|
||||
reward_result = reward_db.get(RewardQuery.id == pending.reward_id)
|
||||
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)
|
||||
)
|
||||
send_event_to_user(
|
||||
"user123",
|
||||
Event(
|
||||
EventType.CHILD_REWARD_REQUEST.value,
|
||||
ChildRewardRequest(id, reward.id, ChildRewardRequest.REQUEST_CANCELLED)
|
||||
)
|
||||
)
|
||||
|
||||
child_db.update(child.to_dict(), ChildQuery.id == id)
|
||||
send_event_to_user("user123", Event(EventType.CHILD_MODIFIED.value, ChildModified(id, ChildModified.OPERATION_EDIT)))
|
||||
return jsonify({'message': f'Child {id} updated.'}), 200
|
||||
|
||||
@child_api.route('/child/list', methods=['GET'])
|
||||
def list_children():
|
||||
children = child_db.all()
|
||||
return jsonify({'children': children}), 200
|
||||
|
||||
# Child DELETE
|
||||
@child_api.route('/child/<id>', methods=['DELETE'])
|
||||
def delete_child(id):
|
||||
ChildQuery = Query()
|
||||
if child_db.remove(ChildQuery.id == id):
|
||||
send_event_to_user("user123",
|
||||
Event(EventType.CHILD_MODIFIED.value, ChildModified(id, ChildModified.OPERATION_DELETE)))
|
||||
return jsonify({'message': f'Child {id} deleted.'}), 200
|
||||
return jsonify({'error': 'Child not found'}), 404
|
||||
|
||||
@child_api.route('/child/<id>/assign-task', methods=['POST'])
|
||||
def assign_task_to_child(id):
|
||||
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)
|
||||
if not result:
|
||||
return jsonify({'error': 'Child not found'}), 404
|
||||
|
||||
child = result[0]
|
||||
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
|
||||
|
||||
# python
|
||||
@child_api.route('/child/<id>/set-tasks', methods=['PUT'])
|
||||
def set_child_tasks(id):
|
||||
data = request.get_json() or {}
|
||||
task_ids = data.get('task_ids')
|
||||
if not isinstance(task_ids, list):
|
||||
return jsonify({'error': 'task_ids must be a list'}), 400
|
||||
|
||||
# Deduplicate and drop falsy values
|
||||
new_task_ids = [tid for tid in dict.fromkeys(task_ids) if tid]
|
||||
|
||||
ChildQuery = Query()
|
||||
result = child_db.search(ChildQuery.id == id)
|
||||
if not result:
|
||||
return jsonify({'error': 'Child not found'}), 404
|
||||
|
||||
# Optional: validate task IDs exist in the task DB
|
||||
TaskQuery = Query()
|
||||
valid_task_ids = []
|
||||
for tid in new_task_ids:
|
||||
if task_db.get(TaskQuery.id == tid):
|
||||
valid_task_ids.append(tid)
|
||||
# Replace tasks with validated IDs
|
||||
child_db.update({'tasks': valid_task_ids}, ChildQuery.id == id)
|
||||
send_event_to_user("user123", Event(EventType.CHILD_TASKS_SET.value, ChildTasksSet(id, valid_task_ids)))
|
||||
return jsonify({
|
||||
'message': f'Tasks set for child {id}.',
|
||||
'task_ids': valid_task_ids,
|
||||
'count': len(valid_task_ids)
|
||||
}), 200
|
||||
|
||||
|
||||
|
||||
@child_api.route('/child/<id>/remove-task', methods=['POST'])
|
||||
def remove_task_from_child(id):
|
||||
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)
|
||||
if not result:
|
||||
return jsonify({'error': 'Child not found'}), 404
|
||||
|
||||
child = result[0]
|
||||
if task_id in child.get('tasks', []):
|
||||
child['tasks'].remove(task_id)
|
||||
child_db.update({'tasks': child['tasks']}, ChildQuery.id == id)
|
||||
return jsonify({'message': f'Task {task_id} removed from {child["name"]}.'}), 200
|
||||
return jsonify({'error': 'Task not assigned to child'}), 400
|
||||
|
||||
@child_api.route('/child/<id>/list-tasks', methods=['GET'])
|
||||
def list_child_tasks(id):
|
||||
ChildQuery = Query()
|
||||
result = child_db.search(ChildQuery.id == id)
|
||||
if not result:
|
||||
return jsonify({'error': 'Child not found'}), 404
|
||||
|
||||
child = result[0]
|
||||
task_ids = child.get('tasks', [])
|
||||
|
||||
TaskQuery = Query()
|
||||
child_tasks = []
|
||||
for tid in task_ids:
|
||||
task = task_db.get(TaskQuery.id == tid)
|
||||
if not task:
|
||||
continue
|
||||
ct = ChildTask(task.get('name'), task.get('is_good'), task.get('points'), task.get('image_id'), task.get('id'))
|
||||
child_tasks.append(ct.to_dict())
|
||||
|
||||
return jsonify({'tasks': child_tasks}), 200
|
||||
|
||||
@child_api.route('/child/<id>/list-assignable-tasks', methods=['GET'])
|
||||
def list_assignable_tasks(id):
|
||||
ChildQuery = Query()
|
||||
result = child_db.search(ChildQuery.id == id)
|
||||
if not result:
|
||||
return jsonify({'error': 'Child not found'}), 404
|
||||
|
||||
child = result[0]
|
||||
assigned_ids = set(child.get('tasks', []))
|
||||
|
||||
# Collect all task ids from the task database
|
||||
all_task_ids = [t.get('id') for t in task_db.all() if t and t.get('id')]
|
||||
|
||||
# Filter out already assigned
|
||||
assignable_ids = [tid for tid in all_task_ids if tid not in assigned_ids]
|
||||
|
||||
# Fetch full task details and wrap in ChildTask
|
||||
TaskQuery = Query()
|
||||
assignable_tasks = []
|
||||
for tid in assignable_ids:
|
||||
task = task_db.get(TaskQuery.id == tid)
|
||||
if not task:
|
||||
continue
|
||||
ct = ChildTask(task.get('name'), task.get('is_good'), task.get('points'), task.get('image_id'), task.get('id'))
|
||||
assignable_tasks.append(ct.to_dict())
|
||||
|
||||
return jsonify({'tasks': assignable_tasks, 'count': len(assignable_tasks)}), 200
|
||||
|
||||
|
||||
@child_api.route('/child/<id>/list-all-tasks', methods=['GET'])
|
||||
def list_all_tasks(id):
|
||||
ChildQuery = Query()
|
||||
result = child_db.search(ChildQuery.id == id)
|
||||
if not result:
|
||||
return jsonify({'error': 'Child not found'}), 404
|
||||
|
||||
child = result[0]
|
||||
assigned_ids = set(child.get('tasks', []))
|
||||
|
||||
# Get all tasks from database
|
||||
all_tasks = task_db.all()
|
||||
|
||||
assigned_tasks = []
|
||||
assignable_tasks = []
|
||||
|
||||
for task in all_tasks:
|
||||
if not task or not task.get('id'):
|
||||
continue
|
||||
|
||||
ct = ChildTask(
|
||||
task.get('name'),
|
||||
task.get('is_good'),
|
||||
task.get('points'),
|
||||
task.get('image_id'),
|
||||
task.get('id')
|
||||
)
|
||||
|
||||
if task.get('id') in assigned_ids:
|
||||
assigned_tasks.append(ct.to_dict())
|
||||
else:
|
||||
assignable_tasks.append(ct.to_dict())
|
||||
|
||||
return jsonify({
|
||||
'assigned_tasks': assigned_tasks,
|
||||
'assignable_tasks': assignable_tasks,
|
||||
'assigned_count': len(assigned_tasks),
|
||||
'assignable_count': len(assignable_tasks)
|
||||
}), 200
|
||||
|
||||
|
||||
@child_api.route('/child/<id>/trigger-task', methods=['POST'])
|
||||
def trigger_child_task(id):
|
||||
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)
|
||||
if not result:
|
||||
return jsonify({'error': 'Child not found'}), 404
|
||||
|
||||
child: Child = Child.from_dict(result[0])
|
||||
if task_id not in child.tasks:
|
||||
return jsonify({'error': f'Task not found assigned to child {child.name}'}), 404
|
||||
# look up the task and get the details
|
||||
TaskQuery = Query()
|
||||
task_result = task_db.search(TaskQuery.id == task_id)
|
||||
if not task_result:
|
||||
return jsonify({'error': 'Task not found in task database'}), 404
|
||||
task: Task = Task.from_dict(task_result[0])
|
||||
# update the child's points based on task type
|
||||
if task.is_good:
|
||||
child.points += task.points
|
||||
else:
|
||||
child.points -= task.points
|
||||
child.points = max(child.points, 0)
|
||||
# update the child in the database
|
||||
child_db.update({'points': child.points}, ChildQuery.id == id)
|
||||
send_event_to_user("user123", Event(EventType.CHILD_TASK_TRIGGERED.value, ChildTaskTriggered(task.id, child.id, child.points)))
|
||||
|
||||
return jsonify({'message': f'{task.name} points assigned to {child.name}.', 'points': child.points, 'id': child.id}), 200
|
||||
|
||||
@child_api.route('/child/<id>/assign-reward', methods=['POST'])
|
||||
def assign_reward_to_child(id):
|
||||
data = request.get_json()
|
||||
reward_id = data.get('reward_id')
|
||||
if not reward_id:
|
||||
return jsonify({'error': 'reward_id is required'}), 400
|
||||
|
||||
ChildQuery = Query()
|
||||
result = child_db.search(ChildQuery.id == id)
|
||||
if not result:
|
||||
return jsonify({'error': 'Child not found'}), 404
|
||||
|
||||
child = result[0]
|
||||
if reward_id not in child.get('rewards', []):
|
||||
child['rewards'].append(reward_id)
|
||||
child_db.update({'rewards': child['rewards']}, ChildQuery.id == id)
|
||||
return jsonify({'message': f'Reward {reward_id} assigned to {child["name"]}.'}), 200
|
||||
|
||||
@child_api.route('/child/<id>/list-all-rewards', methods=['GET'])
|
||||
def list_all_rewards(id):
|
||||
ChildQuery = Query()
|
||||
result = child_db.search(ChildQuery.id == id)
|
||||
if not result:
|
||||
return jsonify({'error': 'Child not found'}), 404
|
||||
|
||||
child = result[0]
|
||||
assigned_ids = set(child.get('rewards', []))
|
||||
|
||||
# Get all rewards from database
|
||||
all_rewards = reward_db.all()
|
||||
|
||||
assigned_rewards = []
|
||||
assignable_rewards = []
|
||||
|
||||
for reward in all_rewards:
|
||||
if not reward or not reward.get('id'):
|
||||
continue
|
||||
|
||||
cr = ChildReward(
|
||||
reward.get('name'),
|
||||
reward.get('cost'),
|
||||
reward.get('image_id'),
|
||||
reward.get('id')
|
||||
)
|
||||
|
||||
if reward.get('id') in assigned_ids:
|
||||
assigned_rewards.append(cr.to_dict())
|
||||
else:
|
||||
assignable_rewards.append(cr.to_dict())
|
||||
|
||||
return jsonify({
|
||||
'assigned_rewards': assigned_rewards,
|
||||
'assignable_rewards': assignable_rewards,
|
||||
'assigned_count': len(assigned_rewards),
|
||||
'assignable_count': len(assignable_rewards)
|
||||
}), 200
|
||||
|
||||
|
||||
@child_api.route('/child/<id>/set-rewards', methods=['PUT'])
|
||||
def set_child_rewards(id):
|
||||
data = request.get_json() or {}
|
||||
reward_ids = data.get('reward_ids')
|
||||
if not isinstance(reward_ids, list):
|
||||
return jsonify({'error': 'reward_ids must be a list'}), 400
|
||||
|
||||
# Deduplicate and drop falsy values
|
||||
new_reward_ids = [rid for rid in dict.fromkeys(reward_ids) if rid]
|
||||
|
||||
ChildQuery = Query()
|
||||
result = child_db.search(ChildQuery.id == id)
|
||||
if not result:
|
||||
return jsonify({'error': 'Child not found'}), 404
|
||||
|
||||
# Optional: validate reward IDs exist in the reward DB
|
||||
RewardQuery = Query()
|
||||
valid_reward_ids = []
|
||||
for rid in new_reward_ids:
|
||||
if reward_db.get(RewardQuery.id == rid):
|
||||
valid_reward_ids.append(rid)
|
||||
|
||||
# Replace rewards with validated IDs
|
||||
child_db.update({'rewards': valid_reward_ids}, ChildQuery.id == id)
|
||||
send_event_to_user("user123", Event(EventType.CHILD_REWARDS_SET.value, ChildRewardsSet(id, valid_reward_ids)))
|
||||
return jsonify({
|
||||
'message': f'Rewards set for child {id}.',
|
||||
'reward_ids': valid_reward_ids,
|
||||
'count': len(valid_reward_ids)
|
||||
}), 200
|
||||
|
||||
|
||||
@child_api.route('/child/<id>/remove-reward', methods=['POST'])
|
||||
def remove_reward_from_child(id):
|
||||
data = request.get_json()
|
||||
reward_id = data.get('reward_id')
|
||||
if not reward_id:
|
||||
return jsonify({'error': 'reward_id is required'}), 400
|
||||
|
||||
ChildQuery = Query()
|
||||
result = child_db.search(ChildQuery.id == id)
|
||||
if not result:
|
||||
return jsonify({'error': 'Child not found'}), 404
|
||||
|
||||
child = result[0]
|
||||
if reward_id in child.get('rewards', []):
|
||||
child['rewards'].remove(reward_id)
|
||||
child_db.update({'rewards': child['rewards']}, ChildQuery.id == id)
|
||||
return jsonify({'message': f'Reward {reward_id} removed from {child["name"]}.'}), 200
|
||||
return jsonify({'error': 'Reward not assigned to child'}), 400
|
||||
|
||||
@child_api.route('/child/<id>/list-rewards', methods=['GET'])
|
||||
def list_child_rewards(id):
|
||||
ChildQuery = Query()
|
||||
result = child_db.search(ChildQuery.id == id)
|
||||
if not result:
|
||||
return jsonify({'error': 'Child not found'}), 404
|
||||
|
||||
child = result[0]
|
||||
reward_ids = child.get('rewards', [])
|
||||
|
||||
RewardQuery = Query()
|
||||
child_rewards = []
|
||||
for rid in reward_ids:
|
||||
reward = reward_db.get(RewardQuery.id == rid)
|
||||
if not reward:
|
||||
continue
|
||||
cr = ChildReward(reward.get('name'), reward.get('cost'), reward.get('image_id'), reward.get('id'))
|
||||
child_rewards.append(cr.to_dict())
|
||||
|
||||
return jsonify({'rewards': child_rewards}), 200
|
||||
|
||||
@child_api.route('/child/<id>/list-assignable-rewards', methods=['GET'])
|
||||
def list_assignable_rewards(id):
|
||||
ChildQuery = Query()
|
||||
result = child_db.search(ChildQuery.id == id)
|
||||
if not result:
|
||||
return jsonify({'error': 'Child not found'}), 404
|
||||
|
||||
child = result[0]
|
||||
assigned_ids = set(child.get('rewards', []))
|
||||
|
||||
all_reward_ids = [r.get('id') for r in reward_db.all() if r and r.get('id')]
|
||||
assignable_ids = [rid for rid in all_reward_ids if rid not in assigned_ids]
|
||||
|
||||
RewardQuery = Query()
|
||||
assignable_rewards = []
|
||||
for rid in assignable_ids:
|
||||
reward = reward_db.get(RewardQuery.id == rid)
|
||||
if not reward:
|
||||
continue
|
||||
cr = ChildReward(reward.get('name'), reward.get('cost'), reward.get('image_id'), reward.get('id'))
|
||||
assignable_rewards.append(cr.to_dict())
|
||||
|
||||
return jsonify({'rewards': assignable_rewards, 'count': len(assignable_rewards)}), 200
|
||||
|
||||
@child_api.route('/child/<id>/trigger-reward', methods=['POST'])
|
||||
def trigger_child_reward(id):
|
||||
data = request.get_json()
|
||||
reward_id = data.get('reward_id')
|
||||
if not reward_id:
|
||||
return jsonify({'error': 'reward_id is required'}), 400
|
||||
|
||||
ChildQuery = Query()
|
||||
result = child_db.search(ChildQuery.id == id)
|
||||
if not result:
|
||||
return jsonify({'error': 'Child not found'}), 404
|
||||
|
||||
child: Child = Child.from_dict(result[0])
|
||||
if reward_id not in child.rewards:
|
||||
return jsonify({'error': f'Reward not found assigned to child {child.name}'}), 404
|
||||
# look up the task and get the details
|
||||
RewardQuery = Query()
|
||||
reward_result = reward_db.search(RewardQuery.id == reward_id)
|
||||
if not reward_result:
|
||||
return jsonify({'error': 'Reward not found in reward database'}), 404
|
||||
reward: Reward = Reward.from_dict(reward_result[0])
|
||||
|
||||
# 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)
|
||||
)
|
||||
if removed:
|
||||
send_event_to_user("user123", Event(EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest(reward.id, child.id, ChildRewardRequest.REQUEST_GRANTED)))
|
||||
|
||||
|
||||
# update the child's points based on reward cost
|
||||
child.points -= reward.cost
|
||||
# update the child in the database
|
||||
child_db.update({'points': child.points}, ChildQuery.id == id)
|
||||
send_event_to_user("user123", Event(EventType.CHILD_REWARD_TRIGGERED.value, ChildRewardTriggered(reward.id, child.id, child.points)))
|
||||
|
||||
|
||||
return jsonify({'message': f'{reward.name} assigned to {child.name}.', 'points': child.points, 'id': child.id}), 200
|
||||
|
||||
@child_api.route('/child/<id>/affordable-rewards', methods=['GET'])
|
||||
def list_affordable_rewards(id):
|
||||
ChildQuery = Query()
|
||||
result = child_db.search(ChildQuery.id == id)
|
||||
if not result:
|
||||
return jsonify({'error': 'Child not found'}), 404
|
||||
|
||||
child = Child.from_dict(result[0])
|
||||
points = child.points
|
||||
reward_ids = child.rewards
|
||||
RewardQuery = Query()
|
||||
affordable = [
|
||||
Reward.from_dict(reward).to_dict() for reward_id in reward_ids
|
||||
if (reward := reward_db.get(RewardQuery.id == reward_id)) and points >= Reward.from_dict(reward).cost
|
||||
]
|
||||
return jsonify({'affordable_rewards': affordable}), 200
|
||||
|
||||
@child_api.route('/child/<id>/reward-status', methods=['GET'])
|
||||
def reward_status(id):
|
||||
ChildQuery = Query()
|
||||
result = child_db.search(ChildQuery.id == id)
|
||||
if not result:
|
||||
return jsonify({'error': 'Child not found'}), 404
|
||||
|
||||
child = Child.from_dict(result[0])
|
||||
points = child.points
|
||||
reward_ids = child.rewards
|
||||
|
||||
RewardQuery = Query()
|
||||
statuses = []
|
||||
for reward_id in reward_ids:
|
||||
reward: Reward = Reward.from_dict(reward_db.get(RewardQuery.id == reward_id))
|
||||
if not reward:
|
||||
continue
|
||||
points_needed = max(0, reward.cost - 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
|
||||
pending_query = Query()
|
||||
pending = pending_reward_db.get((pending_query.child_id == child.id) & (pending_query.reward_id == reward.id))
|
||||
status = RewardStatus(reward.id, reward.name, points_needed, reward.cost, pending is not None, reward.image_id)
|
||||
statuses.append(status.to_dict())
|
||||
|
||||
statuses.sort(key=lambda s: (not s['redeeming'], s['cost']))
|
||||
return jsonify({'reward_status': statuses}), 200
|
||||
|
||||
|
||||
@child_api.route('/child/<id>/request-reward', methods=['POST'])
|
||||
def request_reward(id):
|
||||
data = request.get_json()
|
||||
reward_id = data.get('reward_id')
|
||||
if not reward_id:
|
||||
return jsonify({'error': 'reward_id is required'}), 400
|
||||
|
||||
ChildQuery = Query()
|
||||
result = child_db.search(ChildQuery.id == id)
|
||||
if not result:
|
||||
return jsonify({'error': 'Child not found'}), 404
|
||||
|
||||
child = Child.from_dict(result[0])
|
||||
if reward_id not in child.rewards:
|
||||
return jsonify({'error': f'Reward not assigned to child {child.name}'}), 404
|
||||
|
||||
RewardQuery = Query()
|
||||
reward_result = reward_db.search(RewardQuery.id == reward_id)
|
||||
if not reward_result:
|
||||
return jsonify({'error': 'Reward not found in reward database'}), 404
|
||||
|
||||
reward = Reward.from_dict(reward_result[0])
|
||||
|
||||
# Check if child has enough points
|
||||
if child.points < reward.cost:
|
||||
points_needed = reward.cost - child.points
|
||||
return jsonify({
|
||||
'error': 'Insufficient points',
|
||||
'points_needed': points_needed,
|
||||
'current_points': child.points,
|
||||
'reward_cost': reward.cost
|
||||
}), 400
|
||||
|
||||
pending = PendingReward(child_id=child.id, reward_id=reward.id)
|
||||
pending_reward_db.insert(pending.to_dict())
|
||||
send_event_to_user("user123", Event(EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest(child.id, reward.id, ChildRewardRequest.REQUEST_CREATED)))
|
||||
|
||||
return jsonify({
|
||||
'message': f'Reward request for {reward.name} submitted for {child.name}.',
|
||||
'reward_id': reward.id,
|
||||
'reward_name': reward.name,
|
||||
'child_id': child.id,
|
||||
'child_name': child.name,
|
||||
'cost': reward.cost
|
||||
}), 200
|
||||
|
||||
@child_api.route('/child/<id>/cancel-request-reward', methods=['POST'])
|
||||
def cancel_request_reward(id):
|
||||
data = request.get_json()
|
||||
reward_id = data.get('reward_id')
|
||||
if not reward_id:
|
||||
return jsonify({'error': 'reward_id is required'}), 400
|
||||
|
||||
ChildQuery = Query()
|
||||
result = child_db.search(ChildQuery.id == id)
|
||||
if not result:
|
||||
return jsonify({'error': 'Child not found'}), 404
|
||||
|
||||
child = Child.from_dict(result[0])
|
||||
|
||||
# Remove matching pending reward request
|
||||
PendingQuery = Query()
|
||||
removed = pending_reward_db.remove(
|
||||
(PendingQuery.child_id == child.id) & (PendingQuery.reward_id == reward_id)
|
||||
)
|
||||
|
||||
if not removed:
|
||||
return jsonify({'error': 'No pending request found for this reward'}), 404
|
||||
|
||||
# Notify user that the request was cancelled
|
||||
send_event_to_user(
|
||||
"user123",
|
||||
Event(
|
||||
EventType.CHILD_REWARD_REQUEST.value,
|
||||
ChildRewardRequest(child.id, reward_id, ChildRewardRequest.REQUEST_CANCELLED)
|
||||
)
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'message': f'Reward request cancelled for {child.name}.',
|
||||
'child_id': child.id,
|
||||
'reward_id': reward_id,
|
||||
'removed_count': len(removed)
|
||||
}), 200
|
||||
|
||||
|
||||
|
||||
@child_api.route('/pending-rewards', methods=['GET'])
|
||||
def list_pending_rewards():
|
||||
pending_rewards = pending_reward_db.all()
|
||||
reward_responses = []
|
||||
|
||||
RewardQuery = 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)
|
||||
if not reward_result:
|
||||
continue
|
||||
reward = Reward.from_dict(reward_result)
|
||||
|
||||
# Look up child details
|
||||
child_result = child_db.get(ChildQuery.id == pending.child_id)
|
||||
if not child_result:
|
||||
continue
|
||||
child = Child.from_dict(child_result)
|
||||
|
||||
# Create response object
|
||||
response = PendingRewardResponse(
|
||||
_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
|
||||
)
|
||||
reward_responses.append(response.to_dict())
|
||||
|
||||
return jsonify({'rewards': reward_responses}), 200
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from tinydb import Query
|
||||
|
||||
from events.sse import send_event_to_user
|
||||
from events.types.event import Event
|
||||
from events.types.event_types import EventType
|
||||
from events.types.reward_modified import RewardModified
|
||||
from models.reward import Reward
|
||||
from db.db import reward_db, child_db
|
||||
|
||||
reward_api = Blueprint('reward_api', __name__)
|
||||
|
||||
# Reward endpoints
|
||||
@reward_api.route('/reward/add', methods=['PUT'])
|
||||
def add_reward():
|
||||
data = request.get_json()
|
||||
name = data.get('name')
|
||||
description = data.get('description')
|
||||
cost = data.get('cost')
|
||||
image = data.get('image_id', '')
|
||||
if not name or description is None or cost is None:
|
||||
return jsonify({'error': 'Name, description, and cost are required'}), 400
|
||||
reward = Reward(name=name, description=description, cost=cost, image_id=image)
|
||||
reward_db.insert(reward.to_dict())
|
||||
send_event_to_user("user123", Event(EventType.REWARD_MODIFIED.value,
|
||||
RewardModified(reward.id, RewardModified.OPERATION_ADD)))
|
||||
|
||||
return jsonify({'message': f'Reward {name} added.'}), 201
|
||||
|
||||
|
||||
|
||||
@reward_api.route('/reward/<id>', methods=['GET'])
|
||||
def get_reward(id):
|
||||
RewardQuery = Query()
|
||||
result = reward_db.search(RewardQuery.id == id)
|
||||
if not result:
|
||||
return jsonify({'error': 'Reward not found'}), 404
|
||||
return jsonify(result[0]), 200
|
||||
|
||||
@reward_api.route('/reward/list', methods=['GET'])
|
||||
def list_rewards():
|
||||
rewards = reward_db.all()
|
||||
return jsonify({'rewards': rewards}), 200
|
||||
|
||||
@reward_api.route('/reward/<id>', methods=['DELETE'])
|
||||
def delete_reward(id):
|
||||
RewardQuery = Query()
|
||||
removed = reward_db.remove(RewardQuery.id == id)
|
||||
if removed:
|
||||
# remove the reward id from any child's reward list
|
||||
ChildQuery = Query()
|
||||
for child in child_db.all():
|
||||
rewards = child.get('rewards', [])
|
||||
if id in rewards:
|
||||
rewards.remove(id)
|
||||
child_db.update({'rewards': rewards}, ChildQuery.id == child.get('id'))
|
||||
send_event_to_user("user123", Event(EventType.REWARD_MODIFIED.value,
|
||||
RewardModified(id, RewardModified.OPERATION_DELETE)))
|
||||
|
||||
return jsonify({'message': f'Reward {id} deleted.'}), 200
|
||||
return jsonify({'error': 'Reward not found'}), 404
|
||||
|
||||
@reward_api.route('/reward/<id>/edit', methods=['PUT'])
|
||||
def edit_reward(id):
|
||||
RewardQuery = Query()
|
||||
existing = reward_db.get(RewardQuery.id == id)
|
||||
if not existing:
|
||||
return jsonify({'error': 'Reward not found'}), 404
|
||||
|
||||
data = request.get_json(force=True) or {}
|
||||
updates = {}
|
||||
|
||||
if 'name' in data:
|
||||
name = (data.get('name') or '').strip()
|
||||
if not name:
|
||||
return jsonify({'error': 'Name cannot be empty'}), 400
|
||||
updates['name'] = name
|
||||
|
||||
if 'description' in data:
|
||||
desc = (data.get('description') or '').strip()
|
||||
if not desc:
|
||||
return jsonify({'error': 'Description cannot be empty'}), 400
|
||||
updates['description'] = desc
|
||||
|
||||
if 'cost' in data:
|
||||
cost = data.get('cost')
|
||||
if not isinstance(cost, int):
|
||||
return jsonify({'error': 'Cost must be an integer'}), 400
|
||||
if cost <= 0:
|
||||
return jsonify({'error': 'Cost must be a positive integer'}), 400
|
||||
updates['cost'] = cost
|
||||
|
||||
if 'image_id' in data:
|
||||
updates['image_id'] = data.get('image_id', '')
|
||||
|
||||
if not updates:
|
||||
return jsonify({'error': 'No valid fields to update'}), 400
|
||||
|
||||
reward_db.update(updates, RewardQuery.id == id)
|
||||
updated = reward_db.get(RewardQuery.id == id)
|
||||
send_event_to_user("user123", Event(EventType.REWARD_MODIFIED.value,
|
||||
RewardModified(id, RewardModified.OPERATION_EDIT)))
|
||||
|
||||
return jsonify(updated), 200
|
||||
100
api/task_api.py
100
api/task_api.py
@@ -1,100 +0,0 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from tinydb import Query
|
||||
|
||||
from events.sse import send_event_to_user
|
||||
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
|
||||
from db.db import task_db, child_db
|
||||
|
||||
task_api = Blueprint('task_api', __name__)
|
||||
|
||||
# Task endpoints
|
||||
@task_api.route('/task/add', methods=['PUT'])
|
||||
def add_task():
|
||||
data = request.get_json()
|
||||
name = data.get('name')
|
||||
points = data.get('points')
|
||||
is_good = data.get('is_good')
|
||||
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)
|
||||
task_db.insert(task.to_dict())
|
||||
send_event_to_user("user123", Event(EventType.TASK_MODIFIED.value,
|
||||
TaskModified(task.id, TaskModified.OPERATION_ADD)))
|
||||
return jsonify({'message': f'Task {name} added.'}), 201
|
||||
|
||||
@task_api.route('/task/<id>', methods=['GET'])
|
||||
def get_task(id):
|
||||
TaskQuery = Query()
|
||||
result = task_db.search(TaskQuery.id == id)
|
||||
if not result:
|
||||
return jsonify({'error': 'Task not found'}), 404
|
||||
return jsonify(result[0]), 200
|
||||
|
||||
@task_api.route('/task/list', methods=['GET'])
|
||||
def list_tasks():
|
||||
tasks = task_db.all()
|
||||
return jsonify({'tasks': tasks}), 200
|
||||
|
||||
@task_api.route('/task/<id>', methods=['DELETE'])
|
||||
def delete_task(id):
|
||||
TaskQuery = Query()
|
||||
removed = task_db.remove(TaskQuery.id == id)
|
||||
if removed:
|
||||
# remove the task id from any child's task list
|
||||
ChildQuery = Query()
|
||||
for child in child_db.all():
|
||||
tasks = child.get('tasks', [])
|
||||
if id in tasks:
|
||||
tasks.remove(id)
|
||||
child_db.update({'tasks': tasks}, ChildQuery.id == child.get('id'))
|
||||
send_event_to_user("user123", Event(EventType.TASK_MODIFIED.value,
|
||||
TaskModified(id, TaskModified.OPERATION_DELETE)))
|
||||
|
||||
return jsonify({'message': f'Task {id} deleted.'}), 200
|
||||
return jsonify({'error': 'Task not found'}), 404
|
||||
|
||||
@task_api.route('/task/<id>/edit', methods=['PUT'])
|
||||
def edit_task(id):
|
||||
TaskQuery = Query()
|
||||
existing = task_db.get(TaskQuery.id == id)
|
||||
if not existing:
|
||||
return jsonify({'error': 'Task not found'}), 404
|
||||
|
||||
data = request.get_json(force=True) or {}
|
||||
updates = {}
|
||||
|
||||
if 'name' in data:
|
||||
name = data.get('name', '').strip()
|
||||
if not name:
|
||||
return jsonify({'error': 'Name cannot be empty'}), 400
|
||||
updates['name'] = name
|
||||
|
||||
if 'points' in data:
|
||||
points = data.get('points')
|
||||
if not isinstance(points, int):
|
||||
return jsonify({'error': 'Points must be an integer'}), 400
|
||||
if points <= 0:
|
||||
return jsonify({'error': 'Points must be a positive integer'}), 400
|
||||
updates['points'] = points
|
||||
|
||||
if 'is_good' in data:
|
||||
is_good = data.get('is_good')
|
||||
if not isinstance(is_good, bool):
|
||||
return jsonify({'error': 'is_good must be a boolean'}), 400
|
||||
updates['is_good'] = is_good
|
||||
|
||||
if 'image_id' in data:
|
||||
updates['image_id'] = data.get('image_id', '')
|
||||
|
||||
if not updates:
|
||||
return jsonify({'error': 'No valid fields to update'}), 400
|
||||
|
||||
task_db.update(updates, TaskQuery.id == id)
|
||||
updated = task_db.get(TaskQuery.id == id)
|
||||
send_event_to_user("user123", Event(EventType.TASK_MODIFIED.value,
|
||||
TaskModified(id, TaskModified.OPERATION_EDIT)))
|
||||
return jsonify(updated), 200
|
||||
83
backend/.gitignore
vendored
Normal file
83
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,83 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
PIPFILE.lock
|
||||
|
||||
# Virtual Environment
|
||||
venv/
|
||||
ENV/
|
||||
env/
|
||||
.venv
|
||||
|
||||
# PyCharm
|
||||
.idea/
|
||||
*.iml
|
||||
*.iws
|
||||
.idea_modules/
|
||||
|
||||
# VS Code
|
||||
.vscode/
|
||||
|
||||
# Database files
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
data/db/*.json
|
||||
data/images/
|
||||
test_data/
|
||||
|
||||
# Flask
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Node.js / Vue (web directory)
|
||||
web/node_modules/
|
||||
web/npm-debug.log*
|
||||
web/yarn-debug.log*
|
||||
web/yarn-error.log*
|
||||
web/dist/
|
||||
web/.nuxt/
|
||||
web/.cache/
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Testing
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.tox/
|
||||
|
||||
/chore.bundle
|
||||
/tree.json
|
||||
0
Jenkinsfile → backend/Jenkinsfile
vendored
0
Jenkinsfile → backend/Jenkinsfile
vendored
155
backend/api/admin_api.py
Normal file
155
backend/api/admin_api.py
Normal file
@@ -0,0 +1,155 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from datetime import datetime, timedelta
|
||||
from tinydb import Query
|
||||
|
||||
from db.db import users_db
|
||||
from models.user import User
|
||||
from api.utils import admin_required
|
||||
from config.deletion_config import (
|
||||
ACCOUNT_DELETION_THRESHOLD_HOURS,
|
||||
MIN_THRESHOLD_HOURS,
|
||||
MAX_THRESHOLD_HOURS,
|
||||
validate_threshold
|
||||
)
|
||||
from utils.account_deletion_scheduler import trigger_deletion_manually
|
||||
|
||||
admin_api = Blueprint('admin_api', __name__)
|
||||
|
||||
|
||||
@admin_api.route('/admin/deletion-queue', methods=['GET'])
|
||||
@admin_required
|
||||
def get_deletion_queue():
|
||||
"""
|
||||
Get list of users pending deletion.
|
||||
Returns users marked for deletion with their deletion due dates.
|
||||
"""
|
||||
try:
|
||||
Query_ = Query()
|
||||
marked_users = users_db.search(Query_.marked_for_deletion == True)
|
||||
|
||||
users_data = []
|
||||
for user_dict in marked_users:
|
||||
user = User.from_dict(user_dict)
|
||||
|
||||
# Calculate deletion_due_at
|
||||
deletion_due_at = None
|
||||
if user.marked_for_deletion_at:
|
||||
try:
|
||||
marked_at = datetime.fromisoformat(user.marked_for_deletion_at)
|
||||
due_at = marked_at + timedelta(hours=ACCOUNT_DELETION_THRESHOLD_HOURS)
|
||||
deletion_due_at = due_at.isoformat()
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
users_data.append({
|
||||
'id': user.id,
|
||||
'email': user.email,
|
||||
'marked_for_deletion_at': user.marked_for_deletion_at,
|
||||
'deletion_due_at': deletion_due_at,
|
||||
'deletion_in_progress': user.deletion_in_progress,
|
||||
'deletion_attempted_at': user.deletion_attempted_at
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'count': len(users_data),
|
||||
'users': users_data
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e), 'code': 'SERVER_ERROR'}), 500
|
||||
|
||||
@admin_api.route('/admin/deletion-threshold', methods=['GET'])
|
||||
@admin_required
|
||||
def get_deletion_threshold():
|
||||
"""
|
||||
Get current deletion threshold configuration.
|
||||
"""
|
||||
return jsonify({
|
||||
'threshold_hours': ACCOUNT_DELETION_THRESHOLD_HOURS,
|
||||
'threshold_min': MIN_THRESHOLD_HOURS,
|
||||
'threshold_max': MAX_THRESHOLD_HOURS
|
||||
}), 200
|
||||
|
||||
@admin_api.route('/admin/deletion-threshold', methods=['PUT'])
|
||||
@admin_required
|
||||
def update_deletion_threshold():
|
||||
"""
|
||||
Update deletion threshold.
|
||||
Note: This updates the runtime value but doesn't persist to environment variables.
|
||||
For permanent changes, update the ACCOUNT_DELETION_THRESHOLD_HOURS env variable.
|
||||
"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
|
||||
if not data or 'threshold_hours' not in data:
|
||||
return jsonify({
|
||||
'error': 'threshold_hours is required',
|
||||
'code': 'MISSING_THRESHOLD'
|
||||
}), 400
|
||||
|
||||
new_threshold = data['threshold_hours']
|
||||
|
||||
# Validate type
|
||||
if not isinstance(new_threshold, int):
|
||||
return jsonify({
|
||||
'error': 'threshold_hours must be an integer',
|
||||
'code': 'INVALID_TYPE'
|
||||
}), 400
|
||||
|
||||
# Validate range
|
||||
if new_threshold < MIN_THRESHOLD_HOURS:
|
||||
return jsonify({
|
||||
'error': f'threshold_hours must be at least {MIN_THRESHOLD_HOURS}',
|
||||
'code': 'THRESHOLD_TOO_LOW'
|
||||
}), 400
|
||||
|
||||
if new_threshold > MAX_THRESHOLD_HOURS:
|
||||
return jsonify({
|
||||
'error': f'threshold_hours must be at most {MAX_THRESHOLD_HOURS}',
|
||||
'code': 'THRESHOLD_TOO_HIGH'
|
||||
}), 400
|
||||
|
||||
# Update the global config
|
||||
import config.deletion_config as config
|
||||
config.ACCOUNT_DELETION_THRESHOLD_HOURS = new_threshold
|
||||
|
||||
# Validate and log warning if needed
|
||||
validate_threshold()
|
||||
|
||||
return jsonify({
|
||||
'message': 'Deletion threshold updated successfully',
|
||||
'threshold_hours': new_threshold
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e), 'code': 'SERVER_ERROR'}), 500
|
||||
|
||||
@admin_api.route('/admin/deletion-queue/trigger', methods=['POST'])
|
||||
@admin_required
|
||||
def trigger_deletion_queue():
|
||||
"""
|
||||
Manually trigger the deletion scheduler to process the queue immediately.
|
||||
Returns stats about the run.
|
||||
"""
|
||||
try:
|
||||
# Trigger the deletion process
|
||||
result = trigger_deletion_manually()
|
||||
|
||||
# Get updated queue stats
|
||||
Query_ = Query()
|
||||
marked_users = users_db.search(Query_.marked_for_deletion == True)
|
||||
|
||||
# Count users that were just processed (this is simplified)
|
||||
processed = result.get('queued_users', 0)
|
||||
|
||||
# In a real implementation, you'd return actual stats from the deletion run
|
||||
# For now, we'll return simplified stats
|
||||
return jsonify({
|
||||
'message': 'Deletion scheduler triggered',
|
||||
'processed': processed,
|
||||
'deleted': 0, # TODO: Track this in the deletion function
|
||||
'failed': 0 # TODO: Track this in the deletion function
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e), 'code': 'SERVER_ERROR'}), 500
|
||||
503
backend/api/auth_api.py
Normal file
503
backend/api/auth_api.py
Normal file
@@ -0,0 +1,503 @@
|
||||
import hashlib
|
||||
import logging
|
||||
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
|
||||
import utils.email_sender as email_sender
|
||||
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,
|
||||
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 api.utils import normalize_email
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
auth_api = Blueprint('auth_api', __name__)
|
||||
UserQuery = Query()
|
||||
TokenQuery = Query()
|
||||
TOKEN_EXPIRY_MINUTES = 60 * 4
|
||||
RESET_PASSWORD_TOKEN_EXPIRY_MINUTES = 10
|
||||
ACCESS_TOKEN_EXPIRY_MINUTES = 15
|
||||
E2E_TEST_EMAIL = 'e2e@test.com'
|
||||
E2E_TEST_PASSWORD = 'E2eTestPass1!'
|
||||
E2E_TEST_PIN = '1234'
|
||||
|
||||
|
||||
def send_verification_email(to_email, token):
|
||||
email_sender.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='Strict')
|
||||
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()
|
||||
required_fields = ['first_name', 'last_name', 'email', 'password']
|
||||
if not all(field in data for field in required_fields):
|
||||
return jsonify({'error': 'Missing required fields', 'code': MISSING_FIELDS}), 400
|
||||
email = data.get('email', '')
|
||||
norm_email = normalize_email(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)
|
||||
now_iso = datetime.utcnow().isoformat()
|
||||
user = User(
|
||||
first_name=data['first_name'],
|
||||
last_name=data['last_name'],
|
||||
email=norm_email,
|
||||
password=generate_password_hash(data['password']),
|
||||
verified=False,
|
||||
verify_token=token,
|
||||
verify_token_created=now_iso,
|
||||
image_id="boy01"
|
||||
)
|
||||
users_db.insert(user.to_dict())
|
||||
send_verification_email(norm_email, token)
|
||||
return jsonify({'message': 'User created, verification email sent'}), 201
|
||||
|
||||
@auth_api.route('/verify', methods=['GET'])
|
||||
def verify():
|
||||
token = request.args.get('token')
|
||||
status = 'success'
|
||||
reason = ''
|
||||
code = ''
|
||||
user_dict = None
|
||||
user = None
|
||||
|
||||
if not token:
|
||||
status = 'error'
|
||||
reason = 'Missing token'
|
||||
code = MISSING_TOKEN
|
||||
else:
|
||||
user_dict = users_db.get(Query().verify_token == token)
|
||||
user = User.from_dict(user_dict) if user_dict else None
|
||||
if not user:
|
||||
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:
|
||||
status = 'error'
|
||||
reason = 'Token timestamp missing'
|
||||
code = TOKEN_TIMESTAMP_MISSING
|
||||
else:
|
||||
created_dt = datetime.fromisoformat(created_str).replace(tzinfo=timezone.utc)
|
||||
if datetime.now(timezone.utc) - created_dt > timedelta(minutes=TOKEN_EXPIRY_MINUTES):
|
||||
status = 'error'
|
||||
reason = 'Token expired'
|
||||
code = TOKEN_EXPIRED
|
||||
else:
|
||||
user.verified = True
|
||||
user.verify_token = None
|
||||
user.verify_token_created = None
|
||||
users_db.update(user.to_dict(), Query().email == user.email)
|
||||
|
||||
http_status = 200 if status == 'success' else 400
|
||||
if http_status == 200 and user is not None:
|
||||
if not user.email:
|
||||
logger.error("Verified user has no email field.")
|
||||
else:
|
||||
user_image_dir = get_user_image_dir(user.id)
|
||||
os.makedirs(user_image_dir, exist_ok=True)
|
||||
|
||||
return jsonify({'status': status, 'reason': reason, 'code': code}), http_status
|
||||
|
||||
@auth_api.route('/resend-verify', methods=['POST'])
|
||||
def resend_verify():
|
||||
data = request.get_json()
|
||||
email = data.get('email', '')
|
||||
if not email:
|
||||
return jsonify({'error': 'Missing email', 'code': MISSING_EMAIL}), 400
|
||||
norm_email = normalize_email(email)
|
||||
|
||||
user_dict = users_db.get(UserQuery.email == norm_email)
|
||||
user = User.from_dict(user_dict) if user_dict else None
|
||||
if not user:
|
||||
return jsonify({'error': 'User not found', 'code': USER_NOT_FOUND}), 404
|
||||
|
||||
if user.verified:
|
||||
return jsonify({'error': 'Account already verified', 'code': ALREADY_VERIFIED}), 400
|
||||
|
||||
token = secrets.token_urlsafe(32)
|
||||
now_iso = datetime.utcnow().isoformat()
|
||||
user.verify_token = token
|
||||
user.verify_token_created = now_iso
|
||||
users_db.update(user.to_dict(), UserQuery.email == norm_email)
|
||||
send_verification_email(norm_email, token)
|
||||
return jsonify({'message': 'Verification email resent'}), 200
|
||||
|
||||
@auth_api.route('/login', methods=['POST'])
|
||||
def login():
|
||||
data = request.get_json()
|
||||
email = data.get('email', '')
|
||||
password = data.get('password')
|
||||
if not email or not password:
|
||||
return jsonify({'error': 'Missing email or password', 'code': MISSING_EMAIL_OR_PASSWORD}), 400
|
||||
norm_email = normalize_email(email)
|
||||
|
||||
user_dict = users_db.get(UserQuery.email == norm_email)
|
||||
user = User.from_dict(user_dict) if user_dict else None
|
||||
if not user or not check_password_hash(user.password, password):
|
||||
return jsonify({'error': 'Invalid credentials', 'code': INVALID_CREDENTIALS}), 401
|
||||
|
||||
if not user.verified:
|
||||
return jsonify({'error': 'This account has not verified', 'code': NOT_VERIFIED}), 403
|
||||
|
||||
# Block login for marked accounts
|
||||
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
|
||||
|
||||
# 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'})
|
||||
_set_auth_cookies(resp, access_token, raw_refresh)
|
||||
return resp, 200
|
||||
|
||||
@auth_api.route('/me', methods=['GET'])
|
||||
def me():
|
||||
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,
|
||||
'first_name': user.first_name,
|
||||
'last_name': user.last_name,
|
||||
'verified': user.verified
|
||||
}), 200
|
||||
except jwt.ExpiredSignatureError:
|
||||
return jsonify({'error': 'Token expired', 'code': TOKEN_EXPIRED}), 401
|
||||
except jwt.InvalidTokenError:
|
||||
return jsonify({'error': 'Invalid token', 'code': INVALID_TOKEN}), 401
|
||||
|
||||
@auth_api.route('/request-password-reset', methods=['POST'])
|
||||
def request_password_reset():
|
||||
data = request.get_json()
|
||||
email = data.get('email', '')
|
||||
norm_email = normalize_email(email)
|
||||
|
||||
success_msg = 'If this email is registered, you will receive a password reset link shortly.'
|
||||
|
||||
if not email:
|
||||
return jsonify({'error': 'Missing email', 'code': MISSING_EMAIL}), 400
|
||||
|
||||
user_dict = users_db.get(UserQuery.email == norm_email)
|
||||
user = User.from_dict(user_dict) if user_dict else None
|
||||
if user:
|
||||
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
|
||||
user.reset_token_created = now_iso
|
||||
users_db.update(user.to_dict(), UserQuery.email == norm_email)
|
||||
send_reset_password_email(norm_email, token)
|
||||
|
||||
return jsonify({'message': success_msg}), 200
|
||||
|
||||
@auth_api.route('/validate-reset-token', methods=['GET'])
|
||||
def validate_reset_token():
|
||||
token = request.args.get('token')
|
||||
if not token:
|
||||
return jsonify({'error': 'Missing token', 'code': MISSING_TOKEN}), 400
|
||||
|
||||
user_dict = users_db.get(UserQuery.reset_token == token)
|
||||
user = User.from_dict(user_dict) if user_dict else None
|
||||
if not user:
|
||||
return jsonify({'error': 'Invalid token', 'code': INVALID_TOKEN}), 400
|
||||
|
||||
created_str = user.reset_token_created
|
||||
if not created_str:
|
||||
return jsonify({'error': 'Token timestamp missing', 'code': TOKEN_TIMESTAMP_MISSING}), 400
|
||||
|
||||
created_dt = datetime.fromisoformat(created_str).replace(tzinfo=timezone.utc)
|
||||
if datetime.now(timezone.utc) - created_dt > timedelta(minutes=RESET_PASSWORD_TOKEN_EXPIRY_MINUTES):
|
||||
return jsonify({'error': 'Token expired', 'code': TOKEN_EXPIRED}), 400
|
||||
|
||||
return jsonify({'message': 'Token is valid'}), 200
|
||||
|
||||
@auth_api.route('/reset-password', methods=['POST'])
|
||||
def reset_password():
|
||||
data = request.get_json()
|
||||
token = data.get('token')
|
||||
new_password = data.get('password')
|
||||
|
||||
if not token or not new_password:
|
||||
return jsonify({'error': 'Missing token or password'}), 400
|
||||
|
||||
user_dict = users_db.get(UserQuery.reset_token == token)
|
||||
user = User.from_dict(user_dict) if user_dict else None
|
||||
if not user:
|
||||
return jsonify({'error': 'Invalid token', 'code': INVALID_TOKEN}), 400
|
||||
|
||||
created_str = user.reset_token_created
|
||||
if not created_str:
|
||||
return jsonify({'error': 'Token timestamp missing', 'code': TOKEN_TIMESTAMP_MISSING}), 400
|
||||
|
||||
created_dt = datetime.fromisoformat(created_str).replace(tzinfo=timezone.utc)
|
||||
if datetime.now(timezone.utc) - created_dt > timedelta(minutes=RESET_PASSWORD_TOKEN_EXPIRY_MINUTES):
|
||||
return jsonify({'error': 'Token expired', 'code': TOKEN_EXPIRED}), 400
|
||||
|
||||
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)
|
||||
|
||||
# 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'})
|
||||
_clear_auth_cookies(resp)
|
||||
return resp, 200
|
||||
|
||||
|
||||
@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()
|
||||
|
||||
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
|
||||
1336
backend/api/child_api.py
Normal file
1336
backend/api/child_api.py
Normal file
File diff suppressed because it is too large
Load Diff
173
backend/api/child_override_api.py
Normal file
173
backend/api/child_override_api.py
Normal file
@@ -0,0 +1,173 @@
|
||||
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, task_db, reward_db
|
||||
from db.child_overrides import (
|
||||
insert_override,
|
||||
get_override,
|
||||
get_overrides_for_child,
|
||||
delete_override
|
||||
)
|
||||
from models.child_override import ChildOverride
|
||||
from events.types.event import Event
|
||||
from events.types.event_types import EventType
|
||||
from events.types.child_override_set import ChildOverrideSetPayload
|
||||
from events.types.child_override_deleted import ChildOverrideDeletedPayload
|
||||
import logging
|
||||
|
||||
child_override_api = Blueprint('child_override_api', __name__)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@child_override_api.route('/child/<child_id>/override', methods=['PUT'])
|
||||
def set_child_override(child_id):
|
||||
"""
|
||||
Set or update a custom value for a task/reward for a specific child.
|
||||
"""
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
|
||||
|
||||
# Validate child exists and belongs to user
|
||||
ChildQuery = Query()
|
||||
child_result = child_db.search((ChildQuery.id == child_id) & (ChildQuery.user_id == user_id))
|
||||
if not child_result:
|
||||
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
|
||||
|
||||
child_dict = child_result[0]
|
||||
|
||||
# Parse request data
|
||||
data = request.get_json() or {}
|
||||
entity_id = data.get('entity_id')
|
||||
entity_type = data.get('entity_type')
|
||||
custom_value = data.get('custom_value')
|
||||
|
||||
# Validate required fields
|
||||
if not entity_id:
|
||||
return jsonify({'error': 'entity_id is required', 'code': ErrorCodes.MISSING_FIELD, 'field': 'entity_id'}), 400
|
||||
if not entity_type:
|
||||
return jsonify({'error': 'entity_type is required', 'code': ErrorCodes.MISSING_FIELD, 'field': 'entity_type'}), 400
|
||||
if custom_value is None:
|
||||
return jsonify({'error': 'custom_value is required', 'code': ErrorCodes.MISSING_FIELD, 'field': 'custom_value'}), 400
|
||||
|
||||
# Validate entity_type
|
||||
if entity_type not in ['task', 'reward']:
|
||||
return jsonify({'error': 'entity_type must be "task" or "reward"', 'code': ErrorCodes.INVALID_VALUE, 'field': 'entity_type'}), 400
|
||||
|
||||
# Validate custom_value range
|
||||
if not isinstance(custom_value, int) or custom_value < 0 or custom_value > 10000:
|
||||
return jsonify({'error': 'custom_value must be an integer between 0 and 10000', 'code': ErrorCodes.INVALID_VALUE, 'field': 'custom_value'}), 400
|
||||
|
||||
# Validate entity exists and is assigned to child
|
||||
if entity_type == 'task':
|
||||
EntityQuery = Query()
|
||||
entity_result = task_db.search(
|
||||
(EntityQuery.id == entity_id) &
|
||||
((EntityQuery.user_id == user_id) | (EntityQuery.user_id == None))
|
||||
)
|
||||
if not entity_result:
|
||||
return jsonify({'error': 'Task not found', 'code': ErrorCodes.TASK_NOT_FOUND}), 404
|
||||
|
||||
# Check if task is assigned to child
|
||||
assigned_tasks = child_dict.get('tasks', [])
|
||||
if entity_id not in assigned_tasks:
|
||||
return jsonify({'error': 'Task not assigned to child', 'code': ErrorCodes.ENTITY_NOT_ASSIGNED}), 404
|
||||
|
||||
else: # reward
|
||||
EntityQuery = Query()
|
||||
entity_result = reward_db.search(
|
||||
(EntityQuery.id == entity_id) &
|
||||
((EntityQuery.user_id == user_id) | (EntityQuery.user_id == None))
|
||||
)
|
||||
if not entity_result:
|
||||
return jsonify({'error': 'Reward not found', 'code': ErrorCodes.REWARD_NOT_FOUND}), 404
|
||||
|
||||
# Check if reward is assigned to child
|
||||
assigned_rewards = child_dict.get('rewards', [])
|
||||
if entity_id not in assigned_rewards:
|
||||
return jsonify({'error': 'Reward not assigned to child', 'code': ErrorCodes.ENTITY_NOT_ASSIGNED}), 404
|
||||
|
||||
# Create and insert override
|
||||
try:
|
||||
override = ChildOverride.create_override(
|
||||
child_id=child_id,
|
||||
entity_id=entity_id,
|
||||
entity_type=entity_type,
|
||||
custom_value=custom_value
|
||||
)
|
||||
insert_override(override)
|
||||
|
||||
# Send SSE event
|
||||
resp = send_event_for_current_user(
|
||||
Event(EventType.CHILD_OVERRIDE_SET.value, ChildOverrideSetPayload(override))
|
||||
)
|
||||
if resp:
|
||||
return resp
|
||||
|
||||
return jsonify({'override': override.to_dict()}), 200
|
||||
|
||||
except ValueError as e:
|
||||
return jsonify({'error': str(e), 'code': ErrorCodes.VALIDATION_ERROR}), 400
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting override: {e}")
|
||||
return jsonify({'error': 'Internal server error', 'code': ErrorCodes.INTERNAL_ERROR}), 500
|
||||
|
||||
|
||||
@child_override_api.route('/child/<child_id>/overrides', methods=['GET'])
|
||||
def get_child_overrides(child_id):
|
||||
"""
|
||||
Get all overrides for a specific child.
|
||||
"""
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
|
||||
|
||||
# Validate child exists and belongs to user
|
||||
ChildQuery = Query()
|
||||
child_result = child_db.search((ChildQuery.id == child_id) & (ChildQuery.user_id == user_id))
|
||||
if not child_result:
|
||||
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
|
||||
|
||||
# Get all overrides for child
|
||||
overrides = get_overrides_for_child(child_id)
|
||||
|
||||
return jsonify({'overrides': [o.to_dict() for o in overrides]}), 200
|
||||
|
||||
|
||||
@child_override_api.route('/child/<child_id>/override/<entity_id>', methods=['DELETE'])
|
||||
def delete_child_override(child_id, entity_id):
|
||||
"""
|
||||
Delete an override (reset to default).
|
||||
"""
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
|
||||
|
||||
# Validate child exists and belongs to user
|
||||
ChildQuery = Query()
|
||||
child_result = child_db.search((ChildQuery.id == child_id) & (ChildQuery.user_id == user_id))
|
||||
if not child_result:
|
||||
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
|
||||
|
||||
# Get override to determine entity_type for event
|
||||
override = get_override(child_id, entity_id)
|
||||
if not override:
|
||||
return jsonify({'error': 'Override not found', 'code': ErrorCodes.OVERRIDE_NOT_FOUND}), 404
|
||||
|
||||
entity_type = override.entity_type
|
||||
|
||||
# Delete override
|
||||
deleted = delete_override(child_id, entity_id)
|
||||
if not deleted:
|
||||
return jsonify({'error': 'Override not found', 'code': ErrorCodes.OVERRIDE_NOT_FOUND}), 404
|
||||
|
||||
# Send SSE event
|
||||
resp = send_event_for_current_user(
|
||||
Event(EventType.CHILD_OVERRIDE_DELETED.value,
|
||||
ChildOverrideDeletedPayload(child_id, entity_id, entity_type))
|
||||
)
|
||||
if resp:
|
||||
return resp
|
||||
|
||||
return jsonify({'message': 'Override deleted'}), 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
|
||||
155
backend/api/chore_schedule_api.py
Normal file
155
backend/api/chore_schedule_api.py
Normal file
@@ -0,0 +1,155 @@
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
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,
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
delete_extension_for_child_task(child_id, task_id)
|
||||
upsert_schedule(schedule)
|
||||
|
||||
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 date
|
||||
existing = get_extension(child_id, task_id, date)
|
||||
if existing:
|
||||
return jsonify({'error': 'Chore already extended for this date', 'code': 'ALREADY_EXTENDED'}), 409
|
||||
|
||||
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
|
||||
37
backend/api/error_codes.py
Normal file
37
backend/api/error_codes.py
Normal file
@@ -0,0 +1,37 @@
|
||||
MISSING_FIELDS = "MISSING_FIELDS"
|
||||
EMAIL_EXISTS = "EMAIL_EXISTS"
|
||||
MISSING_TOKEN = "MISSING_TOKEN"
|
||||
INVALID_TOKEN = "INVALID_TOKEN"
|
||||
TOKEN_TIMESTAMP_MISSING = "TOKEN_TIMESTAMP_MISSING"
|
||||
TOKEN_EXPIRED = "TOKEN_EXPIRED"
|
||||
MISSING_EMAIL = "MISSING_EMAIL"
|
||||
USER_NOT_FOUND = "USER_NOT_FOUND"
|
||||
ALREADY_VERIFIED = "ALREADY_VERIFIED"
|
||||
MISSING_EMAIL_OR_PASSWORD = "MISSING_EMAIL_OR_PASSWORD"
|
||||
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:
|
||||
"""Centralized error codes for API responses."""
|
||||
UNAUTHORIZED = "UNAUTHORIZED"
|
||||
CHILD_NOT_FOUND = "CHILD_NOT_FOUND"
|
||||
TASK_NOT_FOUND = "TASK_NOT_FOUND"
|
||||
REWARD_NOT_FOUND = "REWARD_NOT_FOUND"
|
||||
ENTITY_NOT_ASSIGNED = "ENTITY_NOT_ASSIGNED"
|
||||
OVERRIDE_NOT_FOUND = "OVERRIDE_NOT_FOUND"
|
||||
MISSING_FIELD = "MISSING_FIELD"
|
||||
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"
|
||||
@@ -1,15 +1,18 @@
|
||||
import os
|
||||
UPLOAD_FOLDER = os.path.abspath(os.path.join(os.path.dirname(__file__), '../../data/images'))
|
||||
import os
|
||||
|
||||
from PIL import Image as PILImage, UnidentifiedImageError
|
||||
from flask import Blueprint, request, jsonify, send_file
|
||||
from tinydb import Query
|
||||
|
||||
from api.utils import get_current_user_id, sanitize_email, get_validated_user_id
|
||||
from config.paths import get_user_image_dir
|
||||
|
||||
from db.db import image_db
|
||||
from models.image import Image
|
||||
|
||||
image_api = Blueprint('image_api', __name__)
|
||||
UPLOAD_FOLDER = get_user_image_dir("user123")
|
||||
ALLOWED_EXTENSIONS = {'jpg', 'jpeg', 'png'}
|
||||
IMAGE_TYPE_PROFILE = 1
|
||||
IMAGE_TYPE_ICON = 2
|
||||
@@ -20,6 +23,9 @@ def allowed_file(filename):
|
||||
|
||||
@image_api.route('/image/upload', methods=['POST'])
|
||||
def upload():
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||
if 'file' not in request.files:
|
||||
return jsonify({'error': 'No file part in the request'}), 400
|
||||
file = request.files['file']
|
||||
@@ -60,13 +66,14 @@ def upload():
|
||||
|
||||
format_extension_map = {'JPEG': '.jpg', 'PNG': '.png'}
|
||||
extension = format_extension_map.get(original_format, '.png')
|
||||
|
||||
image_record = Image(extension=extension, permanent=perm, type=image_type, user="user123")
|
||||
image_record = Image(extension=extension, permanent=perm, type=image_type, user_id=user_id)
|
||||
filename = image_record.id + extension
|
||||
filepath = os.path.abspath(os.path.join(UPLOAD_FOLDER, filename))
|
||||
user_image_dir = get_user_image_dir(user_id)
|
||||
os.makedirs(user_image_dir, exist_ok=True)
|
||||
|
||||
filepath = os.path.abspath(os.path.join(get_user_image_dir(sanitize_email(user_id)), filename))
|
||||
|
||||
try:
|
||||
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
|
||||
# Save with appropriate format
|
||||
save_params = {}
|
||||
if pil_image.format == 'JPEG':
|
||||
@@ -82,25 +89,38 @@ def upload():
|
||||
|
||||
@image_api.route('/image/request/<id>', methods=['GET'])
|
||||
def request_image(id):
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||
ImageQuery = Query()
|
||||
image: Image = Image.from_dict(image_db.get(ImageQuery.id == id))
|
||||
if not image:
|
||||
image_record = image_db.get(ImageQuery.id == id)
|
||||
if not image_record:
|
||||
return jsonify({'error': 'Image not found'}), 404
|
||||
image = Image.from_dict(image_record)
|
||||
# Allow if image.user_id is None (public image), or matches user_id
|
||||
if image.user_id is not None and image.user_id != user_id:
|
||||
return jsonify({'error': 'Forbidden: image does not belong to user', 'code': 'FORBIDDEN'}), 403
|
||||
filename = f"{image.id}{image.extension}"
|
||||
filepath = os.path.abspath(os.path.join(get_user_image_dir(image.user), filename))
|
||||
if image.user_id is None:
|
||||
filepath = os.path.abspath(os.path.join(get_user_image_dir("default"), filename))
|
||||
else:
|
||||
filepath = os.path.abspath(os.path.join(get_user_image_dir(image.user_id), filename))
|
||||
if not os.path.exists(filepath):
|
||||
return jsonify({'error': 'File not found'}), 404
|
||||
return send_file(filepath)
|
||||
|
||||
@image_api.route('/image/list', methods=['GET'])
|
||||
def list_images():
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||
image_type = request.args.get('type', type=int)
|
||||
ImageQuery = Query()
|
||||
if image_type is not None:
|
||||
if image_type not in [IMAGE_TYPE_PROFILE, IMAGE_TYPE_ICON]:
|
||||
return jsonify({'error': 'Invalid image type'}), 400
|
||||
images = image_db.search(ImageQuery.type == image_type)
|
||||
images = image_db.search((ImageQuery.type == image_type) & ((ImageQuery.user_id == user_id) | (ImageQuery.user_id == None)))
|
||||
else:
|
||||
images = image_db.all()
|
||||
images = image_db.search((ImageQuery.user_id == user_id) | (ImageQuery.user_id == None))
|
||||
image_ids = [img['id'] for img in images]
|
||||
return jsonify({'ids': image_ids, 'count': len(image_ids)}), 200
|
||||
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
|
||||
}
|
||||
163
backend/api/reward_api.py
Normal file
163
backend/api/reward_api.py
Normal file
@@ -0,0 +1,163 @@
|
||||
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_rewards_set import ChildRewardsSet
|
||||
from db.db import reward_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.reward_modified import RewardModified
|
||||
from models.reward import Reward
|
||||
|
||||
reward_api = Blueprint('reward_api', __name__)
|
||||
|
||||
# Reward endpoints
|
||||
@reward_api.route('/reward/add', methods=['PUT'])
|
||||
def add_reward():
|
||||
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')
|
||||
description = data.get('description')
|
||||
cost = data.get('cost')
|
||||
image = data.get('image_id', '')
|
||||
if not name or description is None or cost is None:
|
||||
return jsonify({'error': 'Name, description, and cost are required'}), 400
|
||||
reward = Reward(name=name, description=description, cost=cost, image_id=image, user_id=user_id)
|
||||
reward_db.insert(reward.to_dict())
|
||||
send_event_for_current_user(Event(EventType.REWARD_MODIFIED.value, RewardModified(reward.id, RewardModified.OPERATION_ADD)))
|
||||
return jsonify({'message': f'Reward {name} added.'}), 201
|
||||
|
||||
|
||||
|
||||
@reward_api.route('/reward/<id>', methods=['GET'])
|
||||
def get_reward(id):
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||
RewardQuery = Query()
|
||||
result = reward_db.search((RewardQuery.id == id) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None)))
|
||||
if not result:
|
||||
return jsonify({'error': 'Reward not found'}), 404
|
||||
return jsonify(result[0]), 200
|
||||
|
||||
@reward_api.route('/reward/list', methods=['GET'])
|
||||
def list_rewards():
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||
ids_param = request.args.get('ids')
|
||||
RewardQuery = Query()
|
||||
rewards = reward_db.search((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None))
|
||||
if ids_param is not None:
|
||||
if ids_param.strip() == '':
|
||||
rewards = []
|
||||
else:
|
||||
ids = set(ids_param.split(','))
|
||||
rewards = [reward for reward in rewards if reward.get('id') in ids]
|
||||
|
||||
# Filter out default rewards if user-specific version exists (case/whitespace-insensitive)
|
||||
user_rewards = {r['name'].strip().lower(): r for r in rewards if r.get('user_id') == user_id}
|
||||
filtered_rewards = []
|
||||
for r in 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)
|
||||
|
||||
# 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):
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||
RewardQuery = Query()
|
||||
reward = reward_db.get(RewardQuery.id == id)
|
||||
if not reward:
|
||||
return jsonify({'error': 'Reward not found'}), 404
|
||||
if reward.get('user_id') is None:
|
||||
import logging
|
||||
logging.warning(f"Forbidden delete attempt on system reward: id={id}, by user_id={user_id}")
|
||||
return jsonify({'error': 'System rewards cannot be deleted.'}), 403
|
||||
removed = reward_db.remove((RewardQuery.id == id) & (RewardQuery.user_id == user_id))
|
||||
if removed:
|
||||
# Cascade delete overrides for this reward
|
||||
deleted_count = delete_overrides_for_entity(id)
|
||||
if deleted_count > 0:
|
||||
import logging
|
||||
logging.info(f"Cascade deleted {deleted_count} overrides for reward {id}")
|
||||
|
||||
# remove the reward id from any child's reward list
|
||||
ChildQuery = Query()
|
||||
for child in child_db.all():
|
||||
rewards = child.get('rewards', [])
|
||||
if id in rewards:
|
||||
rewards.remove(id)
|
||||
child_db.update({'rewards': rewards}, ChildQuery.id == child.get('id'))
|
||||
send_event_for_current_user(Event(EventType.CHILD_REWARDS_SET.value, ChildRewardsSet(id, rewards)))
|
||||
send_event_for_current_user(Event(EventType.REWARD_MODIFIED.value, RewardModified(id, RewardModified.OPERATION_DELETE)))
|
||||
return jsonify({'message': f'Reward {id} deleted.'}), 200
|
||||
return jsonify({'error': 'Reward not found'}), 404
|
||||
|
||||
@reward_api.route('/reward/<id>/edit', methods=['PUT'])
|
||||
def edit_reward(id):
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||
RewardQuery = Query()
|
||||
existing = reward_db.get((RewardQuery.id == id) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None)))
|
||||
if not existing:
|
||||
return jsonify({'error': 'Reward not found'}), 404
|
||||
|
||||
reward = Reward.from_dict(existing)
|
||||
is_dirty = False
|
||||
|
||||
data = request.get_json(force=True) or {}
|
||||
if 'name' in data:
|
||||
name = (data.get('name') or '').strip()
|
||||
if not name:
|
||||
return jsonify({'error': 'Name cannot be empty'}), 400
|
||||
reward.name = name
|
||||
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
|
||||
is_dirty = True
|
||||
|
||||
if 'cost' in data:
|
||||
cost = data.get('cost')
|
||||
if not isinstance(cost, int):
|
||||
return jsonify({'error': 'Cost must be an integer'}), 400
|
||||
if cost <= 0:
|
||||
return jsonify({'error': 'Cost must be a positive integer'}), 400
|
||||
reward.cost = cost
|
||||
is_dirty = True
|
||||
|
||||
if 'image_id' in data:
|
||||
reward.image_id = data.get('image_id', '')
|
||||
is_dirty = True
|
||||
|
||||
if not is_dirty:
|
||||
return jsonify({'error': 'No valid fields to update'}), 400
|
||||
|
||||
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())
|
||||
send_event_for_current_user(Event(EventType.REWARD_MODIFIED.value,
|
||||
RewardModified(new_reward.id, RewardModified.OPERATION_ADD)))
|
||||
return jsonify(new_reward.to_dict()), 200
|
||||
|
||||
reward_db.update(reward.to_dict(), (RewardQuery.id == id) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None)))
|
||||
send_event_for_current_user(Event(EventType.REWARD_MODIFIED.value,
|
||||
RewardModified(id, RewardModified.OPERATION_EDIT)))
|
||||
return jsonify(reward.to_dict()), 200
|
||||
191
backend/api/task_api.py
Normal file
191
backend/api/task_api.py
Normal file
@@ -0,0 +1,191 @@
|
||||
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
|
||||
|
||||
task_api = Blueprint('task_api', __name__)
|
||||
|
||||
# Task endpoints
|
||||
@task_api.route('/task/add', methods=['PUT'])
|
||||
def add_task():
|
||||
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')
|
||||
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 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)))
|
||||
return jsonify({'message': f'Task {name} added.'}), 201
|
||||
|
||||
@task_api.route('/task/<id>', methods=['GET'])
|
||||
def get_task(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)))
|
||||
if not result:
|
||||
return jsonify({'error': 'Task not found'}), 404
|
||||
return jsonify(result[0]), 200
|
||||
|
||||
@task_api.route('/task/list', methods=['GET'])
|
||||
def list_tasks():
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||
ids_param = request.args.get('ids')
|
||||
TaskQuery = Query()
|
||||
tasks = task_db.search((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None))
|
||||
if ids_param is not None:
|
||||
if ids_param.strip() == '':
|
||||
tasks = []
|
||||
else:
|
||||
ids = set(ids_param.split(','))
|
||||
tasks = [task for task in tasks if task.get('id') in ids]
|
||||
|
||||
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 # Skip default if user version exists
|
||||
filtered_tasks.append(t)
|
||||
|
||||
# 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):
|
||||
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)
|
||||
if not task:
|
||||
return jsonify({'error': 'Task not found'}), 404
|
||||
if task.get('user_id') is None:
|
||||
import logging
|
||||
logging.warning(f"Forbidden delete attempt on system task: id={id}, by user_id={user_id}")
|
||||
return jsonify({'error': 'System tasks cannot be deleted.'}), 403
|
||||
removed = task_db.remove((TaskQuery.id == id) & (TaskQuery.user_id == user_id))
|
||||
if removed:
|
||||
# Cascade delete overrides for this task
|
||||
deleted_count = delete_overrides_for_entity(id)
|
||||
if deleted_count > 0:
|
||||
import logging
|
||||
logging.info(f"Cascade deleted {deleted_count} overrides for task {id}")
|
||||
|
||||
# remove the task id from any child's task list
|
||||
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'Task {id} deleted.'}), 200
|
||||
return jsonify({'error': 'Task not found'}), 404
|
||||
|
||||
@task_api.route('/task/<id>/edit', methods=['PUT'])
|
||||
def edit_task(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)))
|
||||
if not existing:
|
||||
return jsonify({'error': 'Task not found'}), 404
|
||||
|
||||
task = Task.from_dict(existing)
|
||||
is_dirty = False
|
||||
|
||||
data = request.get_json(force=True) or {}
|
||||
updates = {}
|
||||
|
||||
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):
|
||||
return jsonify({'error': 'Points must be an integer'}), 400
|
||||
if points <= 0:
|
||||
return jsonify({'error': 'Points must be a positive integer'}), 400
|
||||
task.points = points
|
||||
is_dirty = True
|
||||
|
||||
if 'is_good' in data:
|
||||
is_good = data.get('is_good')
|
||||
if not isinstance(is_good, bool):
|
||||
return jsonify({'error': 'is_good must be a boolean'}), 400
|
||||
# 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:
|
||||
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: # public task
|
||||
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
|
||||
73
backend/api/tracking_api.py
Normal file
73
backend/api/tracking_api.py
Normal file
@@ -0,0 +1,73 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
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
|
||||
|
||||
|
||||
tracking_api = Blueprint('tracking_api', __name__)
|
||||
|
||||
|
||||
@tracking_api.route('/admin/tracking', methods=['GET'])
|
||||
@admin_required
|
||||
def get_tracking():
|
||||
"""
|
||||
Admin endpoint to query tracking events with filters and pagination.
|
||||
|
||||
Query params:
|
||||
- child_id: Filter by child ID (optional)
|
||||
- user_id: Filter by user ID (optional, admin only)
|
||||
- entity_type: Filter by entity type (task/reward/penalty) (optional)
|
||||
- action: Filter by action type (activated/requested/redeemed/cancelled) (optional)
|
||||
- limit: Max results (default 50, max 500)
|
||||
- offset: Pagination offset (default 0)
|
||||
"""
|
||||
child_id = request.args.get('child_id')
|
||||
filter_user_id = request.args.get('user_id')
|
||||
entity_type = request.args.get('entity_type')
|
||||
action = request.args.get('action')
|
||||
limit = int(request.args.get('limit', 50))
|
||||
offset = int(request.args.get('offset', 0))
|
||||
|
||||
# Validate limit
|
||||
limit = min(max(limit, 1), 500)
|
||||
offset = max(offset, 0)
|
||||
|
||||
# Validate filters
|
||||
if entity_type and entity_type not in ['task', 'reward', 'penalty']:
|
||||
return jsonify({'error': 'Invalid entity_type', 'code': 'INVALID_ENTITY_TYPE'}), 400
|
||||
|
||||
if action and action not in ['activated', 'requested', 'redeemed', 'cancelled']:
|
||||
return jsonify({'error': 'Invalid action', 'code': 'INVALID_ACTION'}), 400
|
||||
|
||||
# Query tracking events
|
||||
if child_id:
|
||||
events, total = get_tracking_events_by_child(
|
||||
child_id=child_id,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
entity_type=entity_type,
|
||||
action=action
|
||||
)
|
||||
elif filter_user_id:
|
||||
events, total = get_tracking_events_by_user(
|
||||
user_id=filter_user_id,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
entity_type=entity_type
|
||||
)
|
||||
else:
|
||||
return jsonify({
|
||||
'error': 'Either child_id or user_id is required',
|
||||
'code': 'MISSING_FILTER'
|
||||
}), 400
|
||||
|
||||
# Convert to dict
|
||||
events_data = [event.to_dict() for event in events]
|
||||
|
||||
return jsonify({
|
||||
'tracking_events': events_data,
|
||||
'total': total,
|
||||
'limit': limit,
|
||||
'offset': offset,
|
||||
'count': len(events_data)
|
||||
}), 200
|
||||
251
backend/api/user_api.py
Normal file
251
backend/api/user_api.py
Normal file
@@ -0,0 +1,251 @@
|
||||
from flask import Blueprint, request, jsonify, current_app
|
||||
from events.types.user_modified import UserModified
|
||||
from models.user import User
|
||||
from tinydb import Query
|
||||
from db.db import users_db
|
||||
import jwt
|
||||
import random
|
||||
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('access_token')
|
||||
if not token:
|
||||
return None
|
||||
try:
|
||||
payload = jwt.decode(token, current_app.config['SECRET_KEY'], algorithms=['HS256'])
|
||||
user_id = payload.get('user_id')
|
||||
user_dict = users_db.get(UserQuery.id == user_id)
|
||||
return User.from_dict(user_dict) if user_dict else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@user_api.route('/user/profile', methods=['GET'])
|
||||
def get_profile():
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||
user = get_current_user()
|
||||
if not user:
|
||||
return jsonify({'error': 'Unauthorized'}), 401
|
||||
return jsonify({
|
||||
'first_name': user.first_name,
|
||||
'last_name': user.last_name,
|
||||
'email': user.email,
|
||||
'image_id': user.image_id
|
||||
}), 200
|
||||
|
||||
@user_api.route('/user/profile', methods=['PUT'])
|
||||
def update_profile():
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||
user = get_current_user()
|
||||
if not user:
|
||||
return jsonify({'error': 'Unauthorized'}), 401
|
||||
data = request.get_json()
|
||||
# Only allow first_name, last_name, image_id to be updated
|
||||
first_name = data.get('first_name')
|
||||
last_name = data.get('last_name')
|
||||
image_id = data.get('image_id')
|
||||
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
|
||||
users_db.update(user.to_dict(), UserQuery.email == user.email)
|
||||
|
||||
# Create tracking event
|
||||
metadata = {}
|
||||
if first_name is not None:
|
||||
metadata['first_name_updated'] = True
|
||||
if last_name is not None:
|
||||
metadata['last_name_updated'] = True
|
||||
if image_id is not None:
|
||||
metadata['image_updated'] = True
|
||||
|
||||
tracking_event = TrackingEvent.create_event(
|
||||
user_id=user_id,
|
||||
child_id=None, # No child for user profile
|
||||
entity_type='user',
|
||||
entity_id=user.id,
|
||||
action='updated',
|
||||
points_before=0, # Not relevant
|
||||
points_after=0,
|
||||
metadata=metadata
|
||||
)
|
||||
insert_tracking_event(tracking_event)
|
||||
log_tracking_event(tracking_event)
|
||||
|
||||
# Send SSE event
|
||||
send_event_for_current_user(Event(EventType.PROFILE_UPDATED.value, ProfileUpdated(user.id)))
|
||||
|
||||
return jsonify({'message': 'Profile updated'}), 200
|
||||
|
||||
@user_api.route('/user/image', methods=['PUT'])
|
||||
def update_image():
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||
user = get_current_user()
|
||||
if not user:
|
||||
return jsonify({'error': 'Unauthorized'}), 401
|
||||
data = request.get_json()
|
||||
image_id = data.get('image_id')
|
||||
if not image_id:
|
||||
return jsonify({'error': 'Missing image_id'}), 400
|
||||
user.image_id = image_id
|
||||
users_db.update(user.to_dict(), UserQuery.email == user.email)
|
||||
return jsonify({'message': 'Image updated', 'image_id': image_id}), 200
|
||||
|
||||
@user_api.route('/user/check-pin', methods=['POST'])
|
||||
def check_pin():
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||
user = get_current_user()
|
||||
if not user:
|
||||
return jsonify({'error': 'Unauthorized'}), 401
|
||||
data = request.get_json()
|
||||
pin = data.get('pin')
|
||||
if not pin:
|
||||
return jsonify({'error': 'Missing pin'}), 400
|
||||
if user.pin and pin == user.pin:
|
||||
return jsonify({'valid': True}), 200
|
||||
return jsonify({'valid': False}), 200
|
||||
|
||||
@user_api.route('/user/has-pin', methods=['GET'])
|
||||
def has_pin():
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||
user = get_current_user()
|
||||
if not user:
|
||||
return jsonify({'error': 'Unauthorized'}), 401
|
||||
return jsonify({'has_pin': bool(user.pin)}), 200
|
||||
|
||||
@user_api.route('/user/request-pin-setup', methods=['POST'])
|
||||
def request_pin_setup():
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||
user = get_current_user()
|
||||
if not user or not user.verified:
|
||||
return jsonify({'error': 'Unauthorized'}), 401
|
||||
# Generate 6-digit/character code
|
||||
code = ''.join(random.choices(string.ascii_uppercase + string.digits, k=6))
|
||||
user.pin_setup_code = code
|
||||
user.pin_setup_code_created = datetime.utcnow().isoformat()
|
||||
users_db.update(user.to_dict(), UserQuery.email == user.email)
|
||||
# Send email
|
||||
send_pin_setup_email(user.email, code)
|
||||
return jsonify({'message': 'Verification code sent to your email.'}), 200
|
||||
|
||||
def send_pin_setup_email(email, code):
|
||||
# Use the reusable email sender
|
||||
email_sender.send_pin_setup_email(email, code)
|
||||
|
||||
@user_api.route('/user/verify-pin-setup', methods=['POST'])
|
||||
def verify_pin_setup():
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||
user = get_current_user()
|
||||
if not user or not user.verified:
|
||||
return jsonify({'error': 'Unauthorized'}), 401
|
||||
data = request.get_json()
|
||||
code = data.get('code')
|
||||
if not code:
|
||||
return jsonify({'error': 'Missing code'}), 400
|
||||
if not user.pin_setup_code or not user.pin_setup_code_created:
|
||||
return jsonify({'error': 'No code requested'}), 400
|
||||
# Check expiry (10 min)
|
||||
created = datetime.fromisoformat(user.pin_setup_code_created)
|
||||
if datetime.utcnow() > created + timedelta(minutes=10):
|
||||
return jsonify({'error': 'Code expired'}), 400
|
||||
if code.strip().upper() != user.pin_setup_code.upper():
|
||||
return jsonify({'error': 'Invalid code'}), 400
|
||||
return jsonify({'message': 'Code verified'}), 200
|
||||
|
||||
@user_api.route('/user/set-pin', methods=['POST'])
|
||||
def set_pin():
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||
user = get_current_user()
|
||||
if not user or not user.verified:
|
||||
return jsonify({'error': 'Unauthorized'}), 401
|
||||
data = request.get_json()
|
||||
pin = data.get('pin')
|
||||
if not pin or not pin.isdigit() or not (4 <= len(pin) <= 6):
|
||||
return jsonify({'error': 'PIN must be 4-6 digits'}), 400
|
||||
# Only allow if code was recently verified
|
||||
if not user.pin_setup_code or not user.pin_setup_code_created:
|
||||
return jsonify({'error': 'No code verified'}), 400
|
||||
created = datetime.fromisoformat(user.pin_setup_code_created)
|
||||
if datetime.utcnow() > created + timedelta(minutes=10):
|
||||
return jsonify({'error': 'Code expired'}), 400
|
||||
# Set pin, clear code
|
||||
user.pin = pin
|
||||
user.pin_setup_code = ''
|
||||
user.pin_setup_code_created = None
|
||||
users_db.update(user.to_dict(), UserQuery.email == user.email)
|
||||
return jsonify({'message': 'Parent PIN set'}), 200
|
||||
|
||||
@user_api.route('/user/mark-for-deletion', methods=['POST'])
|
||||
def mark_for_deletion():
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||
user = get_current_user()
|
||||
if not user:
|
||||
return jsonify({'error': 'Unauthorized'}), 401
|
||||
|
||||
# Validate email from request body
|
||||
data = request.get_json()
|
||||
email = data.get('email', '').strip()
|
||||
if not email:
|
||||
return jsonify({'error': 'Email is required', 'code': 'EMAIL_REQUIRED'}), 400
|
||||
|
||||
# Verify email matches the logged-in user - make sure to normalize the email address first
|
||||
if normalize_email(email) != normalize_email(user.email):
|
||||
return jsonify({'error': 'Email does not match your account', 'code': 'EMAIL_MISMATCH'}), 400
|
||||
|
||||
# Check if already marked
|
||||
if user.marked_for_deletion:
|
||||
return jsonify({'error': 'Account already marked for deletion', 'code': ALREADY_MARKED}), 400
|
||||
|
||||
# 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
|
||||
97
backend/api/utils.py
Normal file
97
backend/api/utils.py
Normal file
@@ -0,0 +1,97 @@
|
||||
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:
|
||||
"""Normalize email for uniqueness checks (Gmail: remove dots and +aliases)."""
|
||||
email = email.strip().lower()
|
||||
if '@' not in email:
|
||||
return email
|
||||
local, domain = email.split('@', 1)
|
||||
if domain in ('gmail.com', 'googlemail.com'):
|
||||
local = local.split('+', 1)[0].replace('.', '')
|
||||
return f"{local}@{domain}"
|
||||
|
||||
def sanitize_email(email):
|
||||
return email.replace('@', '_at_').replace('.', '_dot_')
|
||||
|
||||
def get_current_user_id():
|
||||
token = request.cookies.get('access_token')
|
||||
if not token:
|
||||
return None
|
||||
try:
|
||||
payload = jwt.decode(token, current_app.config['SECRET_KEY'], algorithms=['HS256'])
|
||||
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
|
||||
|
||||
def get_validated_user_id():
|
||||
user_id = get_current_user_id()
|
||||
if not user_id or not users_db.get(Query().id == user_id):
|
||||
return None
|
||||
return user_id
|
||||
|
||||
def send_event_for_current_user(event):
|
||||
user_id = get_current_user_id()
|
||||
if not user_id:
|
||||
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
|
||||
61
backend/config/deletion_config.py
Normal file
61
backend/config/deletion_config.py
Normal file
@@ -0,0 +1,61 @@
|
||||
import os
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Account deletion threshold in hours
|
||||
# Default: 720 hours (30 days)
|
||||
# Minimum: 24 hours (1 day)
|
||||
# Maximum: 720 hours (30 days)
|
||||
|
||||
try:
|
||||
ACCOUNT_DELETION_THRESHOLD_HOURS = int(os.getenv('ACCOUNT_DELETION_THRESHOLD_HOURS', '720'))
|
||||
except ValueError as e:
|
||||
raise ValueError(
|
||||
f"ACCOUNT_DELETION_THRESHOLD_HOURS must be a valid integer. "
|
||||
f"Invalid value: {os.getenv('ACCOUNT_DELETION_THRESHOLD_HOURS')}"
|
||||
) from e
|
||||
|
||||
# Validation
|
||||
MIN_THRESHOLD_HOURS = 24
|
||||
MAX_THRESHOLD_HOURS = 720
|
||||
|
||||
def validate_threshold(threshold_hours=None):
|
||||
"""
|
||||
Validate the account deletion threshold.
|
||||
|
||||
Args:
|
||||
threshold_hours: Optional threshold value to validate. If None, validates the module's global value.
|
||||
|
||||
Returns True if valid, raises ValueError if invalid.
|
||||
"""
|
||||
value = threshold_hours if threshold_hours is not None else ACCOUNT_DELETION_THRESHOLD_HOURS
|
||||
|
||||
if value < MIN_THRESHOLD_HOURS:
|
||||
raise ValueError(
|
||||
f"ACCOUNT_DELETION_THRESHOLD_HOURS must be at least {MIN_THRESHOLD_HOURS} hours. "
|
||||
f"Current value: {value}"
|
||||
)
|
||||
|
||||
if value > MAX_THRESHOLD_HOURS:
|
||||
raise ValueError(
|
||||
f"ACCOUNT_DELETION_THRESHOLD_HOURS must be at most {MAX_THRESHOLD_HOURS} hours. "
|
||||
f"Current value: {value}"
|
||||
)
|
||||
|
||||
# Warn if threshold is less than 7 days (168 hours)
|
||||
if value < 168:
|
||||
logger.warning(
|
||||
f"Account deletion threshold is set to {value} hours, "
|
||||
"which is below the recommended minimum of 7 days (168 hours). "
|
||||
"Users will have limited time to recover their accounts."
|
||||
)
|
||||
|
||||
if threshold_hours is None:
|
||||
# Only log this when validating the module's global value
|
||||
logger.info(f"Account deletion threshold: {ACCOUNT_DELETION_THRESHOLD_HOURS} hours")
|
||||
|
||||
return True
|
||||
|
||||
# Validate on module import
|
||||
validate_threshold()
|
||||
@@ -9,19 +9,33 @@ TEST_DATA_DIR_NAME = 'test_data'
|
||||
# Project root (two levels up from this file)
|
||||
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
def get_base_data_dir(data_env: str | None = None) -> str:
|
||||
"""
|
||||
Return the absolute base data directory path for the given env.
|
||||
data_env: 'prod' uses `data`, anything else uses `test_data`.
|
||||
"""
|
||||
env = (data_env or os.environ.get('DATA_ENV', 'prod')).lower()
|
||||
base_name = DATA_DIR_NAME if env == 'prod' else TEST_DATA_DIR_NAME
|
||||
return os.path.join(PROJECT_ROOT, base_name)
|
||||
|
||||
def get_database_dir(db_env: str | None = None) -> str:
|
||||
"""
|
||||
Return the absolute base directory path for the given DB env.
|
||||
db_env: 'prod' uses `data/db`, anything else uses `test_data/db`.
|
||||
"""
|
||||
env = (db_env or os.environ.get('DB_ENV', 'prod')).lower()
|
||||
base_name = DATA_DIR_NAME if env == 'prod' else TEST_DATA_DIR_NAME
|
||||
return os.path.join(PROJECT_ROOT, base_name, 'db')
|
||||
return os.path.join(PROJECT_ROOT, get_base_data_dir(env), 'db')
|
||||
|
||||
def get_user_image_dir(username: str | None) -> str:
|
||||
"""
|
||||
Return the absolute directory path for storing images for a specific user.
|
||||
"""
|
||||
if username:
|
||||
return os.path.join(PROJECT_ROOT, DATA_DIR_NAME, 'images', username)
|
||||
return os.path.join(PROJECT_ROOT, get_base_data_dir(), 'images', username)
|
||||
return os.path.join(PROJECT_ROOT, 'resources', 'images')
|
||||
|
||||
def get_logs_dir() -> str:
|
||||
"""
|
||||
Return the absolute directory path for application logs.
|
||||
"""
|
||||
return os.path.join(PROJECT_ROOT, 'logs')
|
||||
@@ -2,7 +2,7 @@
|
||||
# file: config/version.py
|
||||
import os
|
||||
|
||||
BASE_VERSION = "1.0.3" # update manually when releasing features
|
||||
BASE_VERSION = "1.0.5" # update manually when releasing features
|
||||
|
||||
def get_full_version() -> str:
|
||||
"""
|
||||
144
backend/data/db/tasks.json.bak.20260228_104347
Normal file
144
backend/data/db/tasks.json.bak.20260228_104347
Normal file
@@ -0,0 +1,144 @@
|
||||
{
|
||||
"_default": {
|
||||
"1": {
|
||||
"id": "57c21328-637e-4df3-be5b-7f619cbf4076",
|
||||
"created_at": 1771343995.1881204,
|
||||
"updated_at": 1771343995.188121,
|
||||
"name": "Take out trash",
|
||||
"points": 20,
|
||||
"is_good": true,
|
||||
"image_id": "trash-can",
|
||||
"user_id": null
|
||||
},
|
||||
"2": {
|
||||
"id": "70316500-e4ce-4399-8e4b-86a4046fafcb",
|
||||
"created_at": 1771343995.1881304,
|
||||
"updated_at": 1771343995.1881304,
|
||||
"name": "Make your bed",
|
||||
"points": 25,
|
||||
"is_good": true,
|
||||
"image_id": "make-the-bed",
|
||||
"user_id": null
|
||||
},
|
||||
"3": {
|
||||
"id": "71afb2e5-18de-4f99-9e1e-2f4e391e6c2c",
|
||||
"created_at": 1771343995.1881359,
|
||||
"updated_at": 1771343995.1881359,
|
||||
"name": "Sweep and clean kitchen",
|
||||
"points": 15,
|
||||
"is_good": true,
|
||||
"image_id": "vacuum",
|
||||
"user_id": null
|
||||
},
|
||||
"4": {
|
||||
"id": "e0aae53d-d4b6-4203-b910-004917db6003",
|
||||
"created_at": 1771343995.1881409,
|
||||
"updated_at": 1771343995.188141,
|
||||
"name": "Do homework early",
|
||||
"points": 30,
|
||||
"is_good": true,
|
||||
"image_id": "homework",
|
||||
"user_id": null
|
||||
},
|
||||
"5": {
|
||||
"id": "0ba544f6-2d61-4009-af8f-bcb4e94b7a11",
|
||||
"created_at": 1771343995.188146,
|
||||
"updated_at": 1771343995.188146,
|
||||
"name": "Be good for the day",
|
||||
"points": 15,
|
||||
"is_good": true,
|
||||
"image_id": "good",
|
||||
"user_id": null
|
||||
},
|
||||
"6": {
|
||||
"id": "8b5750d4-5a58-40cb-a31b-667569069d34",
|
||||
"created_at": 1771343995.1881511,
|
||||
"updated_at": 1771343995.1881511,
|
||||
"name": "Clean your mess",
|
||||
"points": 20,
|
||||
"is_good": true,
|
||||
"image_id": "broom",
|
||||
"user_id": null
|
||||
},
|
||||
"7": {
|
||||
"id": "aec5fb49-06d0-43c4-aa09-9583064b7275",
|
||||
"created_at": 1771343995.1881557,
|
||||
"updated_at": 1771343995.1881557,
|
||||
"name": "Fighting",
|
||||
"points": 10,
|
||||
"is_good": false,
|
||||
"image_id": "fighting",
|
||||
"user_id": null
|
||||
},
|
||||
"8": {
|
||||
"id": "0221ab72-c6c0-429f-a5f1-bc3d843fce9e",
|
||||
"created_at": 1771343995.1881602,
|
||||
"updated_at": 1771343995.1881602,
|
||||
"name": "Yelling at parents",
|
||||
"points": 10,
|
||||
"is_good": false,
|
||||
"image_id": "yelling",
|
||||
"user_id": null
|
||||
},
|
||||
"9": {
|
||||
"id": "672bfc74-4b85-4e8e-a2d0-74f14ab966cc",
|
||||
"created_at": 1771343995.1881647,
|
||||
"updated_at": 1771343995.1881647,
|
||||
"name": "Lying",
|
||||
"points": 10,
|
||||
"is_good": false,
|
||||
"image_id": "lying",
|
||||
"user_id": null
|
||||
},
|
||||
"10": {
|
||||
"id": "d8cc254f-922b-4dc2-ac4c-32fc3bbda584",
|
||||
"created_at": 1771343995.1881692,
|
||||
"updated_at": 1771343995.1881695,
|
||||
"name": "Not doing what told",
|
||||
"points": 5,
|
||||
"is_good": false,
|
||||
"image_id": "ignore",
|
||||
"user_id": null
|
||||
},
|
||||
"11": {
|
||||
"id": "8be18d9a-48e6-402b-a0ba-630a2d50e325",
|
||||
"created_at": 1771343995.188174,
|
||||
"updated_at": 1771343995.188174,
|
||||
"name": "Not flushing toilet",
|
||||
"points": 5,
|
||||
"is_good": false,
|
||||
"image_id": "toilet",
|
||||
"user_id": null
|
||||
},
|
||||
"12": {
|
||||
"id": "b3b44115-529b-4eb3-9f8b-686dd24547a1",
|
||||
"created_at": 1771345063.4665146,
|
||||
"updated_at": 1771345063.4665148,
|
||||
"name": "Take out trash",
|
||||
"points": 21,
|
||||
"is_good": true,
|
||||
"image_id": "trash-can",
|
||||
"user_id": "a5f05d38-7f7c-4663-b00f-3d6138e0e246"
|
||||
},
|
||||
"13": {
|
||||
"id": "c74fc8c7-5af1-4d40-afbb-6da2647ca18b",
|
||||
"created_at": 1771345069.1633172,
|
||||
"updated_at": 1771345069.1633174,
|
||||
"name": "aaa",
|
||||
"points": 1,
|
||||
"is_good": true,
|
||||
"image_id": "computer-game",
|
||||
"user_id": "a5f05d38-7f7c-4663-b00f-3d6138e0e246"
|
||||
},
|
||||
"14": {
|
||||
"id": "65e79bbd-6cdf-4636-9e9d-f608206dbd80",
|
||||
"created_at": 1772251855.4823341,
|
||||
"updated_at": 1772251855.4823341,
|
||||
"name": "Be Cool \ud83d\ude0e",
|
||||
"points": 5,
|
||||
"type": "kindness",
|
||||
"image_id": "58d4adb9-3cee-4d7c-8e90-d81173716ce5",
|
||||
"user_id": "6da06108-0db8-46be-b4cb-60ce7b54564d"
|
||||
}
|
||||
}
|
||||
}
|
||||
2615
backend/data/db/tracking_events.json.bak.20260228_104347
Normal file
2615
backend/data/db/tracking_events.json.bak.20260228_104347
Normal file
File diff suppressed because it is too large
Load Diff
146
backend/db/child_overrides.py
Normal file
146
backend/db/child_overrides.py
Normal file
@@ -0,0 +1,146 @@
|
||||
"""Helper functions for child override database operations."""
|
||||
import logging
|
||||
from typing import Optional, List
|
||||
from tinydb import Query
|
||||
from db.db import child_overrides_db
|
||||
from models.child_override import ChildOverride
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def insert_override(override: ChildOverride) -> str:
|
||||
"""
|
||||
Insert or update an override. Only one override per (child_id, entity_id).
|
||||
|
||||
Args:
|
||||
override: ChildOverride instance to insert or update
|
||||
|
||||
Returns:
|
||||
The override ID
|
||||
"""
|
||||
try:
|
||||
OverrideQuery = Query()
|
||||
existing = child_overrides_db.get(
|
||||
(OverrideQuery.child_id == override.child_id) &
|
||||
(OverrideQuery.entity_id == override.entity_id)
|
||||
)
|
||||
|
||||
if existing:
|
||||
# Update existing override
|
||||
override.touch() # Update timestamp
|
||||
child_overrides_db.update(override.to_dict(), doc_ids=[existing.doc_id])
|
||||
logger.info(f"Override updated: child={override.child_id}, entity={override.entity_id}, value={override.custom_value}")
|
||||
else:
|
||||
# Insert new override
|
||||
child_overrides_db.insert(override.to_dict())
|
||||
logger.info(f"Override created: child={override.child_id}, entity={override.entity_id}, value={override.custom_value}")
|
||||
|
||||
return override.id
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to insert override: {e}")
|
||||
raise
|
||||
|
||||
|
||||
def get_override(child_id: str, entity_id: str) -> Optional[ChildOverride]:
|
||||
"""
|
||||
Get override for a specific child and entity.
|
||||
|
||||
Args:
|
||||
child_id: Child ID
|
||||
entity_id: Entity ID (task or reward)
|
||||
|
||||
Returns:
|
||||
ChildOverride instance or None if not found
|
||||
"""
|
||||
OverrideQuery = Query()
|
||||
result = child_overrides_db.get(
|
||||
(OverrideQuery.child_id == child_id) &
|
||||
(OverrideQuery.entity_id == entity_id)
|
||||
)
|
||||
return ChildOverride.from_dict(result) if result else None
|
||||
|
||||
|
||||
def get_overrides_for_child(child_id: str) -> List[ChildOverride]:
|
||||
"""
|
||||
Get all overrides for a specific child.
|
||||
|
||||
Args:
|
||||
child_id: Child ID
|
||||
|
||||
Returns:
|
||||
List of ChildOverride instances
|
||||
"""
|
||||
OverrideQuery = Query()
|
||||
results = child_overrides_db.search(OverrideQuery.child_id == child_id)
|
||||
return [ChildOverride.from_dict(r) for r in results]
|
||||
|
||||
|
||||
def delete_override(child_id: str, entity_id: str) -> bool:
|
||||
"""
|
||||
Delete a specific override.
|
||||
|
||||
Args:
|
||||
child_id: Child ID
|
||||
entity_id: Entity ID
|
||||
|
||||
Returns:
|
||||
True if deleted, False if not found
|
||||
"""
|
||||
try:
|
||||
OverrideQuery = Query()
|
||||
deleted = child_overrides_db.remove(
|
||||
(OverrideQuery.child_id == child_id) &
|
||||
(OverrideQuery.entity_id == entity_id)
|
||||
)
|
||||
if deleted:
|
||||
logger.info(f"Override deleted: child={child_id}, entity={entity_id}")
|
||||
return True
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete override: {e}")
|
||||
raise
|
||||
|
||||
|
||||
def delete_overrides_for_child(child_id: str) -> int:
|
||||
"""
|
||||
Delete all overrides for a child.
|
||||
|
||||
Args:
|
||||
child_id: Child ID
|
||||
|
||||
Returns:
|
||||
Count of deleted overrides
|
||||
"""
|
||||
try:
|
||||
OverrideQuery = Query()
|
||||
deleted = child_overrides_db.remove(OverrideQuery.child_id == child_id)
|
||||
count = len(deleted)
|
||||
if count > 0:
|
||||
logger.info(f"Overrides cascade deleted for child: child_id={child_id}, count={count}")
|
||||
return count
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete overrides for child: {e}")
|
||||
raise
|
||||
|
||||
|
||||
def delete_overrides_for_entity(entity_id: str) -> int:
|
||||
"""
|
||||
Delete all overrides for an entity.
|
||||
|
||||
Args:
|
||||
entity_id: Entity ID (task or reward)
|
||||
|
||||
Returns:
|
||||
Count of deleted overrides
|
||||
"""
|
||||
try:
|
||||
OverrideQuery = Query()
|
||||
deleted = child_overrides_db.remove(OverrideQuery.entity_id == entity_id)
|
||||
count = len(deleted)
|
||||
if count > 0:
|
||||
logger.info(f"Overrides cascade deleted for entity: entity_id={entity_id}, count={count}")
|
||||
return count
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete overrides for entity: {e}")
|
||||
raise
|
||||
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,6 +72,13 @@ 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')
|
||||
|
||||
# Use separate TinyDB instances/files for each collection
|
||||
_child_db = TinyDB(child_path, indent=2)
|
||||
@@ -79,6 +86,13 @@ _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)
|
||||
|
||||
# Expose table objects wrapped with locking
|
||||
child_db = LockedTable(_child_db)
|
||||
@@ -86,6 +100,13 @@ 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)
|
||||
|
||||
if os.environ.get('DB_ENV', 'prod') == 'test':
|
||||
child_db.truncate()
|
||||
@@ -93,4 +114,11 @@ 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()
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
# File: db/debug.py
|
||||
|
||||
from tinydb import Query
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from api.image_api import IMAGE_TYPE_ICON, IMAGE_TYPE_PROFILE
|
||||
from db.db import task_db, reward_db, image_db
|
||||
@@ -13,16 +15,16 @@ from models.task import Task
|
||||
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:
|
||||
@@ -86,18 +88,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())
|
||||
@@ -119,7 +121,22 @@ def createDefaultRewards():
|
||||
reward_db.insert(reward.to_dict())
|
||||
|
||||
def initializeImages():
|
||||
"""Initialize the image database with default images if empty."""
|
||||
|
||||
"""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')
|
||||
os.makedirs(default_img_dir, exist_ok=True)
|
||||
|
||||
# Step 2: Copy all image files from resources/images/ to data/images/default
|
||||
src_img_dir = os.path.join(os.path.dirname(__file__), '../resources/images')
|
||||
if os.path.exists(src_img_dir):
|
||||
for fname in os.listdir(src_img_dir):
|
||||
src_path = os.path.join(src_img_dir, fname)
|
||||
dst_path = os.path.join(default_img_dir, fname)
|
||||
if os.path.isfile(src_path):
|
||||
shutil.copy2(src_path, dst_path)
|
||||
|
||||
# Original DB initialization logic
|
||||
if len(image_db.all()) == 0:
|
||||
image_defs = [
|
||||
('boy01', IMAGE_TYPE_PROFILE, '.png', True),
|
||||
32
backend/db/task_extensions.py
Normal file
32
backend/db/task_extensions.py
Normal file
@@ -0,0 +1,32 @@
|
||||
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))
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user