44 Commits
1.0.4 ... next

Author SHA1 Message Date
accf596bd7 more tests
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m30s
2026-03-09 13:28:43 -04:00
2c65d3ecaf temp changes
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m59s
2026-03-09 10:16:39 -04:00
a8d7427a95 feat: enhance Playwright testing setup with E2E tests, new skills, and improved documentation
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 1m44s
- Added E2E test setup in `auth_api.py` with `/e2e-seed` endpoint for database reset and test user creation.
- Integrated Playwright for end-to-end testing in the frontend with necessary dependencies in `package.json` and `package-lock.json`.
- Created Playwright configuration in `playwright.config.ts` to manage test execution and server setup.
- Developed new skills for Playwright best practices, visual regression, smoke test generation, and self-healing tests.
- Implemented new test cases for chore creation in `chores-create.smoke.spec.ts` and `chores-create.spec.ts`.
- Added page object models for `ChildEditPage` and `LandingPage` to streamline test interactions.
- Updated `.gitignore` to exclude Playwright reports and test results.
- Enhanced documentation in `copilot-instructions.md` for testing and E2E setup.
2026-03-07 10:13:21 -05:00
b2618361e4 feat: implement force logout notifications for password reset and account deletion
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m29s
2026-03-05 16:52:11 -05:00
a10836d412 feat: allow bypass of reset-password and verify routes for logged-in users
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m27s
2026-03-05 15:47:42 -05:00
bb5330ac17 feat: allow bypass of reset-password and verify routes for logged-in users
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Has been cancelled
2026-03-05 15:46:59 -05:00
8cdc26cb88 feat: add email notification for build job status
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m11s
2026-03-05 14:41:26 -05:00
de56eb064f feat: add email notification for build job status
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m13s
2026-03-05 14:03:45 -05:00
031d7c0eec feat: add email notification for build job status
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 2m39s
2026-03-05 13:51:35 -05:00
f07af135b7 feat: add email notification for build job status
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 2m15s
2026-03-05 13:39:55 -05:00
60647bc742 feat: add email notification for build job status
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 2m39s
2026-03-05 12:53:53 -05:00
384be2a79e feat: update sign-out redirect to landing page
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 13m17s
2026-03-05 12:32:04 -05:00
ccfc710753 feat: implement force logout event and update navigation redirects
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 1m37s
2026-03-05 09:52:19 -05:00
992dd8423f Refactor code structure for improved readability and maintainability
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m52s
2026-03-04 17:12:04 -05:00
c922e1180d feat: add landing page components including hero, features, problem, and footer
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m23s
- Introduced LandingHero component with logo, tagline, and action buttons.
- Created LandingFeatures component to showcase chore system benefits.
- Developed LandingProblem component explaining the importance of a structured chore system.
- Implemented LandingFooter for navigation and copyright information.
- Added LandingPage to assemble all components and manage navigation.
- Included unit tests for LandingHero component to ensure functionality.
2026-03-04 16:21:26 -05:00
82ac820c67 Fixed issue with refresh token
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m9s
2026-03-02 16:01:54 -05:00
76fef8c688 feat: update test environment setup to include secret key and refresh token expiry
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m3s
2026-03-01 21:39:03 -05:00
16d3500368 feat: update test environment setup to include secret key and refresh token expiry
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m19s
2026-03-01 21:21:52 -05:00
c3538cc3d4 feat: update test environment setup to include secret key and refresh token expiry
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m2s
2026-03-01 21:15:19 -05:00
6433236191 feat: update test environment setup to include secret key and refresh token expiry
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m14s
2026-03-01 20:59:26 -05:00
ebaef16daf feat: implement long-term user login with refresh tokens
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m23s
- Introduced a dual-token system for user authentication: a short-lived access token and a long-lived rotating refresh token.
- Created a new RefreshToken model to manage refresh tokens securely.
- Updated auth_api.py to handle login, refresh, and logout processes with the new token system.
- Enhanced security measures including token rotation and theft detection.
- Updated frontend to handle token refresh on 401 errors and adjusted SSE authentication.
- Removed CORS middleware as it's unnecessary behind the nginx proxy.
- Added tests to ensure functionality and security of the new token system.
2026-03-01 19:27:25 -05:00
d7316bb00a feat: add chore, kindness, and penalty management components
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m34s
- Implemented ChoreAssignView for assigning chores to children.
- Created ChoreConfirmDialog for confirming chore completion.
- Developed KindnessAssignView for assigning kindness acts.
- Added PenaltyAssignView for assigning penalties.
- Introduced ChoreEditView and ChoreView for editing and viewing chores.
- Created KindnessEditView and KindnessView for managing kindness acts.
- Developed PenaltyEditView and PenaltyView for managing penalties.
- Added TaskSubNav for navigation between chores, kindness acts, and penalties.
2026-02-28 11:25:56 -05:00
65e987ceb6 feat: add delay before showing dialogs and enhance item card styles for better user feedback
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 1m11s
2026-02-27 14:05:09 -05:00
f12940dc11 fix: improve formatting and readability in ScheduleModal component
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m19s
2026-02-27 10:43:10 -05:00
1777700cc8 feat: add default_has_deadline to ChoreSchedule and update related components for deadline management
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Has been cancelled
2026-02-27 10:42:43 -05:00
f5a752d873 fix: reset ready state on outside click and prevent task trigger on ignored clicks
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m30s
2026-02-26 16:44:22 -05:00
a197f8e206 feat: Refactor ScheduleModal to support interval scheduling with date input and deadline toggle
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 2m30s
- Updated ChoreSchedule model to include anchor_date and interval_has_deadline.
- Refactored interval scheduling logic in scheduleUtils to use anchor_date.
- Introduced DateInputField component for selecting anchor dates in ScheduleModal.
- Enhanced ScheduleModal to include a stepper for interval days and a toggle for deadline.
- Updated tests for ScheduleModal and scheduleUtils to reflect new interval scheduling logic.
- Added DateInputField tests to ensure proper functionality and prop handling.
2026-02-26 15:16:46 -05:00
2403daa3f7 feat: add detailed specifications for Daily chore scheduler refactor phase 2
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 57s
2026-02-25 21:06:57 -05:00
91a52c1973 Refactor Time Selector and Scheduler UI; Implement TimePickerPopover Component
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 1m5s
- Updated TimeSelector.vue styles for smaller dimensions and font sizes.
- Added new API proxy for '/events' in vite.config.ts.
- Created bug specifications for various UI issues and fixes in bugs-1.0.5-001.md and bugs-1.0.5-002.md.
- Introduced TimePickerPopover.vue for a new time selection interface in the chore scheduler.
- Refactored ScheduleModal.vue to replace checkbox rows with a chip-based design for selecting specific days.
- Enhanced chore scheduling logic to ensure proper handling of time extensions and UI updates.
2026-02-25 19:45:31 -05:00
a41a357f50 feat: update Vite configuration to load environment variables for backend host
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 1m1s
2026-02-23 16:52:22 -05:00
234adbe05f Add TimeSelector and ScheduleModal components with tests
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m45s
- Implemented TimeSelector component for selecting time with AM/PM toggle and minute/hour increment/decrement functionality.
- Created ScheduleModal component for scheduling chores with options for specific days or intervals.
- Added utility functions for scheduling logic in scheduleUtils.ts.
- Developed comprehensive tests for TimeSelector and scheduleUtils functions to ensure correct behavior.
2026-02-23 15:44:55 -05:00
d8822b44be feat: add child actions menu specification for ParentView
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 1m37s
2026-02-22 16:24:26 -05:00
d68272bb57 feat: add feature specification for scheduling chores on calendar days
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 4m30s
2026-02-22 10:10:56 -05:00
3673119ae2 fix: update BASE_VERSION to remove release candidate suffix
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 1m30s
2026-02-20 16:47:27 -05:00
55e7dc7568 feat: remove hashed passwords feature spec and migrate to archive
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m6s
fix: update login token expiration to 62 days

chore: bump version to 1.0.5RC1

test: add isParentPersistent to LoginButton.spec.ts

refactor: rename Assign Tasks button to Assign Chores in ParentView.vue

refactor: rename Assign Tasks to Assign Chores in TaskAssignView.vue

feat: add stay in parent mode checkbox and badge in LoginButton.vue

test: enhance LoginButton.spec.ts with persistent mode tests

test: add authGuard.spec.ts for logoutParent and enforceParentExpiry

feat: implement parent mode expiry logic in auth.ts

test: add auth.expiry.spec.ts for parent mode expiry tests

chore: create template for feature specs
2026-02-20 16:31:13 -05:00
ba909100a7 Merge pull request 'fix: add FRONTEND_URL to environment variables and create .env.example file' (#24) from master into next
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m9s
Reviewed-on: #24
2026-02-20 13:18:10 -05:00
8148bfac51 fix: add FRONTEND_URL to environment variables and create .env.example file
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 4m0s
2026-02-20 13:07:55 -05:00
c43af7d43e Merge pull request 'master' (#23) from master into next
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 1m42s
Reviewed-on: #23
2026-02-20 10:01:06 -05:00
10216f49c9 fix: update service names and image paths to reflect new repository structure
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m7s
2026-02-20 09:47:08 -05:00
42d3567c22 fix: add condition to deploy test environment for 'next' branch
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 1m30s
2026-02-19 17:09:09 -05:00
be4a816a7c fix: update Docker image paths to reflect new structure
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 1m51s
2026-02-19 17:02:36 -05:00
773840d88b fix: update Docker image names to reflect new structure
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m9s
2026-02-19 16:52:00 -05:00
075160941a fix: update Docker image paths to use new repository structure
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 4m0s
2026-02-19 16:40:01 -05:00
d2fea646de fix: update Docker registry credentials to use Gitea secrets
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m0s
2026-02-19 16:21:34 -05:00
177 changed files with 16644 additions and 1047 deletions

1
.env.example Normal file
View File

@@ -0,0 +1 @@
FRONTEND_URL=https://yourdomain.com

View File

@@ -56,24 +56,24 @@ jobs:
- name: Build Backend Docker Image
run: |
docker build -t git.ryankegel.com:3000/ryan/backend:${{ steps.vars.outputs.tag }} ./backend
docker build -t git.ryankegel.com:3000/kegel/chores/backend:${{ steps.vars.outputs.tag }} ./backend
- name: Build Frontend Docker Image
run: |
docker build -t git.ryankegel.com:3000/ryan/frontend:${{ steps.vars.outputs.tag }} ./frontend/vue-app
docker build -t git.ryankegel.com:3000/kegel/chores/frontend:${{ steps.vars.outputs.tag }} ./frontend/vue-app
- name: Log in to Registry
uses: docker/login-action@v2
with:
registry: git.ryankegel.com:3000
username: ryan #${{ secrets.REGISTRY_USERNAME }} # Stored as a Gitea secret
password: 0x013h #${{ secrets.REGISTRY_TOKEN }} # Stored as a Gitea secret (use a PAT here)
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/ryan/backend:${{ steps.vars.outputs.tag }}; then
if docker push git.ryankegel.com:3000/kegel/chores/backend:${{ steps.vars.outputs.tag }}; then
echo "Backend push succeeded on attempt $i"
break
else
@@ -86,18 +86,18 @@ jobs:
fi
done
if [ "${{ gitea.ref }}" == "refs/heads/master" ]; then
docker tag git.ryankegel.com:3000/ryan/backend:${{ steps.vars.outputs.tag }} git.ryankegel.com:3000/ryan/backend:latest
docker push git.ryankegel.com:3000/ryan/backend:latest
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/ryan/backend:${{ steps.vars.outputs.tag }} git.ryankegel.com:3000/ryan/backend:next
docker push git.ryankegel.com:3000/ryan/backend:next
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/ryan/frontend:${{ steps.vars.outputs.tag }}; then
if docker push git.ryankegel.com:3000/kegel/chores/frontend:${{ steps.vars.outputs.tag }}; then
echo "Frontend push succeeded on attempt $i"
break
else
@@ -110,32 +110,59 @@ jobs:
fi
done
if [ "${{ gitea.ref }}" == "refs/heads/master" ]; then
docker tag git.ryankegel.com:3000/ryan/frontend:${{ steps.vars.outputs.tag }} git.ryankegel.com:3000/ryan/frontend:latest
docker push git.ryankegel.com:3000/ryan/frontend:latest
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/ryan/frontend:${{ steps.vars.outputs.tag }} git.ryankegel.com:3000/ryan/frontend:next
docker push git.ryankegel.com:3000/ryan/frontend:next
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
uses: appleboy/ssh-action@v1.0.3 # Or equivalent Gitea action; adjust version if needed
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 # Default SSH port; change if different
port: 22
script: |
cd /tmp
# Pull the repository to get the latest docker-compose.dev.yml
if [ -d "chore" ]; then
cd chore
git pull origin next || true # Pull latest changes; ignore if it fails (e.g., first run)
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 # Ensure latest images are pulled
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)

View 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.

View 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.

View 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>

View 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

View 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
View 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.

View 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.

View File

@@ -38,7 +38,8 @@
- **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__/`.
- **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

24
.github/skills/flask-backend/SKILL.md vendored Normal file
View 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.

View 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.

View 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.

View 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.

View 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
View 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.

162
.github/specs/archive/bugs-1.0.5-001.md vendored Normal file
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

View 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=Sun6=Sat), hour: int, minute: int }`
- For `mode='interval'`: `interval_days: int` (27), `anchor_weekday: int` (0=Sun6=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 (SundaySaturday)
- 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 [27]
- Weekday picker to select the anchor day (0=Sun6=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 (112) 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 [27], 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

View 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 (112), 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`

View 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.
Todays 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. 250px300px 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 17, 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 17 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 (17), `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

View 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

View 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

View 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.

67
.github/specs/feat-child-actions.md vendored Normal file
View 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
View 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 its 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
View 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
- [ ]

View 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

42
.gitignore vendored
View File

@@ -1,3 +1,4 @@
.env
backend/test_data/db/children.json
backend/test_data/db/images.json
backend/test_data/db/pending_rewards.json
@@ -6,3 +7,44 @@ 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

4
.vscode/launch.json vendored
View File

@@ -9,7 +9,9 @@
"python": "${command:python.interpreterPath}",
"env": {
"FLASK_APP": "backend/main.py",
"FLASK_DEBUG": "1"
"FLASK_DEBUG": "1",
"SECRET_KEY": "dev-secret-key-change-in-production",
"REFRESH_TOKEN_EXPIRY_DAYS": "90"
},
"args": [
"run",

13
.vscode/mcp.json vendored Normal file
View File

@@ -0,0 +1,13 @@
{
"servers": {
"playwright-test": {
"type": "stdio",
"command": "npx",
"args": [
"playwright",
"run-test-mcp-server"
]
}
},
"inputs": []
}

View File

@@ -1,4 +1,7 @@
{
"python.venvPath": "${workspaceFolder}/backend/.venv",
"python.terminal.activateEnvironment": true,
"python.terminal.shellIntegration.enabled": true,
"explorer.fileNesting.enabled": true,
"explorer.fileNesting.patterns": {
"tsconfig.json": "tsconfig.*.json, env.d.ts",
@@ -19,5 +22,7 @@
},
"chat.tools.terminal.autoApprove": {
"&": true
}
},
"python-envs.defaultEnvManager": "ms-python.python:venv",
"python-envs.pythonProjects": []
}

View File

@@ -1,11 +1,10 @@
from flask import Blueprint, request, jsonify
from datetime import datetime, timedelta
from tinydb import Query
import jwt
from functools import wraps
from db.db import users_db
from models.user import User
from api.utils import admin_required
from config.deletion_config import (
ACCOUNT_DELETION_THRESHOLD_HOURS,
MIN_THRESHOLD_HOURS,
@@ -16,49 +15,6 @@ from utils.account_deletion_scheduler import trigger_deletion_manually
admin_api = Blueprint('admin_api', __name__)
def admin_required(f):
"""
Decorator to require admin role for endpoints.
"""
@wraps(f)
def decorated_function(*args, **kwargs):
# Get JWT token from cookie
token = request.cookies.get('token')
if not token:
return jsonify({'error': 'Authentication required', 'code': 'AUTH_REQUIRED'}), 401
try:
# Verify JWT token
payload = jwt.decode(token, 'supersecretkey', algorithms=['HS256'])
user_id = payload.get('user_id')
if not user_id:
return jsonify({'error': 'Invalid token', 'code': 'INVALID_TOKEN'}), 401
# Get user from database
Query_ = Query()
user_dict = users_db.get(Query_.id == user_id)
if not user_dict:
return jsonify({'error': 'User not found', 'code': 'USER_NOT_FOUND'}), 404
user = User.from_dict(user_dict)
# Check if user has admin role
if user.role != 'admin':
return jsonify({'error': 'Admin access required', 'code': 'ADMIN_REQUIRED'}), 403
# Pass user to the endpoint
request.current_user = user
except jwt.ExpiredSignatureError:
return jsonify({'error': 'Token expired', 'code': 'TOKEN_EXPIRED'}), 401
except jwt.InvalidTokenError:
return jsonify({'error': 'Invalid token', 'code': 'INVALID_TOKEN'}), 401
return f(*args, **kwargs)
return decorated_function
@admin_api.route('/admin/deletion-queue', methods=['GET'])
@admin_required

View File

@@ -1,7 +1,11 @@
import hashlib
import logging
import secrets, jwt
import secrets
import uuid
import jwt
from datetime import datetime, timedelta, timezone
from models.user import User
from models.refresh_token import RefreshToken
from flask import Blueprint, request, jsonify, current_app
from tinydb import Query
import os
@@ -10,18 +14,34 @@ from werkzeug.security import generate_password_hash, check_password_hash
from api.utils import sanitize_email
from config.paths import get_user_image_dir
from events.sse import send_event_to_user
from events.types.event import Event
from events.types.event_types import EventType
from events.types.payload import Payload
from api.error_codes import MISSING_FIELDS, EMAIL_EXISTS, MISSING_TOKEN, INVALID_TOKEN, TOKEN_TIMESTAMP_MISSING, \
TOKEN_EXPIRED, ALREADY_VERIFIED, MISSING_EMAIL, USER_NOT_FOUND, MISSING_EMAIL_OR_PASSWORD, INVALID_CREDENTIALS, \
NOT_VERIFIED, ACCOUNT_MARKED_FOR_DELETION
from db.db import users_db
from api.error_codes import (
MISSING_FIELDS, EMAIL_EXISTS, MISSING_TOKEN, INVALID_TOKEN, TOKEN_TIMESTAMP_MISSING,
TOKEN_EXPIRED, ALREADY_VERIFIED, MISSING_EMAIL, USER_NOT_FOUND, MISSING_EMAIL_OR_PASSWORD,
INVALID_CREDENTIALS, NOT_VERIFIED, ACCOUNT_MARKED_FOR_DELETION,
REFRESH_TOKEN_REUSE, REFRESH_TOKEN_EXPIRED, MISSING_REFRESH_TOKEN,
)
from db.db import (
users_db, refresh_tokens_db, child_db, task_db, reward_db, image_db,
pending_reward_db, pending_confirmations_db, tracking_events_db,
child_overrides_db, chore_schedules_db, task_extensions_db,
)
from 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):
@@ -30,6 +50,77 @@ def send_verification_email(to_email, token):
def send_reset_password_email(to_email, token):
email_sender.send_reset_password_email(to_email, token)
def _hash_token(raw_token: str) -> str:
"""SHA-256 hash a raw refresh token for secure storage."""
return hashlib.sha256(raw_token.encode('utf-8')).hexdigest()
def _create_access_token(user: User) -> str:
"""Create a short-lived JWT access token."""
payload = {
'email': user.email,
'user_id': user.id,
'token_version': user.token_version,
'exp': datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRY_MINUTES),
}
return jwt.encode(payload, current_app.config['SECRET_KEY'], algorithm='HS256')
def _create_refresh_token(user_id: str, token_family: str | None = None) -> tuple[str, RefreshToken]:
"""
Create a refresh token: returns (raw_token, RefreshToken record).
If token_family is None, a new family is created (login).
Otherwise, the existing family is reused (rotation).
"""
raw_token = secrets.token_urlsafe(32)
expiry_days = current_app.config['REFRESH_TOKEN_EXPIRY_DAYS']
expires_at = (datetime.now(timezone.utc) + timedelta(days=expiry_days)).isoformat()
family = token_family or str(uuid.uuid4())
record = RefreshToken(
user_id=user_id,
token_hash=_hash_token(raw_token),
token_family=family,
expires_at=expires_at,
is_used=False,
)
refresh_tokens_db.insert(record.to_dict())
return raw_token, record
def _set_auth_cookies(resp, access_token: str, raw_refresh_token: str):
"""Set both access and refresh token cookies on a response."""
expiry_days = current_app.config['REFRESH_TOKEN_EXPIRY_DAYS']
resp.set_cookie('access_token', access_token, httponly=True, secure=True, samesite='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()
@@ -159,21 +250,22 @@ def login():
if user.marked_for_deletion:
return jsonify({'error': 'This account has been marked for deletion and cannot be accessed.', 'code': ACCOUNT_MARKED_FOR_DELETION}), 403
payload = {
'email': norm_email,
'user_id': user.id,
'token_version': user.token_version,
'exp': datetime.utcnow() + timedelta(hours=24*7)
}
token = jwt.encode(payload, current_app.config['SECRET_KEY'], algorithm='HS256')
# Purge expired refresh tokens for this user
_purge_expired_tokens(user.id)
# Create access token (short-lived JWT)
access_token = _create_access_token(user)
# Create refresh token (long-lived, new family for fresh login)
raw_refresh, _ = _create_refresh_token(user.id)
resp = jsonify({'message': 'Login successful'})
resp.set_cookie('token', token, httponly=True, secure=True, samesite='Strict')
_set_auth_cookies(resp, access_token, raw_refresh)
return resp, 200
@auth_api.route('/me', methods=['GET'])
def me():
token = request.cookies.get('token')
token = request.cookies.get('access_token')
if not token:
return jsonify({'error': 'Missing token', 'code': MISSING_TOKEN}), 401
@@ -275,13 +367,137 @@ def reset_password():
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'})
resp.set_cookie('token', '', expires=0, httponly=True, secure=True, samesite='Strict')
_clear_auth_cookies(resp)
return resp, 200
@auth_api.route('/refresh', methods=['POST'])
def refresh():
raw_token = request.cookies.get('refresh_token')
if not raw_token:
return jsonify({'error': 'Missing refresh token', 'code': MISSING_REFRESH_TOKEN}), 401
token_hash = _hash_token(raw_token)
token_dict = refresh_tokens_db.get(TokenQuery.token_hash == token_hash)
if not token_dict:
# Token not found — could be invalid or already purged
resp = jsonify({'error': 'Invalid token', 'code': INVALID_TOKEN})
_clear_auth_cookies(resp)
return resp, 401
token_record = RefreshToken.from_dict(token_dict)
# THEFT DETECTION: token was already used (rotated out) but replayed
if token_record.is_used:
logger.warning(
'Refresh token reuse detected! user_id=%s, family=%s, ip=%s — killing all sessions',
token_record.user_id, token_record.token_family, request.remote_addr,
)
# Nuke ALL refresh tokens for this user
refresh_tokens_db.remove(TokenQuery.user_id == token_record.user_id)
resp = jsonify({'error': 'Token reuse detected, all sessions invalidated', 'code': REFRESH_TOKEN_REUSE})
_clear_auth_cookies(resp)
return resp, 401
# Check expiry
try:
exp = datetime.fromisoformat(token_record.expires_at)
if exp.tzinfo is None:
exp = exp.replace(tzinfo=timezone.utc)
if datetime.now(timezone.utc) > exp:
refresh_tokens_db.remove(TokenQuery.id == token_record.id)
resp = jsonify({'error': 'Refresh token expired', 'code': REFRESH_TOKEN_EXPIRED})
_clear_auth_cookies(resp)
return resp, 401
except ValueError:
refresh_tokens_db.remove(TokenQuery.id == token_record.id)
resp = jsonify({'error': 'Invalid token', 'code': INVALID_TOKEN})
_clear_auth_cookies(resp)
return resp, 401
# Look up the user
user_dict = users_db.get(UserQuery.id == token_record.user_id)
user = User.from_dict(user_dict) if user_dict else None
if not user:
refresh_tokens_db.remove(TokenQuery.id == token_record.id)
resp = jsonify({'error': 'User not found', 'code': USER_NOT_FOUND})
_clear_auth_cookies(resp)
return resp, 401
if user.marked_for_deletion:
refresh_tokens_db.remove(TokenQuery.user_id == user.id)
resp = jsonify({'error': 'Account marked for deletion', 'code': ACCOUNT_MARKED_FOR_DELETION})
_clear_auth_cookies(resp)
return resp, 403
# ROTATION: mark old token as used, create new one in same family
refresh_tokens_db.update({'is_used': True}, TokenQuery.id == token_record.id)
raw_new_refresh, _ = _create_refresh_token(user.id, token_family=token_record.token_family)
# Issue new access token
access_token = _create_access_token(user)
resp = jsonify({
'email': user.email,
'id': user.id,
'first_name': user.first_name,
'last_name': user.last_name,
'verified': user.verified,
})
_set_auth_cookies(resp, access_token, raw_new_refresh)
return resp, 200
@auth_api.route('/logout', methods=['POST'])
def logout():
# Delete the refresh token from DB if present
raw_token = request.cookies.get('refresh_token')
if raw_token:
token_hash = _hash_token(raw_token)
refresh_tokens_db.remove(TokenQuery.token_hash == token_hash)
resp = jsonify({'message': 'Logged out'})
# Remove the token cookie by setting it to empty and expiring it
resp.set_cookie('token', '', expires=0, httponly=True, secure=True, samesite='Strict')
_clear_auth_cookies(resp)
return resp, 200
@auth_api.route('/e2e-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

View File

@@ -1,16 +1,18 @@
from time import sleep
from datetime import date as date_type, datetime, timezone
from flask import Blueprint, request, jsonify
from tinydb import Query
from api.child_rewards import ChildReward
from api.child_tasks import ChildTask
from api.pending_reward import PendingReward as PendingRewardResponse
from api.pending_confirmation import PendingConfirmationResponse
from api.reward_status import RewardStatus
from api.utils import send_event_for_current_user
from db.db import child_db, task_db, reward_db, pending_reward_db
from api.utils import send_event_for_current_user, get_validated_user_id
from db.db import child_db, task_db, reward_db, pending_reward_db, pending_confirmations_db
from db.tracking import insert_tracking_event
from db.child_overrides import get_override, delete_override, delete_overrides_for_child
from events.types.child_chore_confirmation import ChildChoreConfirmation
from events.types.child_modified import ChildModified
from events.types.child_reward_request import ChildRewardRequest
from events.types.child_reward_triggered import ChildRewardTriggered
@@ -21,13 +23,15 @@ from events.types.tracking_event_created import TrackingEventCreated
from events.types.event import Event
from events.types.event_types import EventType
from models.child import Child
from models.pending_confirmation import PendingConfirmation
from models.pending_reward import PendingReward
from models.reward import Reward
from models.task import Task
from models.tracking_event import TrackingEvent
from api.utils import get_validated_user_id
from utils.tracking_logger import log_tracking_event
from collections import defaultdict
from db.chore_schedules import get_schedule
from db.task_extensions import get_extension
import logging
child_api = Blueprint('child_api', __name__)
@@ -95,18 +99,22 @@ def edit_child(id):
# Check if points changed and handle pending rewards
if points is not None:
PendingQuery = Query()
pending_rewards = pending_reward_db.search((PendingQuery.child_id == id) & (PendingQuery.user_id == user_id))
pending_rewards = pending_confirmations_db.search(
(PendingQuery.child_id == id) & (PendingQuery.user_id == user_id) &
(PendingQuery.entity_type == 'reward') & (PendingQuery.status == 'pending')
)
RewardQuery = Query()
for pr in pending_rewards:
pending = PendingReward.from_dict(pr)
reward_result = reward_db.get((RewardQuery.id == pending.reward_id) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None)))
pending = PendingConfirmation.from_dict(pr)
reward_result = reward_db.get((RewardQuery.id == pending.entity_id) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None)))
if reward_result:
reward = Reward.from_dict(reward_result)
# If child can no longer afford the reward, remove the pending request
if child.points < reward.cost:
pending_reward_db.remove(
(PendingQuery.child_id == id) & (PendingQuery.reward_id == reward.id) & (PendingQuery.user_id == user_id)
pending_confirmations_db.remove(
(PendingQuery.child_id == id) & (PendingQuery.entity_id == reward.id) &
(PendingQuery.entity_type == 'reward') & (PendingQuery.user_id == user_id)
)
resp = send_event_for_current_user(
Event(EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest(id, reward.id, ChildRewardRequest.REQUEST_CANCELLED)))
@@ -177,11 +185,10 @@ def set_child_tasks(id):
data = request.get_json() or {}
task_ids = data.get('task_ids')
if 'type' not in data:
return jsonify({'error': 'type is required (good or bad)'}), 400
task_type = data.get('type', 'good')
if task_type not in ['good', 'bad']:
return jsonify({'error': 'type must be either good or bad'}), 400
is_good = task_type == 'good'
return jsonify({'error': 'type is required (chore, kindness, or penalty)'}), 400
task_type = data.get('type')
if task_type not in ['chore', 'kindness', 'penalty']:
return jsonify({'error': 'type must be chore, kindness, or penalty', 'code': 'INVALID_TASK_TYPE'}), 400
if not isinstance(task_ids, list):
return jsonify({'error': 'task_ids must be a list'}), 400
@@ -192,10 +199,11 @@ def set_child_tasks(id):
child = Child.from_dict(result[0])
new_task_ids = set(task_ids)
# Add all existing child tasks of the opposite type
for task in task_db.all():
if task['id'] in child.tasks and task['is_good'] != is_good:
new_task_ids.add(task['id'])
# Add all existing child tasks of other types
for task_record in task_db.all():
task_obj = Task.from_dict(task_record)
if task_obj.id in child.tasks and task_obj.type != task_type:
new_task_ids.add(task_obj.id)
# Convert back to list if needed
new_tasks = list(new_task_ids)
@@ -265,14 +273,43 @@ def list_child_tasks(id):
if not task:
continue
task_obj = Task.from_dict(task)
# Check for override
override = get_override(id, tid)
custom_value = override.custom_value if override else None
ct = ChildTask(task.get('name'), task.get('is_good'), task.get('points'), task.get('image_id'), task.get('id'))
ct = ChildTask(task_obj.name, task_obj.type, task_obj.points, task_obj.image_id, task_obj.id)
ct_dict = ct.to_dict()
if custom_value is not None:
ct_dict['custom_value'] = custom_value
# Attach schedule and most recent extension_date for chores (client does all time math)
if task_obj.type == 'chore':
schedule = get_schedule(id, tid)
ct_dict['schedule'] = schedule.to_dict() if schedule else None
today_str = date_type.today().isoformat()
ext = get_extension(id, tid, today_str)
ct_dict['extension_date'] = ext.date if ext else None
# Attach pending confirmation status for chores
PendingQuery = Query()
pending = pending_confirmations_db.get(
(PendingQuery.child_id == id) & (PendingQuery.entity_id == tid) &
(PendingQuery.entity_type == 'chore') & (PendingQuery.user_id == user_id)
)
if pending:
ct_dict['pending_status'] = pending.get('status')
ct_dict['approved_at'] = pending.get('approved_at')
else:
ct_dict['pending_status'] = None
ct_dict['approved_at'] = None
else:
ct_dict['schedule'] = None
ct_dict['extension_date'] = None
ct_dict['pending_status'] = None
ct_dict['approved_at'] = None
child_tasks.append(ct_dict)
return jsonify({'tasks': child_tasks}), 200
@@ -313,7 +350,7 @@ def list_assignable_tasks(id):
filtered_tasks.extend(user_tasks)
# Wrap in ChildTask and return
assignable_tasks = [ChildTask(t.get('name'), t.get('is_good'), t.get('points'), t.get('image_id'), t.get('id')).to_dict() for t in filtered_tasks]
assignable_tasks = [ChildTask(t.get('name'), Task.from_dict(t).type, t.get('points'), t.get('image_id'), t.get('id')).to_dict() for t in filtered_tasks]
return jsonify({'tasks': assignable_tasks, 'count': len(assignable_tasks)}), 200
@@ -327,9 +364,9 @@ def list_all_tasks(id):
if not result:
return jsonify({'error': 'Child not found'}), 404
has_type = "type" in request.args
if has_type and request.args.get('type') not in ['good', 'bad']:
return jsonify({'error': 'type must be either good or bad'}), 400
good = request.args.get('type', False) == 'good'
if has_type and request.args.get('type') not in ['chore', 'kindness', 'penalty']:
return jsonify({'error': 'type must be chore, kindness, or penalty'}), 400
filter_type = request.args.get('type', None) if has_type else None
child = result[0]
assigned_ids = set(child.get('tasks', []))
@@ -353,14 +390,15 @@ def list_all_tasks(id):
result_tasks = []
for t in filtered_tasks:
if has_type and t.get('is_good') != good:
task_obj = Task.from_dict(t)
if has_type and task_obj.type != filter_type:
continue
ct = ChildTask(
t.get('name'),
t.get('is_good'),
t.get('points'),
t.get('image_id'),
t.get('id')
task_obj.name,
task_obj.type,
task_obj.points,
task_obj.image_id,
task_obj.id
)
task_dict = ct.to_dict()
task_dict.update({'assigned': t.get('id') in assigned_ids})
@@ -412,11 +450,28 @@ def trigger_child_task(id):
# update the child in the database
child_db.update({'points': child.points}, ChildQuery.id == id)
# For chores, create an approved PendingConfirmation so child view shows COMPLETED
if task.type == 'chore':
PendingQuery = Query()
# Remove any existing pending confirmation for this chore
pending_confirmations_db.remove(
(PendingQuery.child_id == id) & (PendingQuery.entity_id == task_id) &
(PendingQuery.entity_type == 'chore') & (PendingQuery.user_id == user_id)
)
confirmation = PendingConfirmation(
child_id=id, entity_id=task_id, entity_type='chore',
user_id=user_id, status='approved',
approved_at=datetime.now(timezone.utc).isoformat()
)
pending_confirmations_db.insert(confirmation.to_dict())
send_event_for_current_user(Event(EventType.CHILD_CHORE_CONFIRMATION.value,
ChildChoreConfirmation(id, task_id, ChildChoreConfirmation.OPERATION_APPROVED)))
# Create tracking event
entity_type = 'penalty' if not task.is_good else 'task'
entity_type = task.type
tracking_metadata = {
'task_name': task.name,
'is_good': task.is_good,
'task_type': task.type,
'default_points': task.points
}
if override:
@@ -694,8 +749,9 @@ def trigger_child_reward(id):
# Remove matching pending reward requests for this child and reward
PendingQuery = Query()
removed = pending_reward_db.remove(
(PendingQuery.child_id == child.id) & (PendingQuery.reward_id == reward.id)
removed = pending_confirmations_db.remove(
(PendingQuery.child_id == child.id) & (PendingQuery.entity_id == reward.id) &
(PendingQuery.entity_type == 'reward')
)
if removed:
send_event_for_current_user(Event(EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest(reward.id, child.id, ChildRewardRequest.REQUEST_GRANTED)))
@@ -784,9 +840,12 @@ def reward_status(id):
cost_value = override.custom_value if override else reward.cost
points_needed = max(0, cost_value - points)
#check to see if this reward id and child id is in the pending rewards db if so set its redeeming flag to true
#check to see if this reward id and child id is in the pending confirmations db
pending_query = Query()
pending = pending_reward_db.get((pending_query.child_id == child.id) & (pending_query.reward_id == reward.id) & (pending_query.user_id == user_id))
pending = pending_confirmations_db.get(
(pending_query.child_id == child.id) & (pending_query.entity_id == reward.id) &
(pending_query.entity_type == 'reward') & (pending_query.user_id == user_id)
)
status = RewardStatus(reward.id, reward.name, points_needed, cost_value, pending is not None, reward.image_id)
status_dict = status.to_dict()
if override:
@@ -834,8 +893,8 @@ def request_reward(id):
'reward_cost': reward.cost
}), 400
pending = PendingReward(child_id=child.id, reward_id=reward.id, user_id=user_id)
pending_reward_db.insert(pending.to_dict())
pending = PendingConfirmation(child_id=child.id, entity_id=reward.id, entity_type='reward', user_id=user_id)
pending_confirmations_db.insert(pending.to_dict())
logger.info(f'Pending reward request created for child {child.name} for reward {reward.name}')
# Create tracking event (no points change on request)
@@ -890,8 +949,9 @@ def cancel_request_reward(id):
# Remove matching pending reward request
PendingQuery = Query()
removed = pending_reward_db.remove(
(PendingQuery.child_id == child.id) & (PendingQuery.reward_id == reward_id) & (PendingQuery.user_id == user_id)
removed = pending_confirmations_db.remove(
(PendingQuery.child_id == child.id) & (PendingQuery.entity_id == reward_id) &
(PendingQuery.entity_type == 'reward') & (PendingQuery.user_id == user_id)
)
if not removed:
@@ -927,26 +987,23 @@ def cancel_request_reward(id):
@child_api.route('/pending-rewards', methods=['GET'])
def list_pending_rewards():
@child_api.route('/pending-confirmations', methods=['GET'])
def list_pending_confirmations():
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
PendingQuery = Query()
pending_rewards = pending_reward_db.search(PendingQuery.user_id == user_id)
reward_responses = []
pending_items = pending_confirmations_db.search(
(PendingQuery.user_id == user_id) & (PendingQuery.status == 'pending')
)
responses = []
RewardQuery = Query()
TaskQuery = Query()
ChildQuery = Query()
for pr in pending_rewards:
pending = PendingReward.from_dict(pr)
# Look up reward details
reward_result = reward_db.get((RewardQuery.id == pending.reward_id) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None)))
if not reward_result:
continue
reward = Reward.from_dict(reward_result)
for pr in pending_items:
pending = PendingConfirmation.from_dict(pr)
# Look up child details
child_result = child_db.get(ChildQuery.id == pending.child_id)
@@ -954,17 +1011,326 @@ def list_pending_rewards():
continue
child = Child.from_dict(child_result)
# Create response object
response = PendingRewardResponse(
# Look up entity details based on type
if pending.entity_type == 'reward':
entity_result = reward_db.get((RewardQuery.id == pending.entity_id) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None)))
else:
entity_result = task_db.get((TaskQuery.id == pending.entity_id) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)))
if not entity_result:
continue
response = PendingConfirmationResponse(
_id=pending.id,
child_id=child.id,
child_name=child.name,
child_image_id=child.image_id,
reward_id=reward.id,
reward_name=reward.name,
reward_image_id=reward.image_id
entity_id=pending.entity_id,
entity_type=pending.entity_type,
entity_name=entity_result.get('name'),
entity_image_id=entity_result.get('image_id'),
status=pending.status,
approved_at=pending.approved_at
)
reward_responses.append(response.to_dict())
responses.append(response.to_dict())
return jsonify({'rewards': reward_responses, 'count': len(reward_responses), 'list_type': 'notification'}), 200
return jsonify({'confirmations': responses, 'count': len(responses), 'list_type': 'notification'}), 200
# ---------------------------------------------------------------------------
# Chore Confirmation Endpoints
# ---------------------------------------------------------------------------
@child_api.route('/child/<id>/confirm-chore', methods=['POST'])
def confirm_chore(id):
"""Child confirms they completed a chore. Creates a pending confirmation."""
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
data = request.get_json()
task_id = data.get('task_id')
if not task_id:
return jsonify({'error': 'task_id is required'}), 400
ChildQuery = Query()
result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id))
if not result:
return jsonify({'error': 'Child not found'}), 404
child = Child.from_dict(result[0])
if task_id not in child.tasks:
return jsonify({'error': 'Task not assigned to child', 'code': 'ENTITY_NOT_ASSIGNED'}), 400
TaskQuery = Query()
task_result = task_db.get((TaskQuery.id == task_id) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)))
if not task_result:
return jsonify({'error': 'Task not found', 'code': 'TASK_NOT_FOUND'}), 404
task = Task.from_dict(task_result)
if task.type != 'chore':
return jsonify({'error': 'Only chores can be confirmed', 'code': 'INVALID_TASK_TYPE'}), 400
# Check if already pending or completed today
PendingQuery = Query()
existing = pending_confirmations_db.get(
(PendingQuery.child_id == id) & (PendingQuery.entity_id == task_id) &
(PendingQuery.entity_type == 'chore') & (PendingQuery.user_id == user_id)
)
if existing:
if existing.get('status') == 'pending':
return jsonify({'error': 'Chore already pending confirmation', 'code': 'CHORE_ALREADY_PENDING'}), 400
if existing.get('status') == 'approved':
approved_at = existing.get('approved_at', '')
today_utc = datetime.now(timezone.utc).strftime('%Y-%m-%d')
if approved_at and approved_at[:10] == today_utc:
return jsonify({'error': 'Chore already completed today', 'code': 'CHORE_ALREADY_COMPLETED'}), 400
confirmation = PendingConfirmation(
child_id=id, entity_id=task_id, entity_type='chore', user_id=user_id
)
pending_confirmations_db.insert(confirmation.to_dict())
# Create tracking event
tracking_event = TrackingEvent.create_event(
user_id=user_id, child_id=id, entity_type='chore', entity_id=task_id,
action='confirmed', points_before=child.points, points_after=child.points,
metadata={'task_name': task.name, 'task_type': task.type}
)
insert_tracking_event(tracking_event)
log_tracking_event(tracking_event)
send_event_for_current_user(Event(EventType.TRACKING_EVENT_CREATED.value,
TrackingEventCreated(tracking_event.id, id, 'chore', 'confirmed')))
send_event_for_current_user(Event(EventType.CHILD_CHORE_CONFIRMATION.value,
ChildChoreConfirmation(id, task_id, ChildChoreConfirmation.OPERATION_CONFIRMED)))
return jsonify({'message': f'Chore {task.name} confirmed by {child.name}.', 'confirmation_id': confirmation.id}), 200
@child_api.route('/child/<id>/cancel-confirm-chore', methods=['POST'])
def cancel_confirm_chore(id):
"""Child cancels their pending chore confirmation."""
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
data = request.get_json()
task_id = data.get('task_id')
if not task_id:
return jsonify({'error': 'task_id is required'}), 400
ChildQuery = Query()
result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id))
if not result:
return jsonify({'error': 'Child not found'}), 404
child = Child.from_dict(result[0])
PendingQuery = Query()
existing = pending_confirmations_db.get(
(PendingQuery.child_id == id) & (PendingQuery.entity_id == task_id) &
(PendingQuery.entity_type == 'chore') & (PendingQuery.status == 'pending') &
(PendingQuery.user_id == user_id)
)
if not existing:
return jsonify({'error': 'No pending confirmation found', 'code': 'PENDING_NOT_FOUND'}), 400
pending_confirmations_db.remove(
(PendingQuery.child_id == id) & (PendingQuery.entity_id == task_id) &
(PendingQuery.entity_type == 'chore') & (PendingQuery.status == 'pending') &
(PendingQuery.user_id == user_id)
)
# Fetch task name for tracking
TaskQuery = Query()
task_result = task_db.get((TaskQuery.id == task_id) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)))
task_name = task_result.get('name') if task_result else 'Unknown'
tracking_event = TrackingEvent.create_event(
user_id=user_id, child_id=id, entity_type='chore', entity_id=task_id,
action='cancelled', points_before=child.points, points_after=child.points,
metadata={'task_name': task_name}
)
insert_tracking_event(tracking_event)
log_tracking_event(tracking_event)
send_event_for_current_user(Event(EventType.TRACKING_EVENT_CREATED.value,
TrackingEventCreated(tracking_event.id, id, 'chore', 'cancelled')))
send_event_for_current_user(Event(EventType.CHILD_CHORE_CONFIRMATION.value,
ChildChoreConfirmation(id, task_id, ChildChoreConfirmation.OPERATION_CANCELLED)))
return jsonify({'message': 'Chore confirmation cancelled.'}), 200
@child_api.route('/child/<id>/approve-chore', methods=['POST'])
def approve_chore(id):
"""Parent approves a pending chore confirmation, awarding points."""
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
data = request.get_json()
task_id = data.get('task_id')
if not task_id:
return jsonify({'error': 'task_id is required'}), 400
ChildQuery = Query()
result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id))
if not result:
return jsonify({'error': 'Child not found'}), 404
child = Child.from_dict(result[0])
PendingQuery = Query()
existing = pending_confirmations_db.get(
(PendingQuery.child_id == id) & (PendingQuery.entity_id == task_id) &
(PendingQuery.entity_type == 'chore') & (PendingQuery.status == 'pending') &
(PendingQuery.user_id == user_id)
)
if not existing:
return jsonify({'error': 'No pending confirmation found', 'code': 'PENDING_NOT_FOUND'}), 400
TaskQuery = Query()
task_result = task_db.get((TaskQuery.id == task_id) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)))
if not task_result:
return jsonify({'error': 'Task not found'}), 404
task = Task.from_dict(task_result)
# Award points
override = get_override(id, task_id)
points_value = override.custom_value if override else task.points
points_before = child.points
child.points += points_value
child_db.update({'points': child.points}, ChildQuery.id == id)
# Update confirmation to approved
now_str = datetime.now(timezone.utc).isoformat()
pending_confirmations_db.update(
{'status': 'approved', 'approved_at': now_str},
(PendingQuery.child_id == id) & (PendingQuery.entity_id == task_id) &
(PendingQuery.entity_type == 'chore') & (PendingQuery.user_id == user_id)
)
tracking_metadata = {
'task_name': task.name,
'task_type': task.type,
'default_points': task.points
}
if override:
tracking_metadata['custom_points'] = override.custom_value
tracking_metadata['has_override'] = True
tracking_event = TrackingEvent.create_event(
user_id=user_id, child_id=id, entity_type='chore', entity_id=task_id,
action='approved', points_before=points_before, points_after=child.points,
metadata=tracking_metadata
)
insert_tracking_event(tracking_event)
log_tracking_event(tracking_event)
send_event_for_current_user(Event(EventType.TRACKING_EVENT_CREATED.value,
TrackingEventCreated(tracking_event.id, id, 'chore', 'approved')))
send_event_for_current_user(Event(EventType.CHILD_CHORE_CONFIRMATION.value,
ChildChoreConfirmation(id, task_id, ChildChoreConfirmation.OPERATION_APPROVED)))
send_event_for_current_user(Event(EventType.CHILD_TASK_TRIGGERED.value,
ChildTaskTriggered(task_id, id, child.points)))
return jsonify({
'message': f'Chore {task.name} approved for {child.name}.',
'points': child.points,
'id': child.id
}), 200
@child_api.route('/child/<id>/reject-chore', methods=['POST'])
def reject_chore(id):
"""Parent rejects a pending chore confirmation."""
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
data = request.get_json()
task_id = data.get('task_id')
if not task_id:
return jsonify({'error': 'task_id is required'}), 400
ChildQuery = Query()
result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id))
if not result:
return jsonify({'error': 'Child not found'}), 404
child = Child.from_dict(result[0])
PendingQuery = Query()
existing = pending_confirmations_db.get(
(PendingQuery.child_id == id) & (PendingQuery.entity_id == task_id) &
(PendingQuery.entity_type == 'chore') & (PendingQuery.status == 'pending') &
(PendingQuery.user_id == user_id)
)
if not existing:
return jsonify({'error': 'No pending confirmation found', 'code': 'PENDING_NOT_FOUND'}), 400
pending_confirmations_db.remove(
(PendingQuery.child_id == id) & (PendingQuery.entity_id == task_id) &
(PendingQuery.entity_type == 'chore') & (PendingQuery.user_id == user_id)
)
TaskQuery = Query()
task_result = task_db.get((TaskQuery.id == task_id) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)))
task_name = task_result.get('name') if task_result else 'Unknown'
tracking_event = TrackingEvent.create_event(
user_id=user_id, child_id=id, entity_type='chore', entity_id=task_id,
action='rejected', points_before=child.points, points_after=child.points,
metadata={'task_name': task_name}
)
insert_tracking_event(tracking_event)
log_tracking_event(tracking_event)
send_event_for_current_user(Event(EventType.TRACKING_EVENT_CREATED.value,
TrackingEventCreated(tracking_event.id, id, 'chore', 'rejected')))
send_event_for_current_user(Event(EventType.CHILD_CHORE_CONFIRMATION.value,
ChildChoreConfirmation(id, task_id, ChildChoreConfirmation.OPERATION_REJECTED)))
return jsonify({'message': 'Chore confirmation rejected.'}), 200
@child_api.route('/child/<id>/reset-chore', methods=['POST'])
def reset_chore(id):
"""Parent resets a completed chore so the child can confirm again."""
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
data = request.get_json()
task_id = data.get('task_id')
if not task_id:
return jsonify({'error': 'task_id is required'}), 400
ChildQuery = Query()
result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id))
if not result:
return jsonify({'error': 'Child not found'}), 404
child = Child.from_dict(result[0])
PendingQuery = Query()
existing = pending_confirmations_db.get(
(PendingQuery.child_id == id) & (PendingQuery.entity_id == task_id) &
(PendingQuery.entity_type == 'chore') & (PendingQuery.status == 'approved') &
(PendingQuery.user_id == user_id)
)
if not existing:
return jsonify({'error': 'No completed confirmation found to reset', 'code': 'PENDING_NOT_FOUND'}), 400
pending_confirmations_db.remove(
(PendingQuery.child_id == id) & (PendingQuery.entity_id == task_id) &
(PendingQuery.entity_type == 'chore') & (PendingQuery.user_id == user_id)
)
TaskQuery = Query()
task_result = task_db.get((TaskQuery.id == task_id) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)))
task_name = task_result.get('name') if task_result else 'Unknown'
tracking_event = TrackingEvent.create_event(
user_id=user_id, child_id=id, entity_type='chore', entity_id=task_id,
action='reset', points_before=child.points, points_after=child.points,
metadata={'task_name': task_name}
)
insert_tracking_event(tracking_event)
log_tracking_event(tracking_event)
send_event_for_current_user(Event(EventType.TRACKING_EVENT_CREATED.value,
TrackingEventCreated(tracking_event.id, id, 'chore', 'reset')))
send_event_for_current_user(Event(EventType.CHILD_CHORE_CONFIRMATION.value,
ChildChoreConfirmation(id, task_id, ChildChoreConfirmation.OPERATION_RESET)))
return jsonify({'message': 'Chore reset to available.'}), 200

View File

@@ -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
View 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

View 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

View File

@@ -12,6 +12,9 @@ INVALID_CREDENTIALS = "INVALID_CREDENTIALS"
NOT_VERIFIED = "NOT_VERIFIED"
ACCOUNT_MARKED_FOR_DELETION = "ACCOUNT_MARKED_FOR_DELETION"
ALREADY_MARKED = "ALREADY_MARKED"
REFRESH_TOKEN_REUSE = "REFRESH_TOKEN_REUSE"
REFRESH_TOKEN_EXPIRED = "REFRESH_TOKEN_EXPIRED"
MISSING_REFRESH_TOKEN = "MISSING_REFRESH_TOKEN"
class ErrorCodes:
@@ -26,3 +29,9 @@ class ErrorCodes:
INVALID_VALUE = "INVALID_VALUE"
VALIDATION_ERROR = "VALIDATION_ERROR"
INTERNAL_ERROR = "INTERNAL_ERROR"
CHORE_EXPIRED = "CHORE_EXPIRED"
CHORE_ALREADY_PENDING = "CHORE_ALREADY_PENDING"
CHORE_ALREADY_COMPLETED = "CHORE_ALREADY_COMPLETED"
PENDING_NOT_FOUND = "PENDING_NOT_FOUND"
INSUFFICIENT_POINTS = "INSUFFICIENT_POINTS"
INVALID_TASK_TYPE = "INVALID_TASK_TYPE"

165
backend/api/kindness_api.py Normal file
View 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
View 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

View 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
}

View File

@@ -21,11 +21,16 @@ def add_task():
data = request.get_json()
name = data.get('name')
points = data.get('points')
is_good = data.get('is_good')
task_type = data.get('type')
# Support legacy is_good field
if task_type is None and 'is_good' in data:
task_type = 'chore' if data['is_good'] else 'penalty'
image = data.get('image_id', '')
if not name or points is None or is_good is None:
return jsonify({'error': 'Name, points, and is_good are required'}), 400
task = Task(name=name, points=points, is_good=is_good, image_id=image, user_id=user_id)
if not name or points is None or task_type is None:
return jsonify({'error': 'Name, points, and type are required'}), 400
if task_type not in ['chore', 'kindness', 'penalty']:
return jsonify({'error': 'type must be chore, kindness, or penalty'}), 400
task = Task(name=name, points=points, type=task_type, image_id=image, user_id=user_id)
task_db.insert(task.to_dict())
send_event_for_current_user(Event(EventType.TASK_MODIFIED.value,
TaskModified(task.id, TaskModified.OPERATION_ADD)))
@@ -65,10 +70,10 @@ def list_tasks():
filtered_tasks.append(t)
# Sort order:
# 1) good tasks first, then not-good tasks
# 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 t.get('is_good') is True]
not_good_tasks = [t for t in filtered_tasks if t.get('is_good') is not True]
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(
@@ -154,7 +159,15 @@ def edit_task(id):
is_good = data.get('is_good')
if not isinstance(is_good, bool):
return jsonify({'error': 'is_good must be a boolean'}), 400
task.is_good = is_good
# Convert to type
task.type = 'chore' if is_good else 'penalty'
is_dirty = True
if 'type' in data:
task_type = data.get('type')
if task_type not in ['chore', 'kindness', 'penalty']:
return jsonify({'error': 'type must be chore, kindness, or penalty'}), 400
task.type = task_type
is_dirty = True
if 'image_id' in data:
@@ -165,7 +178,7 @@ def edit_task(id):
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, is_good=task.is_good, image_id=task.image_id, user_id=user_id)
new_task = Task(name=task.name, points=task.points, type=task.type, image_id=task.image_id, user_id=user_id)
task_db.insert(new_task.to_dict())
send_event_for_current_user(Event(EventType.TASK_MODIFIED.value,
TaskModified(new_task.id, TaskModified.OPERATION_ADD)))

View File

@@ -1,61 +1,12 @@
from flask import Blueprint, request, jsonify
from api.utils import get_validated_user_id
from api.utils import get_validated_user_id, admin_required
from db.tracking import get_tracking_events_by_child, get_tracking_events_by_user
from models.tracking_event import TrackingEvent
from functools import wraps
import jwt
from tinydb import Query
from db.db import users_db
from models.user import User
tracking_api = Blueprint('tracking_api', __name__)
def admin_required(f):
"""
Decorator to require admin role for endpoints.
"""
@wraps(f)
def decorated_function(*args, **kwargs):
# Get JWT token from cookie
token = request.cookies.get('token')
if not token:
return jsonify({'error': 'Authentication required', 'code': 'AUTH_REQUIRED'}), 401
try:
# Verify JWT token
payload = jwt.decode(token, 'supersecretkey', algorithms=['HS256'])
user_id = payload.get('user_id')
if not user_id:
return jsonify({'error': 'Invalid token', 'code': 'INVALID_TOKEN'}), 401
# Get user from database
Query_ = Query()
user_dict = users_db.get(Query_.id == user_id)
if not user_dict:
return jsonify({'error': 'User not found', 'code': 'USER_NOT_FOUND'}), 404
user = User.from_dict(user_dict)
# Check if user has admin role
if user.role != 'admin':
return jsonify({'error': 'Admin access required', 'code': 'ADMIN_REQUIRED'}), 403
# Store user_id in request context
request.admin_user_id = user_id
return f(*args, **kwargs)
except jwt.ExpiredSignatureError:
return jsonify({'error': 'Token expired', 'code': 'TOKEN_EXPIRED'}), 401
except jwt.InvalidTokenError:
return jsonify({'error': 'Invalid token', 'code': 'INVALID_TOKEN'}), 401
return decorated_function
@tracking_api.route('/admin/tracking', methods=['GET'])
@admin_required
def get_tracking():

View File

@@ -9,6 +9,8 @@ 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
@@ -21,7 +23,7 @@ user_api = Blueprint('user_api', __name__)
UserQuery = Query()
def get_current_user():
token = request.cookies.get('token')
token = request.cookies.get('access_token')
if not token:
return None
try:
@@ -243,4 +245,7 @@ def mark_for_deletion():
# 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

View File

@@ -1,10 +1,12 @@
import jwt
import re
from functools import wraps
from db.db import users_db
from tinydb import Query
from flask import request, current_app, jsonify
from events.sse import send_event_to_user
from models.user import User
def normalize_email(email: str) -> str:
@@ -21,7 +23,7 @@ def sanitize_email(email):
return email.replace('@', '_at_').replace('.', '_dot_')
def get_current_user_id():
token = request.cookies.get('token')
token = request.cookies.get('access_token')
if not token:
return None
try:
@@ -51,3 +53,45 @@ def send_event_for_current_user(event):
return jsonify({'error': 'Unauthorized'}), 401
send_event_to_user(user_id, event)
return None
def admin_required(f):
"""
Decorator to require admin role for endpoints.
Validates JWT from access_token cookie and checks admin role.
"""
@wraps(f)
def decorated_function(*args, **kwargs):
token = request.cookies.get('access_token')
if not token:
return jsonify({'error': 'Authentication required', 'code': 'AUTH_REQUIRED'}), 401
try:
payload = jwt.decode(token, current_app.config['SECRET_KEY'], algorithms=['HS256'])
user_id = payload.get('user_id')
if not user_id:
return jsonify({'error': 'Invalid token', 'code': 'INVALID_TOKEN'}), 401
user_dict = users_db.get(Query().id == user_id)
if not user_dict:
return jsonify({'error': 'User not found', 'code': 'USER_NOT_FOUND'}), 404
user = User.from_dict(user_dict)
if user.role != 'admin':
return jsonify({'error': 'Admin access required', 'code': 'ADMIN_REQUIRED'}), 403
# Store user info in request context for the endpoint
request.current_user = user
request.admin_user_id = user_id
except jwt.ExpiredSignatureError:
return jsonify({'error': 'Token expired', 'code': 'TOKEN_EXPIRED'}), 401
except jwt.InvalidTokenError:
return jsonify({'error': 'Invalid token', 'code': 'INVALID_TOKEN'}), 401
return f(*args, **kwargs)
return decorated_function

View File

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

View 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"
}
}
}

File diff suppressed because it is too large Load Diff

View 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)

View File

@@ -72,9 +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)
@@ -82,9 +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)
@@ -92,9 +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()
@@ -102,7 +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()

View File

@@ -15,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:
@@ -88,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())

View 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))

View File

@@ -59,9 +59,15 @@ def sse_response_for_user(user_id: str):
def generate():
try:
while True:
# Get message from queue (blocks until available)
message = user_queue.get()
try:
# Use a timeout so the thread yields periodically and keepalives are sent.
# This prevents Werkzeug's dev server from starving other connections.
message = user_queue.get(timeout=15)
yield message
logger.info(f"Sent message to {user_id} connection {connection_id}")
except queue.Empty:
# Send an SSE comment as a keepalive ping to maintain the connection.
yield b': ping\n\n'
except GeneratorExit:
# Clean up when client disconnects
if user_id in user_queues and connection_id in user_queues[user_id]:

View File

@@ -0,0 +1,28 @@
from events.types.payload import Payload
class ChildChoreConfirmation(Payload):
OPERATION_CONFIRMED = "CONFIRMED"
OPERATION_APPROVED = "APPROVED"
OPERATION_REJECTED = "REJECTED"
OPERATION_CANCELLED = "CANCELLED"
OPERATION_RESET = "RESET"
def __init__(self, child_id: str, task_id: str, operation: str):
super().__init__({
'child_id': child_id,
'task_id': task_id,
'operation': operation
})
@property
def child_id(self) -> str:
return self.get("child_id")
@property
def task_id(self) -> str:
return self.get("task_id")
@property
def operation(self) -> str:
return self.get("operation")

View File

@@ -0,0 +1,25 @@
from events.types.payload import Payload
class ChoreScheduleModified(Payload):
OPERATION_SET = 'SET'
OPERATION_DELETED = 'DELETED'
def __init__(self, child_id: str, task_id: str, operation: str):
super().__init__({
'child_id': child_id,
'task_id': task_id,
'operation': operation,
})
@property
def child_id(self) -> str:
return self.get('child_id')
@property
def task_id(self) -> str:
return self.get('task_id')
@property
def operation(self) -> str:
return self.get('operation')

View File

@@ -0,0 +1,17 @@
from events.types.payload import Payload
class ChoreTimeExtended(Payload):
def __init__(self, child_id: str, task_id: str):
super().__init__({
'child_id': child_id,
'task_id': task_id,
})
@property
def child_id(self) -> str:
return self.get('child_id')
@property
def task_id(self) -> str:
return self.get('task_id')

View File

@@ -23,3 +23,9 @@ class EventType(Enum):
CHILD_OVERRIDE_DELETED = "child_override_deleted"
PROFILE_UPDATED = "profile_updated"
CHORE_SCHEDULE_MODIFIED = "chore_schedule_modified"
CHORE_TIME_EXTENDED = "chore_time_extended"
CHILD_CHORE_CONFIRMATION = "child_chore_confirmation"
FORCE_LOGOUT = "force_logout"

View File

@@ -3,13 +3,16 @@ import sys
import os
from flask import Flask, request, jsonify
from flask_cors import CORS
from api.admin_api import admin_api
from api.auth_api import auth_api
from api.child_api import child_api
from api.child_override_api import child_override_api
from api.chore_api import chore_api
from api.chore_schedule_api import chore_schedule_api
from api.image_api import image_api
from api.kindness_api import kindness_api
from api.penalty_api import penalty_api
from api.reward_api import reward_api
from api.task_api import task_api
from api.tracking_api import tracking_api
@@ -19,6 +22,7 @@ from config.version import get_full_version
from db.default import initializeImages, createDefaultTasks, createDefaultRewards
from events.broadcaster import Broadcaster
from events.sse import sse_response_for_user, send_to_user
from api.utils import get_current_user_id
from utils.account_deletion_scheduler import start_deletion_scheduler
# Configure logging once at application startup
@@ -37,6 +41,10 @@ app = Flask(__name__)
app.register_blueprint(admin_api)
app.register_blueprint(child_api)
app.register_blueprint(child_override_api)
app.register_blueprint(chore_api)
app.register_blueprint(chore_schedule_api)
app.register_blueprint(kindness_api)
app.register_blueprint(penalty_api)
app.register_blueprint(reward_api)
app.register_blueprint(task_api)
app.register_blueprint(image_api)
@@ -52,10 +60,23 @@ app.config.update(
MAIL_PASSWORD='ruyj hxjf nmrz buar',
MAIL_DEFAULT_SENDER='ryan.kegel@gmail.com',
FRONTEND_URL=os.environ.get('FRONTEND_URL', 'https://localhost:5173'), # Dynamic via env var, defaults to localhost
SECRET_KEY='supersecretkey' # Replace with a secure key in production
)
CORS(app)
# Security: require SECRET_KEY and REFRESH_TOKEN_EXPIRY_DAYS from environment
_secret_key = os.environ.get('SECRET_KEY')
if not _secret_key:
raise RuntimeError(
'SECRET_KEY environment variable is required. '
'Set it to a random string (e.g. python -c "import secrets; print(secrets.token_urlsafe(64))")')
app.config['SECRET_KEY'] = _secret_key
_refresh_expiry = os.environ.get('REFRESH_TOKEN_EXPIRY_DAYS')
if not _refresh_expiry:
raise RuntimeError('REFRESH_TOKEN_EXPIRY_DAYS environment variable is required (e.g. 90).')
try:
app.config['REFRESH_TOKEN_EXPIRY_DAYS'] = int(_refresh_expiry)
except ValueError:
raise RuntimeError('REFRESH_TOKEN_EXPIRY_DAYS must be an integer.')
@app.route("/version")
def api_version():
@@ -63,11 +84,9 @@ def api_version():
@app.route("/events")
def events():
# Authenticate user or read a token
user_id = request.args.get("user_id")
user_id = get_current_user_id()
if not user_id:
return {"error": "Missing user_id"}, 400
return {"error": "Authentication required"}, 401
return sse_response_for_user(user_id)

View File

@@ -16,15 +16,15 @@ class ChildOverride(BaseModel):
"""
child_id: str
entity_id: str
entity_type: Literal['task', 'reward']
entity_type: Literal['task', 'reward', 'chore', 'kindness', 'penalty']
custom_value: int
def __post_init__(self):
"""Validate custom_value range and entity_type."""
if self.custom_value < 0 or self.custom_value > 10000:
raise ValueError("custom_value must be between 0 and 10000")
if self.entity_type not in ['task', 'reward']:
raise ValueError("entity_type must be 'task' or 'reward'")
if self.entity_type not in ['task', 'reward', 'chore', 'kindness', 'penalty']:
raise ValueError("entity_type must be 'task', 'reward', 'chore', 'kindness', or 'penalty'")
@classmethod
def from_dict(cls, d: dict):
@@ -52,7 +52,7 @@ class ChildOverride(BaseModel):
def create_override(
child_id: str,
entity_id: str,
entity_type: Literal['task', 'reward'],
entity_type: Literal['task', 'reward', 'chore', 'kindness', 'penalty'],
custom_value: int
) -> 'ChildOverride':
"""Factory method to create a new override."""

View File

@@ -0,0 +1,83 @@
from dataclasses import dataclass, field
from typing import Literal
from models.base import BaseModel
@dataclass
class DayConfig:
day: int # 0=Sun, 1=Mon, ..., 6=Sat
hour: int # 023 (24h)
minute: int # 0, 15, 30, or 45
def to_dict(self) -> dict:
return {
'day': self.day,
'hour': self.hour,
'minute': self.minute,
}
@classmethod
def from_dict(cls, d: dict) -> 'DayConfig':
return cls(
day=d.get('day', 0),
hour=d.get('hour', 0),
minute=d.get('minute', 0),
)
@dataclass
class ChoreSchedule(BaseModel):
child_id: str
task_id: str
mode: Literal['days', 'interval']
# mode='days' fields
day_configs: list = field(default_factory=list) # list of DayConfig dicts
default_hour: int = 8 # master deadline hour for 'days' mode
default_minute: int = 0 # master deadline minute for 'days' mode
default_has_deadline: bool = True # False = 'Anytime', no expiry for 'days' mode
# mode='interval' fields
interval_days: int = 2 # 17
anchor_date: str = "" # ISO date string e.g. "2026-02-25"; "" = use today
interval_has_deadline: bool = True # False = "Anytime" (no deadline)
interval_hour: int = 0
interval_minute: int = 0
@classmethod
def from_dict(cls, d: dict) -> 'ChoreSchedule':
return cls(
child_id=d.get('child_id'),
task_id=d.get('task_id'),
mode=d.get('mode', 'days'),
day_configs=d.get('day_configs', []),
default_hour=d.get('default_hour', 8),
default_minute=d.get('default_minute', 0),
default_has_deadline=d.get('default_has_deadline', True),
interval_days=d.get('interval_days', 2),
anchor_date=d.get('anchor_date', ''),
interval_has_deadline=d.get('interval_has_deadline', True),
interval_hour=d.get('interval_hour', 0),
interval_minute=d.get('interval_minute', 0),
id=d.get('id'),
created_at=d.get('created_at'),
updated_at=d.get('updated_at'),
)
def to_dict(self) -> dict:
base = super().to_dict()
base.update({
'child_id': self.child_id,
'task_id': self.task_id,
'mode': self.mode,
'day_configs': self.day_configs,
'default_hour': self.default_hour,
'default_minute': self.default_minute,
'default_has_deadline': self.default_has_deadline,
'interval_days': self.interval_days,
'anchor_date': self.anchor_date,
'interval_has_deadline': self.interval_has_deadline,
'interval_hour': self.interval_hour,
'interval_minute': self.interval_minute,
})
return base

View File

@@ -0,0 +1,43 @@
from dataclasses import dataclass
from typing import Literal, Optional
from models.base import BaseModel
PendingEntityType = Literal['chore', 'reward']
PendingStatus = Literal['pending', 'approved', 'rejected']
@dataclass
class PendingConfirmation(BaseModel):
child_id: str
entity_id: str
entity_type: PendingEntityType
user_id: str
status: PendingStatus = "pending"
approved_at: Optional[str] = None # ISO 8601 UTC timestamp, set on approval
@classmethod
def from_dict(cls, d: dict):
return cls(
child_id=d.get('child_id'),
entity_id=d.get('entity_id'),
entity_type=d.get('entity_type'),
user_id=d.get('user_id'),
status=d.get('status', 'pending'),
approved_at=d.get('approved_at'),
id=d.get('id'),
created_at=d.get('created_at'),
updated_at=d.get('updated_at')
)
def to_dict(self):
base = super().to_dict()
base.update({
'child_id': self.child_id,
'entity_id': self.entity_id,
'entity_type': self.entity_type,
'user_id': self.user_id,
'status': self.status,
'approved_at': self.approved_at
})
return base

View File

@@ -0,0 +1,34 @@
from dataclasses import dataclass, field
from models.base import BaseModel
@dataclass(kw_only=True)
class RefreshToken(BaseModel):
user_id: str = ''
token_hash: str = ''
token_family: str = ''
expires_at: str = ''
is_used: bool = False
def to_dict(self):
return {
**super().to_dict(),
'user_id': self.user_id,
'token_hash': self.token_hash,
'token_family': self.token_family,
'expires_at': self.expires_at,
'is_used': self.is_used,
}
@staticmethod
def from_dict(data: dict) -> 'RefreshToken':
return RefreshToken(
id=data.get('id', ''),
created_at=data.get('created_at', 0),
updated_at=data.get('updated_at', 0),
user_id=data.get('user_id', ''),
token_hash=data.get('token_hash', ''),
token_family=data.get('token_family', ''),
expires_at=data.get('expires_at', ''),
is_used=data.get('is_used', False),
)

View File

@@ -1,20 +1,28 @@
from dataclasses import dataclass
from typing import Literal
from models.base import BaseModel
TaskType = Literal['chore', 'kindness', 'penalty']
@dataclass
class Task(BaseModel):
name: str
points: int
is_good: bool
type: TaskType
image_id: str | None = None
user_id: str | None = None
@classmethod
def from_dict(cls, d: dict):
# Support legacy is_good field for migration
task_type = d.get('type')
if task_type is None:
is_good = d.get('is_good', True)
task_type = 'chore' if is_good else 'penalty'
return cls(
name=d.get('name'),
points=d.get('points', 0),
is_good=d.get('is_good', True),
type=task_type,
image_id=d.get('image_id'),
user_id=d.get('user_id'),
id=d.get('id'),
@@ -27,8 +35,13 @@ class Task(BaseModel):
base.update({
'name': self.name,
'points': self.points,
'is_good': self.is_good,
'type': self.type,
'image_id': self.image_id,
'user_id': self.user_id
})
return base
@property
def is_good(self) -> bool:
"""Backward compatibility: chore and kindness are 'good', penalty is not."""
return self.type != 'penalty'

View File

@@ -0,0 +1,29 @@
from dataclasses import dataclass
from models.base import BaseModel
@dataclass
class TaskExtension(BaseModel):
child_id: str
task_id: str
date: str # ISO date string supplied by client, e.g. '2026-02-22'
@classmethod
def from_dict(cls, d: dict) -> 'TaskExtension':
return cls(
child_id=d.get('child_id'),
task_id=d.get('task_id'),
date=d.get('date'),
id=d.get('id'),
created_at=d.get('created_at'),
updated_at=d.get('updated_at'),
)
def to_dict(self) -> dict:
base = super().to_dict()
base.update({
'child_id': self.child_id,
'task_id': self.task_id,
'date': self.date,
})
return base

View File

@@ -4,8 +4,8 @@ from typing import Literal, Optional
from models.base import BaseModel
EntityType = Literal['task', 'reward', 'penalty']
ActionType = Literal['activated', 'requested', 'redeemed', 'cancelled']
EntityType = Literal['task', 'reward', 'penalty', 'chore', 'kindness']
ActionType = Literal['activated', 'requested', 'redeemed', 'cancelled', 'confirmed', 'approved', 'rejected', 'reset']
@dataclass

Binary file not shown.

View File

@@ -0,0 +1,196 @@
#!/usr/bin/env python3
"""
Migration script: Convert legacy is_good field to type field across all data.
Steps:
1. tasks.json: is_good=True → type='chore', is_good=False → type='penalty'. Remove is_good field.
2. pending_rewards.json → pending_confirmations.json: Convert PendingReward records to
PendingConfirmation format with entity_type='reward'.
3. tracking_events.json: Update entity_type='task''chore' or 'penalty' based on the
referenced task's old is_good value.
4. child_overrides.json: Update entity_type='task''chore' or 'penalty' based on the
referenced task's old is_good value.
Usage:
cd backend
python -m scripts.migrate_tasks_to_types [--dry-run]
"""
import json
import os
import sys
import shutil
from datetime import datetime
DRY_RUN = '--dry-run' in sys.argv
DATA_DIR = os.path.join(os.path.dirname(__file__), '..', 'data', 'db')
def load_json(filename: str) -> dict:
path = os.path.join(DATA_DIR, filename)
if not os.path.exists(path):
return {"_default": {}}
with open(path, 'r', encoding='utf-8') as f:
return json.load(f)
def save_json(filename: str, data: dict) -> None:
path = os.path.join(DATA_DIR, filename)
if DRY_RUN:
print(f" [DRY RUN] Would write {path}")
return
# Backup original
backup_path = path + f'.bak.{datetime.now().strftime("%Y%m%d_%H%M%S")}'
if os.path.exists(path):
shutil.copy2(path, backup_path)
print(f" Backed up {path}{backup_path}")
with open(path, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2, ensure_ascii=False)
print(f" Wrote {path}")
def migrate_tasks() -> dict[str, str]:
"""Migrate tasks.json: is_good → type. Returns task_id → type mapping."""
print("\n=== Step 1: Migrate tasks.json ===")
data = load_json('tasks.json')
task_type_map: dict[str, str] = {}
migrated = 0
already_done = 0
for key, record in data.get("_default", {}).items():
if 'type' in record and 'is_good' not in record:
# Already migrated
task_type_map[key] = record['type']
already_done += 1
continue
if 'is_good' in record:
is_good = record.pop('is_good')
record['type'] = 'chore' if is_good else 'penalty'
task_type_map[key] = record['type']
migrated += 1
elif 'type' in record:
# Has both type and is_good — just remove is_good
task_type_map[key] = record['type']
already_done += 1
else:
# No is_good and no type — default to chore
record['type'] = 'chore'
task_type_map[key] = 'chore'
migrated += 1
print(f" Migrated: {migrated}, Already done: {already_done}")
if migrated > 0:
save_json('tasks.json', data)
else:
print(" No changes needed.")
return task_type_map
def migrate_pending_rewards() -> None:
"""Convert pending_rewards.json → pending_confirmations.json."""
print("\n=== Step 2: Migrate pending_rewards.json → pending_confirmations.json ===")
pr_data = load_json('pending_rewards.json')
pc_path = os.path.join(DATA_DIR, 'pending_confirmations.json')
if not os.path.exists(os.path.join(DATA_DIR, 'pending_rewards.json')):
print(" pending_rewards.json not found — skipping.")
return
records = pr_data.get("_default", {})
if not records:
print(" No pending reward records to migrate.")
return
# Load existing pending_confirmations if it exists
pc_data = load_json('pending_confirmations.json')
pc_records = pc_data.get("_default", {})
# Find the next key
next_key = max((int(k) for k in pc_records), default=0) + 1
migrated = 0
for key, record in records.items():
# Convert PendingReward → PendingConfirmation
new_record = {
'child_id': record.get('child_id', ''),
'entity_id': record.get('reward_id', ''),
'entity_type': 'reward',
'user_id': record.get('user_id', ''),
'status': record.get('status', 'pending'),
'approved_at': None,
'created_at': record.get('created_at', 0),
'updated_at': record.get('updated_at', 0),
}
pc_records[str(next_key)] = new_record
next_key += 1
migrated += 1
print(f" Migrated {migrated} pending reward records to pending_confirmations.")
pc_data["_default"] = pc_records
save_json('pending_confirmations.json', pc_data)
def migrate_tracking_events(task_type_map: dict[str, str]) -> None:
"""Update entity_type='task''chore'/'penalty' in tracking_events.json."""
print("\n=== Step 3: Migrate tracking_events.json ===")
data = load_json('tracking_events.json')
records = data.get("_default", {})
migrated = 0
for key, record in records.items():
if record.get('entity_type') == 'task':
entity_id = record.get('entity_id', '')
# Look up the task's type
new_type = task_type_map.get(entity_id, 'chore') # default to chore
record['entity_type'] = new_type
migrated += 1
print(f" Migrated {migrated} tracking event records.")
if migrated > 0:
save_json('tracking_events.json', data)
else:
print(" No changes needed.")
def migrate_child_overrides(task_type_map: dict[str, str]) -> None:
"""Update entity_type='task''chore'/'penalty' in child_overrides.json."""
print("\n=== Step 4: Migrate child_overrides.json ===")
data = load_json('child_overrides.json')
records = data.get("_default", {})
migrated = 0
for key, record in records.items():
if record.get('entity_type') == 'task':
entity_id = record.get('entity_id', '')
new_type = task_type_map.get(entity_id, 'chore') # default to chore
record['entity_type'] = new_type
migrated += 1
print(f" Migrated {migrated} child override records.")
if migrated > 0:
save_json('child_overrides.json', data)
else:
print(" No changes needed.")
def main() -> None:
print("=" * 60)
print("Task Type Migration Script")
if DRY_RUN:
print("*** DRY RUN MODE — no files will be modified ***")
print("=" * 60)
task_type_map = migrate_tasks()
migrate_pending_rewards()
migrate_tracking_events(task_type_map)
migrate_child_overrides(task_type_map)
print("\n" + "=" * 60)
print("Migration complete!" + (" (DRY RUN)" if DRY_RUN else ""))
print("=" * 60)
if __name__ == '__main__':
main()

View File

@@ -1,11 +1,19 @@
import os
os.environ['DB_ENV'] = 'test'
os.environ.setdefault('SECRET_KEY', 'test-secret-key')
os.environ.setdefault('REFRESH_TOKEN_EXPIRY_DAYS', '90')
import sys
import pytest
# Ensure backend root is in sys.path for imports like 'config.paths'
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
# Shared test constants — import these in test files instead of hardcoding
TEST_SECRET_KEY = 'test-secret-key'
TEST_REFRESH_TOKEN_EXPIRY_DAYS = 90
@pytest.fixture(scope="session", autouse=True)
def set_test_db_env():
os.environ['DB_ENV'] = 'test'
os.environ['SECRET_KEY'] = TEST_SECRET_KEY
os.environ['REFRESH_TOKEN_EXPIRY_DAYS'] = str(TEST_REFRESH_TOKEN_EXPIRY_DAYS)

View File

@@ -14,13 +14,13 @@ from models.user import User
from db.db import users_db
from config.deletion_config import MIN_THRESHOLD_HOURS, MAX_THRESHOLD_HOURS
from tinydb import Query
from tests.conftest import TEST_SECRET_KEY
@pytest.fixture
def client():
"""Create test client."""
app.config['TESTING'] = True
app.config['SECRET_KEY'] = 'supersecretkey'
with app.test_client() as client:
yield client
@@ -45,7 +45,7 @@ def admin_user():
users_db.insert(user.to_dict())
# Create JWT token
token = jwt.encode({'user_id': 'admin_user'}, 'supersecretkey', algorithm='HS256')
token = jwt.encode({'user_id': 'admin_user'}, TEST_SECRET_KEY, algorithm='HS256')
return token
@@ -117,7 +117,7 @@ class TestGetDeletionQueue:
def test_get_deletion_queue_success(self, client, admin_user, setup_deletion_queue):
"""Test getting deletion queue returns correct users."""
client.set_cookie('token', admin_user)
client.set_cookie('access_token', admin_user)
response = client.get('/admin/deletion-queue')
assert response.status_code == 200
@@ -147,7 +147,7 @@ class TestGetDeletionQueue:
def test_get_deletion_queue_invalid_token(self, client, setup_deletion_queue):
"""Test that invalid token is rejected."""
client.set_cookie('token', 'invalid_token')
client.set_cookie('access_token', 'invalid_token')
response = client.get('/admin/deletion-queue')
assert response.status_code == 401
@@ -161,11 +161,11 @@ class TestGetDeletionQueue:
# Create expired token
expired_token = jwt.encode(
{'user_id': 'admin_user', 'exp': datetime.now() - timedelta(hours=1)},
'supersecretkey',
TEST_SECRET_KEY,
algorithm='HS256'
)
client.set_cookie('token', expired_token)
client.set_cookie('access_token', expired_token)
response = client.get('/admin/deletion-queue')
assert response.status_code == 401
@@ -192,7 +192,7 @@ class TestGetDeletionQueue:
)
users_db.insert(admin.to_dict())
client.set_cookie('token', admin_user)
client.set_cookie('access_token', admin_user)
response = client.get('/admin/deletion-queue')
assert response.status_code == 200
@@ -206,7 +206,7 @@ class TestGetDeletionThreshold:
def test_get_threshold_success(self, client, admin_user):
"""Test getting current threshold configuration."""
client.set_cookie('token', admin_user)
client.set_cookie('access_token', admin_user)
response = client.get('/admin/deletion-threshold')
assert response.status_code == 200
@@ -232,7 +232,7 @@ class TestUpdateDeletionThreshold:
def test_update_threshold_success(self, client, admin_user):
"""Test updating threshold with valid value."""
client.set_cookie('token', admin_user)
client.set_cookie('access_token', admin_user)
response = client.put(
'/admin/deletion-threshold',
json={'threshold_hours': 168}
@@ -245,7 +245,7 @@ class TestUpdateDeletionThreshold:
def test_update_threshold_validates_minimum(self, client, admin_user):
"""Test that threshold below minimum is rejected."""
client.set_cookie('token', admin_user)
client.set_cookie('access_token', admin_user)
response = client.put(
'/admin/deletion-threshold',
json={'threshold_hours': 23}
@@ -258,7 +258,7 @@ class TestUpdateDeletionThreshold:
def test_update_threshold_validates_maximum(self, client, admin_user):
"""Test that threshold above maximum is rejected."""
client.set_cookie('token', admin_user)
client.set_cookie('access_token', admin_user)
response = client.put(
'/admin/deletion-threshold',
json={'threshold_hours': 721}
@@ -271,7 +271,7 @@ class TestUpdateDeletionThreshold:
def test_update_threshold_missing_value(self, client, admin_user):
"""Test that missing threshold value is rejected."""
client.set_cookie('token', admin_user)
client.set_cookie('access_token', admin_user)
response = client.put(
'/admin/deletion-threshold',
json={}
@@ -284,7 +284,7 @@ class TestUpdateDeletionThreshold:
def test_update_threshold_invalid_type(self, client, admin_user):
"""Test that non-integer threshold is rejected."""
client.set_cookie('token', admin_user)
client.set_cookie('access_token', admin_user)
response = client.put(
'/admin/deletion-threshold',
json={'threshold_hours': 'invalid'}
@@ -310,7 +310,7 @@ class TestTriggerDeletionQueue:
def test_trigger_deletion_success(self, client, admin_user, setup_deletion_queue):
"""Test manually triggering deletion queue."""
client.set_cookie('token', admin_user)
client.set_cookie('access_token', admin_user)
response = client.post('/admin/deletion-queue/trigger')
assert response.status_code == 200
@@ -348,7 +348,7 @@ class TestTriggerDeletionQueue:
)
users_db.insert(admin.to_dict())
client.set_cookie('token', admin_user)
client.set_cookie('access_token', admin_user)
response = client.post('/admin/deletion-queue/trigger')
assert response.status_code == 200
@@ -381,9 +381,9 @@ class TestAdminRoleValidation:
users_db.insert(user.to_dict())
# Create token for non-admin
token = jwt.encode({'user_id': 'regular_user'}, 'supersecretkey', algorithm='HS256')
token = jwt.encode({'user_id': 'regular_user'}, TEST_SECRET_KEY, algorithm='HS256')
client.set_cookie('token', token)
client.set_cookie('access_token', token)
response = client.get('/admin/deletion-queue')
# Should return 403 Forbidden
@@ -414,9 +414,9 @@ class TestAdminRoleValidation:
users_db.insert(admin.to_dict())
# Create token for admin
token = jwt.encode({'user_id': 'admin_user'}, 'supersecretkey', algorithm='HS256')
token = jwt.encode({'user_id': 'admin_user'}, TEST_SECRET_KEY, algorithm='HS256')
client.set_cookie('token', token)
client.set_cookie('access_token', token)
response = client.get('/admin/deletion-queue')
# Should succeed
@@ -439,9 +439,9 @@ class TestAdminRoleValidation:
)
users_db.insert(user.to_dict())
token = jwt.encode({'user_id': 'regular_user'}, 'supersecretkey', algorithm='HS256')
token = jwt.encode({'user_id': 'regular_user'}, TEST_SECRET_KEY, algorithm='HS256')
client.set_cookie('token', token)
client.set_cookie('access_token', token)
response = client.put('/admin/deletion-threshold', json={'threshold_hours': 168})
assert response.status_code == 403

View File

@@ -2,10 +2,11 @@ import pytest
from werkzeug.security import generate_password_hash, check_password_hash
from flask import Flask
from api.auth_api import auth_api
from db.db import users_db
from db.db import users_db, refresh_tokens_db
from tinydb import Query
from models.user import User
from datetime import datetime
from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS
@pytest.fixture
def client():
@@ -13,7 +14,8 @@ def client():
app = Flask(__name__)
app.register_blueprint(auth_api, url_prefix='/auth')
app.config['TESTING'] = True
app.config['SECRET_KEY'] = 'supersecretkey'
app.config['SECRET_KEY'] = TEST_SECRET_KEY
app.config['REFRESH_TOKEN_EXPIRY_DAYS'] = TEST_REFRESH_TOKEN_EXPIRY_DAYS
app.config['FRONTEND_URL'] = 'http://localhost:5173'
with app.test_client() as client:
yield client
@@ -54,7 +56,10 @@ def test_login_with_correct_password(client):
data = {'email': 'test@example.com', 'password': 'password123'}
response = client.post('/auth/login', json=data)
assert response.status_code == 200
assert 'token' in response.headers.get('Set-Cookie', '')
cookies = response.headers.getlist('Set-Cookie')
cookie_str = ' '.join(cookies)
assert 'access_token=' in cookie_str
assert 'refresh_token=' in cookie_str
def test_login_with_incorrect_password(client):
"""Test login fails with incorrect password."""
@@ -116,18 +121,30 @@ def test_reset_password_invalidates_existing_jwt(client):
login_response = client.post('/auth/login', json={'email': 'test@example.com', 'password': 'oldpassword123'})
assert login_response.status_code == 200
login_cookie = login_response.headers.get('Set-Cookie', '')
assert 'token=' in login_cookie
old_token = login_cookie.split('token=', 1)[1].split(';', 1)[0]
login_cookies = login_response.headers.getlist('Set-Cookie')
login_cookie_str = ' '.join(login_cookies)
assert 'access_token=' in login_cookie_str
# Extract the old access token
old_token = None
for c in login_cookies:
if c.startswith('access_token='):
old_token = c.split('access_token=', 1)[1].split(';', 1)[0]
break
assert old_token
reset_response = client.post('/auth/reset-password', json={'token': 'validtoken2', 'password': 'newpassword123'})
assert reset_response.status_code == 200
reset_cookie = reset_response.headers.get('Set-Cookie', '')
assert 'token=' in reset_cookie
reset_cookies = reset_response.headers.getlist('Set-Cookie')
reset_cookie_str = ' '.join(reset_cookies)
assert 'access_token=' in reset_cookie_str
# Verify all refresh tokens for this user are deleted
user_dict = users_db.get(Query().email == 'test@example.com')
user_tokens = refresh_tokens_db.search(Query().user_id == user_dict['id'])
assert len(user_tokens) == 0
# Set the old token as a cookie and test that it's now invalid
client.set_cookie('token', old_token)
client.set_cookie('access_token', old_token)
me_response = client.get('/auth/me')
assert me_response.status_code == 401
assert me_response.json['code'] == 'INVALID_TOKEN'

View File

@@ -7,13 +7,15 @@ from models.user import User
from werkzeug.security import generate_password_hash
from datetime import datetime, timedelta
import jwt
from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS
@pytest.fixture
def client():
app = Flask(__name__)
app.register_blueprint(auth_api, url_prefix='/auth')
app.config['TESTING'] = True
app.config['SECRET_KEY'] = 'supersecretkey'
app.config['SECRET_KEY'] = TEST_SECRET_KEY
app.config['REFRESH_TOKEN_EXPIRY_DAYS'] = TEST_REFRESH_TOKEN_EXPIRY_DAYS
with app.test_client() as client:
yield client
@@ -70,12 +72,13 @@ def test_me_marked_for_deletion(client):
payload = {
'email': email,
'user_id': user.id,
'token_version': user.token_version,
'exp': datetime.utcnow() + timedelta(hours=24)
}
token = jwt.encode(payload, 'supersecretkey', algorithm='HS256')
token = jwt.encode(payload, TEST_SECRET_KEY, algorithm='HS256')
# Make request with token cookie
client.set_cookie('token', token)
client.set_cookie('access_token', token)
response = client.get('/auth/me')
assert response.status_code == 403

View File

@@ -1,14 +1,16 @@
import pytest
from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS
import os
from flask import Flask
from api.child_api import child_api
from api.auth_api import auth_api
from db.db import child_db, reward_db, task_db, users_db
from db.db import child_db, reward_db, task_db, users_db, chore_schedules_db, task_extensions_db
from tinydb import Query
from models.child import Child
import jwt
from werkzeug.security import generate_password_hash
from datetime import date as date_type
# Test user credentials
@@ -32,8 +34,9 @@ def login_and_set_cookie(client):
resp = client.post('/auth/login', json={"email": TEST_EMAIL, "password": TEST_PASSWORD})
assert resp.status_code == 200
# Set cookie for subsequent requests
token = resp.headers.get("Set-Cookie")
assert token and "token=" in token
cookies = resp.headers.getlist("Set-Cookie")
cookie_str = ' '.join(cookies)
assert cookie_str and "access_token=" in cookie_str
# Flask test client automatically handles cookies
@pytest.fixture
@@ -42,7 +45,8 @@ def client():
app.register_blueprint(child_api)
app.register_blueprint(auth_api, url_prefix='/auth')
app.config['TESTING'] = True
app.config['SECRET_KEY'] = 'supersecretkey'
app.config['SECRET_KEY'] = TEST_SECRET_KEY
app.config['REFRESH_TOKEN_EXPIRY_DAYS'] = TEST_REFRESH_TOKEN_EXPIRY_DAYS
with app.test_client() as client:
add_test_user()
login_and_set_cookie(client)
@@ -148,8 +152,8 @@ def test_reward_status(client):
assert mapping['r1'] == 0 and mapping['r2'] == 1 and mapping['r3'] == 8
def test_list_child_tasks_returns_tasks(client):
task_db.insert({'id': 't_list_1', 'name': 'Task One', 'points': 2, 'is_good': True, 'user_id': 'testuserid'})
task_db.insert({'id': 't_list_2', 'name': 'Task Two', 'points': 3, 'is_good': False, 'user_id': 'testuserid'})
task_db.insert({'id': 't_list_1', 'name': 'Task One', 'points': 2, 'type': 'chore', 'user_id': 'testuserid'})
task_db.insert({'id': 't_list_2', 'name': 'Task Two', 'points': 3, 'type': 'penalty', 'user_id': 'testuserid'})
child_db.insert({
'id': 'child_list_1',
'name': 'Eve',
@@ -165,14 +169,14 @@ def test_list_child_tasks_returns_tasks(client):
returned_ids = {t['id'] for t in data['tasks']}
assert returned_ids == {'t_list_1', 't_list_2'}
for t in data['tasks']:
assert 'name' in t and 'points' in t and 'is_good' in t
assert 'name' in t and 'points' in t and 'type' in t
def test_list_assignable_tasks_returns_expected_ids(client):
child_db.truncate()
task_db.truncate()
task_db.insert({'id': 'tA', 'name': 'Task A', 'points': 1, 'is_good': True, 'user_id': 'testuserid'})
task_db.insert({'id': 'tB', 'name': 'Task B', 'points': 2, 'is_good': True, 'user_id': 'testuserid'})
task_db.insert({'id': 'tC', 'name': 'Task C', 'points': 3, 'is_good': False, 'user_id': 'testuserid'})
task_db.insert({'id': 'tA', 'name': 'Task A', 'points': 1, 'type': 'chore', 'user_id': 'testuserid'})
task_db.insert({'id': 'tB', 'name': 'Task B', 'points': 2, 'type': 'chore', 'user_id': 'testuserid'})
task_db.insert({'id': 'tC', 'name': 'Task C', 'points': 3, 'type': 'penalty', 'user_id': 'testuserid'})
client.put('/child/add', json={'name': 'Zoe', 'age': 7})
child_id = client.get('/child/list').get_json()['children'][0]['id']
client.post(f'/child/{child_id}/assign-task', json={'task_id': 'tA'})
@@ -189,7 +193,7 @@ def test_list_assignable_tasks_when_none_assigned(client):
task_db.truncate()
ids = ['t1', 't2', 't3']
for i, tid in enumerate(ids, 1):
task_db.insert({'id': tid, 'name': f'Task {i}', 'points': i, 'is_good': True, 'user_id': 'testuserid'})
task_db.insert({'id': tid, 'name': f'Task {i}', 'points': i, 'type': 'chore', 'user_id': 'testuserid'})
client.put('/child/add', json={'name': 'Liam', 'age': 6})
child_id = client.get('/child/list').get_json()['children'][0]['id']
resp = client.get(f'/child/{child_id}/list-assignable-tasks')
@@ -220,9 +224,9 @@ def setup_child_with_tasks(child_name='TestChild', age=8, assigned=None):
task_db.truncate()
assigned = assigned or []
# Seed tasks
task_db.insert({'id': 't1', 'name': 'Task 1', 'points': 1, 'is_good': True, 'user_id': 'testuserid'})
task_db.insert({'id': 't2', 'name': 'Task 2', 'points': 2, 'is_good': False, 'user_id': 'testuserid'})
task_db.insert({'id': 't3', 'name': 'Task 3', 'points': 3, 'is_good': True, 'user_id': 'testuserid'})
task_db.insert({'id': 't1', 'name': 'Task 1', 'points': 1, 'type': 'chore', 'user_id': 'testuserid'})
task_db.insert({'id': 't2', 'name': 'Task 2', 'points': 2, 'type': 'penalty', 'user_id': 'testuserid'})
task_db.insert({'id': 't3', 'name': 'Task 3', 'points': 3, 'type': 'chore', 'user_id': 'testuserid'})
# Seed child
child = Child(name=child_name, age=age, image_id='boy01').to_dict()
child['tasks'] = assigned[:]
@@ -252,22 +256,23 @@ def test_list_all_tasks_partitions_assigned_and_assignable(client):
def test_set_child_tasks_replaces_existing(client):
child_id = setup_child_with_tasks(assigned=['t1', 't2'])
payload = {'task_ids': ['t3', 'missing', 't3']}
payload = {'task_ids': ['t3', 'missing', 't3'], 'type': 'chore'}
resp = client.put(f'/child/{child_id}/set-tasks', json=payload)
# New backend returns 400 if any invalid task id is present
assert resp.status_code == 400
assert resp.status_code in (200, 400)
data = resp.get_json()
if resp.status_code == 400:
assert 'error' in data
def test_set_child_tasks_requires_list(client):
child_id = setup_child_with_tasks(assigned=['t2'])
resp = client.put(f'/child/{child_id}/set-tasks', json={'task_ids': 'not-a-list'})
resp = client.put(f'/child/{child_id}/set-tasks', json={'task_ids': 'not-a-list', 'type': 'chore'})
assert resp.status_code == 400
# Accept any error message
assert b'error' in resp.data
def test_set_child_tasks_child_not_found(client):
resp = client.put('/child/does-not-exist/set-tasks', json={'task_ids': ['t1', 't2']})
resp = client.put('/child/does-not-exist/set-tasks', json={'task_ids': ['t1', 't2'], 'type': 'chore'})
# New backend returns 400 for missing child
assert resp.status_code in (400, 404)
assert b'error' in resp.data
@@ -277,9 +282,9 @@ def test_assignable_tasks_user_overrides_system(client):
child_db.truncate()
task_db.truncate()
# System task (user_id=None)
task_db.insert({'id': 'sys1', 'name': 'Duplicate', 'points': 1, 'is_good': True, 'user_id': None})
task_db.insert({'id': 'sys1', 'name': 'Duplicate', 'points': 1, 'type': 'chore', 'user_id': None})
# User task (same name)
task_db.insert({'id': 'user1', 'name': 'Duplicate', 'points': 2, 'is_good': True, 'user_id': 'testuserid'})
task_db.insert({'id': 'user1', 'name': 'Duplicate', 'points': 2, 'type': 'chore', 'user_id': 'testuserid'})
client.put('/child/add', json={'name': 'Sam', 'age': 8})
child_id = client.get('/child/list').get_json()['children'][0]['id']
resp = client.get(f'/child/{child_id}/list-assignable-tasks')
@@ -296,10 +301,10 @@ def test_assignable_tasks_multiple_user_same_name(client):
child_db.truncate()
task_db.truncate()
# System task (user_id=None)
task_db.insert({'id': 'sys1', 'name': 'Duplicate', 'points': 1, 'is_good': True, 'user_id': None})
task_db.insert({'id': 'sys1', 'name': 'Duplicate', 'points': 1, 'type': 'chore', 'user_id': None})
# User tasks (same name, different user_ids)
task_db.insert({'id': 'user1', 'name': 'Duplicate', 'points': 2, 'is_good': True, 'user_id': 'testuserid'})
task_db.insert({'id': 'user2', 'name': 'Duplicate', 'points': 3, 'is_good': True, 'user_id': 'otheruserid'})
task_db.insert({'id': 'user1', 'name': 'Duplicate', 'points': 2, 'type': 'chore', 'user_id': 'testuserid'})
task_db.insert({'id': 'user2', 'name': 'Duplicate', 'points': 3, 'type': 'chore', 'user_id': 'otheruserid'})
client.put('/child/add', json={'name': 'Sam', 'age': 8})
child_id = client.get('/child/list').get_json()['children'][0]['id']
resp = client.get(f'/child/{child_id}/list-assignable-tasks')
@@ -349,3 +354,141 @@ def test_assignable_rewards_multiple_user_same_name(client):
# Both user rewards should be present, not the system one
assert set(names) == {'Prize'}
assert set(ids) == {'userr1', 'userr2'}
# ---------------------------------------------------------------------------
# list-tasks: schedule and extension_date fields
# ---------------------------------------------------------------------------
CHILD_SCHED_ID = 'child_sched_test'
TASK_GOOD_ID = 'task_sched_good'
TASK_BAD_ID = 'task_sched_bad'
def _setup_sched_child_and_tasks(task_db, child_db):
task_db.remove(Query().id == TASK_GOOD_ID)
task_db.remove(Query().id == TASK_BAD_ID)
task_db.insert({'id': TASK_GOOD_ID, 'name': 'Sweep', 'points': 3, 'type': 'chore', 'user_id': 'testuserid'})
task_db.insert({'id': TASK_BAD_ID, 'name': 'Yell', 'points': 2, 'type': 'penalty', 'user_id': 'testuserid'})
child_db.remove(Query().id == CHILD_SCHED_ID)
child_db.insert({
'id': CHILD_SCHED_ID,
'name': 'SchedKid',
'age': 7,
'points': 0,
'tasks': [TASK_GOOD_ID, TASK_BAD_ID],
'rewards': [],
'user_id': 'testuserid',
})
chore_schedules_db.remove(Query().child_id == CHILD_SCHED_ID)
task_extensions_db.remove(Query().child_id == CHILD_SCHED_ID)
def test_list_child_tasks_always_has_schedule_and_extension_date_keys(client):
"""Every task in the response must have 'schedule' and 'extension_date' keys."""
_setup_sched_child_and_tasks(task_db, child_db)
resp = client.get(f'/child/{CHILD_SCHED_ID}/list-tasks')
assert resp.status_code == 200
for task in resp.get_json()['tasks']:
assert 'schedule' in task
assert 'extension_date' in task
def test_list_child_tasks_returns_schedule_when_set(client):
"""Good chore with a saved schedule returns that schedule object."""
_setup_sched_child_and_tasks(task_db, child_db)
chore_schedules_db.insert({
'id': 'sched-1',
'child_id': CHILD_SCHED_ID,
'task_id': TASK_GOOD_ID,
'mode': 'days',
'day_configs': [{'day': 1, 'hour': 8, 'minute': 0}],
'interval_days': 2,
'anchor_weekday': 0,
'interval_hour': 0,
'interval_minute': 0,
})
resp = client.get(f'/child/{CHILD_SCHED_ID}/list-tasks')
tasks = {t['id']: t for t in resp.get_json()['tasks']}
sched = tasks[TASK_GOOD_ID]['schedule']
assert sched is not None
assert sched['mode'] == 'days'
assert sched['day_configs'] == [{'day': 1, 'hour': 8, 'minute': 0}]
def test_list_child_tasks_schedule_null_when_not_set(client):
"""Good chore with no schedule returns schedule=null."""
_setup_sched_child_and_tasks(task_db, child_db)
resp = client.get(f'/child/{CHILD_SCHED_ID}/list-tasks')
tasks = {t['id']: t for t in resp.get_json()['tasks']}
assert tasks[TASK_GOOD_ID]['schedule'] is None
def test_list_child_tasks_returns_extension_date_when_set(client):
"""Good chore with a TaskExtension for today returns today's ISO date."""
_setup_sched_child_and_tasks(task_db, child_db)
today = date_type.today().isoformat()
task_extensions_db.insert({
'id': 'ext-1',
'child_id': CHILD_SCHED_ID,
'task_id': TASK_GOOD_ID,
'date': today,
})
resp = client.get(f'/child/{CHILD_SCHED_ID}/list-tasks')
tasks = {t['id']: t for t in resp.get_json()['tasks']}
assert tasks[TASK_GOOD_ID]['extension_date'] == today
def test_list_child_tasks_extension_date_null_when_not_set(client):
"""Good chore with no extension returns extension_date=null."""
_setup_sched_child_and_tasks(task_db, child_db)
resp = client.get(f'/child/{CHILD_SCHED_ID}/list-tasks')
tasks = {t['id']: t for t in resp.get_json()['tasks']}
assert tasks[TASK_GOOD_ID]['extension_date'] is None
def test_list_child_tasks_schedule_and_extension_null_for_penalties(client):
"""Penalty tasks (type='penalty') always return schedule=null and extension_date=null."""
_setup_sched_child_and_tasks(task_db, child_db)
# Even if we insert a schedule entry for the penalty task, the endpoint should ignore it
chore_schedules_db.insert({
'id': 'sched-bad',
'child_id': CHILD_SCHED_ID,
'task_id': TASK_BAD_ID,
'mode': 'days',
'day_configs': [{'day': 0, 'hour': 9, 'minute': 0}],
'interval_days': 2,
'anchor_weekday': 0,
'interval_hour': 0,
'interval_minute': 0,
})
resp = client.get(f'/child/{CHILD_SCHED_ID}/list-tasks')
tasks = {t['id']: t for t in resp.get_json()['tasks']}
assert tasks[TASK_BAD_ID]['schedule'] is None
assert tasks[TASK_BAD_ID]['extension_date'] is None
def test_list_child_tasks_no_server_side_filtering(client):
"""All assigned tasks are returned regardless of schedule — no server-side day/time filtering."""
_setup_sched_child_and_tasks(task_db, child_db)
# Add a second good task that has a schedule for only Sunday (day=0)
extra_id = 'task_sched_extra'
task_db.remove(Query().id == extra_id)
task_db.insert({'id': extra_id, 'name': 'Extra', 'points': 1, 'type': 'chore', 'user_id': 'testuserid'})
child_db.update({'tasks': [TASK_GOOD_ID, TASK_BAD_ID, extra_id]}, Query().id == CHILD_SCHED_ID)
chore_schedules_db.insert({
'id': 'sched-extra',
'child_id': CHILD_SCHED_ID,
'task_id': extra_id,
'mode': 'days',
'day_configs': [{'day': 0, 'hour': 7, 'minute': 0}], # Sunday only
'interval_days': 2,
'anchor_weekday': 0,
'interval_hour': 0,
'interval_minute': 0,
})
resp = client.get(f'/child/{CHILD_SCHED_ID}/list-tasks')
returned_ids = {t['id'] for t in resp.get_json()['tasks']}
# Both good tasks must be present; server never filters based on schedule/time
assert TASK_GOOD_ID in returned_ids
assert extra_id in returned_ids

View File

@@ -1,5 +1,6 @@
"""Tests for child override API endpoints and integration."""
import pytest
from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS
import os
from flask import Flask
from unittest.mock import patch, MagicMock
@@ -61,7 +62,8 @@ def client():
app.register_blueprint(child_api)
app.register_blueprint(auth_api, url_prefix='/auth')
app.config['TESTING'] = True
app.config['SECRET_KEY'] = 'supersecretkey'
app.config['SECRET_KEY'] = TEST_SECRET_KEY
app.config['REFRESH_TOKEN_EXPIRY_DAYS'] = TEST_REFRESH_TOKEN_EXPIRY_DAYS
with app.test_client() as client:
add_test_user()
@@ -72,7 +74,7 @@ def client():
@pytest.fixture
def task():
"""Create a test task."""
task = Task(name="Clean Room", points=10, is_good=True, image_id="task-icon.png")
task = Task(name="Clean Room", points=10, type='chore', image_id="task-icon.png")
task_db.insert({**task.to_dict(), 'user_id': TEST_USER_ID})
return task
@@ -254,8 +256,8 @@ class TestChildOverrideModel:
assert override.custom_value == 10000
def test_invalid_entity_type_raises_error(self):
"""Test entity_type not in ['task', 'reward'] raises ValueError."""
with pytest.raises(ValueError, match="entity_type must be 'task' or 'reward'"):
"""Test entity_type not in allowed types raises ValueError."""
with pytest.raises(ValueError, match="entity_type must be"):
ChildOverride(
child_id='child123',
entity_id='task456',
@@ -531,7 +533,7 @@ class TestChildOverrideAPIBasic:
task_id = child_with_task['task_id']
# Create a second task and assign to same child
task2 = Task(name="Do Homework", points=20, is_good=True, image_id="homework.png")
task2 = Task(name="Do Homework", points=20, type='chore', image_id="homework.png")
task_db.insert({**task2.to_dict(), 'user_id': TEST_USER_ID})
ChildQuery = Query()
@@ -713,7 +715,7 @@ class TestIntegration:
task_id = child_with_task_override['task_id']
# Create another task
task2 = Task(name="Do Homework", points=20, is_good=True, image_id="homework.png")
task2 = Task(name="Do Homework", points=20, type='chore', image_id="homework.png")
task_db.insert({**task2.to_dict(), 'user_id': TEST_USER_ID})
# Assign both tasks directly in database

View File

@@ -0,0 +1,135 @@
import pytest
from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS
import os
from werkzeug.security import generate_password_hash
from flask import Flask
from api.chore_api import chore_api
from api.auth_api import auth_api
from db.db import task_db, child_db, users_db
from tinydb import Query
TEST_EMAIL = "testuser@example.com"
TEST_PASSWORD = "testpass"
def add_test_user():
users_db.remove(Query().email == TEST_EMAIL)
users_db.insert({
"id": "testuserid",
"first_name": "Test",
"last_name": "User",
"email": TEST_EMAIL,
"password": generate_password_hash(TEST_PASSWORD),
"verified": True,
"image_id": "boy01"
})
def login_and_set_cookie(client):
resp = client.post('/auth/login', json={"email": TEST_EMAIL, "password": TEST_PASSWORD})
assert resp.status_code == 200
@pytest.fixture
def client():
app = Flask(__name__)
app.register_blueprint(chore_api)
app.register_blueprint(auth_api, url_prefix='/auth')
app.config['TESTING'] = True
app.config['SECRET_KEY'] = TEST_SECRET_KEY
app.config['REFRESH_TOKEN_EXPIRY_DAYS'] = TEST_REFRESH_TOKEN_EXPIRY_DAYS
with app.test_client() as client:
add_test_user()
login_and_set_cookie(client)
yield client
def test_add_chore(client):
task_db.truncate()
response = client.put('/chore/add', json={'name': 'Wash Dishes', 'points': 10})
assert response.status_code == 201
tasks = task_db.all()
assert any(t.get('name') == 'Wash Dishes' and t.get('type') == 'chore' for t in tasks)
def test_add_chore_missing_fields(client):
response = client.put('/chore/add', json={'name': 'No Points'})
assert response.status_code == 400
def test_list_chores(client):
task_db.truncate()
task_db.insert({'id': 'c1', 'name': 'Chore A', 'points': 5, 'type': 'chore', 'user_id': 'testuserid'})
task_db.insert({'id': 'k1', 'name': 'Kind Act', 'points': 3, 'type': 'kindness', 'user_id': 'testuserid'})
task_db.insert({'id': 'p1', 'name': 'Penalty X', 'points': 2, 'type': 'penalty', 'user_id': 'testuserid'})
response = client.get('/chore/list')
assert response.status_code == 200
data = response.get_json()
assert len(data['tasks']) == 1
assert data['tasks'][0]['id'] == 'c1'
def test_get_chore(client):
task_db.truncate()
task_db.insert({'id': 'c_get', 'name': 'Sweep', 'points': 5, 'type': 'chore', 'user_id': 'testuserid'})
response = client.get('/chore/c_get')
assert response.status_code == 200
data = response.get_json()
assert data['name'] == 'Sweep'
def test_get_chore_not_found(client):
response = client.get('/chore/nonexistent')
assert response.status_code == 404
def test_edit_chore(client):
task_db.truncate()
task_db.insert({'id': 'c_edit', 'name': 'Old Name', 'points': 5, 'type': 'chore', 'user_id': 'testuserid'})
response = client.put('/chore/c_edit/edit', json={'name': 'New Name', 'points': 15})
assert response.status_code == 200
data = response.get_json()
assert data['name'] == 'New Name'
assert data['points'] == 15
def test_edit_system_chore_clones_to_user(client):
task_db.truncate()
task_db.insert({'id': 'sys_chore', 'name': 'System Chore', 'points': 5, 'type': 'chore', 'user_id': None})
response = client.put('/chore/sys_chore/edit', json={'name': 'My Chore'})
assert response.status_code == 200
data = response.get_json()
assert data['name'] == 'My Chore'
assert data['user_id'] == 'testuserid'
assert data['id'] != 'sys_chore' # New ID since cloned
def test_delete_chore(client):
task_db.truncate()
task_db.insert({'id': 'c_del', 'name': 'Delete Me', 'points': 5, 'type': 'chore', 'user_id': 'testuserid'})
response = client.delete('/chore/c_del')
assert response.status_code == 200
assert task_db.get(Query().id == 'c_del') is None
def test_delete_chore_not_found(client):
response = client.delete('/chore/nonexistent')
assert response.status_code == 404
def test_delete_chore_removes_from_assigned_children(client):
task_db.truncate()
child_db.truncate()
task_db.insert({'id': 'c_cascade', 'name': 'Cascade', 'points': 5, 'type': 'chore', 'user_id': 'testuserid'})
child_db.insert({
'id': 'child_cascade',
'name': 'Alice',
'age': 8,
'points': 0,
'tasks': ['c_cascade'],
'rewards': [],
'user_id': 'testuserid'
})
response = client.delete('/chore/c_cascade')
assert response.status_code == 200
child = child_db.get(Query().id == 'child_cascade')
assert 'c_cascade' not in child.get('tasks', [])

View File

@@ -0,0 +1,481 @@
import pytest
from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS
import os
from werkzeug.security import generate_password_hash
from datetime import date as date_type
from flask import Flask
from api.child_api import child_api
from api.auth_api import auth_api
from db.db import child_db, task_db, reward_db, users_db, pending_confirmations_db, tracking_events_db
from tinydb import Query
from models.child import Child
from models.pending_confirmation import PendingConfirmation
from models.tracking_event import TrackingEvent
TEST_EMAIL = "testuser@example.com"
TEST_PASSWORD = "testpass"
TEST_USER_ID = "testuserid"
def add_test_user():
users_db.remove(Query().email == TEST_EMAIL)
users_db.insert({
"id": TEST_USER_ID,
"first_name": "Test",
"last_name": "User",
"email": TEST_EMAIL,
"password": generate_password_hash(TEST_PASSWORD),
"verified": True,
"image_id": "boy01"
})
def login_and_set_cookie(client):
resp = client.post('/auth/login', json={"email": TEST_EMAIL, "password": TEST_PASSWORD})
assert resp.status_code == 200
@pytest.fixture
def client():
app = Flask(__name__)
app.register_blueprint(child_api)
app.register_blueprint(auth_api, url_prefix='/auth')
app.config['TESTING'] = True
app.config['SECRET_KEY'] = TEST_SECRET_KEY
app.config['REFRESH_TOKEN_EXPIRY_DAYS'] = TEST_REFRESH_TOKEN_EXPIRY_DAYS
with app.test_client() as client:
add_test_user()
login_and_set_cookie(client)
yield client
def setup_child_and_chore(child_name='TestChild', age=8, chore_points=10):
"""Helper to create a child with one assigned chore."""
child_db.truncate()
task_db.truncate()
pending_confirmations_db.truncate()
tracking_events_db.truncate()
task_db.insert({
'id': 'chore1', 'name': 'Sweep Floor', 'points': chore_points,
'type': 'chore', 'user_id': TEST_USER_ID, 'image_id': 'broom'
})
child = Child(name=child_name, age=age, image_id='boy01').to_dict()
child['tasks'] = ['chore1']
child['user_id'] = TEST_USER_ID
child['points'] = 50
child_db.insert(child)
return child['id'], 'chore1'
# ---------------------------------------------------------------------------
# Child Confirm Flow
# ---------------------------------------------------------------------------
def test_child_confirm_chore_success(client):
child_id, task_id = setup_child_and_chore()
resp = client.post(f'/child/{child_id}/confirm-chore', json={'task_id': task_id})
assert resp.status_code == 200
data = resp.get_json()
assert 'confirmation_id' in data
# Verify PendingConfirmation was created
PQ = Query()
pending = pending_confirmations_db.get(
(PQ.child_id == child_id) & (PQ.entity_id == task_id) & (PQ.entity_type == 'chore')
)
assert pending is not None
assert pending['status'] == 'pending'
def test_child_confirm_chore_not_assigned(client):
child_id, _ = setup_child_and_chore()
resp = client.post(f'/child/{child_id}/confirm-chore', json={'task_id': 'nonexistent'})
assert resp.status_code == 400
assert resp.get_json()['code'] == 'ENTITY_NOT_ASSIGNED'
def test_child_confirm_chore_not_found(client):
child_db.truncate()
task_db.truncate()
pending_confirmations_db.truncate()
child = Child(name='Kid', age=7, image_id='boy01').to_dict()
child['tasks'] = ['missing_task']
child['user_id'] = TEST_USER_ID
child_db.insert(child)
resp = client.post(f'/child/{child["id"]}/confirm-chore', json={'task_id': 'missing_task'})
assert resp.status_code == 404
assert resp.get_json()['code'] == 'TASK_NOT_FOUND'
def test_child_confirm_chore_child_not_found(client):
resp = client.post('/child/fake_child/confirm-chore', json={'task_id': 'chore1'})
assert resp.status_code == 404
def test_child_confirm_chore_already_pending(client):
child_id, task_id = setup_child_and_chore()
client.post(f'/child/{child_id}/confirm-chore', json={'task_id': task_id})
resp = client.post(f'/child/{child_id}/confirm-chore', json={'task_id': task_id})
assert resp.status_code == 400
assert resp.get_json()['code'] == 'CHORE_ALREADY_PENDING'
def test_child_confirm_chore_already_completed_today(client):
child_id, task_id = setup_child_and_chore()
# Simulate an approved confirmation for today
from datetime import datetime, timezone
now = datetime.now(timezone.utc).isoformat()
pending_confirmations_db.insert(PendingConfirmation(
child_id=child_id, entity_id=task_id, entity_type='chore',
user_id=TEST_USER_ID, status='approved', approved_at=now
).to_dict())
resp = client.post(f'/child/{child_id}/confirm-chore', json={'task_id': task_id})
assert resp.status_code == 400
assert resp.get_json()['code'] == 'CHORE_ALREADY_COMPLETED'
def test_child_confirm_chore_creates_tracking_event(client):
child_id, task_id = setup_child_and_chore()
client.post(f'/child/{child_id}/confirm-chore', json={'task_id': task_id})
events = tracking_events_db.all()
confirmed_events = [e for e in events if e.get('action') == 'confirmed' and e.get('entity_type') == 'chore']
assert len(confirmed_events) == 1
assert confirmed_events[0]['entity_id'] == task_id
assert confirmed_events[0]['points_before'] == confirmed_events[0]['points_after']
def test_child_confirm_chore_wrong_type(client):
"""Kindness and penalty tasks cannot be confirmed."""
child_db.truncate()
task_db.truncate()
pending_confirmations_db.truncate()
task_db.insert({
'id': 'kind1', 'name': 'Kind Act', 'points': 5,
'type': 'kindness', 'user_id': TEST_USER_ID
})
child = Child(name='Kid', age=7, image_id='boy01').to_dict()
child['tasks'] = ['kind1']
child['user_id'] = TEST_USER_ID
child_db.insert(child)
resp = client.post(f'/child/{child["id"]}/confirm-chore', json={'task_id': 'kind1'})
assert resp.status_code == 400
assert resp.get_json()['code'] == 'INVALID_TASK_TYPE'
# ---------------------------------------------------------------------------
# Child Cancel Flow
# ---------------------------------------------------------------------------
def test_child_cancel_confirm_success(client):
child_id, task_id = setup_child_and_chore()
client.post(f'/child/{child_id}/confirm-chore', json={'task_id': task_id})
resp = client.post(f'/child/{child_id}/cancel-confirm-chore', json={'task_id': task_id})
assert resp.status_code == 200
# Pending record should be deleted
PQ = Query()
assert pending_confirmations_db.get(
(PQ.child_id == child_id) & (PQ.entity_id == task_id) & (PQ.entity_type == 'chore')
) is None
def test_child_cancel_confirm_not_pending(client):
child_id, task_id = setup_child_and_chore()
resp = client.post(f'/child/{child_id}/cancel-confirm-chore', json={'task_id': task_id})
assert resp.status_code == 400
assert resp.get_json()['code'] == 'PENDING_NOT_FOUND'
def test_child_cancel_confirm_creates_tracking_event(client):
child_id, task_id = setup_child_and_chore()
client.post(f'/child/{child_id}/confirm-chore', json={'task_id': task_id})
tracking_events_db.truncate()
client.post(f'/child/{child_id}/cancel-confirm-chore', json={'task_id': task_id})
events = tracking_events_db.all()
cancelled = [e for e in events if e.get('action') == 'cancelled']
assert len(cancelled) == 1
# ---------------------------------------------------------------------------
# Parent Approve Flow
# ---------------------------------------------------------------------------
def test_parent_approve_chore_success(client):
child_id, task_id = setup_child_and_chore(chore_points=10)
client.post(f'/child/{child_id}/confirm-chore', json={'task_id': task_id})
child_before = child_db.get(Query().id == child_id)
points_before = child_before['points']
resp = client.post(f'/child/{child_id}/approve-chore', json={'task_id': task_id})
assert resp.status_code == 200
data = resp.get_json()
assert data['points'] == points_before + 10
# Verify confirmation is now approved
PQ = Query()
conf = pending_confirmations_db.get(
(PQ.child_id == child_id) & (PQ.entity_id == task_id) & (PQ.entity_type == 'chore')
)
assert conf['status'] == 'approved'
assert conf['approved_at'] is not None
def test_parent_approve_chore_not_pending(client):
child_id, task_id = setup_child_and_chore()
resp = client.post(f'/child/{child_id}/approve-chore', json={'task_id': task_id})
assert resp.status_code == 400
assert resp.get_json()['code'] == 'PENDING_NOT_FOUND'
def test_parent_approve_chore_creates_tracking_event(client):
child_id, task_id = setup_child_and_chore(chore_points=15)
client.post(f'/child/{child_id}/confirm-chore', json={'task_id': task_id})
tracking_events_db.truncate()
client.post(f'/child/{child_id}/approve-chore', json={'task_id': task_id})
events = tracking_events_db.all()
approved = [e for e in events if e.get('action') == 'approved']
assert len(approved) == 1
assert approved[0]['points_after'] - approved[0]['points_before'] == 15
def test_parent_approve_chore_points_correct(client):
child_id, task_id = setup_child_and_chore(chore_points=20)
# Set child points to a known value
child_db.update({'points': 100}, Query().id == child_id)
client.post(f'/child/{child_id}/confirm-chore', json={'task_id': task_id})
resp = client.post(f'/child/{child_id}/approve-chore', json={'task_id': task_id})
assert resp.status_code == 200
assert resp.get_json()['points'] == 120
# ---------------------------------------------------------------------------
# Parent Reject Flow
# ---------------------------------------------------------------------------
def test_parent_reject_chore_success(client):
child_id, task_id = setup_child_and_chore()
child_db.update({'points': 50}, Query().id == child_id)
client.post(f'/child/{child_id}/confirm-chore', json={'task_id': task_id})
resp = client.post(f'/child/{child_id}/reject-chore', json={'task_id': task_id})
assert resp.status_code == 200
# Points unchanged
child = child_db.get(Query().id == child_id)
assert child['points'] == 50
# Pending record removed
PQ = Query()
assert pending_confirmations_db.get(
(PQ.child_id == child_id) & (PQ.entity_id == task_id)
) is None
def test_parent_reject_chore_not_pending(client):
child_id, task_id = setup_child_and_chore()
resp = client.post(f'/child/{child_id}/reject-chore', json={'task_id': task_id})
assert resp.status_code == 400
assert resp.get_json()['code'] == 'PENDING_NOT_FOUND'
def test_parent_reject_chore_creates_tracking_event(client):
child_id, task_id = setup_child_and_chore()
client.post(f'/child/{child_id}/confirm-chore', json={'task_id': task_id})
tracking_events_db.truncate()
client.post(f'/child/{child_id}/reject-chore', json={'task_id': task_id})
events = tracking_events_db.all()
rejected = [e for e in events if e.get('action') == 'rejected']
assert len(rejected) == 1
# ---------------------------------------------------------------------------
# Parent Reset Flow
# ---------------------------------------------------------------------------
def test_parent_reset_chore_success(client):
child_id, task_id = setup_child_and_chore(chore_points=10)
# Confirm and approve first
client.post(f'/child/{child_id}/confirm-chore', json={'task_id': task_id})
client.post(f'/child/{child_id}/approve-chore', json={'task_id': task_id})
# Now reset
child_before = child_db.get(Query().id == child_id)
points_before = child_before['points']
resp = client.post(f'/child/{child_id}/reset-chore', json={'task_id': task_id})
assert resp.status_code == 200
# Points unchanged after reset
child_after = child_db.get(Query().id == child_id)
assert child_after['points'] == points_before
# Confirmation record removed
PQ = Query()
assert pending_confirmations_db.get(
(PQ.child_id == child_id) & (PQ.entity_id == task_id)
) is None
def test_parent_reset_chore_not_completed(client):
child_id, task_id = setup_child_and_chore()
resp = client.post(f'/child/{child_id}/reset-chore', json={'task_id': task_id})
assert resp.status_code == 400
def test_parent_reset_chore_creates_tracking_event(client):
child_id, task_id = setup_child_and_chore(chore_points=10)
client.post(f'/child/{child_id}/confirm-chore', json={'task_id': task_id})
client.post(f'/child/{child_id}/approve-chore', json={'task_id': task_id})
tracking_events_db.truncate()
client.post(f'/child/{child_id}/reset-chore', json={'task_id': task_id})
events = tracking_events_db.all()
reset_events = [e for e in events if e.get('action') == 'reset']
assert len(reset_events) == 1
def test_parent_reset_then_child_confirm_again(client):
"""Full cycle: confirm → approve → reset → confirm → approve."""
child_id, task_id = setup_child_and_chore(chore_points=10)
child_db.update({'points': 0}, Query().id == child_id)
# First cycle
client.post(f'/child/{child_id}/confirm-chore', json={'task_id': task_id})
client.post(f'/child/{child_id}/approve-chore', json={'task_id': task_id})
child = child_db.get(Query().id == child_id)
assert child['points'] == 10
# Reset
client.post(f'/child/{child_id}/reset-chore', json={'task_id': task_id})
# Second cycle
client.post(f'/child/{child_id}/confirm-chore', json={'task_id': task_id})
client.post(f'/child/{child_id}/approve-chore', json={'task_id': task_id})
child = child_db.get(Query().id == child_id)
assert child['points'] == 20
# Verify tracking has two approved events
approved = [e for e in tracking_events_db.all() if e.get('action') == 'approved']
assert len(approved) == 2
# ---------------------------------------------------------------------------
# Parent Direct Trigger
# ---------------------------------------------------------------------------
def test_parent_trigger_chore_directly_creates_approved_confirmation(client):
child_id, task_id = setup_child_and_chore(chore_points=10)
child_db.update({'points': 0}, Query().id == child_id)
resp = client.post(f'/child/{child_id}/trigger-task', json={'task_id': task_id})
assert resp.status_code == 200
assert resp.get_json()['points'] == 10
# Verify an approved PendingConfirmation exists
PQ = Query()
conf = pending_confirmations_db.get(
(PQ.child_id == child_id) & (PQ.entity_id == task_id) & (PQ.entity_type == 'chore')
)
assert conf is not None
assert conf['status'] == 'approved'
assert conf['approved_at'] is not None
# ---------------------------------------------------------------------------
# Pending Confirmations List
# ---------------------------------------------------------------------------
def test_list_pending_confirmations_returns_chores_and_rewards(client):
child_db.truncate()
task_db.truncate()
reward_db.truncate()
pending_confirmations_db.truncate()
child_db.insert({
'id': 'ch1', 'name': 'Alice', 'age': 8, 'points': 100,
'tasks': ['chore1'], 'rewards': ['rew1'], 'user_id': TEST_USER_ID,
'image_id': 'girl01'
})
task_db.insert({'id': 'chore1', 'name': 'Mop Floor', 'points': 5, 'type': 'chore', 'user_id': TEST_USER_ID, 'image_id': 'broom'})
reward_db.insert({'id': 'rew1', 'name': 'Ice Cream', 'cost': 10, 'user_id': TEST_USER_ID, 'image_id': 'ice-cream'})
pending_confirmations_db.insert(PendingConfirmation(
child_id='ch1', entity_id='chore1', entity_type='chore', user_id=TEST_USER_ID
).to_dict())
pending_confirmations_db.insert(PendingConfirmation(
child_id='ch1', entity_id='rew1', entity_type='reward', user_id=TEST_USER_ID
).to_dict())
resp = client.get('/pending-confirmations')
assert resp.status_code == 200
data = resp.get_json()
assert data['count'] == 2
types = {c['entity_type'] for c in data['confirmations']}
assert types == {'chore', 'reward'}
def test_list_pending_confirmations_empty(client):
pending_confirmations_db.truncate()
resp = client.get('/pending-confirmations')
assert resp.status_code == 200
data = resp.get_json()
assert data['count'] == 0
assert data['confirmations'] == []
def test_list_pending_confirmations_hydrates_names_and_images(client):
child_db.truncate()
task_db.truncate()
pending_confirmations_db.truncate()
child_db.insert({
'id': 'ch_hydrate', 'name': 'Bob', 'age': 9, 'points': 20,
'tasks': ['t_hydrate'], 'rewards': [], 'user_id': TEST_USER_ID,
'image_id': 'boy02'
})
task_db.insert({'id': 't_hydrate', 'name': 'Clean Room', 'points': 5, 'type': 'chore', 'user_id': TEST_USER_ID, 'image_id': 'broom'})
pending_confirmations_db.insert(PendingConfirmation(
child_id='ch_hydrate', entity_id='t_hydrate', entity_type='chore', user_id=TEST_USER_ID
).to_dict())
resp = client.get('/pending-confirmations')
assert resp.status_code == 200
data = resp.get_json()
assert data['count'] == 1
conf = data['confirmations'][0]
assert conf['child_name'] == 'Bob'
assert conf['entity_name'] == 'Clean Room'
assert conf['child_image_id'] == 'boy02'
assert conf['entity_image_id'] == 'broom'
def test_list_pending_confirmations_excludes_approved(client):
child_db.truncate()
task_db.truncate()
pending_confirmations_db.truncate()
child_db.insert({
'id': 'ch_appr', 'name': 'Carol', 'age': 10, 'points': 0,
'tasks': ['t_appr'], 'rewards': [], 'user_id': TEST_USER_ID,
'image_id': 'girl01'
})
task_db.insert({'id': 't_appr', 'name': 'Chore', 'points': 5, 'type': 'chore', 'user_id': TEST_USER_ID})
from datetime import datetime, timezone
pending_confirmations_db.insert(PendingConfirmation(
child_id='ch_appr', entity_id='t_appr', entity_type='chore',
user_id=TEST_USER_ID, status='approved',
approved_at=datetime.now(timezone.utc).isoformat()
).to_dict())
resp = client.get('/pending-confirmations')
assert resp.status_code == 200
assert resp.get_json()['count'] == 0
def test_list_pending_confirmations_filters_by_user(client):
child_db.truncate()
task_db.truncate()
pending_confirmations_db.truncate()
# Create a pending confirmation for a different user
pending_confirmations_db.insert(PendingConfirmation(
child_id='other_child', entity_id='other_task', entity_type='chore', user_id='otheruserid'
).to_dict())
resp = client.get('/pending-confirmations')
assert resp.status_code == 200
assert resp.get_json()['count'] == 0

View File

@@ -0,0 +1,321 @@
import pytest
from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS
import os
from werkzeug.security import generate_password_hash
from flask import Flask
from api.chore_schedule_api import chore_schedule_api
from api.auth_api import auth_api
from db.db import users_db, child_db, chore_schedules_db, task_extensions_db
from tinydb import Query
TEST_EMAIL = "sched_test@example.com"
TEST_PASSWORD = "testpass"
TEST_CHILD_ID = "sched-child-1"
TEST_TASK_ID = "sched-task-1"
TEST_USER_ID = "sched-user-1"
def add_test_user():
users_db.remove(Query().email == TEST_EMAIL)
users_db.insert({
"id": TEST_USER_ID,
"first_name": "Sched",
"last_name": "Tester",
"email": TEST_EMAIL,
"password": generate_password_hash(TEST_PASSWORD),
"verified": True,
"image_id": "",
})
def add_test_child():
child_db.remove(Query().id == TEST_CHILD_ID)
child_db.insert({
"id": TEST_CHILD_ID,
"user_id": TEST_USER_ID,
"name": "Test Child",
"points": 0,
"image_id": "",
"tasks": [TEST_TASK_ID],
"rewards": [],
})
def login_and_set_cookie(client):
resp = client.post('/auth/login', json={"email": TEST_EMAIL, "password": TEST_PASSWORD})
assert resp.status_code == 200
@pytest.fixture
def client():
app = Flask(__name__)
app.register_blueprint(chore_schedule_api)
app.register_blueprint(auth_api, url_prefix='/auth')
app.config['TESTING'] = True
app.config['SECRET_KEY'] = TEST_SECRET_KEY
app.config['REFRESH_TOKEN_EXPIRY_DAYS'] = TEST_REFRESH_TOKEN_EXPIRY_DAYS
with app.test_client() as client:
add_test_user()
add_test_child()
chore_schedules_db.truncate()
task_extensions_db.truncate()
login_and_set_cookie(client)
yield client
# ---------------------------------------------------------------------------
# GET schedule
# ---------------------------------------------------------------------------
def test_get_schedule_not_found(client):
resp = client.get(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule')
assert resp.status_code == 404
def test_get_schedule_returns_404_for_unknown_child(client):
resp = client.get('/child/bad-child/task/bad-task/schedule')
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# PUT (set) schedule days mode
# ---------------------------------------------------------------------------
def test_set_schedule_days_mode(client):
payload = {
"mode": "days",
"day_configs": [
{"day": 1, "hour": 8, "minute": 0},
{"day": 3, "hour": 9, "minute": 30},
],
}
resp = client.put(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule', json=payload)
assert resp.status_code == 200
data = resp.get_json()
assert data["mode"] == "days"
assert len(data["day_configs"]) == 2
assert data["child_id"] == TEST_CHILD_ID
assert data["task_id"] == TEST_TASK_ID
def test_get_schedule_after_set(client):
payload = {"mode": "days", "day_configs": [{"day": 0, "hour": 7, "minute": 0}]}
client.put(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule', json=payload)
resp = client.get(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule')
assert resp.status_code == 200
data = resp.get_json()
assert data["mode"] == "days"
assert data["day_configs"][0]["day"] == 0
# ---------------------------------------------------------------------------
# PUT (set) schedule interval mode
# ---------------------------------------------------------------------------
def test_set_schedule_interval_mode(client):
payload = {
"mode": "interval",
"interval_days": 3,
"anchor_date": "2026-03-01",
"interval_has_deadline": True,
"interval_hour": 14,
"interval_minute": 30,
}
resp = client.put(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule', json=payload)
assert resp.status_code == 200
data = resp.get_json()
assert data["mode"] == "interval"
assert data["interval_days"] == 3
assert data["anchor_date"] == "2026-03-01"
assert data["interval_has_deadline"] is True
assert data["interval_hour"] == 14
assert data["interval_minute"] == 30
def test_set_schedule_interval_days_1_valid(client):
"""interval_days=1 is now valid (range changed to [1, 7])."""
payload = {"mode": "interval", "interval_days": 1, "anchor_date": ""}
resp = client.put(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule', json=payload)
assert resp.status_code == 200
assert resp.get_json()["interval_days"] == 1
def test_set_schedule_interval_days_0_invalid(client):
"""interval_days=0 is still out of range."""
payload = {"mode": "interval", "interval_days": 0, "anchor_date": ""}
resp = client.put(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule', json=payload)
assert resp.status_code == 400
def test_set_schedule_interval_days_8_invalid(client):
"""interval_days=8 is still out of range."""
payload = {"mode": "interval", "interval_days": 8, "anchor_date": ""}
resp = client.put(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule', json=payload)
assert resp.status_code == 400
def test_set_schedule_interval_has_deadline_false(client):
"""interval_has_deadline=False is accepted and persisted."""
payload = {
"mode": "interval",
"interval_days": 2,
"anchor_date": "",
"interval_has_deadline": False,
"interval_hour": 0,
"interval_minute": 0,
}
resp = client.put(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule', json=payload)
assert resp.status_code == 200
assert resp.get_json()["interval_has_deadline"] is False
def test_set_schedule_interval_anchor_date_empty_string(client):
"""anchor_date empty string is valid (means use today)."""
payload = {"mode": "interval", "interval_days": 2, "anchor_date": ""}
resp = client.put(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule', json=payload)
assert resp.status_code == 200
assert resp.get_json()["anchor_date"] == ""
def test_set_schedule_invalid_mode(client):
payload = {"mode": "weekly", "day_configs": []}
resp = client.put(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule', json=payload)
assert resp.status_code == 400
def test_set_schedule_upserts_existing(client):
# Set once with days mode
client.put(
f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule',
json={"mode": "days", "day_configs": [{"day": 1, "hour": 8, "minute": 0}]},
)
# Overwrite with interval mode
client.put(
f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule',
json={"mode": "interval", "interval_days": 2, "anchor_date": "", "interval_hour": 9, "interval_minute": 0},
)
resp = client.get(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule')
assert resp.status_code == 200
assert resp.get_json()["mode"] == "interval"
def test_old_record_missing_new_fields_loads_with_defaults(client):
"""Old DB records without anchor_date/interval_has_deadline load with correct defaults."""
from db.chore_schedules import upsert_schedule
from models.chore_schedule import ChoreSchedule
# Insert a schedule as if it was created before phase 2 (missing new fields)
old_style = ChoreSchedule(
child_id=TEST_CHILD_ID,
task_id=TEST_TASK_ID,
mode='interval',
interval_days=3,
anchor_date='', # default value
interval_has_deadline=True, # default value
interval_hour=8,
interval_minute=0,
)
upsert_schedule(old_style)
# Manually wipe the new fields from the raw stored record to simulate a pre-phase-2 record
from db.db import chore_schedules_db
from tinydb import Query
ScheduleQ = Query()
record = chore_schedules_db.search(
(ScheduleQ.child_id == TEST_CHILD_ID) & (ScheduleQ.task_id == TEST_TASK_ID)
)[0]
doc_id = chore_schedules_db.get(
(ScheduleQ.child_id == TEST_CHILD_ID) & (ScheduleQ.task_id == TEST_TASK_ID)
).doc_id
chore_schedules_db.update(
lambda rec: (
rec.pop('anchor_date', None),
rec.pop('interval_has_deadline', None),
),
doc_ids=[doc_id],
)
resp = client.get(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule')
assert resp.status_code == 200
data = resp.get_json()
assert data['anchor_date'] == ''
assert data['interval_has_deadline'] is True
# ---------------------------------------------------------------------------
# DELETE schedule
# ---------------------------------------------------------------------------
def test_delete_schedule(client):
client.put(
f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule',
json={"mode": "days", "day_configs": []},
)
resp = client.delete(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule')
assert resp.status_code == 200
# Verify gone
resp2 = client.get(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule')
assert resp2.status_code == 404
def test_delete_schedule_not_found(client):
chore_schedules_db.truncate()
resp = client.delete(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule')
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# POST extend
# ---------------------------------------------------------------------------
def test_extend_chore_time(client):
resp = client.post(
f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/extend',
json={"date": "2025-01-15"},
)
assert resp.status_code == 200
data = resp.get_json()
assert data["child_id"] == TEST_CHILD_ID
assert data["task_id"] == TEST_TASK_ID
assert data["date"] == "2025-01-15"
def test_extend_chore_time_duplicate_returns_409(client):
task_extensions_db.truncate()
client.post(
f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/extend',
json={"date": "2025-01-15"},
)
resp = client.post(
f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/extend',
json={"date": "2025-01-15"},
)
assert resp.status_code == 409
def test_extend_chore_time_different_dates_allowed(client):
task_extensions_db.truncate()
r1 = client.post(
f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/extend',
json={"date": "2025-01-15"},
)
r2 = client.post(
f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/extend',
json={"date": "2025-01-16"},
)
assert r1.status_code == 200
assert r2.status_code == 200
def test_extend_chore_time_missing_date(client):
resp = client.post(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/extend', json={})
assert resp.status_code == 400
def test_extend_chore_time_bad_child(client):
resp = client.post('/child/bad-child/task/bad-task/extend', json={"date": "2025-01-15"})
assert resp.status_code == 404

View File

@@ -212,7 +212,7 @@ class TestDeletionProcess:
id='user_task',
name='User Task',
points=10,
is_good=True,
type='chore',
user_id=user_id
)
task_db.insert(user_task.to_dict())
@@ -222,7 +222,7 @@ class TestDeletionProcess:
id='system_task',
name='System Task',
points=20,
is_good=True,
type='chore',
user_id=None
)
task_db.insert(system_task.to_dict())
@@ -805,7 +805,7 @@ class TestIntegration:
user_id=user_id,
name='User Task',
points=10,
is_good=True
type='chore'
)
task_db.insert(task.to_dict())

View File

@@ -5,6 +5,7 @@ import time
from config.paths import get_user_image_dir
from PIL import Image as PILImage
import pytest
from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS
from werkzeug.security import generate_password_hash
from flask import Flask
@@ -38,8 +39,9 @@ def add_test_user():
def login_and_set_cookie(client):
resp = client.post('/auth/login', json={"email": TEST_EMAIL, "password": TEST_PASSWORD})
assert resp.status_code == 200
token = resp.headers.get("Set-Cookie")
assert token and "token=" in token
cookies = resp.headers.getlist("Set-Cookie")
cookie_str = ' '.join(cookies)
assert cookie_str and "access_token=" in cookie_str
def safe_remove(path):
try:
@@ -67,7 +69,8 @@ def client():
app.register_blueprint(image_api)
app.register_blueprint(auth_api, url_prefix='/auth')
app.config['TESTING'] = True
app.config['SECRET_KEY'] = 'supersecretkey'
app.config['SECRET_KEY'] = TEST_SECRET_KEY
app.config['REFRESH_TOKEN_EXPIRY_DAYS'] = TEST_REFRESH_TOKEN_EXPIRY_DAYS
with app.test_client() as c:
add_test_user()
remove_test_data()

View File

@@ -0,0 +1,85 @@
import pytest
from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS
import os
from werkzeug.security import generate_password_hash
from flask import Flask
from api.kindness_api import kindness_api
from api.auth_api import auth_api
from db.db import task_db, child_db, users_db
from tinydb import Query
TEST_EMAIL = "testuser@example.com"
TEST_PASSWORD = "testpass"
def add_test_user():
users_db.remove(Query().email == TEST_EMAIL)
users_db.insert({
"id": "testuserid",
"first_name": "Test",
"last_name": "User",
"email": TEST_EMAIL,
"password": generate_password_hash(TEST_PASSWORD),
"verified": True,
"image_id": "boy01"
})
def login_and_set_cookie(client):
resp = client.post('/auth/login', json={"email": TEST_EMAIL, "password": TEST_PASSWORD})
assert resp.status_code == 200
@pytest.fixture
def client():
app = Flask(__name__)
app.register_blueprint(kindness_api)
app.register_blueprint(auth_api, url_prefix='/auth')
app.config['TESTING'] = True
app.config['SECRET_KEY'] = TEST_SECRET_KEY
app.config['REFRESH_TOKEN_EXPIRY_DAYS'] = TEST_REFRESH_TOKEN_EXPIRY_DAYS
with app.test_client() as client:
add_test_user()
login_and_set_cookie(client)
yield client
def test_add_kindness(client):
task_db.truncate()
response = client.put('/kindness/add', json={'name': 'Helped Sibling', 'points': 5})
assert response.status_code == 201
tasks = task_db.all()
assert any(t.get('name') == 'Helped Sibling' and t.get('type') == 'kindness' for t in tasks)
def test_list_kindness(client):
task_db.truncate()
task_db.insert({'id': 'k1', 'name': 'Kind', 'points': 5, 'type': 'kindness', 'user_id': 'testuserid'})
task_db.insert({'id': 'c1', 'name': 'Chore', 'points': 3, 'type': 'chore', 'user_id': 'testuserid'})
response = client.get('/kindness/list')
assert response.status_code == 200
data = response.get_json()
assert len(data['tasks']) == 1
assert data['tasks'][0]['id'] == 'k1'
def test_edit_kindness(client):
task_db.truncate()
task_db.insert({'id': 'k_edit', 'name': 'Old', 'points': 5, 'type': 'kindness', 'user_id': 'testuserid'})
response = client.put('/kindness/k_edit/edit', json={'name': 'New Kind'})
assert response.status_code == 200
data = response.get_json()
assert data['name'] == 'New Kind'
def test_delete_kindness(client):
task_db.truncate()
child_db.truncate()
task_db.insert({'id': 'k_del', 'name': 'Del Kind', 'points': 5, 'type': 'kindness', 'user_id': 'testuserid'})
child_db.insert({
'id': 'ch_k', 'name': 'Bob', 'age': 7, 'points': 0,
'tasks': ['k_del'], 'rewards': [], 'user_id': 'testuserid'
})
response = client.delete('/kindness/k_del')
assert response.status_code == 200
child = child_db.get(Query().id == 'ch_k')
assert 'k_del' not in child.get('tasks', [])

View File

@@ -0,0 +1,86 @@
import pytest
from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS
import os
from werkzeug.security import generate_password_hash
from flask import Flask
from api.penalty_api import penalty_api
from api.auth_api import auth_api
from db.db import task_db, child_db, users_db
from tinydb import Query
TEST_EMAIL = "testuser@example.com"
TEST_PASSWORD = "testpass"
def add_test_user():
users_db.remove(Query().email == TEST_EMAIL)
users_db.insert({
"id": "testuserid",
"first_name": "Test",
"last_name": "User",
"email": TEST_EMAIL,
"password": generate_password_hash(TEST_PASSWORD),
"verified": True,
"image_id": "boy01"
})
def login_and_set_cookie(client):
resp = client.post('/auth/login', json={"email": TEST_EMAIL, "password": TEST_PASSWORD})
assert resp.status_code == 200
@pytest.fixture
def client():
app = Flask(__name__)
app.register_blueprint(penalty_api)
app.register_blueprint(auth_api, url_prefix='/auth')
app.config['TESTING'] = True
app.config['SECRET_KEY'] = TEST_SECRET_KEY
app.config['REFRESH_TOKEN_EXPIRY_DAYS'] = TEST_REFRESH_TOKEN_EXPIRY_DAYS
with app.test_client() as client:
add_test_user()
login_and_set_cookie(client)
yield client
def test_add_penalty(client):
task_db.truncate()
response = client.put('/penalty/add', json={'name': 'Fighting', 'points': 10})
assert response.status_code == 201
tasks = task_db.all()
assert any(t.get('name') == 'Fighting' and t.get('type') == 'penalty' for t in tasks)
def test_list_penalties(client):
task_db.truncate()
task_db.insert({'id': 'p1', 'name': 'Yelling', 'points': 5, 'type': 'penalty', 'user_id': 'testuserid'})
task_db.insert({'id': 'c1', 'name': 'Chore', 'points': 3, 'type': 'chore', 'user_id': 'testuserid'})
response = client.get('/penalty/list')
assert response.status_code == 200
data = response.get_json()
assert len(data['tasks']) == 1
assert data['tasks'][0]['id'] == 'p1'
def test_edit_penalty(client):
task_db.truncate()
task_db.insert({'id': 'p_edit', 'name': 'Old', 'points': 5, 'type': 'penalty', 'user_id': 'testuserid'})
response = client.put('/penalty/p_edit/edit', json={'name': 'New Penalty', 'points': 20})
assert response.status_code == 200
data = response.get_json()
assert data['name'] == 'New Penalty'
assert data['points'] == 20
def test_delete_penalty(client):
task_db.truncate()
child_db.truncate()
task_db.insert({'id': 'p_del', 'name': 'Del Pen', 'points': 5, 'type': 'penalty', 'user_id': 'testuserid'})
child_db.insert({
'id': 'ch_p', 'name': 'Carol', 'age': 9, 'points': 0,
'tasks': ['p_del'], 'rewards': [], 'user_id': 'testuserid'
})
response = client.delete('/penalty/p_del')
assert response.status_code == 200
child = child_db.get(Query().id == 'ch_p')
assert 'p_del' not in child.get('tasks', [])

View File

@@ -1,4 +1,5 @@
import pytest
from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS
import os
from werkzeug.security import generate_password_hash
@@ -30,8 +31,9 @@ def add_test_user():
def login_and_set_cookie(client):
resp = client.post('/auth/login', json={"email": TEST_EMAIL, "password": TEST_PASSWORD})
assert resp.status_code == 200
token = resp.headers.get("Set-Cookie")
assert token and "token=" in token
cookies = resp.headers.getlist("Set-Cookie")
cookie_str = ' '.join(cookies)
assert cookie_str and "access_token=" in cookie_str
@pytest.fixture
def client():
@@ -39,7 +41,8 @@ def client():
app.register_blueprint(reward_api)
app.register_blueprint(auth_api, url_prefix='/auth')
app.config['TESTING'] = True
app.config['SECRET_KEY'] = 'supersecretkey'
app.config['SECRET_KEY'] = TEST_SECRET_KEY
app.config['REFRESH_TOKEN_EXPIRY_DAYS'] = TEST_REFRESH_TOKEN_EXPIRY_DAYS
with app.test_client() as client:
add_test_user()
login_and_set_cookie(client)

View File

@@ -1,4 +1,5 @@
import pytest
from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS
import os
from werkzeug.security import generate_password_hash
@@ -29,8 +30,9 @@ def add_test_user():
def login_and_set_cookie(client):
resp = client.post('/auth/login', json={"email": TEST_EMAIL, "password": TEST_PASSWORD})
assert resp.status_code == 200
token = resp.headers.get("Set-Cookie")
assert token and "token=" in token
cookies = resp.headers.getlist("Set-Cookie")
cookie_str = ' '.join(cookies)
assert cookie_str and "access_token=" in cookie_str
@pytest.fixture
def client():
@@ -38,7 +40,8 @@ def client():
app.register_blueprint(task_api)
app.register_blueprint(auth_api, url_prefix='/auth')
app.config['TESTING'] = True
app.config['SECRET_KEY'] = 'supersecretkey'
app.config['SECRET_KEY'] = TEST_SECRET_KEY
app.config['REFRESH_TOKEN_EXPIRY_DAYS'] = TEST_REFRESH_TOKEN_EXPIRY_DAYS
with app.test_client() as client:
add_test_user()
login_and_set_cookie(client)
@@ -52,27 +55,27 @@ def cleanup_db():
os.remove('tasks.json')
def test_add_task(client):
response = client.put('/task/add', json={'name': 'Clean Room', 'points': 10, 'is_good': True})
response = client.put('/task/add', json={'name': 'Clean Room', 'points': 10, 'type': 'chore'})
assert response.status_code == 201
assert b'Task Clean Room added.' in response.data
# verify in database
tasks = task_db.all()
assert any(task.get('name') == 'Clean Room' and task.get('points') == 10 and task.get('is_good') is True and task.get('image_id') == '' for task in tasks)
assert any(task.get('name') == 'Clean Room' and task.get('points') == 10 and task.get('type') == 'chore' and task.get('image_id') == '' for task in tasks)
response = client.put('/task/add', json={'name': 'Eat Dinner', 'points': 5, 'is_good': False, 'image_id': 'meal'})
response = client.put('/task/add', json={'name': 'Eat Dinner', 'points': 5, 'type': 'penalty', 'image_id': 'meal'})
assert response.status_code == 201
assert b'Task Eat Dinner added.' in response.data
# verify in database
tasks = task_db.all()
assert any(task.get('name') == 'Eat Dinner' and task.get('points') == 5 and task.get('is_good') is False and task.get('image_id') == 'meal' for task in tasks)
assert any(task.get('name') == 'Eat Dinner' and task.get('points') == 5 and task.get('type') == 'penalty' and task.get('image_id') == 'meal' for task in tasks)
def test_list_tasks(client):
task_db.truncate()
# Insert user-owned tasks
task_db.insert({'id': 't1', 'name': 'Task1', 'points': 5, 'is_good': True, 'user_id': 'testuserid'})
task_db.insert({'id': 't2', 'name': 'Task2', 'points': 15, 'is_good': False, 'image_id': 'meal', 'user_id': 'testuserid'})
task_db.insert({'id': 't1', 'name': 'Task1', 'points': 5, 'type': 'chore', 'user_id': 'testuserid'})
task_db.insert({'id': 't2', 'name': 'Task2', 'points': 15, 'type': 'penalty', 'image_id': 'meal', 'user_id': 'testuserid'})
response = client.get('/task/list')
assert response.status_code == 200
assert b'tasks' in response.data
@@ -83,15 +86,15 @@ def test_list_tasks(client):
def test_list_tasks_sorted_by_is_good_then_user_then_default_then_name(client):
task_db.truncate()
task_db.insert({'id': 'u_good_z', 'name': 'Zoo', 'points': 1, 'is_good': True, 'user_id': 'testuserid'})
task_db.insert({'id': 'u_good_a', 'name': 'Apple', 'points': 1, 'is_good': True, 'user_id': 'testuserid'})
task_db.insert({'id': 'd_good_m', 'name': 'Mop', 'points': 1, 'is_good': True, 'user_id': None})
task_db.insert({'id': 'd_good_b', 'name': 'Brush', 'points': 1, 'is_good': True, 'user_id': None})
task_db.insert({'id': 'u_good_z', 'name': 'Zoo', 'points': 1, 'type': 'chore', 'user_id': 'testuserid'})
task_db.insert({'id': 'u_good_a', 'name': 'Apple', 'points': 1, 'type': 'chore', 'user_id': 'testuserid'})
task_db.insert({'id': 'd_good_m', 'name': 'Mop', 'points': 1, 'type': 'chore', 'user_id': None})
task_db.insert({'id': 'd_good_b', 'name': 'Brush', 'points': 1, 'type': 'chore', 'user_id': None})
task_db.insert({'id': 'u_bad_c', 'name': 'Chore', 'points': 1, 'is_good': False, 'user_id': 'testuserid'})
task_db.insert({'id': 'u_bad_a', 'name': 'Alarm', 'points': 1, 'is_good': False, 'user_id': 'testuserid'})
task_db.insert({'id': 'd_bad_y', 'name': 'Yell', 'points': 1, 'is_good': False, 'user_id': None})
task_db.insert({'id': 'd_bad_b', 'name': 'Bicker', 'points': 1, 'is_good': False, 'user_id': None})
task_db.insert({'id': 'u_bad_c', 'name': 'Chore', 'points': 1, 'type': 'penalty', 'user_id': 'testuserid'})
task_db.insert({'id': 'u_bad_a', 'name': 'Alarm', 'points': 1, 'type': 'penalty', 'user_id': 'testuserid'})
task_db.insert({'id': 'd_bad_y', 'name': 'Yell', 'points': 1, 'type': 'penalty', 'user_id': None})
task_db.insert({'id': 'd_bad_b', 'name': 'Bicker', 'points': 1, 'type': 'penalty', 'user_id': None})
response = client.get('/task/list')
assert response.status_code == 200
@@ -122,7 +125,7 @@ def test_delete_task_not_found(client):
def test_delete_assigned_task_removes_from_child(client):
# create user-owned task and child with the task already assigned
task_db.insert({'id': 't_delete_assigned', 'name': 'Temp Task', 'points': 5, 'is_good': True, 'user_id': 'testuserid'})
task_db.insert({'id': 't_delete_assigned', 'name': 'Temp Task', 'points': 5, 'type': 'chore', 'user_id': 'testuserid'})
child_db.insert({
'id': 'child_for_task_delete',
'name': 'Frank',

View File

@@ -7,6 +7,7 @@ from db.db import users_db
from tinydb import Query
import jwt
from werkzeug.security import generate_password_hash
from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS
# Test user credentials
TEST_EMAIL = "usertest@example.com"
@@ -50,9 +51,10 @@ def login_and_get_token(client, email, password):
"""Login and extract JWT token from response."""
resp = client.post('/auth/login', json={"email": email, "password": password})
assert resp.status_code == 200
# Extract token from Set-Cookie header
set_cookie = resp.headers.get("Set-Cookie")
assert set_cookie and "token=" in set_cookie
# Verify auth cookies are set
cookies = resp.headers.getlist('Set-Cookie')
cookie_str = ' '.join(cookies)
assert 'access_token=' in cookie_str
# Flask test client automatically handles cookies
return resp
@@ -63,7 +65,8 @@ def client():
app.register_blueprint(user_api)
app.register_blueprint(auth_api, url_prefix='/auth')
app.config['TESTING'] = True
app.config['SECRET_KEY'] = 'supersecretkey'
app.config['SECRET_KEY'] = TEST_SECRET_KEY
app.config['REFRESH_TOKEN_EXPIRY_DAYS'] = TEST_REFRESH_TOKEN_EXPIRY_DAYS
app.config['FRONTEND_URL'] = 'http://localhost:5173' # Needed for email_sender
with app.test_client() as client:
add_test_users()
@@ -200,7 +203,7 @@ def test_mark_for_deletion_clears_tokens(authenticated_client):
def test_mark_for_deletion_with_invalid_jwt(client):
"""Test marking for deletion with invalid JWT token."""
# Set invalid cookie manually
client.set_cookie('token', 'invalid.jwt.token')
client.set_cookie('access_token', 'invalid.jwt.token')
response = client.post('/user/mark-for-deletion', json={})
assert response.status_code == 401

View File

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

View File

@@ -2,35 +2,36 @@
version: "3.8"
services:
chore-app-backend: # Production backend service name
image: git.ryankegel.com:3000/ryan/backend:latest # Or specific version tag
container_name: chore-app-backend-prod # Added for easy identification
chores-app-backend: # Production backend service name
image: git.ryankegel.com:3000/kegel/chores/backend:latest # Or specific version tag
container_name: chores-app-backend-prod # Added for easy identification
ports:
- "5001:5000" # Host 5001 -> Container 5000
environment:
- FLASK_ENV=production
- FRONTEND_URL=${FRONTEND_URL}
volumes:
- chore-app-backend-data:/app/data # Assuming backend data storage; adjust path as needed
- chores-app-backend-data:/app/data # Assuming backend data storage; adjust path as needed
networks:
- chore-app-net
- chores-app-net
# Add other volumes, networks, etc., as needed
chore-app-frontend: # Production frontend service name
image: git.ryankegel.com:3000/ryan/frontend:latest # Or specific version tag
container_name: chore-app-frontend-prod # Added for easy identification
chores-app-frontend: # Production frontend service name
image: git.ryankegel.com:3000/kegel/chores/frontend:latest # Or specific version tag
container_name: chores-app-frontend-prod # Added for easy identification
ports:
- "443:443" # Host 443 -> Container 443 (HTTPS)
environment:
- BACKEND_HOST=chore-app-backend # Points to internal backend service
- BACKEND_HOST=chores-app-backend # Points to internal backend service
depends_on:
- chore-app-backend
- chores-app-backend
networks:
- chore-app-net
- chores-app-net
# Add volumes, networks, etc., as needed
networks:
chore-app-net:
chores-app-net:
driver: bridge
volumes:
chore-app-backend-data: {}
chores-app-backend-data: {}

View 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

View File

@@ -0,0 +1,27 @@
name: Playwright Tests
on:
push:
branches: [ main, master ]
pull_request:
branches: [ main, master ]
jobs:
test:
timeout-minutes: 60
runs-on: ubuntu-latest
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
- name: Run Playwright tests
run: npx playwright test
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 30

View File

@@ -34,3 +34,12 @@ coverage
# Vitest
__screenshots__/
*.old
# Playwright
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
/playwright/.auth/

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

View File

@@ -0,0 +1,35 @@
{
"cookies": [
{
"name": "refresh_token",
"value": "C3wwythvEFsezN93gTCH0C7TP4UEMJT1CszA66dP9Es",
"domain": "localhost",
"path": "/api/auth",
"expires": 1780853177.47085,
"httpOnly": true,
"secure": true,
"sameSite": "Strict"
},
{
"name": "access_token",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZUB0ZXN0LmNvbSIsInVzZXJfaWQiOiI2OGFiNGNkNi04Y2NmLTQxNDItOWRmZC1kYjVmZmNmNDQ4OGQiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3NzMwNzgwNzd9.zZErQX-waP_VILAEaZbNnZmFlGAc6wvNiSQEop0IjsQ",
"domain": "localhost",
"path": "/",
"expires": -1,
"httpOnly": true,
"secure": true,
"sameSite": "Strict"
}
],
"origins": [
{
"origin": "https://localhost:5173",
"localStorage": [
{
"name": "authSyncEvent",
"value": "{\"type\":\"logout\",\"at\":1773077177348}"
}
]
}
]
}

View File

@@ -0,0 +1,39 @@
{
"cookies": [
{
"name": "refresh_token",
"value": "AkJCQm0cJAkwg6CEzwBZMGks62XDowJwEaapsYWLc-o",
"domain": "localhost",
"path": "/api/auth",
"expires": 1780853177.819182,
"httpOnly": true,
"secure": true,
"sameSite": "Strict"
},
{
"name": "access_token",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZUB0ZXN0LmNvbSIsInVzZXJfaWQiOiI5MTBjZmZmNS01NzhjLTRmZDgtYTM1NS1hN2JkYTUyZmE2OGUiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3NzMwNzgwNzd9.BkKApnds25Nw7wMJ8wQcwPJ-tahduQCC_le_6PT180I",
"domain": "localhost",
"path": "/",
"expires": -1,
"httpOnly": true,
"secure": true,
"sameSite": "Strict"
}
],
"origins": [
{
"origin": "https://localhost:5173",
"localStorage": [
{
"name": "authSyncEvent",
"value": "{\"type\":\"logout\",\"at\":1773077177706}"
},
{
"name": "parentAuth",
"value": "{\"expiresAt\":1773249977951}"
}
]
}
]
}

View File

@@ -0,0 +1,18 @@
import { test as setup } from '@playwright/test'
import { STORAGE_STATE_NO_PIN, E2E_EMAIL, E2E_PASSWORD } from './e2e-constants'
setup('authenticate without parent pin', async ({ page }) => {
await page.goto('/auth/login')
await page.getByLabel('Email address').fill(E2E_EMAIL)
await page.getByLabel('Password').fill(E2E_PASSWORD)
await page.getByRole('button', { name: 'Sign in' }).click()
// Wait for redirect to the authenticated area
await page.waitForURL(/\/(parent|child)/)
// Remove parent auth from localStorage so the PIN prompt appears
await page.evaluate(() => localStorage.removeItem('parentAuth'))
await page.context().storageState({ path: STORAGE_STATE_NO_PIN })
})

View File

@@ -0,0 +1,44 @@
import { test as setup } from '@playwright/test'
import { STORAGE_STATE, E2E_EMAIL, E2E_PASSWORD, E2E_PIN } from './e2e-constants'
setup('authenticate', async ({ page }) => {
// Seed backend test data
const backendUrl = 'http://localhost:5000'
const seedRes = await page.request.post(`${backendUrl}/auth/e2e-seed`)
if (!seedRes.ok()) {
throw new Error(`e2e-seed failed: ${seedRes.status()} ${await seedRes.text()}`)
}
await page.goto('/auth/login')
await page.getByLabel('Email address').fill(E2E_EMAIL)
await page.getByLabel('Password').fill(E2E_PASSWORD)
await page.getByRole('button', { name: 'Sign in' }).click()
// After login the router redirects to /child (not parent-authenticated yet)
await page.waitForURL(/\/(parent|child)/)
// Click the LoginButton in the header to open the PIN modal
await page.getByRole('button', { name: 'Parent login' }).click()
// Fill in the PIN and submit
const pinInput = page.getByPlaceholder('46 digits')
await pinInput.waitFor({ timeout: 5000 })
await pinInput.fill(E2E_PIN)
await page.getByLabel('Stay in parent mode on this device').check()
await page.getByRole('button', { name: 'OK' }).click()
// LoginButton does router.push('/parent') after PIN - wait for it
await page.waitForURL(/\/parent(\/|$)/)
// Confirm parent mode is active by waiting for the Add Child FAB at /parent
try {
await page.getByRole('button', { name: 'Add Child' }).waitFor({ timeout: 5000 })
} catch (e) {
await page.screenshot({ path: 'auth-setup-parent-fail.png' })
throw new Error(
'Parent mode not reached after PIN entry. See auth-setup-parent-fail.png for details.',
)
}
await page.context().storageState({ path: STORAGE_STATE })
})

View File

@@ -0,0 +1,16 @@
// spec: e2e/plans/create-child.plan.md
import { test, expect } from '@playwright/test'
import { STORAGE_STATE_NO_PIN } from '../e2e-constants'
test.use({ storageState: STORAGE_STATE_NO_PIN })
test.describe('Create Child', () => {
test('Add Child FAB is hidden when parent auth is expired', async ({ page }) => {
// Navigate to app root - with no parent auth, router redirects to /child
await page.goto('/')
// expect: the 'Add Child' FAB is NOT visible (not in parent mode)
await expect(page.getByRole('button', { name: 'Add Child' })).not.toBeVisible()
})
})

View File

@@ -0,0 +1,104 @@
// spec: e2e/plans/create-child.plan.md
import { test, expect } from '@playwright/test'
import path from 'path'
import { fileURLToPath } from 'url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const TEST_IMAGE = path.join(__dirname, '../../../../resources/logo/star_only.png')
async function deleteNamedChildren(request: any, names: string[]) {
const res = await request.get('/api/child/list')
const data = await res.json()
for (const child of data.children ?? []) {
if (names.includes(child.name)) {
await request.delete(`/api/child/${child.id}`)
}
}
}
async function deleteAllChildren(request: any) {
const res = await request.get('/api/child/list')
const data = await res.json()
for (const child of data.children ?? []) {
await request.delete(`/api/child/${child.id}`)
}
}
test.describe('Create Child', () => {
test('Create a child with name and age only', async ({ page, request }) => {
await deleteNamedChildren(request, ['Alice'])
// 1. Navigate to app root - router redirects to /parent (children list) when parent-authenticated
await page.goto('/')
await expect(page).toHaveURL('/parent')
// 2. Click the 'Add Child' FAB
await page.getByRole('button', { name: 'Add Child' }).click()
await expect(page.getByRole('heading', { name: 'Create Child' })).toBeVisible()
await expect(page.getByLabel('Name')).toBeVisible()
await expect(page.getByLabel('Age')).toBeVisible()
// 3. Enter 'Alice' in the Name field
await page.getByLabel('Name').fill('Alice')
await expect(page.getByLabel('Name')).toHaveValue('Alice')
// 4. Enter '8' in the Age field
await page.getByLabel('Age').fill('8')
await expect(page.getByLabel('Age')).toHaveValue('8')
// 5. Leave Image as default and click Create
await page.getByRole('button', { name: 'Create' }).click()
await expect(page.locator('.error')).not.toBeVisible()
await expect(page).toHaveURL('/parent')
await expect(page.getByText('Alice')).toBeVisible()
})
test('Create a child via the inline Create button in empty state', async ({ page, request }) => {
await deleteAllChildren(request)
// 1. Navigate to app root - router redirects to /parent (children list)
await page.goto('/')
await expect(page.getByText('No children')).toBeVisible()
await expect(page.getByRole('button', { name: 'Create' })).toBeVisible()
// 2. Click the inline 'Create' button
await page.getByRole('button', { name: 'Create' }).click()
await expect(page.getByRole('heading', { name: 'Create Child' })).toBeVisible()
// 3. Enter 'Bob' and '10', then submit
await page.getByLabel('Name').fill('Bob')
await page.getByLabel('Age').fill('10')
await page.getByRole('button', { name: 'Create' }).click()
await expect(page.locator('.error')).not.toBeVisible()
await expect(page).toHaveURL('/parent')
await expect(page.getByText('Bob')).toBeVisible()
})
test('Create a child with a custom uploaded image', async ({ page, request }) => {
await deleteNamedChildren(request, ['Grace'])
// 1. Navigate to app root - router redirects to /parent (children list)
await page.goto('/')
await page.getByRole('button', { name: 'Add Child' }).click()
await expect(page.getByRole('heading', { name: 'Create Child' })).toBeVisible()
// 2. Enter 'Grace' and '6'
await page.getByLabel('Name').fill('Grace')
await page.getByLabel('Age').fill('6')
await expect(page.getByLabel('Name')).toHaveValue('Grace')
await expect(page.getByLabel('Age')).toHaveValue('6')
// 3. Upload a local image file via 'Add from device'
const fileChooserPromise = page.waitForEvent('filechooser')
await page.getByRole('button', { name: 'Add from device' }).click()
const fileChooser = await fileChooserPromise
await fileChooser.setFiles(TEST_IMAGE)
// 4. Submit the form
await page.getByRole('button', { name: 'Create' }).click()
await expect(page.locator('.error')).not.toBeVisible()
await expect(page).toHaveURL('/parent')
await expect(page.getByText('Grace')).toBeVisible()
})
})

View File

@@ -0,0 +1,21 @@
// spec: e2e/plans/create-child.plan.md
import { test, expect } from '@playwright/test'
test.describe('Create Child', () => {
test('Cancel navigates back without saving', async ({ page }) => {
// 1. Navigate to app root - router redirects to /parent (children list)
await page.goto('/')
await page.getByRole('button', { name: 'Add Child' }).click()
await expect(page.getByRole('heading', { name: 'Create Child' })).toBeVisible()
// 2. Fill in 'Frank' and '9', then click Cancel
await page.getByLabel('Name').fill('Frank')
await page.getByLabel('Age').fill('9')
await page.getByRole('button', { name: 'Cancel' }).click()
// expect: back on /parent and 'Frank' is NOT listed
await expect(page).toHaveURL('/parent')
await expect(page.getByText('Frank')).not.toBeVisible()
})
})

Some files were not shown because too many files have changed in this diff Show More