103 Commits
1.0.3 ... 1.0.4

Author SHA1 Message Date
087aa07a74 Releasing 1.0.4 into test
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m1s
-typo
2026-02-19 15:13:43 -05:00
8cb9199ab7 Releasing 1.0.4 into test
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Has been cancelled
2026-02-19 15:13:08 -05:00
bbdabefd62 fixed frontent test errors
All checks were successful
Chore App Build and Push Docker Images / build-and-push (push) Successful in 2m37s
2026-02-19 14:59:28 -05:00
a7ac179e1a fixed frontent test errors
Some checks failed
Chore App Build and Push Docker Images / build-and-push (push) Failing after 1m47s
2026-02-19 13:31:19 -05:00
53236ab019 feat: add caching for frontend dependencies in build workflow
All checks were successful
Chore App Build and Push Docker Images / build-and-push (push) Successful in 2m40s
2026-02-19 12:55:05 -05:00
8708a1a68f feat: add caching for frontend dependencies in build workflow
Some checks failed
Chore App Build and Push Docker Images / build-and-push (push) Failing after 1m48s
2026-02-19 12:42:16 -05:00
8008f1d116 feat: add backend and frontend testing steps to build workflow
Some checks failed
Chore App Build and Push Docker Images / build-and-push (push) Failing after 2m45s
2026-02-19 12:34:33 -05:00
c18d202ecc feat: update version to 1.0.4RC5, enhance notification handling and smooth scroll behavior
All checks were successful
Chore App Build and Push Docker Images / build-and-push (push) Successful in 1m11s
2026-02-19 11:00:14 -05:00
725bf518ea Refactor and enhance various components and tests
All checks were successful
Chore App Build and Push Docker Images / build-and-push (push) Successful in 1m23s
- Remove OverrideEditModal.spec.ts test file.
- Update ParentPinSetup.vue to handle Enter key for code and PIN inputs.
- Modify ChildEditView.vue to add maxlength for age input.
- Enhance ChildView.vue with reward confirmation and cancellation dialogs.
- Update ParentView.vue to handle pending rewards and confirm edits.
- Revise PendingRewardDialog.vue to accept a dynamic message prop.
- Expand ChildView.spec.ts to cover reward dialog interactions.
- Add tests for ParentView.vue to validate pending reward handling.
- Update UserProfile.vue to simplify button styles.
- Adjust RewardView.vue to improve delete confirmation handling.
- Modify ChildrenListView.vue to clarify child creation instructions.
- Refactor EntityEditForm.vue to improve input handling and focus management.
- Enhance ItemList.vue to support item selection.
- Update LoginButton.vue to focus PIN input on error.
- Change ScrollingList.vue empty state color for better visibility.
- Remove capture attribute from ImagePicker.vue file input.
- Update router/index.ts to redirect logged-in users from auth routes.
- Add authGuard.spec.ts to test router authentication logic.
2026-02-19 09:57:59 -05:00
31ea76f013 feat: enhance child edit and view components with improved form handling and validation
All checks were successful
Chore App Build and Push Docker Images / build-and-push (push) Successful in 1m4s
- Added `requireDirty` prop to `EntityEditForm` for dirty state management.
- Updated `ChildEditView` to handle initial data loading and image selection more robustly.
- Refactored `ChildView` to remove unused reward dialog logic and prevent API calls in child mode.
- Improved type definitions for form fields and initial data in `ChildEditView`.
- Enhanced error handling in form submissions across components.
- Implemented cross-tab logout synchronization on password reset in the auth store.
- Added tests for login and entity edit form functionalities to ensure proper behavior.
- Introduced global fetch interceptor for handling unauthorized responses.
- Documented password reset flow and its implications on session management.
2026-02-17 17:18:03 -05:00
5e22e5e0ee Refactor authentication routes to use '/auth' prefix in API calls
All checks were successful
Chore App Build and Push Docker Images / build-and-push (push) Successful in 38s
2026-02-17 10:38:40 -05:00
7e7a2ef49e Implement account deletion handling and improve user feedback
Some checks failed
Chore App Build and Push Docker Images / build-and-push (push) Has been cancelled
- Added checks for accounts marked for deletion in signup, verification, and password reset processes.
- Updated reward and task listing to sort user-created items first.
- Enhanced user API to clear verification and reset tokens when marking accounts for deletion.
- Introduced tests for marked accounts to ensure proper handling in various scenarios.
- Updated profile and reward edit components to reflect changes in validation and data handling.
2026-02-17 10:38:26 -05:00
3e1715e487 added universal launcher
All checks were successful
Chore App Build and Push Docker Images / build-and-push (push) Successful in 44s
2026-02-16 16:20:04 -05:00
11e7fda997 wip 2026-02-16 16:17:17 -05:00
09d42b14c5 wip 2026-02-16 16:04:44 -05:00
3848be32e8 Merge branch 'next' of https://git.ryankegel.com/ryan/chore into next
All checks were successful
Chore App Build and Push Docker Images / build-and-push (push) Successful in 47s
2026-02-16 15:37:17 -05:00
1aff366fd8 - removed test_data
All checks were successful
Chore App Build and Push Docker Images / build-and-push (push) Successful in 1m36s
2026-02-16 15:33:56 -05:00
0ab40f85a4 wip 2026-02-16 15:29:33 -05:00
22889caab4 wip 2026-02-16 15:13:22 -05:00
b538782c09 Merge remote-tracking branch 'origin/wip-sync' into next 2026-02-16 15:02:51 -05:00
Ryan Kegel
7a827b14ef wip 2026-02-16 15:00:52 -05:00
9238d7e3a5 wip 2026-02-15 22:51:51 -05:00
c17838241a WIP Sync 2026-02-14 17:00:43 -05:00
d183e0a4b6 - First round of fixes for RC1
All checks were successful
Chore App Build and Push Docker Images / build-and-push (push) Successful in 2m18s
2026-02-13 16:43:57 -05:00
b25ebaaec0 -test environment
All checks were successful
Chore App Build and Push Docker Images / build-and-push (push) Successful in 57s
2026-02-12 16:17:07 -05:00
ae5b40512c -test environment
Some checks failed
Chore App Build and Push Docker Images / build-and-push (push) Failing after 30s
2026-02-12 16:15:14 -05:00
92635a356c -test environment
Some checks failed
Chore App Build and Push Docker Images / build-and-push (push) Failing after 45s
2026-02-12 16:12:52 -05:00
235269bdb6 -test environment
Some checks failed
Chore App Build and Push Docker Images / build-and-push (push) Failing after 29s
2026-02-11 23:11:54 -05:00
5d4b0ec2c9 -test environment
Some checks failed
Chore App Build and Push Docker Images / build-and-push (push) Failing after 22s
2026-02-11 22:55:28 -05:00
a21cb60aeb -test environment
All checks were successful
Chore App Build and Push Docker Images / build-and-push (push) Successful in 1m38s
2026-02-11 21:36:45 -05:00
e604870e26 -test environment
All checks were successful
Chore App Build and Push Docker Images / build-and-push (push) Successful in 2m7s
2026-02-11 17:08:23 -05:00
c3e35258a1 -test environment
All checks were successful
Chore App Build and Push Docker Images / build-and-push (push) Successful in 52s
2026-02-11 17:00:45 -05:00
d2a56e36c7 -test environment
Some checks failed
Chore App Build and Push Docker Images / build-and-push (push) Failing after 8s
2026-02-11 16:58:32 -05:00
3bfca4e2b0 -test environment
All checks were successful
Chore App Build and Push Docker Images / build-and-push (push) Successful in 1m59s
2026-02-11 16:01:35 -05:00
f5d68aec4a -test environment
Some checks failed
Chore App Build and Push Docker Images / build-and-push (push) Failing after 25s
2026-02-11 15:24:14 -05:00
38c637cc67 updated requirements
All checks were successful
Chore App Build and Push Docker Images / build-and-push (push) Successful in 48s
2026-02-11 15:16:24 -05:00
f29c90897f -test environment
Some checks failed
Chore App Build and Push Docker Images / build-and-push (push) Failing after 21s
2026-02-11 14:56:32 -05:00
efb65b6da3 -attempt to use global ip for registry
All checks were successful
Chore App Build and Push Docker Images / build-and-push (push) Successful in 45s
2026-02-11 10:31:28 -05:00
29563eeb83 -RC 1.0.4
All checks were successful
Chore App Build and Push Docker Images / build-and-push (push) Successful in 15s
2026-02-11 09:50:57 -05:00
fc364621e3 modify gitea
All checks were successful
Chore App Build and Push Docker Images / build-and-push (push) Successful in 12s
2026-02-11 00:04:44 -05:00
dffa4824fb modify gitea
All checks were successful
Gitea Actions Demo / build-and-push (push) Successful in 13s
2026-02-10 23:35:15 -05:00
28166842f1 modify gitea
All checks were successful
Gitea Actions Demo / build-and-push (push) Successful in 11s
2026-02-10 23:28:57 -05:00
484c7f0052 modify gitea
All checks were successful
Gitea Actions Demo / build-and-push (push) Successful in 11s
2026-02-10 23:26:19 -05:00
682e01bbf1 modify gitea
All checks were successful
Gitea Actions Demo / build-and-push (push) Successful in 12s
2026-02-10 23:24:49 -05:00
917ad25f7f more editing
Some checks failed
Gitea Actions Demo / build-and-push (push) Failing after 6s
2026-02-10 23:23:33 -05:00
26f90a4d1f modify gitea
All checks were successful
Gitea Actions Demo / build-and-push (push) Successful in 11s
2026-02-10 23:03:00 -05:00
73b5d831ed Modifying gitea actions.
All checks were successful
Gitea Actions Demo / build-and-push (push) Successful in 29s
2026-02-10 23:00:20 -05:00
401c21ad82 feat: add PendingRewardDialog, RewardConfirmDialog, and TaskConfirmDialog components
All checks were successful
Gitea Actions Demo / build-and-push (push) Successful in 25s
- Implemented PendingRewardDialog for handling pending reward requests.
- Created RewardConfirmDialog for confirming reward redemption.
- Developed TaskConfirmDialog for task confirmation with child name display.

test: add unit tests for ChildView and ParentView components

- Added comprehensive tests for ChildView including task triggering and SSE event handling.
- Implemented tests for ParentView focusing on override modal and SSE event management.

test: add ScrollingList component tests

- Created tests for ScrollingList to verify item fetching, loading states, and custom item classes.
- Included tests for two-step click interactions and edit button display logic.
- Moved toward hashed passwords.
2026-02-10 20:21:05 -05:00
3dee8b80a2 feat: Implement task and reward tracking feature
All checks were successful
Gitea Actions Demo / build-and-push (push) Successful in 24s
- Added tracking events for tasks, penalties, and rewards with timestamps.
- Created new TinyDB table for tracking records to maintain audit history.
- Developed backend API for querying tracking events with filters and pagination.
- Implemented logging for tracking events with per-user rotating log files.
- Added unit tests for tracking event creation, querying, and anonymization.
- Deferred frontend changes for future implementation.
- Established acceptance criteria and documentation for the tracking feature.

feat: Introduce account deletion scheduler

- Implemented a scheduler to delete accounts marked for deletion after a configurable threshold.
- Added new fields to the User model to manage deletion status and attempts.
- Created admin API endpoints for managing deletion thresholds and viewing the deletion queue.
- Integrated error handling and logging for the deletion process.
- Developed unit tests for the deletion scheduler and related API endpoints.
- Documented the deletion process and acceptance criteria.
2026-02-09 15:39:43 -05:00
27f02224ab feat: Implement admin role validation and enhance user management scripts
All checks were successful
Gitea Actions Demo / build-and-push (push) Successful in 17s
2026-02-08 23:19:30 -05:00
060b2953fa Add account deletion scheduler and comprehensive tests
All checks were successful
Gitea Actions Demo / build-and-push (push) Successful in 49s
- Implemented account deletion scheduler in `account_deletion_scheduler.py` to manage user deletions based on a defined threshold.
- Added logging for deletion processes, including success and error messages.
- Created tests for deletion logic, including edge cases, retry logic, and integration tests to ensure complete deletion workflows.
- Ensured that deletion attempts are tracked and that users are marked for manual intervention after exceeding maximum attempts.
- Implemented functionality to check for interrupted deletions on application startup and retry them.
2026-02-08 22:42:36 -05:00
04f50c32ae feat: Refactor path handling for data directories and enhance test setup with user-specific image management
All checks were successful
Gitea Actions Demo / build-and-push (push) Successful in 11s
2026-02-06 17:02:45 -05:00
0d651129cb feat: Implement account deletion (mark for removal) feature
All checks were successful
Gitea Actions Demo / build-and-push (push) Successful in 23s
- Added `marked_for_deletion` and `marked_for_deletion_at` fields to User model (Python and TypeScript) with serialization updates
- Created POST /api/user/mark-for-deletion endpoint with JWT auth, error handling, and SSE event trigger
- Blocked login and password reset for marked users; added new error codes ACCOUNT_MARKED_FOR_DELETION and ALREADY_MARKED
- Updated UserProfile.vue with "Delete My Account" button, confirmation modal (email input), loading state, success/error modals, and sign-out/redirect logic
- Synced error codes and model fields between backend and frontend
- Added and updated backend and frontend tests to cover all flows and edge cases
- All Acceptance Criteria from the spec are complete and verified
2026-02-06 16:19:08 -05:00
47541afbbf Add unit tests for LoginButton component with comprehensive coverage
All checks were successful
Gitea Actions Demo / build-and-push (push) Successful in 46s
2026-02-05 16:37:10 -05:00
fd70eca0c9 feat: add restriction to prevent deletion of system tasks and rewards
All checks were successful
Gitea Actions Demo / build-and-push (push) Successful in 23s
- Implemented logic to hide delete button for system tasks and rewards in ItemList.vue, TaskView.vue, and RewardView.vue.
- Added backend checks in task_api.py and reward_api.py to return 403 for delete requests on system items.
- Ensured that items without a user_id are treated as system items across frontend and backend.
- Updated acceptance criteria to include UI and backend tests for the new functionality.
2026-02-03 14:54:38 -05:00
99d3aeb068 refactor: Update layout and styling components; remove unused CSS files and enhance button styles
All checks were successful
Gitea Actions Demo / build-and-push (push) Successful in 46s
2026-02-02 23:53:04 -05:00
5351932194 feat: Enhance task and reward assignment logic to prioritize user items over system items with the same name; add corresponding tests
All checks were successful
Gitea Actions Demo / build-and-push (push) Successful in 15s
2026-02-01 23:39:55 -05:00
e42c6c1ef2 feat: Implement logic to prevent deletion of system tasks and rewards; update APIs and tests accordingly
All checks were successful
Gitea Actions Demo / build-and-push (push) Successful in 34s
2026-02-01 16:57:12 -05:00
f14de28daa feat: Implement user validation and ownership checks for image, reward, and task APIs
All checks were successful
Gitea Actions Demo / build-and-push (push) Successful in 36s
- Added `get_validated_user_id` utility function to validate user authentication across multiple APIs.
- Updated image upload, request, and listing endpoints to ensure user ownership and proper error handling.
- Enhanced reward management endpoints to include user validation and ownership checks.
- Modified task management endpoints to enforce user authentication and ownership verification.
- Updated models to include `user_id` for images, rewards, tasks, and children to track ownership.
- Implemented frontend changes to ensure UI reflects the ownership of tasks and rewards.
- Added a new feature specification to prevent deletion of system tasks and rewards.
2026-01-31 19:48:51 -05:00
6f5b61de7f feat: normalize email handling in signup, login, and verification processes; refactor event handling in task and reward components
All checks were successful
Gitea Actions Demo / build-and-push (push) Successful in 50s
2026-01-28 16:42:06 -05:00
3066d7d356 feat: add parent PIN setup functionality and email notifications
All checks were successful
Gitea Actions Demo / build-and-push (push) Successful in 23s
- Implemented User model updates to include PIN and related fields.
- Created email sender utility for sending verification and reset emails.
- Developed ParentPinSetup component for setting up a parent PIN with verification code.
- Enhanced UserProfile and EntityEditForm components to support new features.
- Updated routing to include PIN setup and authentication checks.
- Added styles for new components and improved existing styles for consistency.
- Introduced loading states and error handling in various components.
2026-01-27 14:47:49 -05:00
cd9070ec99 Refactor build workflow to separate backend and frontend image builds; enhance clarity and structure
All checks were successful
Gitea Actions Demo / build-and-push (push) Successful in 36s
2026-01-23 22:10:25 -05:00
74d6f5819c Refactor forms to use EntityEditForm component; enhance styles and improve structure
Some checks failed
Gitea Actions Demo / build-and-push (push) Failing after 6s
2026-01-23 19:29:27 -05:00
63769fbe32 Refactor components to use ModalDialog and StatusMessage; update styles and remove unused files
Some checks failed
Gitea Actions Demo / build-and-push (push) Failing after 9s
- Replaced inline modal dialogs in ParentView with a reusable ModalDialog component.
- Introduced StatusMessage component for loading and error states in ParentView.
- Updated styles to use new colors.css and styles.css for consistent theming.
- Removed ChildRewardList.vue and ChildTaskList.vue components as they were no longer needed.
- Adjusted RewardAssignView and TaskAssignView to use new styles and shared button styles.
- Cleaned up imports across components to reflect the new styles and removed unused CSS files.
2026-01-22 16:37:53 -05:00
a0a059472b Moved things around
Some checks failed
Gitea Actions Demo / build-and-push (push) Failing after 6s
2026-01-21 17:18:58 -05:00
a47df7171c Add detailed Copilot instructions and enhance child API logging
All checks were successful
Gitea Actions Demo / build-and-push (push) Successful in 14s
- Introduced a comprehensive instructions document for the Reward project, outlining architecture, data flow, key patterns, and developer workflows.
- Enhanced logging in the child API to track points and reward costs, improving error handling for insufficient points.
- Updated Vue components to reflect changes in reward handling and improve user experience with pending rewards.
2026-01-21 16:35:50 -05:00
59b480621e refactoring
All checks were successful
Gitea Actions Demo / build-and-push (push) Successful in 12s
2026-01-18 21:56:19 -05:00
904185e5c8 refactoring
All checks were successful
Gitea Actions Demo / build-and-push (push) Successful in 12s
2026-01-15 16:42:01 -05:00
dcac2742e9 refactoring
All checks were successful
Gitea Actions Demo / build-and-push (push) Successful in 13s
2026-01-14 14:42:54 -05:00
c7c3cce76d added in gitea actions
All checks were successful
Gitea Actions Demo / build-and-push (push) Successful in 10s
2026-01-13 16:09:38 -05:00
7de7047a4d added in gitea actions
All checks were successful
Gitea Actions Demo / build-and-push (push) Successful in 9s
2026-01-13 16:05:34 -05:00
49c175c01d added in gitea actions
Some checks failed
Gitea Actions Demo / build-and-push (push) Failing after 9s
2026-01-13 16:04:13 -05:00
35c4fcb9bb added in gitea actions
Some checks failed
Gitea Actions Demo / build-and-push (push) Failing after 8s
2026-01-13 16:03:12 -05:00
3b1e1eae6d added in gitea actions
Some checks failed
Gitea Actions Demo / build-and-push (push) Failing after 9s
2026-01-13 16:00:51 -05:00
6cec6bdb50 added in gitea actions
Some checks failed
Gitea Actions Demo / build-and-push (push) Failing after 10s
2026-01-13 15:59:29 -05:00
cc436798d1 added in gitea actions
Some checks failed
Gitea Actions Demo / build-and-push (push) Has been cancelled
2026-01-13 15:54:57 -05:00
cd34d27f76 added in gitea actions
Some checks failed
Gitea Actions Demo / build-and-push (push) Has been cancelled
2026-01-13 15:54:16 -05:00
92020e68ce added in gitea actions
Some checks failed
Gitea Actions Demo / build-and-push (push) Failing after 0s
2026-01-13 15:52:57 -05:00
7b91d2c8a4 added in gitea actions
Some checks failed
Gitea Actions Demo / build-and-push (push) Has been cancelled
2026-01-13 15:38:57 -05:00
696683cf30 added in gitea actions
Some checks failed
Gitea Actions Demo / build-and-push (push) Failing after 9s
2026-01-13 15:37:39 -05:00
96ccc1b04c added in gitea actions
Some checks failed
Gitea Actions Demo / build-and-push (push) Failing after 7s
2026-01-13 15:36:42 -05:00
007187020b added in gitea actions
Some checks failed
Gitea Actions Demo / build-and-push (push) Failing after 8s
2026-01-13 15:34:45 -05:00
39eea3ed07 added in gitea actions
Some checks failed
Gitea Actions Demo / build-and-push (push) Failing after 27s
2026-01-13 15:33:36 -05:00
eac6f4b848 added in gitea actions
Some checks failed
Gitea Actions Demo / build-and-push (push) Has been cancelled
2026-01-13 14:54:51 -05:00
caa28a3a2b added in gitea actions
Some checks failed
Gitea Actions Demo / build-and-push (push) Failing after 7s
2026-01-13 14:51:44 -05:00
fd5a828084 added in gitea actions
Some checks failed
Gitea Actions Demo / build-and-push (push) Has been cancelled
2026-01-13 14:29:52 -05:00
76091ff06c added in gitea actions
Some checks failed
Gitea Actions Demo / build-and-push (push) Failing after 15s
2026-01-13 14:11:32 -05:00
4b1b3cedd1 added in gitea actions
Some checks failed
Gitea Actions Demo / build-and-push (push) Failing after 6s
2026-01-13 14:07:50 -05:00
40a835cfd2 added in gitea actions
Some checks failed
Gitea Actions Demo / build-and-push (push) Failing after 7s
2026-01-13 14:06:55 -05:00
a6936ce609 added in gitea actions
Some checks failed
Gitea Actions Demo / build-and-push (push) Failing after 10s
2026-01-13 13:59:34 -05:00
0fd9c2618d added in gitea actions
Some checks failed
Gitea Actions Demo / build-and-push (push) Failing after 16s
2026-01-13 10:35:35 -05:00
3091c5ca97 added in gitea actions
All checks were successful
Gitea Actions Demo / build-and-push (push) Successful in 21s
2026-01-13 10:30:14 -05:00
ee903f8bd6 added in gitea actions
Some checks failed
Gitea Actions Demo / build-and-push (push) Failing after 11s
2026-01-13 10:04:44 -05:00
c4713dd9ef added in gitea actions
All checks were successful
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 11s
2026-01-12 21:22:35 -05:00
a89d3d7313 starting refactor styling 2026-01-10 22:19:23 -05:00
9a6fbced15 added password reset 2026-01-07 15:28:07 -05:00
5b0fe2adc2 added password reset 2026-01-07 15:16:17 -05:00
fd1057662f added seperate users for backend events 2026-01-06 16:25:09 -05:00
d7fc3c0cab Added beginning of login functionality 2026-01-05 16:58:38 -05:00
1900667328 Added beginning of login functionality 2026-01-05 16:58:10 -05:00
03356d813f Added beginning of login functionality 2026-01-05 16:51:04 -05:00
f65d97a50a Added beginning of login functionality 2026-01-05 16:18:59 -05:00
46af0fb959 Added beginning of login functionality 2026-01-05 15:08:29 -05:00
270 changed files with 21212 additions and 4871 deletions

141
.gitea/workflows/build.yaml Normal file
View File

@@ -0,0 +1,141 @@
name: Chore App Build, Test, and Push Docker Images
run-name: ${{ gitea.actor }} is building Chores [${{ gitea.ref_name }}@${{ gitea.sha }}] 🚀
on:
push:
branches:
- master
- next
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Check out repository code
uses: actions/checkout@v3
- name: Determine Image Tag
id: vars
run: |
version=$(python -c "import sys; sys.path.append('./backend'); from config.version import BASE_VERSION; print(BASE_VERSION)")
current_date=$(date +%Y%m%d)
if [ "${{ gitea.ref }}" == "refs/heads/master" ]; then
echo "tag=$version" >> $GITHUB_OUTPUT
else
echo "tag=next-$version-$current_date" >> $GITHUB_OUTPUT
fi
- name: Set up Python for backend tests
uses: actions/setup-python@v4
with:
python-version: "3.11"
- name: Install backend dependencies
run: |
python -m pip install --upgrade pip
pip install -r backend/requirements.txt
- name: Run backend unit tests
run: |
cd backend
pytest -q
- name: Set up Node.js for frontend tests
uses: actions/setup-node@v4
with:
node-version: "20.19.0"
cache: "npm"
cache-dependency-path: frontend/vue-app/package-lock.json
- name: Install frontend dependencies
run: npm ci
working-directory: frontend/vue-app
- name: Run frontend unit tests
run: npm run test:unit --if-present
working-directory: frontend/vue-app
- name: Build Backend Docker Image
run: |
docker build -t git.ryankegel.com:3000/ryan/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
- 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)
- 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
echo "Backend push succeeded on attempt $i"
break
else
echo "Backend push failed on attempt $i"
if [ $i -lt 3 ]; then
sleep 10
else
exit 1
fi
fi
done
if [ "${{ gitea.ref }}" == "refs/heads/master" ]; then
docker tag git.ryankegel.com:3000/ryan/backend:${{ steps.vars.outputs.tag }} git.ryankegel.com:3000/ryan/backend:latest
docker push git.ryankegel.com:3000/ryan/backend:latest
elif [ "${{ gitea.ref }}" == "refs/heads/next" ]; then
docker tag git.ryankegel.com:3000/ryan/backend:${{ steps.vars.outputs.tag }} git.ryankegel.com:3000/ryan/backend:next
docker push git.ryankegel.com:3000/ryan/backend:next
fi
- name: Push Frontend Image to Gitea Registry
run: |
for i in {1..3}; do
echo "Attempt $i to push frontend image..."
if docker push git.ryankegel.com:3000/ryan/frontend:${{ steps.vars.outputs.tag }}; then
echo "Frontend push succeeded on attempt $i"
break
else
echo "Frontend push failed on attempt $i"
if [ $i -lt 3 ]; then
sleep 10
else
exit 1
fi
fi
done
if [ "${{ gitea.ref }}" == "refs/heads/master" ]; then
docker tag git.ryankegel.com:3000/ryan/frontend:${{ steps.vars.outputs.tag }} git.ryankegel.com:3000/ryan/frontend:latest
docker push git.ryankegel.com:3000/ryan/frontend:latest
elif [ "${{ gitea.ref }}" == "refs/heads/next" ]; then
docker tag git.ryankegel.com:3000/ryan/frontend:${{ steps.vars.outputs.tag }} git.ryankegel.com:3000/ryan/frontend:next
docker push git.ryankegel.com:3000/ryan/frontend:next
fi
- name: Deploy Test Environment
uses: appleboy/ssh-action@v1.0.3 # Or equivalent Gitea action; adjust version if needed
with:
host: ${{ secrets.DEPLOY_TEST_HOST }}
username: ${{ secrets.DEPLOY_TEST_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
port: 22 # Default SSH port; change if different
script: |
cd /tmp
# Pull the repository to get the latest docker-compose.dev.yml
if [ -d "chore" ]; then
cd chore
git pull origin next || true # Pull latest changes; ignore if it fails (e.g., first run)
else
git clone --branch next https://git.ryankegel.com/ryan/chore.git
cd chore
fi
echo "Bringing down previous test environment..."
docker-compose -f docker-compose.test.yml down --volumes --remove-orphans || true
echo "Starting new test environment..."
docker-compose -f docker-compose.test.yml pull # Ensure latest images are pulled
docker-compose -f docker-compose.test.yml up -d

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

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

63
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,63 @@
# Reward Project: AI Coding Agent Instructions
## 🏗️ Architecture & Data Flow
- **Stack**: Flask (Python, backend) + Vue 3 (TypeScript, frontend) + TinyDB (JSON, thread-safe, see `db/`).
- **API**: RESTful endpoints in `api/`, grouped by entity (child, reward, task, user, image, etc). Each API file maps to a business domain.
- **Nginx Proxy**: Frontend nginx proxies `/api/*` to backend, stripping the `/api` prefix. Backend endpoints should NOT include `/api` in their route definitions. Example: Backend defines `@app.route('/user')`, frontend calls `/api/user`.
- **Models**: Maintain strict 1:1 mapping between Python `@dataclass`es (`backend/models/`) and TypeScript interfaces (`frontend/vue-app/src/common/models.ts`).
- **Database**: Use TinyDB with `from_dict()`/`to_dict()` for serialization. All logic should operate on model instances, not raw dicts.
- **Events**: Real-time updates via Server-Sent Events (SSE). Every mutation (add/edit/delete/trigger) must call `send_event_for_current_user` (see `backend/events/`).
- **Changes**: Do not use comments to replace code. All changes must be reflected in both backend and frontend files as needed.
- **Specs**: If specs have a checklist, all items must be completed and marked done.
## 🧩 Key Patterns & Conventions
- **Frontend Styling**: Use only `:root` CSS variables from `colors.css` for all colors, spacing, and tokens. Example: `--btn-primary`, `--list-item-bg-good`.
- **Scoped Styles**: All `.vue` files must use `<style scoped>`. Reference global variables for theme consistency.
- **API Error Handling**: Backend returns JSON with `error` and `code` (see `backend/api/error_codes.py`). Frontend extracts `{ msg, code }` using `parseErrorResponse(res)` from `api.ts`.
- **JWT Auth**: Tokens are stored in HttpOnly, Secure, SameSite=Strict cookies.
- **Code Style**:
1. Follow PEP 8 for Python, and standard TypeScript conventions.
2. Use type annotations everywhere in Python.
3. Place all imports at the top of the file.
4. Vue files should specifically place `<template>`, `<script>`, then `<style>` in that order. Make sure to put ts code in `<script>` only.
## 🚦 Frontend Logic & Event Bus
- **SSE Event Management**: Register listeners in `onMounted`, clean up in `onUnmounted`. Listen for events like `child_task_triggered`, `child_reward_request`, `task_modified`, etc. See `frontend/vue-app/src/common/backendEvents.ts` and `components/BackendEventsListener.vue`.
- **Layout Hierarchy**: Use `ParentLayout` for admin/management, `ChildLayout` for dashboard/focus views.
## ⚖️ Business Logic & Safeguards
- **Token Expiry**: Verification tokens expire in 4 hours; password reset tokens in 10 minutes.
- **Image Assets**: Models use `image_id` for storage; frontend resolves to `image_url` for rendering.
## 🛠️ Developer Workflows
- **Backend**: Run Flask with `python -m flask run --host=0.0.0.0 --port=5000` from the `backend/` directory. Main entry: `backend/main.py`.
- **Virtual Env**: Python is running from a virtual environment located at `backend/.venv/`.
- **Frontend**: From `frontend/vue-app/`, run `npm install` then `npm run dev`.
- **Tests**: Run backend tests with `pytest` in `backend/tests/`. Frontend component tests: `npm run test` in `frontend/vue-app/components/__tests__/`.
- **Debugging**: Use VS Code launch configs or run Flask/Vue dev servers directly. For SSE, use browser dev tools to inspect event streams.
## 📁 Key Files & Directories
- `backend/api/` — Flask API endpoints (one file per entity)
- `backend/models/` — Python dataclasses (business logic, serialization)
- `backend/db/` — TinyDB setup and helpers
- `backend/events/` — SSE event types, broadcaster, payloads
- `frontend/vue-app/` — Vue 3 frontend (see `src/common/`, `src/components/`, `src/layout/`) - Where tests are run from
- `frontend/vue-app/src/common/models.ts` — TypeScript interfaces (mirror Python models)
- `frontend/vue-app/src/common/api.ts` — API helpers, error parsing, validation
- `frontend/vue-app/src/common/backendEvents.ts` — SSE event types and handlers
## 🧠 Integration & Cross-Component Patterns
- **Every backend mutation must trigger an SSE event** for the current user.
- **Frontend state is event-driven**: always listen for and react to SSE events for real-time updates.
- **Model changes require updating both Python and TypeScript definitions** to maintain parity.
---
For any unclear or missing conventions, review the referenced files or ask for clarification. Keep this document concise and actionable for AI agents.

View File

@@ -0,0 +1,87 @@
# Feature: Hash passwords in database
## Overview
**Goal:** Currently passwords for users are stored in the database as plain text. They need to be hashed using a secure algorithm to prevent exposure in case of a data breach.
**User Story:**
As a user, when I create an account with a password, the password needs to be hashed in the database.
As an admin, I would like a script that will convert the current user database passwords into a hash.
---
## Data Model Changes
### Backend Model (`backend/models/user.py`)
No changes required to the `User` dataclass fields. Passwords will remain as strings, but they will now be hashed values instead of plain text.
### Frontend Model (`frontend/vue-app/src/common/models.ts`)
No changes required. The `User` interface does not expose passwords.
---
## Backend Implementation
### Password Hashing
- Use `werkzeug.security.generate_password_hash()` with default settings (PBKDF2 with SHA256, salt, and iterations) for hashing new passwords.
- Use `werkzeug.security.check_password_hash()` for verification during login and password reset.
- Update the following endpoints to hash passwords on input and verify hashes on output:
- `POST /signup` (hash password before storing; existing length/complexity checks apply).
- `POST /login` (verify hash against input).
- `POST /reset-password` (hash new password before storing; existing length/complexity checks apply).
### Migration Script (`backend/scripts/hash_passwords.py`)
Create a new script to hash existing plain text passwords in the database:
- Read all users from `users_db`.
- For each user, check if the password is already hashed (starts with `scrypt:` or `$pbkdf2-sha256$`); if so, skip.
- For plain text passwords, hash using `generate_password_hash()`.
- Update the user record in the database.
- Log the number of users updated.
- Run this script once after deployment to migrate existing data.
**Usage:** `python backend/scripts/hash_passwords.py`
**Security Notes:**
- The script should only be run in a secure environment (e.g., admin access).
- After migration, verify a few users can log in.
- Delete or secure the script post-migration to avoid reuse.
### Error Handling
No new error codes needed. Existing authentication errors (e.g., invalid credentials) remain unchanged.
---
### Backend Tests (`backend/tests/test_auth_api.py`)
- [x] Test signup with password hashing: Verify stored password is hashed (starts with `scrypt:`).
- [x] Test login with correct password: Succeeds.
- [x] Test login with incorrect password: Fails with appropriate error.
- [x] Test password reset: New password is hashed.
- [x] Test migration script: Hashes existing plain text passwords without data loss; skips already-hashed passwords.
---
## Future Considerations
- Monitor for deprecated hashing algorithms and plan upgrades (e.g., to Argon2 if needed).
- Implement password strength requirements on signup/reset if not already present.
- Consider rate limiting on login attempts to prevent brute-force attacks.
---
## Acceptance Criteria (Definition of Done)
### Backend
- [x] Update `/signup` to hash passwords using `werkzeug.security.generate_password_hash()`.
- [x] Update `/login` to verify passwords using `werkzeug.security.check_password_hash()`.
- [x] Update `/reset-password` to hash new passwords.
- [x] Create `backend/scripts/hash_passwords.py` script for migrating existing plain text passwords.
- [x] All backend tests pass, including new hashing tests.

View File

@@ -0,0 +1,19 @@
# Bug: When a user task or reward exists with the same name as a system user or task, both are shown in the assign list.
## The Problem
- **Actual:** When the user creates a task/reward from a system task/reward (copy on edit), and then goes to assign the task/reward, both the system and user task/reward are shown and can be assigned.
- **Expected:** When a user task/reward is created from a system (or even if it has the same name) - show the user item instead in the assign views.
## Investigation Notes
- When a copy on edit happens of a 'good' task and it is changed to 'bad', I can see the 'good' task when assigning tasks and the 'bad' penalty when assigning the penalty
- The backend will have to change to probably check if the names are the same on tasks/rewards and if so, choose to return the user items instead.
- In the case of two items having the same name AND having different user_ids that are not null, then we should show both items.
- The task view and reward view correctly hides the system item. However, the Task Assign View and RewardAssignView are still showing both items.
## The "Red" Tests
- [x] Create a test that performs a copy on edit and then makes sure only that item shows instead of the system item
- [x] Create a test that performs has 2 user items with the same name as a system item. Verify that the user items are shown, but not the system item.
- [x] Create a test where if a system and identically named user task exist that the user tasks is the only one shown in the task assign view and reward assign view.

View File

@@ -0,0 +1,318 @@
# Feature: Account Deletion Scheduler
## Overview
**Goal:** Implement a scheduler in the backend that will delete accounts that are marked for deletion after a period of time.
**User Story:**
As an administrator, I want accounts that are marked for deletion to be deleted around X amount of hours after they were marked. I want the time to be adjustable.
---
## Configuration
### Environment Variables
- `ACCOUNT_DELETION_THRESHOLD_HOURS`: Hours to wait before deleting marked accounts (default: 720 hours / 30 days)
- **Minimum:** 24 hours (enforced for safety)
- **Maximum:** 720 hours (30 days)
- Configurable via environment variable with validation on startup
### Scheduler Settings
- **Check Interval:** Every 1 hour
- **Implementation:** APScheduler (BackgroundScheduler)
- **Restart Handling:** On app restart, scheduler checks for users with `deletion_in_progress = True` and retries them
- **Retry Logic:** Maximum 3 attempts per user; tracked via `deletion_attempted_at` timestamp
---
## Data Model Changes
### User Model (`backend/models/user.py`)
Add two new fields to the `User` dataclass:
- `deletion_in_progress: bool` - Default `False`. Set to `True` when deletion is actively running
- `deletion_attempted_at: datetime | None` - Default `None`. Timestamp of last deletion attempt
**Serialization:**
- Both fields must be included in `to_dict()` and `from_dict()` methods
---
## Deletion Process & Order
When a user is due for deletion (current time >= `marked_for_deletion_at` + threshold), the scheduler performs deletion in this order:
1. **Set Flag:** `deletion_in_progress = True` (prevents concurrent deletion)
2. **Pending Rewards:** Remove all pending rewards for user's children
3. **Children:** Remove all children belonging to the user
4. **Tasks:** Remove all user-created tasks (where `user_id` matches)
5. **Rewards:** Remove all user-created rewards (where `user_id` matches)
6. **Images (Database):** Remove user's uploaded images from `image_db`
7. **Images (Filesystem):** Delete `data/images/[user_id]` directory and all contents
8. **User Record:** Remove the user from `users_db`
9. **Clear Flag:** `deletion_in_progress = False` (only if deletion failed; otherwise user is deleted)
10. **Update Timestamp:** Set `deletion_attempted_at` to current time (if deletion failed)
### Error Handling
- If any step fails, log the error and continue to next step
- If deletion fails completely, update `deletion_attempted_at` and set `deletion_in_progress = False`
- If a user has 3 failed attempts, log a critical error but continue processing other users
- Missing directories or empty tables are not considered errors
---
## Admin API Endpoints
### New Blueprint: `backend/api/admin_api.py`
All endpoints require JWT authentication and admin privileges.
**Note:** Endpoint paths below are as defined in Flask (without `/api` prefix). Frontend accesses them via nginx proxy at `/api/admin/*`.
#### `GET /admin/deletion-queue`
Returns list of users pending deletion.
**Response:** JSON with `count` and `users` array containing user objects with fields: `id`, `email`, `marked_for_deletion_at`, `deletion_due_at`, `deletion_in_progress`, `deletion_attempted_at`
#### `GET /admin/deletion-threshold`
Returns current deletion threshold configuration.
**Response:** JSON with `threshold_hours`, `threshold_min`, and `threshold_max` fields
#### `PUT /admin/deletion-threshold`
Updates deletion threshold (requires admin auth).
**Request:** JSON with `threshold_hours` field
**Response:** JSON with `message` and updated `threshold_hours`
**Validation:**
- Must be between 24 and 720 hours
- Returns 400 error if out of range
#### `POST /admin/deletion-queue/trigger`
Manually triggers the deletion scheduler (processes entire queue immediately).
**Response:** JSON with `message`, `processed`, `deleted`, and `failed` counts
---
## SSE Event
### New Event Type: `USER_DELETED`
**File:** `backend/events/types/user_deleted.py`
**Payload fields:**
- `user_id: str` - ID of deleted user
- `email: str` - Email of deleted user
- `deleted_at: str` - ISO format timestamp of deletion
**Broadcasting:**
- Event is sent only to **admin users** (not broadcast to all users)
- Triggered immediately after successful user deletion
- Frontend admin clients can listen to this event to update UI
---
## Implementation Details
### File Structure
- `backend/config/deletion_config.py` - Configuration with env variable
- `backend/utils/account_deletion_scheduler.py` - Scheduler logic
- `backend/api/admin_api.py` - New admin endpoints
- `backend/events/types/user_deleted.py` - New SSE event
### Scheduler Startup
In `backend/main.py`, import and call `start_deletion_scheduler()` after Flask app setup
### Logging Strategy
**Configuration:**
- Use dedicated logger: `account_deletion_scheduler`
- Log to both stdout (for Docker/dev) and rotating file (for persistence)
- File: `logs/account_deletion.log`
- Rotation: 10MB max file size, keep 5 backups
- Format: `%(asctime)s - %(name)s - %(levelname)s - %(message)s`
**Log Levels:**
- **INFO:** Each deletion step (e.g., "Deleted 5 children for user {user_id}")
- **INFO:** Summary after each run (e.g., "Deletion scheduler run: 3 users processed, 2 deleted, 1 failed")
- **ERROR:** Individual step failures (e.g., "Failed to delete images for user {user_id}: {error}")
- **CRITICAL:** User with 3+ failed attempts (e.g., "User {user_id} has failed deletion 3 times")
- **WARNING:** Threshold set below 168 hours (7 days)
---
## Acceptance Criteria (Definition of Done)
### Data Model
- [x] Add `deletion_in_progress` field to User model
- [x] Add `deletion_attempted_at` field to User model
- [x] Update `to_dict()` and `from_dict()` methods for serialization
- [x] Update TypeScript User interface in frontend
### Configuration
- [x] Create `backend/config/deletion_config.py` with `ACCOUNT_DELETION_THRESHOLD_HOURS`
- [x] Add environment variable support with default (720 hours)
- [x] Enforce minimum threshold of 24 hours
- [x] Enforce maximum threshold of 720 hours
- [x] Log warning if threshold is less than 168 hours
### Backend Implementation
- [x] Create `backend/utils/account_deletion_scheduler.py`
- [x] Implement APScheduler with 1-hour check interval
- [x] Implement deletion logic in correct order (pending_rewards → children → tasks → rewards → images → directory → user)
- [x] Add comprehensive error handling (log and continue)
- [x] Add restart handling (check `deletion_in_progress` flag on startup)
- [x] Add retry logic (max 3 attempts per user)
- [x] Integrate scheduler into `backend/main.py` startup
### Admin API
- [x] Create `backend/api/admin_api.py` blueprint
- [x] Implement `GET /admin/deletion-queue` endpoint
- [x] Implement `GET /admin/deletion-threshold` endpoint
- [x] Implement `PUT /admin/deletion-threshold` endpoint
- [x] Implement `POST /admin/deletion-queue/trigger` endpoint
- [x] Add JWT authentication checks for all admin endpoints
- [x] Add admin role validation
### SSE Event
- [x] Create `backend/events/types/user_deleted.py`
- [x] Add `USER_DELETED` to `event_types.py`
- [x] Implement admin-only event broadcasting
- [x] Trigger event after successful deletion
### Backend Unit Tests
#### Configuration Tests
- [x] Test default threshold value (720 hours)
- [x] Test environment variable override
- [x] Test minimum threshold enforcement (24 hours)
- [x] Test maximum threshold enforcement (720 hours)
- [x] Test invalid threshold values (negative, non-numeric)
#### Scheduler Tests
- [x] Test scheduler identifies users ready for deletion (past threshold)
- [x] Test scheduler ignores users not yet due for deletion
- [x] Test scheduler handles empty database
- [x] Test scheduler runs at correct interval (1 hour)
- [x] Test scheduler handles restart with `deletion_in_progress = True`
- [x] Test scheduler respects retry limit (max 3 attempts)
#### Deletion Process Tests
- [x] Test deletion removes pending_rewards for user's children
- [x] Test deletion removes children for user
- [x] Test deletion removes user's tasks (not system tasks)
- [x] Test deletion removes user's rewards (not system rewards)
- [x] Test deletion removes user's images from database
- [x] Test deletion removes user directory from filesystem
- [x] Test deletion removes user record from database
- [x] Test deletion handles missing directory gracefully
- [x] Test deletion order is correct (children before user, etc.)
- [x] Test `deletion_in_progress` flag is set during deletion
- [x] Test `deletion_attempted_at` is updated on failure
#### Edge Cases
- [x] Test deletion with user who has no children
- [x] Test deletion with user who has no custom tasks/rewards
- [x] Test deletion with user who has no uploaded images
- [x] Test partial deletion failure (continue with other users)
- [x] Test concurrent deletion attempts (flag prevents double-deletion)
- [x] Test user with exactly 3 failed attempts (logs critical, no retry)
#### Admin API Tests
- [x] Test `GET /admin/deletion-queue` returns correct users
- [x] Test `GET /admin/deletion-queue` requires authentication
- [x] Test `GET /admin/deletion-threshold` returns current threshold
- [x] Test `PUT /admin/deletion-threshold` updates threshold
- [x] Test `PUT /admin/deletion-threshold` validates min/max
- [x] Test `PUT /admin/deletion-threshold` requires admin role
- [x] Test `POST /admin/deletion-queue/trigger` triggers scheduler
- [x] Test `POST /admin/deletion-queue/trigger` returns summary
#### Integration Tests
- [x] Test full deletion flow from marking to deletion
- [x] Test multiple users deleted in same scheduler run
- [x] Test deletion with restart midway (recovery)
### Logging & Monitoring
- [x] Configure dedicated scheduler logger with rotating file handler
- [x] Create `logs/` directory for log files
- [x] Log each deletion step with INFO level
- [x] Log summary after each scheduler run (users processed, deleted, failed)
- [x] Log errors with user ID for debugging
- [x] Log critical error for users with 3+ failed attempts
- [x] Log warning if threshold is set below 168 hours
### Documentation
- [x] Create `README.md` at project root
- [x] Document scheduler feature and behavior
- [x] Document environment variable `ACCOUNT_DELETION_THRESHOLD_HOURS`
- [x] Document deletion process and order
- [x] Document admin API endpoints
- [x] Document restart/retry behavior
---
## Testing Strategy
All tests should use `DB_ENV=test` and operate on test databases in `backend/test_data/`.
### Unit Test Files
- `backend/tests/test_deletion_config.py` - Configuration validation
- `backend/tests/test_deletion_scheduler.py` - Scheduler logic
- `backend/tests/test_admin_api.py` - Admin endpoints
### Test Fixtures
- Create users with various `marked_for_deletion_at` timestamps
- Create users with children, tasks, rewards, images
- Create users with `deletion_in_progress = True` (for restart tests)
### Assertions
- Database records are removed in correct order
- Filesystem directories are deleted
- Flags and timestamps are updated correctly
- Error handling works (log and continue)
- Admin API responses match expected format
---
## Future Considerations
- Archive deleted accounts instead of hard deletion
- Email notification to admin when deletion completes
- Configurable retry count (currently hardcoded to 3)
- Soft delete with recovery option (within grace period)

View File

@@ -0,0 +1,149 @@
# Tracking Feature Implementation Summary
## ✅ Implementation Complete
All acceptance criteria from [feat-tracking.md](.github/specs/active/feat-dynamic-points/feat-tracking.md) have been implemented and tested.
---
## 📦 What Was Delivered
### Backend
1. **Data Model** ([tracking_event.py](backend/models/tracking_event.py))
- `TrackingEvent` dataclass with full type safety
- Factory method `create_event()` for server-side timestamp generation
- Delta invariant validation (`delta == points_after - points_before`)
2. **Database Layer** ([tracking.py](backend/db/tracking.py))
- New TinyDB table: `tracking_events.json`
- Helper functions: `insert_tracking_event`, `get_tracking_events_by_child`, `get_tracking_events_by_user`, `anonymize_tracking_events_for_user`
- Offset-based pagination with sorting by `occurred_at` (desc)
3. **Audit Logging** ([tracking_logger.py](backend/utils/tracking_logger.py))
- Per-user rotating file handlers (`logs/tracking_user_<user_id>.log`)
- 10MB max file size, 5 backups
- Structured log format with all event metadata
4. **API Integration** ([child_api.py](backend/api/child_api.py))
- Tracking added to:
- `POST /child/<id>/trigger-task` → action: `activated`
- `POST /child/<id>/request-reward` → action: `requested`
- `POST /child/<id>/trigger-reward` → action: `redeemed`
- `POST /child/<id>/cancel-request-reward` → action: `cancelled`
5. **Admin API** ([tracking_api.py](backend/api/tracking_api.py))
- `GET /admin/tracking` with filters:
- `child_id` (required if no `user_id`)
- `user_id` (admin only)
- `entity_type` (task|reward|penalty)
- `action` (activated|requested|redeemed|cancelled)
- `limit` (default 50, max 500)
- `offset` (default 0)
- Returns total count for future pagination UI
6. **SSE Events** ([event_types.py](backend/events/types/event_types.py), [tracking_event_created.py](backend/events/types/tracking_event_created.py))
- New event type: `TRACKING_EVENT_CREATED`
- Payload: `tracking_event_id`, `child_id`, `entity_type`, `action`
- Emitted on every tracking event creation
---
### Frontend
1. **TypeScript Models** ([models.ts](frontend/vue-app/src/common/models.ts))
- `TrackingEvent` interface (1:1 parity with Python)
- Type aliases: `EntityType`, `ActionType`
- `TrackingEventCreatedPayload` for SSE events
2. **API Helpers** ([api.ts](frontend/vue-app/src/common/api.ts))
- `getTrackingEventsForChild()` function with all filter params
3. **SSE Registration**
- Event type registered in type union
- Ready for future UI components
---
### Tests
**Backend Unit Tests** ([test_tracking.py](backend/tests/test_tracking.py)):
- ✅ Tracking event creation with factory method
- ✅ Delta invariant validation
- ✅ Insert and query tracking events
- ✅ Filtering by `entity_type` and `action`
- ✅ Offset-based pagination
- ✅ User anonymization on deletion
- ✅ Points change correctness (positive/negative/zero delta)
- ✅ No points change for request/cancel actions
---
## 🔑 Key Design Decisions
1. **Append-only tracking table** - No deletions, only anonymization on user deletion
2. **Server timestamps** - `occurred_at` always uses server time (UTC) to avoid client clock drift
3. **Separate logging** - Per-user audit logs independent of database
4. **Offset pagination** - Simpler than cursors, sufficient for expected scale
5. **No UI (yet)** - API/models/SSE only; UI deferred to future phase
---
## 🚀 Usage Examples
### Backend: Create a tracking event
```python
from models.tracking_event import TrackingEvent
from db.tracking import insert_tracking_event
from utils.tracking_logger import log_tracking_event
event = TrackingEvent.create_event(
user_id='user123',
child_id='child456',
entity_type='task',
entity_id='task789',
action='activated',
points_before=50,
points_after=60,
metadata={'task_name': 'Homework'}
)
insert_tracking_event(event)
log_tracking_event(event)
```
### Frontend: Query tracking events
```typescript
import { getTrackingEventsForChild } from "@/common/api";
const res = await getTrackingEventsForChild({
childId: "child456",
entityType: "task",
limit: 20,
offset: 0,
});
const data = await res.json();
// { tracking_events: [...], total: 42, count: 20, limit: 20, offset: 0 }
```
---
## 📋 Migration Notes
1. **New database file**: `backend/data/db/tracking_events.json` will be created automatically on first tracking event.
2. **New log directory**: `backend/logs/tracking_user_<user_id>.log` files will be created per user.
3. **No breaking changes** to existing APIs or data models.
---
## 🔮 Future Enhancements (Not in This Phase)
- Admin/parent UI for viewing tracking history
- Badges and certificates based on tracking data
- Analytics and reporting dashboards
- Export tracking data (CSV, JSON)
- Time-based filters (date range queries)

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View File

@@ -0,0 +1,519 @@
# Feature: Dynamic Point and Cost Customization
## Overview
**Goal:** Allow parents to customize the point value of tasks/penalties and the cost of rewards on a per-child basis after assignment.
**User Story:**
As a parent, I want to assign different point values to the same task for different children, so I can tailor rewards to each child's needs and motivations. For example, "Clean Room" might be worth 10 points for one child but 5 points for another.
**Process:**
1. **Assignment First**: Tasks, penalties, and rewards must be assigned to a child before their points/cost can be customized.
2. **Edit Button Access**: After the first click on an item in ScrollingList (when it centers), an edit button appears in the corner (34x34px, using `edit.png` icon).
3. **Modal Customization**: Clicking the edit button opens a modal with a number input field allowing values from **0 to 10000**.
4. **Default Values**: The field defaults to the last user-set value or the entity's default points/cost if never customized.
5. **Visual Indicator**: Items with custom values show a ✏️ emoji badge next to the points/cost number.
6. **Activation Behavior**: The second click on an item activates it (triggers task/reward), not the first click.
**Architecture Decisions:**
- **Storage**: Use a separate `child_overrides.json` table (not embedded in child model) to store per-child customizations.
- **Lifecycle**: Overrides reset to default when a child is unassigned from a task/reward. Overrides are deleted when the entity or child is deleted (cascade).
- **Validation**: Allow 0 points/cost (not minimum 1). Disable save button on invalid input (empty, negative, >10000).
- **UI Flow**: First click centers item and shows edit button. Second click activates entity. Edit button opens modal for customization.
**UI:**
- Before first click: [feat-dynamic-points-before.png](feat-dynamic-points-before.png)
- After first click: [feat-dynamic-points-after.png](feat-dynamic-points-after.png)
- Edit button icon: `frontend/vue-app/public/edit.png` (34x34px)
- Button position: Corner of ScrollingList item, not interfering with text
- Badge: ✏️ emoji displayed next to points/cost number when override exists
---
## Configuration
**No new configuration required.** Range validation (0-10000) is hardcoded per requirements.
---
## Data Model Changes
### New Model: `ChildOverride`
**Python** (`backend/models/child_override.py`):
Create a dataclass that inherits from `BaseModel` with the following fields:
- `child_id` (str): ID of the child this override applies to
- `entity_id` (str): ID of the task/penalty/reward being customized
- `entity_type` (Literal['task', 'reward']): Type of entity
- `custom_value` (int): Custom points or cost value
Validation requirements:
- `custom_value` must be between 0 and 10000 (inclusive)
- `entity_type` must be either 'task' or 'reward'
- Include `__post_init__` method to enforce these validations
- Include static factory method `create_override()` that accepts the four main fields and returns a new instance
**TypeScript** (`frontend/vue-app/src/common/models.ts`):
Create an interface with 1:1 parity to the Python model:
- Define `EntityType` as a union type: 'task' | 'reward'
- Include all fields: `id`, `child_id`, `entity_id`, `entity_type`, `custom_value`, `created_at`, `updated_at`
- All string fields except `custom_value` which is number
### Database Table
**New Table**: `child_overrides.json`
**Indexes**:
- `child_id` (for lookup by child)
- `entity_id` (for lookup by task/reward)
- Composite `(child_id, entity_id)` (for uniqueness constraint)
**Database Helper** (`backend/db/child_overrides.py`):
Create database helper functions using TinyDB and the `child_overrides_db` table:
- `insert_override(override)`: Insert or update (upsert) based on composite key (child_id, entity_id). Only one override allowed per child-entity pair.
- `get_override(child_id, entity_id)`: Return Optional[ChildOverride] for a specific child and entity combination
- `get_overrides_for_child(child_id)`: Return List[ChildOverride] for all overrides belonging to a child
- `delete_override(child_id, entity_id)`: Delete specific override, return bool indicating success
- `delete_overrides_for_child(child_id)`: Delete all overrides for a child, return count deleted
- `delete_overrides_for_entity(entity_id)`: Delete all overrides for an entity, return count deleted
All functions should use `from_dict()` and `to_dict()` for model serialization.
---
## SSE Events
### 1. `child_override_set`
**Emitted When**: A parent sets or updates a custom value for a task/reward.
**Payload** (`backend/events/types/child_override_set.py`):
Create a dataclass `ChildOverrideSetPayload` that inherits from `EventPayload` with a single field:
- `override` (ChildOverride): The override object that was set
**TypeScript** (`frontend/vue-app/src/common/backendEvents.ts`):
Create an interface `ChildOverrideSetPayload` with:
- `override` (ChildOverride): The override object that was set
### 2. `child_override_deleted`
**Emitted When**: An override is deleted (manual reset, unassignment, or cascade).
**Payload** (`backend/events/types/child_override_deleted.py`):
Create a dataclass `ChildOverrideDeletedPayload` that inherits from `EventPayload` with three fields:
- `child_id` (str): ID of the child
- `entity_id` (str): ID of the entity
- `entity_type` (str): Type of entity ('task' or 'reward')
**TypeScript** (`frontend/vue-app/src/common/backendEvents.ts`):
Create an interface `ChildOverrideDeletedPayload` with:
- `child_id` (string): ID of the child
- `entity_id` (string): ID of the entity
- `entity_type` (string): Type of entity
---
## API Design
### 1. **PUT** `/child/<child_id>/override`
**Purpose**: Set or update a custom value for a task/reward.
**Auth**: User must own the child.
**Request Body**:
JSON object with three required fields:
- `entity_id` (string): UUID of the task or reward
- `entity_type` (string): Either "task" or "reward"
- `custom_value` (number): Integer between 0 and 10000
**Validation**:
- `entity_type` must be "task" or "reward"
- `custom_value` must be 0-10000
- Entity must be assigned to child
- Child must exist and belong to user
**Response**:
JSON object with a single key `override` containing the complete ChildOverride object with all fields (id, child_id, entity_id, entity_type, custom_value, created_at, updated_at in ISO format).
**Errors**:
- 404: Child not found or not owned
- 404: Entity not assigned to child
- 400: Invalid entity_type
- 400: custom_value out of range
**SSE**: Emits `child_override_set` to user.
### 2. **GET** `/child/<child_id>/overrides`
**Purpose**: Get all overrides for a child.
**Auth**: User must own the child.
**Response**:
JSON object with a single key `overrides` containing an array of ChildOverride objects. Each object includes all standard fields (id, child_id, entity_id, entity_type, custom_value, created_at, updated_at).
**Errors**:
- 404: Child not found or not owned
### 3. **DELETE** `/child/<child_id>/override/<entity_id>`
**Purpose**: Delete an override (reset to default).
**Auth**: User must own the child.
**Response**:
JSON object with `message` field set to "Override deleted".
**Errors**:
- 404: Child not found or not owned
- 404: Override not found
**SSE**: Emits `child_override_deleted` to user.
### Modified Endpoints
Update these existing endpoints to include override information:
1. **GET** `/child/<child_id>/list-tasks` - Include `custom_value` in task objects if override exists
2. **GET** `/child/<child_id>/list-rewards` - Include `custom_value` in reward objects if override exists
3. **POST** `/child/<child_id>/trigger-task` - Use `custom_value` if override exists when awarding points
4. **POST** `/child/<child_id>/trigger-reward` - Use `custom_value` if override exists when deducting points
5. **PUT** `/child/<child_id>/set-tasks` - Delete overrides for unassigned tasks
6. **PUT** `/child/<child_id>/set-rewards` - Delete overrides for unassigned rewards
---
## Implementation Details
### File Structure
**Backend**:
- `backend/models/child_override.py` - ChildOverride model
- `backend/db/child_overrides.py` - Database helpers
- `backend/api/child_override_api.py` - New API endpoints (PUT, GET, DELETE)
- `backend/events/types/child_override_set.py` - SSE event payload
- `backend/events/types/child_override_deleted.py` - SSE event payload
- `backend/events/types/event_types.py` - Add CHILD_OVERRIDE_SET, CHILD_OVERRIDE_DELETED enums
- `backend/tests/test_child_override_api.py` - Unit tests
**Frontend**:
- `frontend/vue-app/src/common/models.ts` - Add ChildOverride interface
- `frontend/vue-app/src/common/api.ts` - Add setChildOverride(), getChildOverrides(), deleteChildOverride()
- `frontend/vue-app/src/common/backendEvents.ts` - Add event types
- `frontend/vue-app/src/components/OverrideEditModal.vue` - New modal component
- `frontend/vue-app/src/components/ScrollingList.vue` - Add edit button and ✏️ badge
- `frontend/vue-app/components/__tests__/OverrideEditModal.spec.ts` - Component tests
### Logging Strategy
**Backend**: Log override operations to per-user rotating log files (same pattern as tracking).
**Log Messages**:
- `Override set: child={child_id}, entity={entity_id}, type={entity_type}, value={custom_value}`
- `Override deleted: child={child_id}, entity={entity_id}`
- `Overrides cascade deleted for child: child_id={child_id}, count={count}`
- `Overrides cascade deleted for entity: entity_id={entity_id}, count={count}`
**Frontend**: No additional logging beyond standard error handling.
---
## Acceptance Criteria (Definition of Done)
### Data Model
- [x] `ChildOverride` Python dataclass created with validation (0-10000 range, entity_type literal)
- [x] `ChildOverride` TypeScript interface created (1:1 parity with Python)
- [x] `child_overrides.json` TinyDB table created in `backend/db/db.py`
- [x] Database helper functions created (insert, get, delete by child, delete by entity)
- [x] Composite uniqueness constraint enforced (child_id, entity_id)
### Backend Implementation
- [x] PUT `/child/<child_id>/override` endpoint created with validation
- [x] GET `/child/<child_id>/overrides` endpoint created
- [x] DELETE `/child/<child_id>/override/<entity_id>` endpoint created
- [x] GET `/child/<child_id>/list-tasks` modified to include `custom_value` when override exists
- [x] GET `/child/<child_id>/list-rewards` modified to include `custom_value` when override exists
- [x] POST `/child/<child_id>/trigger-task` modified to use override value
- [x] POST `/child/<child_id>/trigger-reward` modified to use override value
- [x] PUT `/child/<child_id>/set-tasks` modified to delete overrides for unassigned tasks
- [x] PUT `/child/<child_id>/set-rewards` modified to delete overrides for unassigned rewards
- [x] Cascade delete implemented: deleting child removes all its overrides
- [x] Cascade delete implemented: deleting task/reward removes all its overrides
- [x] Authorization checks: user must own child to access overrides
- [x] Validation: entity must be assigned to child before override can be set
### SSE Events
- [x] `child_override_set` event type added to event_types.py
- [x] `child_override_deleted` event type added to event_types.py
- [x] `ChildOverrideSetPayload` class created (Python)
- [x] `ChildOverrideDeletedPayload` class created (Python)
- [x] PUT endpoint emits `child_override_set` event
- [x] DELETE endpoint emits `child_override_deleted` event
- [x] Frontend TypeScript interfaces for event payloads created
### Frontend Implementation
- [x] `OverrideEditModal.vue` component created
- [x] Modal has number input field with 0-10000 validation
- [x] Modal disables save button on invalid input (empty, negative, >10000)
- [x] Modal defaults to current override value or entity default
- [x] Modal calls PUT `/child/<id>/override` API on save
- [x] Edit button (34x34px) added to ScrollingList items
- [x] Edit button only appears after first click (when item is centered)
- [x] Edit button uses `edit.png` icon from public folder
- [x] ✏️ emoji badge displayed next to points/cost when override exists
- [x] Badge only shows for items with active overrides
- [x] Second click on item activates entity (not first click)
- [x] SSE listeners registered for `child_override_set` and `child_override_deleted`
- [x] Real-time UI updates when override events received
### Backend Unit Tests
#### API Tests (`backend/tests/test_child_override_api.py`)
- [x] Test PUT creates new override with valid data
- [x] Test PUT updates existing override
- [x] Test PUT returns 400 for custom_value < 0
- [x] Test PUT returns 400 for custom_value > 10000
- [x] Test PUT returns 400 for invalid entity_type
- [ ] Test PUT returns 404 for non-existent child
- [ ] Test PUT returns 404 for unassigned entity
- [ ] Test PUT returns 403 for child not owned by user
- [ ] Test PUT emits child_override_set event
- [x] Test GET returns all overrides for child
- [ ] Test GET returns empty array when no overrides
- [ ] Test GET returns 404 for non-existent child
- [ ] Test GET returns 403 for child not owned by user
- [x] Test DELETE removes override
- [ ] Test DELETE returns 404 when override doesn't exist
- [ ] Test DELETE returns 404 for non-existent child
- [ ] Test DELETE returns 403 for child not owned by user
- [ ] Test DELETE emits child_override_deleted event
#### Integration Tests
- [ ] Test list-tasks includes custom_value for overridden tasks
- [ ] Test list-tasks shows default points for non-overridden tasks
- [ ] Test list-rewards includes custom_value for overridden rewards
- [ ] Test trigger-task uses custom_value when awarding points
- [ ] Test trigger-task uses default points when no override
- [ ] Test trigger-reward uses custom_value when deducting points
- [ ] Test trigger-reward uses default cost when no override
- [ ] Test set-tasks deletes overrides for unassigned tasks
- [ ] Test set-tasks preserves overrides for still-assigned tasks
- [ ] Test set-rewards deletes overrides for unassigned rewards
- [ ] Test set-rewards preserves overrides for still-assigned rewards
#### Cascade Delete Tests
- [x] Test deleting child removes all its overrides
- [x] Test deleting task removes all overrides for that task
- [x] Test deleting reward removes all overrides for that reward
- [x] Test unassigning task from child deletes override
- [x] Test reassigning task to child resets override (not preserved)
#### Edge Cases
- [x] Test custom_value = 0 is allowed
- [x] Test custom_value = 10000 is allowed
- [ ] Test cannot set override for entity not assigned to child
- [ ] Test cannot set override for non-existent entity
- [ ] Test multiple children can have different overrides for same entity
### Frontend Unit Tests
#### Component Tests (`components/__tests__/OverrideEditModal.spec.ts`)
- [x] Test modal renders with default value
- [x] Test modal renders with existing override value
- [x] Test save button disabled when input is empty
- [x] Test save button disabled when value < 0
- [x] Test save button disabled when value > 10000
- [x] Test save button enabled when value is 0-10000
- [x] Test modal calls API with correct parameters on save
- [x] Test modal emits close event after successful save
- [x] Test modal shows error message on API failure
- [x] Test cancel button closes modal without saving
#### Component Tests (`components/__tests__/ScrollingList.spec.ts`)
- [ ] Test edit button hidden before first click
- [ ] Test edit button appears after first click (when centered)
- [ ] Test edit button opens OverrideEditModal
- [ ] Test ✏️ badge displayed when override exists
- [ ] Test ✏️ badge hidden when no override exists
- [ ] Test second click activates entity (not first click)
- [ ] Test edit button positioned correctly (34x34px, corner)
- [ ] Test edit button doesn't interfere with text
#### Integration Tests
- [ ] Test SSE event updates UI when override is set
- [ ] Test SSE event updates UI when override is deleted
- [ ] Test override value displayed in task/reward list
- [ ] Test points calculation uses override when triggering task
- [ ] Test cost calculation uses override when triggering reward
#### Edge Cases
- [ ] Test 0 points/cost displays correctly
- [ ] Test 10000 points/cost displays correctly
- [ ] Test badge updates immediately after setting override
- [ ] Test badge disappears immediately after deleting override
### Logging & Monitoring
- [ ] Override set operations logged to per-user log files
- [ ] Override delete operations logged
- [ ] Cascade delete operations logged with count
- [ ] Log messages include child_id, entity_id, entity_type, custom_value
### Documentation
- [ ] API endpoints documented in this spec
- [ ] Data model documented in this spec
- [ ] SSE events documented in this spec
- [ ] UI behavior documented in this spec
- [ ] Edge cases and validation rules documented
---
## Testing Strategy
### Unit Test Files
**Backend** (`backend/tests/test_child_override_api.py`):
Create six test classes:
1. **TestChildOverrideModel**: Test model validation (6 tests)
- Valid override creation
- Negative custom_value raises ValueError
- custom_value > 10000 raises ValueError
- custom_value = 0 is allowed
- custom_value = 10000 is allowed
- Invalid entity_type raises ValueError
2. **TestChildOverrideDB**: Test database operations (8 tests)
- Insert new override
- Insert updates existing (upsert behavior)
- Get existing override returns object
- Get nonexistent override returns None
- Get all overrides for a child
- Delete specific override
- Delete all overrides for a child (returns count)
- Delete all overrides for an entity (returns count)
3. **TestChildOverrideAPI**: Test all three API endpoints (18 tests)
- PUT creates new override
- PUT updates existing override
- PUT returns 400 for negative value
- PUT returns 400 for value > 10000
- PUT returns 400 for invalid entity_type
- PUT returns 404 for nonexistent child
- PUT returns 404 for unassigned entity
- PUT returns 403 for child not owned by user
- PUT emits child_override_set event
- GET returns all overrides for child
- GET returns empty array when no overrides
- GET returns 404 for nonexistent child
- GET returns 403 for child not owned
- DELETE removes override successfully
- DELETE returns 404 when override doesn't exist
- DELETE returns 404 for nonexistent child
- DELETE returns 403 for child not owned
- DELETE emits child_override_deleted event
4. **TestIntegration**: Test override integration with existing endpoints (11 tests)
- list-tasks includes custom_value for overridden tasks
- list-tasks shows default points for non-overridden tasks
- list-rewards includes custom_value for overridden rewards
- trigger-task uses custom_value when awarding points
- trigger-task uses default points when no override
- trigger-reward uses custom_value when deducting points
- trigger-reward uses default cost when no override
- set-tasks deletes overrides for unassigned tasks
- set-tasks preserves overrides for still-assigned tasks
- set-rewards deletes overrides for unassigned rewards
- set-rewards preserves overrides for still-assigned rewards
5. **TestCascadeDelete**: Test cascade deletion behavior (5 tests)
- Deleting child removes all its overrides
- Deleting task removes all overrides for that task
- Deleting reward removes all overrides for that reward
- Unassigning task deletes override
- Reassigning task resets override (not preserved)
6. **TestEdgeCases**: Test boundary conditions (5 tests)
- custom_value = 0 is allowed
- custom_value = 10000 is allowed
- Cannot set override for unassigned entity
- Cannot set override for nonexistent entity
- Multiple children can have different overrides for same entity
### Test Fixtures
Create pytest fixtures for common test scenarios:
- `child_with_task`: Uses existing `child` and `task` fixtures, calls set-tasks endpoint to assign task to child, asserts 200 response, returns child dict
- `child_with_task_override`: Builds on `child_with_task`, calls PUT override endpoint to set custom_value=15 for the task, asserts 200 response, returns child dict
- Similar fixtures for rewards: `child_with_reward`, `child_with_reward_override`
- `child_with_overrides`: Child with multiple overrides for testing bulk operations
### Assertions
Test assertions should verify three main areas:
1. **API Response Correctness**: Check status code (200, 400, 403, 404), verify returned override object has correct values for all fields (custom_value, child_id, entity_id, etc.)
2. **SSE Event Emission**: Use mock_sse fixture to assert `send_event_for_current_user` was called exactly once with the correct EventType (CHILD_OVERRIDE_SET or CHILD_OVERRIDE_DELETED)
3. **Points Calculation**: After triggering tasks/rewards, verify the child's points reflect the custom_value (not the default). For example, if default is 10 but override is 15, child.points should increase by 15.
---
## Future Considerations
1. **Bulk Override Management**: Add endpoint to set/get/delete multiple overrides at once for performance.
2. **Override History**: Track changes to override values over time for analytics.
3. **Copy Overrides**: Allow copying overrides from one child to another.
4. **Override Templates**: Save common override patterns as reusable templates.
5. **Percentage-Based Overrides**: Allow setting overrides as percentage of default (e.g., "150% of default").
6. **Override Expiration**: Add optional expiration dates for temporary adjustments.
7. **Undo Override**: Add "Restore Default" button in UI that deletes override with one click.
8. **Admin Dashboard**: Show overview of all overrides across all children for analysis.

View File

@@ -0,0 +1,112 @@
# Feature: Task and Reward Tracking
## Overview
**Goal:** Tasks, Penalties, and Rewards should be recorded when completed (activated), requested, redeemed, and cancelled. A record of the date and time should also be kept for these actions. A log file shall be produced that shows the child's points before and after the action happened.
**User Story:**
As an administrator, I want to know what kind and when a task, penalty, or reward was activated.
As an administrator, I want a log created detailing when a task, penalty, or reward was activated and how points for the affected child has changed.
As a user (parent), when I activate a task or penalty, I want to record the time and what task or penalty was activated.
As a user (parent), when I redeem a reward, I want to record the time and what reward was redeeemed.
As a user (parent/child), when I cancel a reward, I want to record the time and what reward was cancelled.
As a user (child), when I request a reward, I want to record the time and what reward was requested.
**Questions:**
- Tasks/Penalty, rewards should be tracked per child. Should the tracking be recorded in the child database, or should a new database be used linking the tracking to the child?
- If using a new database, should tracking also be linking to user in case of account deletion?
- Does there need to be any frontend changes for now?
**Decisions:**
- Use a **new TinyDB table** (`tracking_events.json`) for tracking records (append-only). Do **not** embed tracking in `child` to avoid large child docs and preserve audit history. Each record includes `child_id` and `user_id`.
- Track events for: task/penalty activated, reward requested, reward redeemed, reward cancelled.
- Store timestamps in **UTC ISO 8601** with timezone (e.g. `2026-02-09T18:42:15Z`). Always use **server time** for `occurred_at` to avoid client clock drift.
- On user deletion: **anonymize** tracking records by setting `user_id` to `null`, preserving child activity history for compliance/audit.
- Keep an **audit log file per user** (e.g. `tracking_user_<user_id>.log`) with points before/after and event metadata. Use rotating file handler.
- Use **offset-based pagination** for tracking queries (simpler with TinyDB, sufficient for expected scale).
- **Frontend changes deferred**: Ship backend API, models, and SSE events only. No UI components in this phase.
---
## Configuration
## Acceptance Criteria (Definition of Done)
### Data Model
- [x] Add `TrackingEvent` model in `backend/models/` with `from_dict()`/`to_dict()` and 1:1 TS interface in [frontend/vue-app/src/common/models.ts](frontend/vue-app/src/common/models.ts)
- [x] `TrackingEvent` fields include: `id`, `user_id`, `child_id`, `entity_type` (task|reward|penalty), `entity_id`, `action` (activated|requested|redeemed|cancelled), `points_before`, `points_after`, `delta`, `occurred_at`, `created_at`, `metadata` (optional dict)
- [x] Ensure `delta == points_after - points_before` invariant
### Backend Implementation
- [x] Create TinyDB table (e.g., `tracking_events.json`) with helper functions in `backend/db/`
- [x] Add tracking write in all mutation endpoints:
- task/penalty activation
- reward request
- reward redeem
- reward cancel
- [x] Build `TrackingEvent` instances from models (no raw dict writes)
- [x] Add server-side validation for required fields and action/entity enums
- [x] Add `send_event_for_current_user` calls for tracking mutations
### Frontend Implementation
- [x] Add `TrackingEvent` interface and enums to [frontend/vue-app/src/common/models.ts](frontend/vue-app/src/common/models.ts)
- [x] Add API helpers for tracking (list per child, optional filters) in [frontend/vue-app/src/common/api.ts](frontend/vue-app/src/common/api.ts)
- [x] Register SSE event type `tracking_event_created` in [frontend/vue-app/src/common/backendEvents.ts](frontend/vue-app/src/common/backendEvents.ts)
- [x] **No UI components** — deferred to future phase
### Admin API
- [x] Add admin endpoint to query tracking by `child_id`, date range, and `entity_type` (e.g. `GET /admin/tracking`)
- [x] Add offset-based pagination parameters (`limit`, `offset`) with sensible defaults (e.g. limit=50, max=500)
- [x] Return total count for pagination UI (future)
### SSE Event
- [x] Add event type `tracking_event_created` with payload containing `tracking_event_id` and minimal denormalized info
- [x] Update [backend/events/types/event_types.py](backend/events/types/event_types.py) and [frontend/vue-app/src/common/backendEvents.ts](frontend/vue-app/src/common/backendEvents.ts)
### Backend Unit Tests
- [x] Create tests for tracking creation on each mutation endpoint (task/penalty activated, reward requested/redeemed/cancelled)
- [x] Validate `points_before/after` and `delta` are correct
- [x] Ensure tracking write does not block core mutation (failure behavior defined)
### Frontend Unit Tests
- [x] Test API helper functions for tracking queries
- [x] Test TypeScript interface matches backend model (type safety)
#### Edge Cases
- [x] Reward cancel after redeem should not create duplicate inconsistent entries
- [x] Multiple activations in rapid sequence must be ordered by `occurred_at` then `created_at`
- [x] Child deleted: tracking records retained and still queryable by admin (archive mode)
- [x] User deleted: anonymize tracking by setting `user_id` to `null`, retain all other fields for audit history
#### Integration Tests
- [x] End-to-end: activate task -> tracking created -> SSE event emitted -> audit log written
- [x] Verify user deletion anonymizes tracking records without breaking queries
### Logging & Monitoring
- [x] Add dedicated tracking logger with **per-user rotating file handler** (e.g. `logs/tracking_user_<user_id>.log`)
- [x] Log one line per tracking event with `user_id`, `child_id`, `entity_type`, `entity_id`, `action`, `points_before`, `points_after`, `delta`, `occurred_at`
- [x] Configure max file size and backup count (e.g. 10MB, 5 backups)
### Documentation
- [x] Update README or docs to include tracking endpoints, schema, and sample responses
- [x] Add migration note for new `tracking_events.json`
---
## Future Considerations
- Reward tracking will be used to determine child ranking (badges and certificates!)
- is_good vs not is_good in task tracking can be used to show the child their balance in good vs not good

View File

@@ -0,0 +1,26 @@
# Feature: Do Not Allow System Tasks or System Rewards To Be Deleted
## Context:
- **Goal:** In Task List view and Reward List view, do not allow items to be deleted by the user if they are system tasks.
- **User Story:** As a [user], I want to only be able to press the delete button on a task or reward if that item is not a system task or reward so that shared system tasks are not deleted for other users.
## Technical Requirements
- **File Affected:** ItemList.vue, TaskView.vue, RewardView.vue, task_api.py, reward_api.py
- **Logic:**
1. Starting with ItemList.vue, we should check to see if any item in the list has an "user_id" property and if that property is null.
2. If the property is null, that means the item is not owned by a user, so do no display a delete button.
3. If the ItemList has it's deletable property as false, don't bother checking each item for user_id as the delete button will not display.
4. As a safeguard, on the backend, the DELETE api requests should check to see if the "user_id" property of the requested task or reward is null. This is done by requesting the item from the database. The request provides the item's id. If the item is a system item, return 403. Let the return tell the requestor that the item is a system item and cannot be deleted.
5. As a safeguard, make PUT/PATCH operations perform a copy-on-edit of the item. This is already implemented.
6. Bulk deletion is not possible, don't make changes for this.
7. For any item in the frontend or backend that does not have a "user_id" property, treat that as a system item (user_id=null)
8. For both task and reward api create an application level constraint on the database that checks for user_id before mutation logic.
## Acceptance Criteria (The "Definition of Done")
- [x] Logic: Task or Reward does not display the delete button when props.deletable is true and a list item is a system item.
- [x] UI: Doesn't show delete button for system items.
- [x] Backend Tests: Unit tests cover a delete API request for a system task or reward and returns a 403.
- [x] Frontend Tests: Add vitest for this feature in the frontend to make sure the delete button hidden or shown.

View File

@@ -0,0 +1,251 @@
# Feature: Account Deletion (Mark for Removal)
## Overview
**Goal:** Allow users to mark their account for deletion from the Profile page.
**User Story:**
As a user, I want to delete my account from the Profile page. When I click "Delete My Account", I want a confirmation dialog that warns me about data loss. After confirming by entering my email, my account will be marked for deletion, I will be signed out, and I will not be able to log in again.
---
## Data Model Changes
### Backend Model (`backend/models/user.py`)
Add the following fields to the `User` class:
```python
marked_for_deletion: bool = False
marked_for_deletion_at: datetime | None = None
```
- Update `to_dict()` and `from_dict()` methods to serialize these fields.
- Import `datetime` from Python standard library if not already imported.
### Frontend Model (`frontend/vue-app/src/common/models.ts`)
Add matching fields to the `User` interface:
```typescript
marked_for_deletion: boolean;
marked_for_deletion_at: string | null;
```
---
## Backend Implementation
### New Error Codes (`backend/api/error_codes.py`)
Add the following error code:
```python
ACCOUNT_MARKED_FOR_DELETION = 'ACCOUNT_MARKED_FOR_DELETION'
ALREADY_MARKED = 'ALREADY_MARKED'
```
### New API Endpoint (`backend/api/user_api.py`)
**Endpoint:** `POST /api/user/mark-for-deletion`
**Authentication:** Requires valid JWT (authenticated user).
**Request:**
```json
{}
```
(Empty body; user is identified from JWT token)
**Response:**
- **Success (200):**
```json
{ "success": true }
```
- **Error (400/401/403):**
```json
{ "error": "Error message", "code": "INVALID_USER" | "ALREADY_MARKED" }
```
**Logic:**
1. Extract current user from JWT token.
2. Validate user exists in database.
3. Check if already marked for deletion:
- If `marked_for_deletion == True`, return error with code `ALREADY_MARKED` (or make idempotent and return success).
4. Set `marked_for_deletion = True` and `marked_for_deletion_at = datetime.now(timezone.utc)`.
5. Save user to database using `users_db.update()`.
6. Trigger SSE event: `send_event_for_current_user('user_marked_for_deletion', { 'user_id': user.id })`.
7. Return success response.
### Login Blocking (`backend/api/auth_api.py`)
In the `/api/login` endpoint, after validating credentials:
1. Check if `user.marked_for_deletion == True`.
2. If yes, return:
```json
{
"error": "This account has been marked for deletion and cannot be accessed.",
"code": "ACCOUNT_MARKED_FOR_DELETION"
}
```
with HTTP status `403`.
### Password Reset Blocking (`backend/api/user_api.py`)
In the `/api/user/request-reset` endpoint:
1. After finding the user by email, check if `user.marked_for_deletion == True`.
2. If yes, **silently ignore the request**:
- Do not send an email.
- Return success response (to avoid leaking account status).
### SSE Event (`backend/events/types/event_types.py`)
Add new event type:
```python
USER_MARKED_FOR_DELETION = 'user_marked_for_deletion'
```
---
## Frontend Implementation
### Files Affected
- `frontend/vue-app/src/components/parent/UserProfile.vue`
- `frontend/vue-app/src/common/models.ts`
- `frontend/vue-app/src/common/errorCodes.ts`
### Error Codes (`frontend/vue-app/src/common/errorCodes.ts`)
Add:
```typescript
export const ACCOUNT_MARKED_FOR_DELETION = "ACCOUNT_MARKED_FOR_DELETION";
export const ALREADY_MARKED = "ALREADY_MARKED";
```
### UI Components (`UserProfile.vue`)
#### 1. Delete Account Button
- **Label:** "Delete My Account"
- **Style:** `.btn-danger-link` (use `--danger` color from `colors.css`)
- **Placement:** Below "Change Password" link, with `24px` margin-top
- **Behavior:** Opens warning modal on click
#### 2. Warning Modal (uses `ModalDialog.vue`)
- **Title:** "Delete Your Account?"
- **Body:**
"This will permanently delete your account and all associated data. This action cannot be undone."
- **Email Confirmation Input:**
- Require user to type their email address to confirm.
- Display message: "Type your email address to confirm:"
- Input field with `v-model` bound to `confirmEmail` ref.
- **Buttons:**
- **"Cancel"** (`.btn-secondary`) — closes modal
- **"Delete My Account"** (`.btn-danger`) — disabled until `confirmEmail` matches user email, triggers API call
#### 3. Loading State
- Disable "Delete My Account" button during API call.
- Show loading spinner or "Deleting..." text.
#### 4. Success Modal
- **Title:** "Account Deleted"
- **Body:**
"Your account has been marked for deletion. You will now be signed out."
- **Button:** "OK" (closes modal, triggers `logoutUser()` and redirects to `/auth/login`)
#### 5. Error Modal
- **Title:** "Error"
- **Body:** Display error message from API using `parseErrorResponse(res).msg`.
- **Button:** "Close"
### Frontend Logic
1. User clicks "Delete My Account" button.
2. Warning modal opens with email confirmation input.
3. User types email and clicks "Delete My Account".
4. Frontend calls `POST /api/user/mark-for-deletion`.
5. On success:
- Close warning modal.
- Show success modal.
- On "OK" click: call `logoutUser()` from `stores/auth.ts`, redirect to `/auth/login`.
6. On error:
- Close warning modal.
- Show error modal with message from API.
---
## Testing
### Backend Tests (`backend/tests/test_user_api.py`)
- [x] Test marking a valid user account (200, `marked_for_deletion = True`, `marked_for_deletion_at` is set).
- [x] Test marking an already-marked account (return error with `ALREADY_MARKED` or be idempotent).
- [x] Test marking with invalid JWT (401).
- [x] Test marking with missing JWT (401).
- [x] Test login attempt by marked user (403, `ACCOUNT_MARKED_FOR_DELETION`).
- [x] Test password reset request by marked user (silently ignored, returns 200 but no email sent).
- [x] Test SSE event is triggered after marking.
### Frontend Tests (`frontend/vue-app/src/components/__tests__/UserProfile.spec.ts`)
- [x] Test "Delete My Account" button renders.
- [x] Test warning modal opens on button click.
- [x] Test "Delete My Account" button in modal is disabled until email matches.
- [x] Test API call is made when user confirms with correct email.
- [x] Test success modal shows after successful API response.
- [x] Test error modal shows on API failure (with error message).
- [x] Test user is signed out after success (calls `logoutUser()`).
- [x] Test redirect to login page after sign-out.
- [x] Test button is disabled during loading.
---
## Future Considerations
- A background scheduler will be implemented to physically delete marked accounts after a grace period (e.g., 30 days).
- Admin panel to view and manage marked accounts.
- Email notification to user when account is marked for deletion (with grace period details).
---
## Acceptance Criteria (Definition of Done)
### Data Model
- [x] Add `marked_for_deletion` and `marked_for_deletion_at` fields to `User` model (backend).
- [x] Add matching fields to `User` interface (frontend).
- [x] Update `to_dict()` and `from_dict()` methods in `User` model.
### Backend
- [x] Create `POST /api/user/mark-for-deletion` endpoint.
- [x] Add `ACCOUNT_MARKED_FOR_DELETION` and `ALREADY_MARKED` error codes.
- [x] Block login for marked users in `/api/login`.
- [x] Block password reset for marked users in `/api/user/request-reset`.
- [x] Trigger `user_marked_for_deletion` SSE event after marking.
- [x] All backend tests pass.
### Frontend
- [x] Add "Delete My Account" button to `UserProfile.vue` below "Change Password".
- [x] Implement warning modal with email confirmation.
- [x] Implement success modal.
- [x] Implement error modal.
- [x] Implement loading state during API call.
- [x] Sign out user after successful account marking.
- [x] Redirect to login page after sign-out.
- [x] Add `ACCOUNT_MARKED_FOR_DELETION` and `ALREADY_MARKED` to `errorCodes.ts`.
- [x] All frontend tests pass.

View File

@@ -0,0 +1,65 @@
# Feature: Replace the text-based "Parent" button with an image icon and modernize the dropdown menu
## Visual Reference:
- **Sample Design:** #mockup.png
- **Design:**
1. Dropdown header colors need to match color theme inside #colors.css
2. The icon button shall be circular and use all the space of it's container. It should be centered in it's container.
3. The three dropdown items should be "Profile", "Child Mode", and "Sign out"
4. Currently, the dropdown shows "Log out" for "Child Mode", that should be changed to "Child Mode"
## Context:
- **Goal:** I want a user image icon to display in place of the current "Parent" button
- **User Story:** As a [user], I want to see the image assigned in my profile as an icon button at the top right of the screen. When I click the button I want to see a dropdown appear if I'm in 'parent mode.' I to have the options to see/edit my profile, go back to child mode, or sign out. In child mode, I want the button to trigger the parent pin modal if clicked.
## Technical Requirements
- **File Affected:** LoginButton.vue, ParentLayout.vue, ChildLayout.vue, AuthLayout.vue
- **Backend:** When LoginButton loads, it should query the backend for the current user data (/user/profile) The returned data will provide the image_id and first_name of the user.
- **Navigation:**
1. When the avatar button is focused, pressing Enter or Space opens the dropdown.
2. When the dropdown is open:
- Up/Down arrow keys move focus between menu items.
- Enter or Space activates the focused menu item.
- Esc closes the dropdown and returns focus to the avatar button.
3. Tabbing away from the dropdown closes it.
- **ARIA:**
1. The avatar button must have aria-haspopup="menu" and aria-expanded reflecting the dropdown state.
2. The dropdown menu must use role="menu", and each item must use role="menuitem".
3. The currently focused menu item should have aria-selected="true".
- **Focus Ring:** All interactive elements (avatar button and dropdown menu items) must display a visible focus ring when focused via keyboard navigation. The focus ring color should use a theme variable from colors.css and meet accessibility contrast guidelines.
- **Mobile & Layout:**
1. The avatar icon button must always be positioned at the top right of the screen, regardless of device size.
2. The icon must never exceed 44px in width or height.
3. On mobile, ensure the button is at least 44x44px for touch accessibility.
- **Avatar Fallback:** If user.first_name does not exist, display a ? as the fallback initial.
- **Dropdown Placement and Animation:**
1. The dropdown menu must always appear directly below the avatar icon, right-aligned to the screen edge.
2. Use a slide down/up animation for showing/hiding the dropdown.
- **State Requirements:**
- Collapsed: Button shows the user.image_id or a fallback icon with the initial of the user.first_name
- Expanded: Shows the dropdown with the three menu options shown in the #mockup.png. -**Menu Item Icons:**: For now, use a stub element or placeholder for each menu item icon, to be replaced with real icons later.
- **Logic:**
1. Clicking an item in the dropdown should already be implemented. Do not change this.
2. When clicking a menu item or clicking outside the menu, collapse the menu.
3. When in 'child mode' (parent not authenticated), show the parent PIN modal or create PIN view (/parent/pin-setup) if user.pin doesn't exist or is empty. (this is already implemented)
## UI Acceptance Criteria (The "Definition of Done")
- [x] UI: Swap the "Parent" button with the user's avatar image.
- [x] UI: Refactor #LoginButton.vue to use new CSS generated from #mockup.png
- [x] Logic: Make sure the dropdown does not show when in child mode.
- [x] Logic: Make sure the parent PIN modal shows when the button is pressed in child mode.
- [x] Logic: Make sure the parent PIN creation view shows when the button is pressed in child mode if no user.pin doesn't exist or is empty.
- [x] Frontend Tests: Add vitest for this feature in the frontend to make sure the logic for button clicking in parent mode and child mode act correctly.
1. [x] Avatar button renders image, initial, or ? as fallback
2. [x] Dropdown opens/closes via click, Enter, Space, Esc, and outside click.
3. [x] Dropdown is positioned and animated correctly.
4. [x] Keyboard navigation (Up/Down, Enter, Space, Esc) works as specified.
5. [x] ARIA attributes and roles are set correctly.
6. [x] Focus ring is visible and uses theme color.
7. [x] Avatar button meets size and position requirements on all devices.
8. [x] Menu logic for parent/child mode is correct.
9. [x] Stub icons are rendered for menu items.

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

86
.gitignore vendored
View File

@@ -1,78 +1,8 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
PIPFILE.lock
# Virtual Environment
venv/
ENV/
env/
.venv
# PyCharm
.idea/
*.iml
*.iws
.idea_modules/
# VS Code
.vscode/
# Database files
*.db
*.sqlite
*.sqlite3
data/db/*.json
# Flask
instance/
.webassets-cache
# Environment variables
.env
.env.local
.env.*.local
# Node.js / Vue (web directory)
web/node_modules/
web/npm-debug.log*
web/yarn-debug.log*
web/yarn-error.log*
web/dist/
web/.nuxt/
web/.cache/
# OS files
.DS_Store
Thumbs.db
*.swp
*.swo
*~
# Logs
*.log
logs/
# Testing
.pytest_cache/
.coverage
htmlcov/
.tox/
backend/test_data/db/children.json
backend/test_data/db/images.json
backend/test_data/db/pending_rewards.json
backend/test_data/db/rewards.json
backend/test_data/db/tasks.json
backend/test_data/db/users.json
logs/account_deletion.log
backend/test_data/db/tracking_events.json

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

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

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

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

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

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

View File

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

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

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

View File

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

View File

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

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

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

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

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

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

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

9
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,9 @@
{
"recommendations": [
"Vue.volar",
"vitest.explorer",
"dbaeumer.vscode-eslint",
"EditorConfig.EditorConfig",
"esbenp.prettier-vscode"
]
}

88
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,88 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Python: Flask",
"type": "python",
"request": "launch",
"module": "flask",
"python": "${command:python.interpreterPath}",
"env": {
"FLASK_APP": "backend/main.py",
"FLASK_DEBUG": "1"
},
"args": [
"run",
"--host=0.0.0.0",
"--port=5000",
"--no-debugger",
"--no-reload"
],
"cwd": "${workspaceFolder}/backend",
"console": "integratedTerminal"
},
{
"name": "Vue: Dev Server",
"type": "node",
"request": "launch",
"runtimeExecutable": "npm",
"runtimeArgs": [
"run",
"dev"
],
"cwd": "${workspaceFolder}/frontend/vue-app",
"console": "integratedTerminal"
},
{
"name": "Chrome: Launch (Vue App)",
"type": "pwa-chrome",
"request": "launch",
"url": "http://localhost:5173",
"webRoot": "${workspaceFolder}/frontend/vue-app"
},
{
"name": "Python: Backend Tests",
"type": "python",
"request": "launch",
"module": "pytest",
"args": [
"tests/"
],
"cwd": "${workspaceFolder}/backend",
"console": "integratedTerminal",
"env": {
"PYTHONPATH": "${workspaceFolder}/backend"
}
},
{
"name": "Vue: Frontend Tests",
"type": "node",
"request": "launch",
"runtimeExecutable": "npm",
"windows": {
"runtimeExecutable": "npm.cmd"
},
"runtimeArgs": [
"run",
"test:unit"
],
"cwd": "${workspaceFolder}/frontend/vue-app",
"console": "integratedTerminal",
"osx": {
"env": {
"PATH": "/opt/homebrew/bin:${env:PATH}"
}
}
}
],
"compounds": [
{
"name": "Full Stack (Backend + Frontend)",
"configurations": [
"Python: Flask",
"Vue: Dev Server",
"Chrome: Launch (Vue App)"
]
}
]
}

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

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

23
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,23 @@
{
"explorer.fileNesting.enabled": true,
"explorer.fileNesting.patterns": {
"tsconfig.json": "tsconfig.*.json, env.d.ts",
"vite.config.*": "jsconfig*, vitest.config.*, cypress.config.*, playwright.config.*",
"package.json": "package-lock.json, pnpm*, .yarnrc*, yarn*, .eslint*, eslint*, .oxlint*, oxlint*, .prettier*, prettier*, .editorconfig"
},
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"[json]": {
"editor.tabSize": 2,
"editor.insertSpaces": true
},
"[jsonc]": {
"editor.defaultFormatter": "vscode.json-language-features"
},
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"chat.tools.terminal.autoApprove": {
"&": true
}
}

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

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

173
README.md Normal file
View File

@@ -0,0 +1,173 @@
# Reward - Chore & Reward Management System
A family-friendly application for managing chores, tasks, and rewards for children.
## 🏗️ Architecture
- **Backend**: Flask (Python) with TinyDB for data persistence
- **Frontend**: Vue 3 (TypeScript) with real-time SSE updates
- **Deployment**: Docker with nginx reverse proxy
## 🚀 Getting Started
### Backend
```bash
cd backend
python -m venv .venv
.venv\Scripts\activate # Windows
source .venv/bin/activate # Linux/Mac
pip install -r requirements.txt
python -m flask run --host=0.0.0.0 --port=5000
```
### Frontend
```bash
cd frontend/vue-app
npm install
npm run dev
```
## 🔧 Configuration
### Environment Variables
| Variable | Description | Default |
| ---------------------------------- | --------------------------------------------- | ------------- |
| `ACCOUNT_DELETION_THRESHOLD_HOURS` | Hours to wait before deleting marked accounts | 720 (30 days) |
| `DB_ENV` | Database environment (`prod` or `test`) | `prod` |
| `DATA_ENV` | Data directory environment (`prod` or `test`) | `prod` |
### Account Deletion Scheduler
The application includes an automated account deletion scheduler that removes user accounts marked for deletion after a configurable threshold period.
**Key Features:**
- Runs every hour checking for accounts due for deletion
- Configurable threshold between 24 hours (minimum) and 720 hours (maximum)
- Automatic retry on failure (max 3 attempts)
- Restart-safe: recovers from interruptions during deletion
**Deletion Process:**
When an account is marked for deletion, the scheduler will automatically:
1. Remove all pending rewards for the user's children
2. Remove all children belonging to the user
3. Remove all user-created tasks
4. Remove all user-created rewards
5. Remove uploaded images from database
6. Delete user's image directory from filesystem
7. Remove the user account
**Configuration:**
Set the deletion threshold via environment variable:
```bash
export ACCOUNT_DELETION_THRESHOLD_HOURS=168 # 7 days
```
**Monitoring:**
- Logs are written to `logs/account_deletion.log` with rotation (10MB max, 5 backups)
- Check logs for deletion summaries and any errors
## 🔌 API Endpoints
### Admin Endpoints
All admin endpoints require JWT authentication and **admin role**.
**Note:** Admin users must be created manually or via the provided script (`backend/scripts/create_admin.py`). The admin role cannot be assigned through the signup API for security reasons.
**Creating an Admin User:**
```bash
cd backend
python scripts/create_admin.py
```
#### Account Deletion Management
- `GET /api/admin/deletion-queue` - View users pending deletion
- `GET /api/admin/deletion-threshold` - Get current deletion threshold
- `PUT /api/admin/deletion-threshold` - Update deletion threshold (24-720 hours)
- `POST /api/admin/deletion-queue/trigger` - Manually trigger deletion scheduler
### User Endpoints
- `POST /api/user/mark-for-deletion` - Mark current user's account for deletion
- `GET /api/me` - Get current user info
- `POST /api/login` - User login
- `POST /api/logout` - User logout
## 🧪 Testing
### Backend Tests
```bash
cd backend
pytest tests/
```
### Frontend Tests
```bash
cd frontend/vue-app
npm run test
```
## 📝 Features
- ✅ User authentication with JWT tokens
- ✅ Child profile management
- ✅ Task assignment and tracking
- ✅ Reward system
- ✅ Real-time updates via SSE
- ✅ Image upload and management
- ✅ Account deletion with grace period
- ✅ Automated cleanup scheduler
## 🔒 Security
- JWT tokens stored in HttpOnly, Secure, SameSite=Strict cookies
- **Role-Based Access Control (RBAC)**: Admin endpoints protected by admin role validation
- Admin users can only be created via direct database manipulation or provided script
- Regular users cannot escalate privileges to admin
- Account deletion requires email confirmation
- Marked accounts blocked from login immediately
## 📁 Project Structure
```
.
├── backend/
│ ├── api/ # REST API endpoints
│ ├── config/ # Configuration files
│ ├── db/ # TinyDB setup
│ ├── events/ # SSE event system
│ ├── models/ # Data models
│ ├── tests/ # Backend tests
│ └── utils/ # Utilities (scheduler, etc)
├── frontend/
│ └── vue-app/
│ └── src/
│ ├── common/ # Shared utilities
│ ├── components/ # Vue components
│ └── layout/ # Layout components
└── .github/
└── specs/ # Feature specifications
```
## 🛠️ Development
For detailed development patterns and conventions, see [`.github/copilot-instructions.md`](.github/copilot-instructions.md).
## 📚 References
- Reset flow (token validation, JWT invalidation, cross-tab logout sync): [`docs/reset-password-reference.md`](docs/reset-password-reference.md)
## 📄 License
Private project - All rights reserved.

View File

@@ -1,671 +0,0 @@
from flask import Blueprint, request, jsonify
from tinydb import Query
from db.db import child_db, task_db, reward_db, pending_reward_db
from api.reward_status import RewardStatus
from api.child_tasks import ChildTask
from api.child_rewards import ChildReward
from events.sse import send_event_to_user
from events.types.child_modified import ChildModified
from events.types.child_reward_request import ChildRewardRequest
from events.types.child_reward_triggered import ChildRewardTriggered
from events.types.child_rewards_set import ChildRewardsSet
from events.types.child_task_triggered import ChildTaskTriggered
from events.types.child_tasks_set import ChildTasksSet
from events.types.event import Event
from events.types.event_types import EventType
from api.pending_reward import PendingReward as PendingRewardResponse
from models.child import Child
from models.pending_reward import PendingReward
from models.task import Task
from models.reward import Reward
child_api = Blueprint('child_api', __name__)
@child_api.route('/child/<name>', methods=['GET'])
@child_api.route('/child/<id>', methods=['GET'])
def get_child(id):
ChildQuery = Query()
result = child_db.search(ChildQuery.id == id)
if not result:
return jsonify({'error': 'Child not found'}), 404
return jsonify(Child.from_dict(result[0]).to_dict()), 200
@child_api.route('/child/add', methods=['PUT'])
def add_child():
data = request.get_json()
name = data.get('name')
age = data.get('age')
image = data.get('image_id', None)
if not name:
return jsonify({'error': 'Name is required'}), 400
if not image:
image = 'boy01'
child = Child(name=name, age=age, image_id=image)
child_db.insert(child.to_dict())
send_event_to_user("user123", Event(EventType.CHILD_MODIFIED.value, ChildModified(child.id, ChildModified.OPERATION_ADD)))
return jsonify({'message': f'Child {name} added.'}), 201
@child_api.route('/child/<id>/edit', methods=['PUT'])
def edit_child(id):
data = request.get_json()
name = data.get('name', None)
age = data.get('age', None)
points = data.get('points', None)
image = data.get('image_id', None)
ChildQuery = Query()
result = child_db.search(ChildQuery.id == id)
if not result:
return jsonify({'error': 'Child not found'}), 404
child = Child.from_dict(result[0])
if name is not None:
child.name = name
if age is not None:
child.age = age
if points is not None:
child.points = points
if image is not None:
child.image_id = image
# Check if points changed and handle pending rewards
if points is not None:
PendingQuery = Query()
pending_rewards = pending_reward_db.search(PendingQuery.child_id == id)
RewardQuery = Query()
for pr in pending_rewards:
pending = PendingReward.from_dict(pr)
reward_result = reward_db.get(RewardQuery.id == pending.reward_id)
if reward_result:
reward = Reward.from_dict(reward_result)
# If child can no longer afford the reward, remove the pending request
if child.points < reward.cost:
pending_reward_db.remove(
(PendingQuery.child_id == id) & (PendingQuery.reward_id == reward.id)
)
send_event_to_user(
"user123",
Event(
EventType.CHILD_REWARD_REQUEST.value,
ChildRewardRequest(id, reward.id, ChildRewardRequest.REQUEST_CANCELLED)
)
)
child_db.update(child.to_dict(), ChildQuery.id == id)
send_event_to_user("user123", Event(EventType.CHILD_MODIFIED.value, ChildModified(id, ChildModified.OPERATION_EDIT)))
return jsonify({'message': f'Child {id} updated.'}), 200
@child_api.route('/child/list', methods=['GET'])
def list_children():
children = child_db.all()
return jsonify({'children': children}), 200
# Child DELETE
@child_api.route('/child/<id>', methods=['DELETE'])
def delete_child(id):
ChildQuery = Query()
if child_db.remove(ChildQuery.id == id):
send_event_to_user("user123",
Event(EventType.CHILD_MODIFIED.value, ChildModified(id, ChildModified.OPERATION_DELETE)))
return jsonify({'message': f'Child {id} deleted.'}), 200
return jsonify({'error': 'Child not found'}), 404
@child_api.route('/child/<id>/assign-task', methods=['POST'])
def assign_task_to_child(id):
data = request.get_json()
task_id = data.get('task_id')
if not task_id:
return jsonify({'error': 'task_id is required'}), 400
ChildQuery = Query()
result = child_db.search(ChildQuery.id == id)
if not result:
return jsonify({'error': 'Child not found'}), 404
child = result[0]
if task_id not in child.get('tasks', []):
child['tasks'].append(task_id)
child_db.update({'tasks': child['tasks']}, ChildQuery.id == id)
return jsonify({'message': f'Task {task_id} assigned to {child['name']}.'}), 200
# python
@child_api.route('/child/<id>/set-tasks', methods=['PUT'])
def set_child_tasks(id):
data = request.get_json() or {}
task_ids = data.get('task_ids')
if not isinstance(task_ids, list):
return jsonify({'error': 'task_ids must be a list'}), 400
# Deduplicate and drop falsy values
new_task_ids = [tid for tid in dict.fromkeys(task_ids) if tid]
ChildQuery = Query()
result = child_db.search(ChildQuery.id == id)
if not result:
return jsonify({'error': 'Child not found'}), 404
# Optional: validate task IDs exist in the task DB
TaskQuery = Query()
valid_task_ids = []
for tid in new_task_ids:
if task_db.get(TaskQuery.id == tid):
valid_task_ids.append(tid)
# Replace tasks with validated IDs
child_db.update({'tasks': valid_task_ids}, ChildQuery.id == id)
send_event_to_user("user123", Event(EventType.CHILD_TASKS_SET.value, ChildTasksSet(id, valid_task_ids)))
return jsonify({
'message': f'Tasks set for child {id}.',
'task_ids': valid_task_ids,
'count': len(valid_task_ids)
}), 200
@child_api.route('/child/<id>/remove-task', methods=['POST'])
def remove_task_from_child(id):
data = request.get_json()
task_id = data.get('task_id')
if not task_id:
return jsonify({'error': 'task_id is required'}), 400
ChildQuery = Query()
result = child_db.search(ChildQuery.id == id)
if not result:
return jsonify({'error': 'Child not found'}), 404
child = result[0]
if task_id in child.get('tasks', []):
child['tasks'].remove(task_id)
child_db.update({'tasks': child['tasks']}, ChildQuery.id == id)
return jsonify({'message': f'Task {task_id} removed from {child["name"]}.'}), 200
return jsonify({'error': 'Task not assigned to child'}), 400
@child_api.route('/child/<id>/list-tasks', methods=['GET'])
def list_child_tasks(id):
ChildQuery = Query()
result = child_db.search(ChildQuery.id == id)
if not result:
return jsonify({'error': 'Child not found'}), 404
child = result[0]
task_ids = child.get('tasks', [])
TaskQuery = Query()
child_tasks = []
for tid in task_ids:
task = task_db.get(TaskQuery.id == tid)
if not task:
continue
ct = ChildTask(task.get('name'), task.get('is_good'), task.get('points'), task.get('image_id'), task.get('id'))
child_tasks.append(ct.to_dict())
return jsonify({'tasks': child_tasks}), 200
@child_api.route('/child/<id>/list-assignable-tasks', methods=['GET'])
def list_assignable_tasks(id):
ChildQuery = Query()
result = child_db.search(ChildQuery.id == id)
if not result:
return jsonify({'error': 'Child not found'}), 404
child = result[0]
assigned_ids = set(child.get('tasks', []))
# Collect all task ids from the task database
all_task_ids = [t.get('id') for t in task_db.all() if t and t.get('id')]
# Filter out already assigned
assignable_ids = [tid for tid in all_task_ids if tid not in assigned_ids]
# Fetch full task details and wrap in ChildTask
TaskQuery = Query()
assignable_tasks = []
for tid in assignable_ids:
task = task_db.get(TaskQuery.id == tid)
if not task:
continue
ct = ChildTask(task.get('name'), task.get('is_good'), task.get('points'), task.get('image_id'), task.get('id'))
assignable_tasks.append(ct.to_dict())
return jsonify({'tasks': assignable_tasks, 'count': len(assignable_tasks)}), 200
@child_api.route('/child/<id>/list-all-tasks', methods=['GET'])
def list_all_tasks(id):
ChildQuery = Query()
result = child_db.search(ChildQuery.id == id)
if not result:
return jsonify({'error': 'Child not found'}), 404
child = result[0]
assigned_ids = set(child.get('tasks', []))
# Get all tasks from database
all_tasks = task_db.all()
assigned_tasks = []
assignable_tasks = []
for task in all_tasks:
if not task or not task.get('id'):
continue
ct = ChildTask(
task.get('name'),
task.get('is_good'),
task.get('points'),
task.get('image_id'),
task.get('id')
)
if task.get('id') in assigned_ids:
assigned_tasks.append(ct.to_dict())
else:
assignable_tasks.append(ct.to_dict())
return jsonify({
'assigned_tasks': assigned_tasks,
'assignable_tasks': assignable_tasks,
'assigned_count': len(assigned_tasks),
'assignable_count': len(assignable_tasks)
}), 200
@child_api.route('/child/<id>/trigger-task', methods=['POST'])
def trigger_child_task(id):
data = request.get_json()
task_id = data.get('task_id')
if not task_id:
return jsonify({'error': 'task_id is required'}), 400
ChildQuery = Query()
result = child_db.search(ChildQuery.id == id)
if not result:
return jsonify({'error': 'Child not found'}), 404
child: Child = Child.from_dict(result[0])
if task_id not in child.tasks:
return jsonify({'error': f'Task not found assigned to child {child.name}'}), 404
# look up the task and get the details
TaskQuery = Query()
task_result = task_db.search(TaskQuery.id == task_id)
if not task_result:
return jsonify({'error': 'Task not found in task database'}), 404
task: Task = Task.from_dict(task_result[0])
# update the child's points based on task type
if task.is_good:
child.points += task.points
else:
child.points -= task.points
child.points = max(child.points, 0)
# update the child in the database
child_db.update({'points': child.points}, ChildQuery.id == id)
send_event_to_user("user123", Event(EventType.CHILD_TASK_TRIGGERED.value, ChildTaskTriggered(task.id, child.id, child.points)))
return jsonify({'message': f'{task.name} points assigned to {child.name}.', 'points': child.points, 'id': child.id}), 200
@child_api.route('/child/<id>/assign-reward', methods=['POST'])
def assign_reward_to_child(id):
data = request.get_json()
reward_id = data.get('reward_id')
if not reward_id:
return jsonify({'error': 'reward_id is required'}), 400
ChildQuery = Query()
result = child_db.search(ChildQuery.id == id)
if not result:
return jsonify({'error': 'Child not found'}), 404
child = result[0]
if reward_id not in child.get('rewards', []):
child['rewards'].append(reward_id)
child_db.update({'rewards': child['rewards']}, ChildQuery.id == id)
return jsonify({'message': f'Reward {reward_id} assigned to {child["name"]}.'}), 200
@child_api.route('/child/<id>/list-all-rewards', methods=['GET'])
def list_all_rewards(id):
ChildQuery = Query()
result = child_db.search(ChildQuery.id == id)
if not result:
return jsonify({'error': 'Child not found'}), 404
child = result[0]
assigned_ids = set(child.get('rewards', []))
# Get all rewards from database
all_rewards = reward_db.all()
assigned_rewards = []
assignable_rewards = []
for reward in all_rewards:
if not reward or not reward.get('id'):
continue
cr = ChildReward(
reward.get('name'),
reward.get('cost'),
reward.get('image_id'),
reward.get('id')
)
if reward.get('id') in assigned_ids:
assigned_rewards.append(cr.to_dict())
else:
assignable_rewards.append(cr.to_dict())
return jsonify({
'assigned_rewards': assigned_rewards,
'assignable_rewards': assignable_rewards,
'assigned_count': len(assigned_rewards),
'assignable_count': len(assignable_rewards)
}), 200
@child_api.route('/child/<id>/set-rewards', methods=['PUT'])
def set_child_rewards(id):
data = request.get_json() or {}
reward_ids = data.get('reward_ids')
if not isinstance(reward_ids, list):
return jsonify({'error': 'reward_ids must be a list'}), 400
# Deduplicate and drop falsy values
new_reward_ids = [rid for rid in dict.fromkeys(reward_ids) if rid]
ChildQuery = Query()
result = child_db.search(ChildQuery.id == id)
if not result:
return jsonify({'error': 'Child not found'}), 404
# Optional: validate reward IDs exist in the reward DB
RewardQuery = Query()
valid_reward_ids = []
for rid in new_reward_ids:
if reward_db.get(RewardQuery.id == rid):
valid_reward_ids.append(rid)
# Replace rewards with validated IDs
child_db.update({'rewards': valid_reward_ids}, ChildQuery.id == id)
send_event_to_user("user123", Event(EventType.CHILD_REWARDS_SET.value, ChildRewardsSet(id, valid_reward_ids)))
return jsonify({
'message': f'Rewards set for child {id}.',
'reward_ids': valid_reward_ids,
'count': len(valid_reward_ids)
}), 200
@child_api.route('/child/<id>/remove-reward', methods=['POST'])
def remove_reward_from_child(id):
data = request.get_json()
reward_id = data.get('reward_id')
if not reward_id:
return jsonify({'error': 'reward_id is required'}), 400
ChildQuery = Query()
result = child_db.search(ChildQuery.id == id)
if not result:
return jsonify({'error': 'Child not found'}), 404
child = result[0]
if reward_id in child.get('rewards', []):
child['rewards'].remove(reward_id)
child_db.update({'rewards': child['rewards']}, ChildQuery.id == id)
return jsonify({'message': f'Reward {reward_id} removed from {child["name"]}.'}), 200
return jsonify({'error': 'Reward not assigned to child'}), 400
@child_api.route('/child/<id>/list-rewards', methods=['GET'])
def list_child_rewards(id):
ChildQuery = Query()
result = child_db.search(ChildQuery.id == id)
if not result:
return jsonify({'error': 'Child not found'}), 404
child = result[0]
reward_ids = child.get('rewards', [])
RewardQuery = Query()
child_rewards = []
for rid in reward_ids:
reward = reward_db.get(RewardQuery.id == rid)
if not reward:
continue
cr = ChildReward(reward.get('name'), reward.get('cost'), reward.get('image_id'), reward.get('id'))
child_rewards.append(cr.to_dict())
return jsonify({'rewards': child_rewards}), 200
@child_api.route('/child/<id>/list-assignable-rewards', methods=['GET'])
def list_assignable_rewards(id):
ChildQuery = Query()
result = child_db.search(ChildQuery.id == id)
if not result:
return jsonify({'error': 'Child not found'}), 404
child = result[0]
assigned_ids = set(child.get('rewards', []))
all_reward_ids = [r.get('id') for r in reward_db.all() if r and r.get('id')]
assignable_ids = [rid for rid in all_reward_ids if rid not in assigned_ids]
RewardQuery = Query()
assignable_rewards = []
for rid in assignable_ids:
reward = reward_db.get(RewardQuery.id == rid)
if not reward:
continue
cr = ChildReward(reward.get('name'), reward.get('cost'), reward.get('image_id'), reward.get('id'))
assignable_rewards.append(cr.to_dict())
return jsonify({'rewards': assignable_rewards, 'count': len(assignable_rewards)}), 200
@child_api.route('/child/<id>/trigger-reward', methods=['POST'])
def trigger_child_reward(id):
data = request.get_json()
reward_id = data.get('reward_id')
if not reward_id:
return jsonify({'error': 'reward_id is required'}), 400
ChildQuery = Query()
result = child_db.search(ChildQuery.id == id)
if not result:
return jsonify({'error': 'Child not found'}), 404
child: Child = Child.from_dict(result[0])
if reward_id not in child.rewards:
return jsonify({'error': f'Reward not found assigned to child {child.name}'}), 404
# look up the task and get the details
RewardQuery = Query()
reward_result = reward_db.search(RewardQuery.id == reward_id)
if not reward_result:
return jsonify({'error': 'Reward not found in reward database'}), 404
reward: Reward = Reward.from_dict(reward_result[0])
# Remove matching pending reward requests for this child and reward
PendingQuery = Query()
removed = pending_reward_db.remove(
(PendingQuery.child_id == child.id) & (PendingQuery.reward_id == reward.id)
)
if removed:
send_event_to_user("user123", Event(EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest(reward.id, child.id, ChildRewardRequest.REQUEST_GRANTED)))
# update the child's points based on reward cost
child.points -= reward.cost
# update the child in the database
child_db.update({'points': child.points}, ChildQuery.id == id)
send_event_to_user("user123", Event(EventType.CHILD_REWARD_TRIGGERED.value, ChildRewardTriggered(reward.id, child.id, child.points)))
return jsonify({'message': f'{reward.name} assigned to {child.name}.', 'points': child.points, 'id': child.id}), 200
@child_api.route('/child/<id>/affordable-rewards', methods=['GET'])
def list_affordable_rewards(id):
ChildQuery = Query()
result = child_db.search(ChildQuery.id == id)
if not result:
return jsonify({'error': 'Child not found'}), 404
child = Child.from_dict(result[0])
points = child.points
reward_ids = child.rewards
RewardQuery = Query()
affordable = [
Reward.from_dict(reward).to_dict() for reward_id in reward_ids
if (reward := reward_db.get(RewardQuery.id == reward_id)) and points >= Reward.from_dict(reward).cost
]
return jsonify({'affordable_rewards': affordable}), 200
@child_api.route('/child/<id>/reward-status', methods=['GET'])
def reward_status(id):
ChildQuery = Query()
result = child_db.search(ChildQuery.id == id)
if not result:
return jsonify({'error': 'Child not found'}), 404
child = Child.from_dict(result[0])
points = child.points
reward_ids = child.rewards
RewardQuery = Query()
statuses = []
for reward_id in reward_ids:
reward: Reward = Reward.from_dict(reward_db.get(RewardQuery.id == reward_id))
if not reward:
continue
points_needed = max(0, reward.cost - points)
#check to see if this reward id and child id is in the pending rewards db if so set its redeeming flag to true
pending_query = Query()
pending = pending_reward_db.get((pending_query.child_id == child.id) & (pending_query.reward_id == reward.id))
status = RewardStatus(reward.id, reward.name, points_needed, reward.cost, pending is not None, reward.image_id)
statuses.append(status.to_dict())
statuses.sort(key=lambda s: (not s['redeeming'], s['cost']))
return jsonify({'reward_status': statuses}), 200
@child_api.route('/child/<id>/request-reward', methods=['POST'])
def request_reward(id):
data = request.get_json()
reward_id = data.get('reward_id')
if not reward_id:
return jsonify({'error': 'reward_id is required'}), 400
ChildQuery = Query()
result = child_db.search(ChildQuery.id == id)
if not result:
return jsonify({'error': 'Child not found'}), 404
child = Child.from_dict(result[0])
if reward_id not in child.rewards:
return jsonify({'error': f'Reward not assigned to child {child.name}'}), 404
RewardQuery = Query()
reward_result = reward_db.search(RewardQuery.id == reward_id)
if not reward_result:
return jsonify({'error': 'Reward not found in reward database'}), 404
reward = Reward.from_dict(reward_result[0])
# Check if child has enough points
if child.points < reward.cost:
points_needed = reward.cost - child.points
return jsonify({
'error': 'Insufficient points',
'points_needed': points_needed,
'current_points': child.points,
'reward_cost': reward.cost
}), 400
pending = PendingReward(child_id=child.id, reward_id=reward.id)
pending_reward_db.insert(pending.to_dict())
send_event_to_user("user123", Event(EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest(child.id, reward.id, ChildRewardRequest.REQUEST_CREATED)))
return jsonify({
'message': f'Reward request for {reward.name} submitted for {child.name}.',
'reward_id': reward.id,
'reward_name': reward.name,
'child_id': child.id,
'child_name': child.name,
'cost': reward.cost
}), 200
@child_api.route('/child/<id>/cancel-request-reward', methods=['POST'])
def cancel_request_reward(id):
data = request.get_json()
reward_id = data.get('reward_id')
if not reward_id:
return jsonify({'error': 'reward_id is required'}), 400
ChildQuery = Query()
result = child_db.search(ChildQuery.id == id)
if not result:
return jsonify({'error': 'Child not found'}), 404
child = Child.from_dict(result[0])
# Remove matching pending reward request
PendingQuery = Query()
removed = pending_reward_db.remove(
(PendingQuery.child_id == child.id) & (PendingQuery.reward_id == reward_id)
)
if not removed:
return jsonify({'error': 'No pending request found for this reward'}), 404
# Notify user that the request was cancelled
send_event_to_user(
"user123",
Event(
EventType.CHILD_REWARD_REQUEST.value,
ChildRewardRequest(child.id, reward_id, ChildRewardRequest.REQUEST_CANCELLED)
)
)
return jsonify({
'message': f'Reward request cancelled for {child.name}.',
'child_id': child.id,
'reward_id': reward_id,
'removed_count': len(removed)
}), 200
@child_api.route('/pending-rewards', methods=['GET'])
def list_pending_rewards():
pending_rewards = pending_reward_db.all()
reward_responses = []
RewardQuery = Query()
ChildQuery = Query()
for pr in pending_rewards:
pending = PendingReward.from_dict(pr)
# Look up reward details
reward_result = reward_db.get(RewardQuery.id == pending.reward_id)
if not reward_result:
continue
reward = Reward.from_dict(reward_result)
# Look up child details
child_result = child_db.get(ChildQuery.id == pending.child_id)
if not child_result:
continue
child = Child.from_dict(child_result)
# Create response object
response = PendingRewardResponse(
_id=pending.id,
child_id=child.id,
child_name=child.name,
child_image_id=child.image_id,
reward_id=reward.id,
reward_name=reward.name,
reward_image_id=reward.image_id
)
reward_responses.append(response.to_dict())
return jsonify({'rewards': reward_responses}), 200

View File

@@ -1,104 +0,0 @@
from flask import Blueprint, request, jsonify
from tinydb import Query
from events.sse import send_event_to_user
from events.types.event import Event
from events.types.event_types import EventType
from events.types.reward_modified import RewardModified
from models.reward import Reward
from db.db import reward_db, child_db
reward_api = Blueprint('reward_api', __name__)
# Reward endpoints
@reward_api.route('/reward/add', methods=['PUT'])
def add_reward():
data = request.get_json()
name = data.get('name')
description = data.get('description')
cost = data.get('cost')
image = data.get('image_id', '')
if not name or description is None or cost is None:
return jsonify({'error': 'Name, description, and cost are required'}), 400
reward = Reward(name=name, description=description, cost=cost, image_id=image)
reward_db.insert(reward.to_dict())
send_event_to_user("user123", Event(EventType.REWARD_MODIFIED.value,
RewardModified(reward.id, RewardModified.OPERATION_ADD)))
return jsonify({'message': f'Reward {name} added.'}), 201
@reward_api.route('/reward/<id>', methods=['GET'])
def get_reward(id):
RewardQuery = Query()
result = reward_db.search(RewardQuery.id == id)
if not result:
return jsonify({'error': 'Reward not found'}), 404
return jsonify(result[0]), 200
@reward_api.route('/reward/list', methods=['GET'])
def list_rewards():
rewards = reward_db.all()
return jsonify({'rewards': rewards}), 200
@reward_api.route('/reward/<id>', methods=['DELETE'])
def delete_reward(id):
RewardQuery = Query()
removed = reward_db.remove(RewardQuery.id == id)
if removed:
# remove the reward id from any child's reward list
ChildQuery = Query()
for child in child_db.all():
rewards = child.get('rewards', [])
if id in rewards:
rewards.remove(id)
child_db.update({'rewards': rewards}, ChildQuery.id == child.get('id'))
send_event_to_user("user123", Event(EventType.REWARD_MODIFIED.value,
RewardModified(id, RewardModified.OPERATION_DELETE)))
return jsonify({'message': f'Reward {id} deleted.'}), 200
return jsonify({'error': 'Reward not found'}), 404
@reward_api.route('/reward/<id>/edit', methods=['PUT'])
def edit_reward(id):
RewardQuery = Query()
existing = reward_db.get(RewardQuery.id == id)
if not existing:
return jsonify({'error': 'Reward not found'}), 404
data = request.get_json(force=True) or {}
updates = {}
if 'name' in data:
name = (data.get('name') or '').strip()
if not name:
return jsonify({'error': 'Name cannot be empty'}), 400
updates['name'] = name
if 'description' in data:
desc = (data.get('description') or '').strip()
if not desc:
return jsonify({'error': 'Description cannot be empty'}), 400
updates['description'] = desc
if 'cost' in data:
cost = data.get('cost')
if not isinstance(cost, int):
return jsonify({'error': 'Cost must be an integer'}), 400
if cost <= 0:
return jsonify({'error': 'Cost must be a positive integer'}), 400
updates['cost'] = cost
if 'image_id' in data:
updates['image_id'] = data.get('image_id', '')
if not updates:
return jsonify({'error': 'No valid fields to update'}), 400
reward_db.update(updates, RewardQuery.id == id)
updated = reward_db.get(RewardQuery.id == id)
send_event_to_user("user123", Event(EventType.REWARD_MODIFIED.value,
RewardModified(id, RewardModified.OPERATION_EDIT)))
return jsonify(updated), 200

View File

@@ -1,100 +0,0 @@
from flask import Blueprint, request, jsonify
from tinydb import Query
from events.sse import send_event_to_user
from events.types.event import Event
from events.types.event_types import EventType
from events.types.task_modified import TaskModified
from models.task import Task
from db.db import task_db, child_db
task_api = Blueprint('task_api', __name__)
# Task endpoints
@task_api.route('/task/add', methods=['PUT'])
def add_task():
data = request.get_json()
name = data.get('name')
points = data.get('points')
is_good = data.get('is_good')
image = data.get('image_id', '')
if not name or points is None or is_good is None:
return jsonify({'error': 'Name, points, and is_good are required'}), 400
task = Task(name=name, points=points, is_good=is_good, image_id=image)
task_db.insert(task.to_dict())
send_event_to_user("user123", Event(EventType.TASK_MODIFIED.value,
TaskModified(task.id, TaskModified.OPERATION_ADD)))
return jsonify({'message': f'Task {name} added.'}), 201
@task_api.route('/task/<id>', methods=['GET'])
def get_task(id):
TaskQuery = Query()
result = task_db.search(TaskQuery.id == id)
if not result:
return jsonify({'error': 'Task not found'}), 404
return jsonify(result[0]), 200
@task_api.route('/task/list', methods=['GET'])
def list_tasks():
tasks = task_db.all()
return jsonify({'tasks': tasks}), 200
@task_api.route('/task/<id>', methods=['DELETE'])
def delete_task(id):
TaskQuery = Query()
removed = task_db.remove(TaskQuery.id == id)
if removed:
# remove the task id from any child's task list
ChildQuery = Query()
for child in child_db.all():
tasks = child.get('tasks', [])
if id in tasks:
tasks.remove(id)
child_db.update({'tasks': tasks}, ChildQuery.id == child.get('id'))
send_event_to_user("user123", Event(EventType.TASK_MODIFIED.value,
TaskModified(id, TaskModified.OPERATION_DELETE)))
return jsonify({'message': f'Task {id} deleted.'}), 200
return jsonify({'error': 'Task not found'}), 404
@task_api.route('/task/<id>/edit', methods=['PUT'])
def edit_task(id):
TaskQuery = Query()
existing = task_db.get(TaskQuery.id == id)
if not existing:
return jsonify({'error': 'Task not found'}), 404
data = request.get_json(force=True) or {}
updates = {}
if 'name' in data:
name = data.get('name', '').strip()
if not name:
return jsonify({'error': 'Name cannot be empty'}), 400
updates['name'] = name
if 'points' in data:
points = data.get('points')
if not isinstance(points, int):
return jsonify({'error': 'Points must be an integer'}), 400
if points <= 0:
return jsonify({'error': 'Points must be a positive integer'}), 400
updates['points'] = points
if 'is_good' in data:
is_good = data.get('is_good')
if not isinstance(is_good, bool):
return jsonify({'error': 'is_good must be a boolean'}), 400
updates['is_good'] = is_good
if 'image_id' in data:
updates['image_id'] = data.get('image_id', '')
if not updates:
return jsonify({'error': 'No valid fields to update'}), 400
task_db.update(updates, TaskQuery.id == id)
updated = task_db.get(TaskQuery.id == id)
send_event_to_user("user123", Event(EventType.TASK_MODIFIED.value,
TaskModified(id, TaskModified.OPERATION_EDIT)))
return jsonify(updated), 200

83
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,83 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
PIPFILE.lock
# Virtual Environment
venv/
ENV/
env/
.venv
# PyCharm
.idea/
*.iml
*.iws
.idea_modules/
# VS Code
.vscode/
# Database files
*.db
*.sqlite
*.sqlite3
data/db/*.json
data/images/
test_data/
# Flask
instance/
.webassets-cache
# Environment variables
.env
.env.local
.env.*.local
# Node.js / Vue (web directory)
web/node_modules/
web/npm-debug.log*
web/yarn-debug.log*
web/yarn-error.log*
web/dist/
web/.nuxt/
web/.cache/
# OS files
.DS_Store
Thumbs.db
*.swp
*.swo
*~
# Logs
*.log
logs/
# Testing
.pytest_cache/
.coverage
htmlcov/
.tox/
/chore.bundle
/tree.json

View File

199
backend/api/admin_api.py Normal file
View File

@@ -0,0 +1,199 @@
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 config.deletion_config import (
ACCOUNT_DELETION_THRESHOLD_HOURS,
MIN_THRESHOLD_HOURS,
MAX_THRESHOLD_HOURS,
validate_threshold
)
from utils.account_deletion_scheduler import trigger_deletion_manually
admin_api = Blueprint('admin_api', __name__)
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
def get_deletion_queue():
"""
Get list of users pending deletion.
Returns users marked for deletion with their deletion due dates.
"""
try:
Query_ = Query()
marked_users = users_db.search(Query_.marked_for_deletion == True)
users_data = []
for user_dict in marked_users:
user = User.from_dict(user_dict)
# Calculate deletion_due_at
deletion_due_at = None
if user.marked_for_deletion_at:
try:
marked_at = datetime.fromisoformat(user.marked_for_deletion_at)
due_at = marked_at + timedelta(hours=ACCOUNT_DELETION_THRESHOLD_HOURS)
deletion_due_at = due_at.isoformat()
except (ValueError, TypeError):
pass
users_data.append({
'id': user.id,
'email': user.email,
'marked_for_deletion_at': user.marked_for_deletion_at,
'deletion_due_at': deletion_due_at,
'deletion_in_progress': user.deletion_in_progress,
'deletion_attempted_at': user.deletion_attempted_at
})
return jsonify({
'count': len(users_data),
'users': users_data
}), 200
except Exception as e:
return jsonify({'error': str(e), 'code': 'SERVER_ERROR'}), 500
@admin_api.route('/admin/deletion-threshold', methods=['GET'])
@admin_required
def get_deletion_threshold():
"""
Get current deletion threshold configuration.
"""
return jsonify({
'threshold_hours': ACCOUNT_DELETION_THRESHOLD_HOURS,
'threshold_min': MIN_THRESHOLD_HOURS,
'threshold_max': MAX_THRESHOLD_HOURS
}), 200
@admin_api.route('/admin/deletion-threshold', methods=['PUT'])
@admin_required
def update_deletion_threshold():
"""
Update deletion threshold.
Note: This updates the runtime value but doesn't persist to environment variables.
For permanent changes, update the ACCOUNT_DELETION_THRESHOLD_HOURS env variable.
"""
try:
data = request.get_json()
if not data or 'threshold_hours' not in data:
return jsonify({
'error': 'threshold_hours is required',
'code': 'MISSING_THRESHOLD'
}), 400
new_threshold = data['threshold_hours']
# Validate type
if not isinstance(new_threshold, int):
return jsonify({
'error': 'threshold_hours must be an integer',
'code': 'INVALID_TYPE'
}), 400
# Validate range
if new_threshold < MIN_THRESHOLD_HOURS:
return jsonify({
'error': f'threshold_hours must be at least {MIN_THRESHOLD_HOURS}',
'code': 'THRESHOLD_TOO_LOW'
}), 400
if new_threshold > MAX_THRESHOLD_HOURS:
return jsonify({
'error': f'threshold_hours must be at most {MAX_THRESHOLD_HOURS}',
'code': 'THRESHOLD_TOO_HIGH'
}), 400
# Update the global config
import config.deletion_config as config
config.ACCOUNT_DELETION_THRESHOLD_HOURS = new_threshold
# Validate and log warning if needed
validate_threshold()
return jsonify({
'message': 'Deletion threshold updated successfully',
'threshold_hours': new_threshold
}), 200
except Exception as e:
return jsonify({'error': str(e), 'code': 'SERVER_ERROR'}), 500
@admin_api.route('/admin/deletion-queue/trigger', methods=['POST'])
@admin_required
def trigger_deletion_queue():
"""
Manually trigger the deletion scheduler to process the queue immediately.
Returns stats about the run.
"""
try:
# Trigger the deletion process
result = trigger_deletion_manually()
# Get updated queue stats
Query_ = Query()
marked_users = users_db.search(Query_.marked_for_deletion == True)
# Count users that were just processed (this is simplified)
processed = result.get('queued_users', 0)
# In a real implementation, you'd return actual stats from the deletion run
# For now, we'll return simplified stats
return jsonify({
'message': 'Deletion scheduler triggered',
'processed': processed,
'deleted': 0, # TODO: Track this in the deletion function
'failed': 0 # TODO: Track this in the deletion function
}), 200
except Exception as e:
return jsonify({'error': str(e), 'code': 'SERVER_ERROR'}), 500

287
backend/api/auth_api.py Normal file
View File

@@ -0,0 +1,287 @@
import logging
import secrets, jwt
from datetime import datetime, timedelta, timezone
from models.user import User
from flask import Blueprint, request, jsonify, current_app
from tinydb import Query
import os
import utils.email_sender as email_sender
from werkzeug.security import generate_password_hash, check_password_hash
from api.utils import sanitize_email
from config.paths import get_user_image_dir
from 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.utils import normalize_email
logger = logging.getLogger(__name__)
auth_api = Blueprint('auth_api', __name__)
UserQuery = Query()
TOKEN_EXPIRY_MINUTES = 60*4
RESET_PASSWORD_TOKEN_EXPIRY_MINUTES = 10
def send_verification_email(to_email, token):
email_sender.send_verification_email(to_email, token)
def send_reset_password_email(to_email, token):
email_sender.send_reset_password_email(to_email, token)
@auth_api.route('/signup', methods=['POST'])
def signup():
data = request.get_json()
required_fields = ['first_name', 'last_name', 'email', 'password']
if not all(field in data for field in required_fields):
return jsonify({'error': 'Missing required fields', 'code': MISSING_FIELDS}), 400
email = data.get('email', '')
norm_email = normalize_email(email)
existing = users_db.get(UserQuery.email == norm_email)
if existing:
user = User.from_dict(existing)
if user.marked_for_deletion:
return jsonify({'error': 'Account marked for deletion', 'code': ACCOUNT_MARKED_FOR_DELETION}), 403
return jsonify({'error': 'Email already exists', 'code': EMAIL_EXISTS}), 400
token = secrets.token_urlsafe(32)
now_iso = datetime.utcnow().isoformat()
user = User(
first_name=data['first_name'],
last_name=data['last_name'],
email=norm_email,
password=generate_password_hash(data['password']),
verified=False,
verify_token=token,
verify_token_created=now_iso,
image_id="boy01"
)
users_db.insert(user.to_dict())
send_verification_email(norm_email, token)
return jsonify({'message': 'User created, verification email sent'}), 201
@auth_api.route('/verify', methods=['GET'])
def verify():
token = request.args.get('token')
status = 'success'
reason = ''
code = ''
user_dict = None
user = None
if not token:
status = 'error'
reason = 'Missing token'
code = MISSING_TOKEN
else:
user_dict = users_db.get(Query().verify_token == token)
user = User.from_dict(user_dict) if user_dict else None
if not user:
status = 'error'
reason = 'Invalid token'
code = INVALID_TOKEN
elif user.marked_for_deletion:
status = 'error'
reason = 'Account marked for deletion'
code = ACCOUNT_MARKED_FOR_DELETION
else:
created_str = user.verify_token_created
if not created_str:
status = 'error'
reason = 'Token timestamp missing'
code = TOKEN_TIMESTAMP_MISSING
else:
created_dt = datetime.fromisoformat(created_str).replace(tzinfo=timezone.utc)
if datetime.now(timezone.utc) - created_dt > timedelta(minutes=TOKEN_EXPIRY_MINUTES):
status = 'error'
reason = 'Token expired'
code = TOKEN_EXPIRED
else:
user.verified = True
user.verify_token = None
user.verify_token_created = None
users_db.update(user.to_dict(), Query().email == user.email)
http_status = 200 if status == 'success' else 400
if http_status == 200 and user is not None:
if not user.email:
logger.error("Verified user has no email field.")
else:
user_image_dir = get_user_image_dir(user.id)
os.makedirs(user_image_dir, exist_ok=True)
return jsonify({'status': status, 'reason': reason, 'code': code}), http_status
@auth_api.route('/resend-verify', methods=['POST'])
def resend_verify():
data = request.get_json()
email = data.get('email', '')
if not email:
return jsonify({'error': 'Missing email', 'code': MISSING_EMAIL}), 400
norm_email = normalize_email(email)
user_dict = users_db.get(UserQuery.email == norm_email)
user = User.from_dict(user_dict) if user_dict else None
if not user:
return jsonify({'error': 'User not found', 'code': USER_NOT_FOUND}), 404
if user.verified:
return jsonify({'error': 'Account already verified', 'code': ALREADY_VERIFIED}), 400
token = secrets.token_urlsafe(32)
now_iso = datetime.utcnow().isoformat()
user.verify_token = token
user.verify_token_created = now_iso
users_db.update(user.to_dict(), UserQuery.email == norm_email)
send_verification_email(norm_email, token)
return jsonify({'message': 'Verification email resent'}), 200
@auth_api.route('/login', methods=['POST'])
def login():
data = request.get_json()
email = data.get('email', '')
password = data.get('password')
if not email or not password:
return jsonify({'error': 'Missing email or password', 'code': MISSING_EMAIL_OR_PASSWORD}), 400
norm_email = normalize_email(email)
user_dict = users_db.get(UserQuery.email == norm_email)
user = User.from_dict(user_dict) if user_dict else None
if not user or not check_password_hash(user.password, password):
return jsonify({'error': 'Invalid credentials', 'code': INVALID_CREDENTIALS}), 401
if not user.verified:
return jsonify({'error': 'This account has not verified', 'code': NOT_VERIFIED}), 403
# Block login for marked accounts
if user.marked_for_deletion:
return jsonify({'error': 'This account has been marked for deletion and cannot be accessed.', 'code': ACCOUNT_MARKED_FOR_DELETION}), 403
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')
resp = jsonify({'message': 'Login successful'})
resp.set_cookie('token', token, httponly=True, secure=True, samesite='Strict')
return resp, 200
@auth_api.route('/me', methods=['GET'])
def me():
token = request.cookies.get('token')
if not token:
return jsonify({'error': 'Missing token', 'code': MISSING_TOKEN}), 401
try:
payload = jwt.decode(token, current_app.config['SECRET_KEY'], algorithms=['HS256'])
user_id = payload.get('user_id', '')
token_version = payload.get('token_version', 0)
user_dict = users_db.get(UserQuery.id == user_id)
user = User.from_dict(user_dict) if user_dict else None
if not user:
return jsonify({'error': 'User not found', 'code': USER_NOT_FOUND}), 404
if token_version != user.token_version:
return jsonify({'error': 'Invalid token', 'code': INVALID_TOKEN}), 401
if user.marked_for_deletion:
return jsonify({'error': 'Account marked for deletion', 'code': ACCOUNT_MARKED_FOR_DELETION}), 403
return jsonify({
'email': user.email,
'id': user_id,
'first_name': user.first_name,
'last_name': user.last_name,
'verified': user.verified
}), 200
except jwt.ExpiredSignatureError:
return jsonify({'error': 'Token expired', 'code': TOKEN_EXPIRED}), 401
except jwt.InvalidTokenError:
return jsonify({'error': 'Invalid token', 'code': INVALID_TOKEN}), 401
@auth_api.route('/request-password-reset', methods=['POST'])
def request_password_reset():
data = request.get_json()
email = data.get('email', '')
norm_email = normalize_email(email)
success_msg = 'If this email is registered, you will receive a password reset link shortly.'
if not email:
return jsonify({'error': 'Missing email', 'code': MISSING_EMAIL}), 400
user_dict = users_db.get(UserQuery.email == norm_email)
user = User.from_dict(user_dict) if user_dict else None
if user:
if user.marked_for_deletion:
return jsonify({'error': 'Account marked for deletion', 'code': ACCOUNT_MARKED_FOR_DELETION}), 403
token = secrets.token_urlsafe(32)
now_iso = datetime.utcnow().isoformat()
user.reset_token = token
user.reset_token_created = now_iso
users_db.update(user.to_dict(), UserQuery.email == norm_email)
send_reset_password_email(norm_email, token)
return jsonify({'message': success_msg}), 200
@auth_api.route('/validate-reset-token', methods=['GET'])
def validate_reset_token():
token = request.args.get('token')
if not token:
return jsonify({'error': 'Missing token', 'code': MISSING_TOKEN}), 400
user_dict = users_db.get(UserQuery.reset_token == token)
user = User.from_dict(user_dict) if user_dict else None
if not user:
return jsonify({'error': 'Invalid token', 'code': INVALID_TOKEN}), 400
created_str = user.reset_token_created
if not created_str:
return jsonify({'error': 'Token timestamp missing', 'code': TOKEN_TIMESTAMP_MISSING}), 400
created_dt = datetime.fromisoformat(created_str).replace(tzinfo=timezone.utc)
if datetime.now(timezone.utc) - created_dt > timedelta(minutes=RESET_PASSWORD_TOKEN_EXPIRY_MINUTES):
return jsonify({'error': 'Token expired', 'code': TOKEN_EXPIRED}), 400
return jsonify({'message': 'Token is valid'}), 200
@auth_api.route('/reset-password', methods=['POST'])
def reset_password():
data = request.get_json()
token = data.get('token')
new_password = data.get('password')
if not token or not new_password:
return jsonify({'error': 'Missing token or password'}), 400
user_dict = users_db.get(UserQuery.reset_token == token)
user = User.from_dict(user_dict) if user_dict else None
if not user:
return jsonify({'error': 'Invalid token', 'code': INVALID_TOKEN}), 400
created_str = user.reset_token_created
if not created_str:
return jsonify({'error': 'Token timestamp missing', 'code': TOKEN_TIMESTAMP_MISSING}), 400
created_dt = datetime.fromisoformat(created_str).replace(tzinfo=timezone.utc)
if datetime.now(timezone.utc) - created_dt > timedelta(minutes=RESET_PASSWORD_TOKEN_EXPIRY_MINUTES):
return jsonify({'error': 'Token expired', 'code': TOKEN_EXPIRED}), 400
user.password = generate_password_hash(new_password)
user.reset_token = None
user.reset_token_created = None
user.token_version += 1
users_db.update(user.to_dict(), UserQuery.email == user.email)
resp = jsonify({'message': 'Password has been reset'})
resp.set_cookie('token', '', expires=0, httponly=True, secure=True, samesite='Strict')
return resp, 200
@auth_api.route('/logout', methods=['POST'])
def logout():
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')
return resp, 200

970
backend/api/child_api.py Normal file
View File

@@ -0,0 +1,970 @@
from time import sleep
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.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 db.tracking import insert_tracking_event
from db.child_overrides import get_override, delete_override, delete_overrides_for_child
from events.types.child_modified import ChildModified
from events.types.child_reward_request import ChildRewardRequest
from events.types.child_reward_triggered import ChildRewardTriggered
from events.types.child_rewards_set import ChildRewardsSet
from events.types.child_task_triggered import ChildTaskTriggered
from events.types.child_tasks_set import ChildTasksSet
from events.types.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_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
import logging
child_api = Blueprint('child_api', __name__)
logger = logging.getLogger(__name__)
@child_api.route('/child/<name>', methods=['GET'])
@child_api.route('/child/<id>', methods=['GET'])
def get_child(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
ChildQuery = Query()
result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id))
if not result:
return jsonify({'error': 'Child not found'}), 404
return jsonify(Child.from_dict(result[0]).to_dict()), 200
@child_api.route('/child/add', methods=['PUT'])
def add_child():
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')
age = data.get('age')
image = data.get('image_id', None)
if not name:
return jsonify({'error': 'Name is required'}), 400
if not image:
image = 'boy01'
child = Child(name=name, age=age, image_id=image, user_id=user_id)
child_db.insert(child.to_dict())
resp = send_event_for_current_user(
Event(EventType.CHILD_MODIFIED.value, ChildModified(child.id, ChildModified.OPERATION_ADD)))
if resp:
return resp
return jsonify({'message': f'Child {name} added.'}), 201
@child_api.route('/child/<id>/edit', methods=['PUT'])
def edit_child(id):
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', None)
age = data.get('age', None)
points = data.get('points', None)
image = data.get('image_id', None)
ChildQuery = Query()
result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id))
if not result:
return jsonify({'error': 'Child not found'}), 404
child = Child.from_dict(result[0])
if name is not None:
child.name = name
if age is not None:
child.age = age
if points is not None:
child.points = points
if image is not None:
child.image_id = image
# Check if points changed and handle pending rewards
if points is not None:
PendingQuery = Query()
pending_rewards = pending_reward_db.search((PendingQuery.child_id == id) & (PendingQuery.user_id == user_id))
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)))
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)
)
resp = send_event_for_current_user(
Event(EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest(id, reward.id, ChildRewardRequest.REQUEST_CANCELLED)))
if resp:
return resp
child_db.update(child.to_dict(), ChildQuery.id == id)
resp = send_event_for_current_user(Event(EventType.CHILD_MODIFIED.value, ChildModified(id, ChildModified.OPERATION_EDIT)))
if resp:
return resp
return jsonify({'message': f'Child {id} updated.'}), 200
@child_api.route('/child/list', methods=['GET'])
def list_children():
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
ChildQuery = Query()
children = child_db.search(ChildQuery.user_id == user_id)
return jsonify({'children': children}), 200
# Child DELETE
@child_api.route('/child/<id>', methods=['DELETE'])
def delete_child(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
ChildQuery = Query()
# Cascade delete overrides for this child
deleted_count = delete_overrides_for_child(id)
if deleted_count > 0:
logger.info(f"Cascade deleted {deleted_count} overrides for child {id}")
if child_db.remove((ChildQuery.id == id) & (ChildQuery.user_id == user_id)):
resp = send_event_for_current_user(Event(EventType.CHILD_MODIFIED.value, ChildModified(id, ChildModified.OPERATION_DELETE)))
if resp:
return resp
return jsonify({'message': f'Child {id} deleted.'}), 200
return jsonify({'error': 'Child not found'}), 404
@child_api.route('/child/<id>/assign-task', methods=['POST'])
def assign_task_to_child(id):
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 = result[0]
if task_id not in child.get('tasks', []):
child['tasks'].append(task_id)
child_db.update({'tasks': child['tasks']}, ChildQuery.id == id)
return jsonify({'message': f"Task {task_id} assigned to {child.get('name')}."}), 200
# python
@child_api.route('/child/<id>/set-tasks', methods=['PUT'])
def set_child_tasks(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
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'
if not isinstance(task_ids, list):
return jsonify({'error': 'task_ids must be a list'}), 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])
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'])
# Convert back to list if needed
new_tasks = list(new_task_ids)
# Identify unassigned tasks and delete their overrides
old_task_ids = set(child.tasks)
unassigned_task_ids = old_task_ids - new_task_ids
for task_id in unassigned_task_ids:
# Only delete overrides for task entities
override = get_override(id, task_id)
if override and override.entity_type == 'task':
delete_override(id, task_id)
logger.info(f"Deleted override for unassigned task: child={id}, task={task_id}")
# Replace tasks with validated IDs
child_db.update({'tasks': new_tasks}, ChildQuery.id == id)
resp = send_event_for_current_user(Event(EventType.CHILD_TASKS_SET.value, ChildTasksSet(id, new_tasks)))
if resp:
return resp
return jsonify({
'message': f'Tasks set for child {id}.',
'task_ids': new_tasks,
'count': len(new_tasks)
}), 200
@child_api.route('/child/<id>/remove-task', methods=['POST'])
def remove_task_from_child(id):
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 = result[0]
if task_id in child.get('tasks', []):
child['tasks'].remove(task_id)
child_db.update({'tasks': child['tasks']}, ChildQuery.id == id)
return jsonify({'message': f'Task {task_id} removed from {child["name"]}.'}), 200
return jsonify({'error': 'Task not assigned to child'}), 400
@child_api.route('/child/<id>/list-tasks', methods=['GET'])
def list_child_tasks(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
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 = result[0]
task_ids = child.get('tasks', [])
TaskQuery = Query()
child_tasks = []
for tid in task_ids:
task = task_db.get((TaskQuery.id == tid) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)))
if not task:
continue
# 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_dict = ct.to_dict()
if custom_value is not None:
ct_dict['custom_value'] = custom_value
child_tasks.append(ct_dict)
return jsonify({'tasks': child_tasks}), 200
@child_api.route('/child/<id>/list-assignable-tasks', methods=['GET'])
def list_assignable_tasks(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
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 = result[0]
assigned_ids = set(child.get('tasks', []))
# Get all assignable tasks (not already assigned)
all_tasks = [t for t in task_db.all() if t and t.get('id') and t.get('id') not in assigned_ids]
# Group by name
from collections import defaultdict
name_to_tasks = defaultdict(list)
for t in all_tasks:
name_to_tasks[t.get('name')].append(t)
filtered_tasks = []
for name, tasks in name_to_tasks.items():
user_tasks = [t for t in tasks if t.get('user_id') is not None]
if len(user_tasks) == 0:
# Only system task exists
filtered_tasks.append(tasks[0])
elif len(user_tasks) == 1:
# Only one user task: show it, not system
filtered_tasks.append(user_tasks[0])
else:
# Multiple user tasks: show all user tasks, not system
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]
return jsonify({'tasks': assignable_tasks, 'count': len(assignable_tasks)}), 200
@child_api.route('/child/<id>/list-all-tasks', methods=['GET'])
def list_all_tasks(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
ChildQuery = Query()
result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_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'
child = result[0]
assigned_ids = set(child.get('tasks', []))
# Get all tasks from database (not filtering out assigned, since this is 'all')
ChildTaskQuery = Query()
all_tasks = task_db.search((ChildTaskQuery.user_id == user_id) | (ChildTaskQuery.user_id == None))
name_to_tasks = defaultdict(list)
for t in all_tasks:
name_to_tasks[t.get('name')].append(t)
filtered_tasks = []
for name, tasks in name_to_tasks.items():
user_tasks = [t for t in tasks if t.get('user_id') is not None]
if len(user_tasks) == 0:
filtered_tasks.append(tasks[0])
elif len(user_tasks) == 1:
filtered_tasks.append(user_tasks[0])
else:
filtered_tasks.extend(user_tasks)
result_tasks = []
for t in filtered_tasks:
if has_type and t.get('is_good') != good:
continue
ct = ChildTask(
t.get('name'),
t.get('is_good'),
t.get('points'),
t.get('image_id'),
t.get('id')
)
task_dict = ct.to_dict()
task_dict.update({'assigned': t.get('id') in assigned_ids})
result_tasks.append(task_dict)
result_tasks.sort(key=lambda t: (not t['assigned'], t['name'].lower()))
return jsonify({ 'tasks': result_tasks, 'count': len(result_tasks), 'list_type': 'task' }), 200
@child_api.route('/child/<id>/trigger-task', methods=['POST'])
def trigger_child_task(id):
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 = Child.from_dict(result[0])
if task_id not in child.tasks:
return jsonify({'error': f'Task not found assigned to child {child.name}'}), 404
# look up the task and get the details
TaskQuery = Query()
task_result = task_db.search((TaskQuery.id == task_id) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)))
if not task_result:
return jsonify({'error': 'Task not found in task database'}), 404
task: Task = Task.from_dict(task_result[0])
# Capture points before modification
points_before = child.points
# Check for override
override = get_override(id, task_id)
points_value = override.custom_value if override else task.points
# update the child's points based on task type
if task.is_good:
child.points += points_value
else:
child.points -= points_value
child.points = max(child.points, 0)
# update the child in the database
child_db.update({'points': child.points}, ChildQuery.id == id)
# Create tracking event
entity_type = 'penalty' if not task.is_good else 'task'
tracking_metadata = {
'task_name': task.name,
'is_good': task.is_good,
'default_points': task.points
}
if override:
tracking_metadata['custom_points'] = override.custom_value
tracking_metadata['has_override'] = True
tracking_event = TrackingEvent.create_event(
user_id=user_id,
child_id=child.id,
entity_type=entity_type,
entity_id=task.id,
action='activated',
points_before=points_before,
points_after=child.points,
metadata=tracking_metadata
)
insert_tracking_event(tracking_event)
log_tracking_event(tracking_event)
# Send tracking event via SSE
send_event_for_current_user(Event(EventType.TRACKING_EVENT_CREATED.value, TrackingEventCreated(tracking_event.id, child.id, entity_type, 'activated')))
resp = send_event_for_current_user(Event(EventType.CHILD_TASK_TRIGGERED.value, ChildTaskTriggered(task.id, child.id, child.points)))
if resp:
return resp
return jsonify({'message': f'{task.name} points assigned to {child.name}.', 'points': child.points, 'id': child.id}), 200
@child_api.route('/child/<id>/assign-reward', methods=['POST'])
def assign_reward_to_child(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
data = request.get_json()
reward_id = data.get('reward_id')
if not reward_id:
return jsonify({'error': 'reward_id is required'}), 400
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 = result[0]
if reward_id not in child.get('rewards', []):
child['rewards'].append(reward_id)
child_db.update({'rewards': child['rewards']}, ChildQuery.id == id)
return jsonify({'message': f'Reward {reward_id} assigned to {child["name"]}.'}), 200
@child_api.route('/child/<id>/list-all-rewards', methods=['GET'])
def list_all_rewards(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
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])
assigned_ids = set(child.rewards)
# Get all rewards from database
ChildRewardQuery = Query()
all_rewards = reward_db.search((ChildRewardQuery.user_id == user_id) | (ChildRewardQuery.user_id == None))
from collections import defaultdict
name_to_rewards = defaultdict(list)
for r in all_rewards:
name_to_rewards[r.get('name')].append(r)
filtered_rewards = []
for name, rewards in name_to_rewards.items():
user_rewards = [r for r in rewards if r.get('user_id') is not None]
if len(user_rewards) == 0:
filtered_rewards.append(rewards[0])
elif len(user_rewards) == 1:
filtered_rewards.append(user_rewards[0])
else:
filtered_rewards.extend(user_rewards)
result_rewards = []
for r in filtered_rewards:
cr = ChildReward(
r.get('name'),
r.get('cost'),
r.get('image_id'),
r.get('id')
)
reward_dict = cr.to_dict()
reward_dict.update({'assigned': r.get('id') in assigned_ids})
result_rewards.append(reward_dict)
result_rewards.sort(key=lambda t: (not t['assigned'], t['name'].lower()))
return jsonify({
'rewards': result_rewards,
'rewards_count': len(result_rewards),
'list_type': 'reward'
}), 200
@child_api.route('/child/<id>/set-rewards', methods=['PUT'])
def set_child_rewards(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
data = request.get_json() or {}
reward_ids = data.get('reward_ids')
if not isinstance(reward_ids, list):
return jsonify({'error': 'reward_ids must be a list'}), 400
# Deduplicate and drop falsy values
new_reward_ids = [rid for rid in dict.fromkeys(reward_ids) if rid]
ChildQuery = Query()
result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id))
if not result:
return jsonify({'error': 'Child not found'}), 404
child = Child.from_dict(result[0])
old_reward_ids = set(child.rewards)
# Optional: validate reward IDs exist in the reward DB
RewardQuery = Query()
valid_reward_ids = []
for rid in new_reward_ids:
if reward_db.get((RewardQuery.id == rid) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None))):
valid_reward_ids.append(rid)
# Identify unassigned rewards and delete their overrides
new_reward_ids_set = set(valid_reward_ids)
unassigned_reward_ids = old_reward_ids - new_reward_ids_set
for reward_id in unassigned_reward_ids:
override = get_override(id, reward_id)
if override and override.entity_type == 'reward':
delete_override(id, reward_id)
logger.info(f"Deleted override for unassigned reward: child={id}, reward={reward_id}")
# Replace rewards with validated IDs
child_db.update({'rewards': valid_reward_ids}, ChildQuery.id == id)
send_event_for_current_user(Event(EventType.CHILD_REWARDS_SET.value, ChildRewardsSet(id, valid_reward_ids)))
return jsonify({
'message': f'Rewards set for child {id}.',
'reward_ids': valid_reward_ids,
'count': len(valid_reward_ids)
}), 200
@child_api.route('/child/<id>/remove-reward', methods=['POST'])
def remove_reward_from_child(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
data = request.get_json()
reward_id = data.get('reward_id')
if not reward_id:
return jsonify({'error': 'reward_id is required'}), 400
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 = result[0]
if reward_id in child.get('rewards', []):
child['rewards'].remove(reward_id)
child_db.update({'rewards': child['rewards']}, ChildQuery.id == id)
return jsonify({'message': f'Reward {reward_id} removed from {child["name"]}.'}), 200
return jsonify({'error': 'Reward not assigned to child'}), 400
@child_api.route('/child/<id>/list-rewards', methods=['GET'])
def list_child_rewards(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
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 = result[0]
reward_ids = child.get('rewards', [])
RewardQuery = Query()
child_rewards = []
for rid in reward_ids:
reward = reward_db.get((RewardQuery.id == rid) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None)))
if not reward:
continue
# Check for override
override = get_override(id, rid)
custom_value = override.custom_value if override else None
cr = ChildReward(reward.get('name'), reward.get('cost'), reward.get('image_id'), reward.get('id'))
cr_dict = cr.to_dict()
if custom_value is not None:
cr_dict['custom_value'] = custom_value
child_rewards.append(cr_dict)
return jsonify({'rewards': child_rewards}), 200
@child_api.route('/child/<id>/list-assignable-rewards', methods=['GET'])
def list_assignable_rewards(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
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 = result[0]
assigned_ids = set(child.get('rewards', []))
# Get all assignable rewards (not already assigned)
all_rewards = [r for r in reward_db.all() if r and r.get('id') and r.get('id') not in assigned_ids]
# Group by name
from collections import defaultdict
name_to_rewards = defaultdict(list)
for r in all_rewards:
name_to_rewards[r.get('name')].append(r)
filtered_rewards = []
for name, rewards in name_to_rewards.items():
user_rewards = [r for r in rewards if r.get('user_id') is not None]
if len(user_rewards) == 0:
filtered_rewards.append(rewards[0])
elif len(user_rewards) == 1:
filtered_rewards.append(user_rewards[0])
else:
filtered_rewards.extend(user_rewards)
assignable_rewards = [ChildReward(r.get('name'), r.get('cost'), r.get('image_id'), r.get('id')).to_dict() for r in filtered_rewards]
return jsonify({'rewards': assignable_rewards, 'count': len(assignable_rewards)}), 200
@child_api.route('/child/<id>/trigger-reward', methods=['POST'])
def trigger_child_reward(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
data = request.get_json()
reward_id = data.get('reward_id')
if not reward_id:
return jsonify({'error': 'reward_id is required'}), 400
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 = Child.from_dict(result[0])
if reward_id not in child.rewards:
return jsonify({'error': f'Reward not found assigned to child {child.name}'}), 404
# look up the task and get the details
RewardQuery = Query()
reward_result = reward_db.search((RewardQuery.id == reward_id) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None)))
if not reward_result:
return jsonify({'error': 'Reward not found in reward database'}), 404
reward: Reward = Reward.from_dict(reward_result[0])
# Check for override
override = get_override(id, reward_id)
cost_value = override.custom_value if override else reward.cost
# Check if child has enough points
logger.info(f'Child {child.name} has {child.points} points, reward {reward.name} costs {cost_value} points')
if child.points < cost_value:
points_needed = cost_value - child.points
return jsonify({
'error': 'Insufficient points',
'points_needed': points_needed,
'current_points': child.points,
'reward_cost': cost_value
}), 400
# Remove matching pending reward requests for this child and reward
PendingQuery = Query()
removed = pending_reward_db.remove(
(PendingQuery.child_id == child.id) & (PendingQuery.reward_id == reward.id)
)
if removed:
send_event_for_current_user(Event(EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest(reward.id, child.id, ChildRewardRequest.REQUEST_GRANTED)))
# Capture points before modification
points_before = child.points
# update the child's points based on reward cost
child.points -= cost_value
# update the child in the database
child_db.update({'points': child.points}, ChildQuery.id == id)
# Create tracking event
tracking_metadata = {
'reward_name': reward.name,
'reward_cost': reward.cost,
'default_cost': reward.cost
}
if override:
tracking_metadata['custom_cost'] = override.custom_value
tracking_metadata['has_override'] = True
tracking_event = TrackingEvent.create_event(
user_id=user_id,
child_id=child.id,
entity_type='reward',
entity_id=reward.id,
action='redeemed',
points_before=points_before,
points_after=child.points,
metadata=tracking_metadata
)
insert_tracking_event(tracking_event)
log_tracking_event(tracking_event)
# Send tracking event via SSE
send_event_for_current_user(Event(EventType.TRACKING_EVENT_CREATED.value, TrackingEventCreated(tracking_event.id, child.id, 'reward', 'redeemed')))
send_event_for_current_user(Event(EventType.CHILD_REWARD_TRIGGERED.value, ChildRewardTriggered(reward.id, child.id, child.points)))
return jsonify({'message': f'{reward.name} assigned to {child.name}.', 'points': child.points, 'id': child.id}), 200
@child_api.route('/child/<id>/affordable-rewards', methods=['GET'])
def list_affordable_rewards(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
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])
points = child.points
reward_ids = child.rewards
RewardQuery = Query()
affordable = [
Reward.from_dict(reward).to_dict() for reward_id in reward_ids
if (reward := reward_db.get((RewardQuery.id == reward_id) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None)))) and points >= Reward.from_dict(reward).cost
]
return jsonify({'affordable_rewards': affordable}), 200
@child_api.route('/child/<id>/reward-status', methods=['GET'])
def reward_status(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
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])
points = child.points
reward_ids = child.rewards
RewardQuery = Query()
statuses = []
for reward_id in reward_ids:
reward_dict = reward_db.get((RewardQuery.id == reward_id) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None)))
if not reward_dict:
continue
reward: Reward = Reward.from_dict(reward_dict)
# Check for override
override = get_override(id, reward_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
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))
status = RewardStatus(reward.id, reward.name, points_needed, cost_value, pending is not None, reward.image_id)
status_dict = status.to_dict()
if override:
status_dict['custom_value'] = override.custom_value
statuses.append(status_dict)
statuses.sort(key=lambda s: (not s['redeeming'], s['cost']))
return jsonify({'reward_status': statuses}), 200
@child_api.route('/child/<id>/request-reward', methods=['POST'])
def request_reward(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
data = request.get_json()
reward_id = data.get('reward_id')
if not reward_id:
return jsonify({'error': 'reward_id is required'}), 400
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 reward_id not in child.rewards:
return jsonify({'error': f'Reward not assigned to child {child.name}'}), 404
RewardQuery = Query()
reward_result = reward_db.search(RewardQuery.id == reward_id)
if not reward_result:
return jsonify({'error': 'Reward not found in reward database'}), 404
reward = Reward.from_dict(reward_result[0])
# Check if child has enough points
logger.info(f'Child {child.name} has {child.points} points, reward {reward.name} costs {reward.cost} points')
if child.points < reward.cost:
points_needed = reward.cost - child.points
return jsonify({
'error': 'Insufficient points',
'points_needed': points_needed,
'current_points': child.points,
'reward_cost': reward.cost
}), 400
pending = PendingReward(child_id=child.id, reward_id=reward.id, user_id=user_id)
pending_reward_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)
tracking_event = TrackingEvent.create_event(
user_id=user_id,
child_id=child.id,
entity_type='reward',
entity_id=reward.id,
action='requested',
points_before=child.points,
points_after=child.points,
metadata={'reward_name': reward.name, 'reward_cost': reward.cost}
)
insert_tracking_event(tracking_event)
log_tracking_event(tracking_event)
# Send tracking event via SSE
send_event_for_current_user(Event(EventType.TRACKING_EVENT_CREATED.value, TrackingEventCreated(tracking_event.id, child.id, 'reward', 'requested')))
send_event_for_current_user(Event(EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest(child.id, reward.id, ChildRewardRequest.REQUEST_CREATED)))
return jsonify({
'message': f'Reward request for {reward.name} submitted for {child.name}.',
'reward_id': reward.id,
'reward_name': reward.name,
'child_id': child.id,
'child_name': child.name,
'cost': reward.cost
}), 200
@child_api.route('/child/<id>/cancel-request-reward', methods=['POST'])
def cancel_request_reward(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
data = request.get_json()
reward_id = data.get('reward_id')
if not reward_id:
return jsonify({'error': 'reward_id is required'}), 400
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])
# Fetch reward details for tracking metadata
RewardQuery = Query()
reward_result = reward_db.get((RewardQuery.id == reward_id) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None)))
reward_name = reward_result.get('name') if reward_result else 'Unknown'
reward_cost = reward_result.get('cost', 0) if reward_result else 0
# 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)
)
if not removed:
return jsonify({'error': 'No pending request found for this reward'}), 404
# Create tracking event (no points change on cancel)
tracking_event = TrackingEvent.create_event(
user_id=user_id,
child_id=child.id,
entity_type='reward',
entity_id=reward_id,
action='cancelled',
points_before=child.points,
points_after=child.points,
metadata={'reward_name': reward_name, 'reward_cost': reward_cost}
)
insert_tracking_event(tracking_event)
log_tracking_event(tracking_event)
# Send tracking event via SSE
send_event_for_current_user(Event(EventType.TRACKING_EVENT_CREATED.value, TrackingEventCreated(tracking_event.id, child.id, 'reward', 'cancelled')))
# Notify user that the request was cancelled
resp = send_event_for_current_user(Event(EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest(child.id, reward_id, ChildRewardRequest.REQUEST_CANCELLED)))
if resp:
return resp
return jsonify({
'message': f'Reward request cancelled for {child.name}.',
'child_id': child.id,
'reward_id': reward_id,
'removed_count': len(removed)
}), 200
@child_api.route('/pending-rewards', methods=['GET'])
def list_pending_rewards():
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 = []
RewardQuery = Query()
ChildQuery = Query()
for pr in pending_rewards:
pending = PendingReward.from_dict(pr)
# Look up reward details
reward_result = reward_db.get((RewardQuery.id == pending.reward_id) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None)))
if not reward_result:
continue
reward = Reward.from_dict(reward_result)
# Look up child details
child_result = child_db.get(ChildQuery.id == pending.child_id)
if not child_result:
continue
child = Child.from_dict(child_result)
# Create response object
response = PendingRewardResponse(
_id=pending.id,
child_id=child.id,
child_name=child.name,
child_image_id=child.image_id,
reward_id=reward.id,
reward_name=reward.name,
reward_image_id=reward.image_id
)
reward_responses.append(response.to_dict())
return jsonify({'rewards': reward_responses, 'count': len(reward_responses), 'list_type': 'notification'}), 200

View File

@@ -0,0 +1,173 @@
from flask import Blueprint, request, jsonify
from tinydb import Query
from api.utils import get_validated_user_id, send_event_for_current_user
from api.error_codes import ErrorCodes
from db.db import child_db, task_db, reward_db
from db.child_overrides import (
insert_override,
get_override,
get_overrides_for_child,
delete_override
)
from models.child_override import ChildOverride
from events.types.event import Event
from events.types.event_types import EventType
from events.types.child_override_set import ChildOverrideSetPayload
from events.types.child_override_deleted import ChildOverrideDeletedPayload
import logging
child_override_api = Blueprint('child_override_api', __name__)
logger = logging.getLogger(__name__)
@child_override_api.route('/child/<child_id>/override', methods=['PUT'])
def set_child_override(child_id):
"""
Set or update a custom value for a task/reward for a specific child.
"""
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
# Validate child exists and belongs to user
ChildQuery = Query()
child_result = child_db.search((ChildQuery.id == child_id) & (ChildQuery.user_id == user_id))
if not child_result:
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
child_dict = child_result[0]
# Parse request data
data = request.get_json() or {}
entity_id = data.get('entity_id')
entity_type = data.get('entity_type')
custom_value = data.get('custom_value')
# Validate required fields
if not entity_id:
return jsonify({'error': 'entity_id is required', 'code': ErrorCodes.MISSING_FIELD, 'field': 'entity_id'}), 400
if not entity_type:
return jsonify({'error': 'entity_type is required', 'code': ErrorCodes.MISSING_FIELD, 'field': 'entity_type'}), 400
if custom_value is None:
return jsonify({'error': 'custom_value is required', 'code': ErrorCodes.MISSING_FIELD, 'field': 'custom_value'}), 400
# Validate entity_type
if entity_type not in ['task', 'reward']:
return jsonify({'error': 'entity_type must be "task" or "reward"', 'code': ErrorCodes.INVALID_VALUE, 'field': 'entity_type'}), 400
# Validate custom_value range
if not isinstance(custom_value, int) or custom_value < 0 or custom_value > 10000:
return jsonify({'error': 'custom_value must be an integer between 0 and 10000', 'code': ErrorCodes.INVALID_VALUE, 'field': 'custom_value'}), 400
# Validate entity exists and is assigned to child
if entity_type == 'task':
EntityQuery = Query()
entity_result = task_db.search(
(EntityQuery.id == entity_id) &
((EntityQuery.user_id == user_id) | (EntityQuery.user_id == None))
)
if not entity_result:
return jsonify({'error': 'Task not found', 'code': ErrorCodes.TASK_NOT_FOUND}), 404
# Check if task is assigned to child
assigned_tasks = child_dict.get('tasks', [])
if entity_id not in assigned_tasks:
return jsonify({'error': 'Task not assigned to child', 'code': ErrorCodes.ENTITY_NOT_ASSIGNED}), 404
else: # reward
EntityQuery = Query()
entity_result = reward_db.search(
(EntityQuery.id == entity_id) &
((EntityQuery.user_id == user_id) | (EntityQuery.user_id == None))
)
if not entity_result:
return jsonify({'error': 'Reward not found', 'code': ErrorCodes.REWARD_NOT_FOUND}), 404
# Check if reward is assigned to child
assigned_rewards = child_dict.get('rewards', [])
if entity_id not in assigned_rewards:
return jsonify({'error': 'Reward not assigned to child', 'code': ErrorCodes.ENTITY_NOT_ASSIGNED}), 404
# Create and insert override
try:
override = ChildOverride.create_override(
child_id=child_id,
entity_id=entity_id,
entity_type=entity_type,
custom_value=custom_value
)
insert_override(override)
# Send SSE event
resp = send_event_for_current_user(
Event(EventType.CHILD_OVERRIDE_SET.value, ChildOverrideSetPayload(override))
)
if resp:
return resp
return jsonify({'override': override.to_dict()}), 200
except ValueError as e:
return jsonify({'error': str(e), 'code': ErrorCodes.VALIDATION_ERROR}), 400
except Exception as e:
logger.error(f"Error setting override: {e}")
return jsonify({'error': 'Internal server error', 'code': ErrorCodes.INTERNAL_ERROR}), 500
@child_override_api.route('/child/<child_id>/overrides', methods=['GET'])
def get_child_overrides(child_id):
"""
Get all overrides for a specific child.
"""
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
# Validate child exists and belongs to user
ChildQuery = Query()
child_result = child_db.search((ChildQuery.id == child_id) & (ChildQuery.user_id == user_id))
if not child_result:
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
# Get all overrides for child
overrides = get_overrides_for_child(child_id)
return jsonify({'overrides': [o.to_dict() for o in overrides]}), 200
@child_override_api.route('/child/<child_id>/override/<entity_id>', methods=['DELETE'])
def delete_child_override(child_id, entity_id):
"""
Delete an override (reset to default).
"""
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
# Validate child exists and belongs to user
ChildQuery = Query()
child_result = child_db.search((ChildQuery.id == child_id) & (ChildQuery.user_id == user_id))
if not child_result:
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
# Get override to determine entity_type for event
override = get_override(child_id, entity_id)
if not override:
return jsonify({'error': 'Override not found', 'code': ErrorCodes.OVERRIDE_NOT_FOUND}), 404
entity_type = override.entity_type
# Delete override
deleted = delete_override(child_id, entity_id)
if not deleted:
return jsonify({'error': 'Override not found', 'code': ErrorCodes.OVERRIDE_NOT_FOUND}), 404
# Send SSE event
resp = send_event_for_current_user(
Event(EventType.CHILD_OVERRIDE_DELETED.value,
ChildOverrideDeletedPayload(child_id, entity_id, entity_type))
)
if resp:
return resp
return jsonify({'message': 'Override deleted'}), 200

View File

@@ -0,0 +1,28 @@
MISSING_FIELDS = "MISSING_FIELDS"
EMAIL_EXISTS = "EMAIL_EXISTS"
MISSING_TOKEN = "MISSING_TOKEN"
INVALID_TOKEN = "INVALID_TOKEN"
TOKEN_TIMESTAMP_MISSING = "TOKEN_TIMESTAMP_MISSING"
TOKEN_EXPIRED = "TOKEN_EXPIRED"
MISSING_EMAIL = "MISSING_EMAIL"
USER_NOT_FOUND = "USER_NOT_FOUND"
ALREADY_VERIFIED = "ALREADY_VERIFIED"
MISSING_EMAIL_OR_PASSWORD = "MISSING_EMAIL_OR_PASSWORD"
INVALID_CREDENTIALS = "INVALID_CREDENTIALS"
NOT_VERIFIED = "NOT_VERIFIED"
ACCOUNT_MARKED_FOR_DELETION = "ACCOUNT_MARKED_FOR_DELETION"
ALREADY_MARKED = "ALREADY_MARKED"
class ErrorCodes:
"""Centralized error codes for API responses."""
UNAUTHORIZED = "UNAUTHORIZED"
CHILD_NOT_FOUND = "CHILD_NOT_FOUND"
TASK_NOT_FOUND = "TASK_NOT_FOUND"
REWARD_NOT_FOUND = "REWARD_NOT_FOUND"
ENTITY_NOT_ASSIGNED = "ENTITY_NOT_ASSIGNED"
OVERRIDE_NOT_FOUND = "OVERRIDE_NOT_FOUND"
MISSING_FIELD = "MISSING_FIELD"
INVALID_VALUE = "INVALID_VALUE"
VALIDATION_ERROR = "VALIDATION_ERROR"
INTERNAL_ERROR = "INTERNAL_ERROR"

View File

@@ -1,15 +1,18 @@
import os
UPLOAD_FOLDER = os.path.abspath(os.path.join(os.path.dirname(__file__), '../../data/images'))
import os
from PIL import Image as PILImage, UnidentifiedImageError
from flask import Blueprint, request, jsonify, send_file
from tinydb import Query
from api.utils import get_current_user_id, sanitize_email, get_validated_user_id
from config.paths import get_user_image_dir
from db.db import image_db
from models.image import Image
image_api = Blueprint('image_api', __name__)
UPLOAD_FOLDER = get_user_image_dir("user123")
ALLOWED_EXTENSIONS = {'jpg', 'jpeg', 'png'}
IMAGE_TYPE_PROFILE = 1
IMAGE_TYPE_ICON = 2
@@ -20,6 +23,9 @@ def allowed_file(filename):
@image_api.route('/image/upload', methods=['POST'])
def upload():
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
if 'file' not in request.files:
return jsonify({'error': 'No file part in the request'}), 400
file = request.files['file']
@@ -60,13 +66,14 @@ def upload():
format_extension_map = {'JPEG': '.jpg', 'PNG': '.png'}
extension = format_extension_map.get(original_format, '.png')
image_record = Image(extension=extension, permanent=perm, type=image_type, user="user123")
image_record = Image(extension=extension, permanent=perm, type=image_type, user_id=user_id)
filename = image_record.id + extension
filepath = os.path.abspath(os.path.join(UPLOAD_FOLDER, filename))
user_image_dir = get_user_image_dir(user_id)
os.makedirs(user_image_dir, exist_ok=True)
filepath = os.path.abspath(os.path.join(get_user_image_dir(sanitize_email(user_id)), filename))
try:
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
# Save with appropriate format
save_params = {}
if pil_image.format == 'JPEG':
@@ -82,25 +89,38 @@ def upload():
@image_api.route('/image/request/<id>', methods=['GET'])
def request_image(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
ImageQuery = Query()
image: Image = Image.from_dict(image_db.get(ImageQuery.id == id))
if not image:
image_record = image_db.get(ImageQuery.id == id)
if not image_record:
return jsonify({'error': 'Image not found'}), 404
image = Image.from_dict(image_record)
# Allow if image.user_id is None (public image), or matches user_id
if image.user_id is not None and image.user_id != user_id:
return jsonify({'error': 'Forbidden: image does not belong to user', 'code': 'FORBIDDEN'}), 403
filename = f"{image.id}{image.extension}"
filepath = os.path.abspath(os.path.join(get_user_image_dir(image.user), filename))
if image.user_id is None:
filepath = os.path.abspath(os.path.join(get_user_image_dir("default"), filename))
else:
filepath = os.path.abspath(os.path.join(get_user_image_dir(image.user_id), filename))
if not os.path.exists(filepath):
return jsonify({'error': 'File not found'}), 404
return send_file(filepath)
@image_api.route('/image/list', methods=['GET'])
def list_images():
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
image_type = request.args.get('type', type=int)
ImageQuery = Query()
if image_type is not None:
if image_type not in [IMAGE_TYPE_PROFILE, IMAGE_TYPE_ICON]:
return jsonify({'error': 'Invalid image type'}), 400
images = image_db.search(ImageQuery.type == image_type)
images = image_db.search((ImageQuery.type == image_type) & ((ImageQuery.user_id == user_id) | (ImageQuery.user_id == None)))
else:
images = image_db.all()
images = image_db.search((ImageQuery.user_id == user_id) | (ImageQuery.user_id == None))
image_ids = [img['id'] for img in images]
return jsonify({'ids': image_ids, 'count': len(image_ids)}), 200

163
backend/api/reward_api.py Normal file
View File

@@ -0,0 +1,163 @@
from flask import Blueprint, request, jsonify
from tinydb import Query
from api.utils import send_event_for_current_user, get_validated_user_id
from events.types.child_rewards_set import ChildRewardsSet
from db.db import reward_db, child_db
from db.child_overrides import delete_overrides_for_entity
from events.types.event import Event
from events.types.event_types import EventType
from events.types.reward_modified import RewardModified
from models.reward import Reward
reward_api = Blueprint('reward_api', __name__)
# Reward endpoints
@reward_api.route('/reward/add', methods=['PUT'])
def add_reward():
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
data = request.get_json()
name = data.get('name')
description = data.get('description')
cost = data.get('cost')
image = data.get('image_id', '')
if not name or description is None or cost is None:
return jsonify({'error': 'Name, description, and cost are required'}), 400
reward = Reward(name=name, description=description, cost=cost, image_id=image, user_id=user_id)
reward_db.insert(reward.to_dict())
send_event_for_current_user(Event(EventType.REWARD_MODIFIED.value, RewardModified(reward.id, RewardModified.OPERATION_ADD)))
return jsonify({'message': f'Reward {name} added.'}), 201
@reward_api.route('/reward/<id>', methods=['GET'])
def get_reward(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
RewardQuery = Query()
result = reward_db.search((RewardQuery.id == id) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None)))
if not result:
return jsonify({'error': 'Reward not found'}), 404
return jsonify(result[0]), 200
@reward_api.route('/reward/list', methods=['GET'])
def list_rewards():
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
ids_param = request.args.get('ids')
RewardQuery = Query()
rewards = reward_db.search((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None))
if ids_param is not None:
if ids_param.strip() == '':
rewards = []
else:
ids = set(ids_param.split(','))
rewards = [reward for reward in rewards if reward.get('id') in ids]
# Filter out default rewards if user-specific version exists (case/whitespace-insensitive)
user_rewards = {r['name'].strip().lower(): r for r in rewards if r.get('user_id') == user_id}
filtered_rewards = []
for r in rewards:
if r.get('user_id') is None and r['name'].strip().lower() in user_rewards:
continue # Skip default if user version exists
filtered_rewards.append(r)
# Sort: user-created items first (by name), then default items (by name)
user_created = sorted([r for r in filtered_rewards if r.get('user_id') == user_id], key=lambda x: x['name'].lower())
default_items = sorted([r for r in filtered_rewards if r.get('user_id') is None], key=lambda x: x['name'].lower())
sorted_rewards = user_created + default_items
return jsonify({'rewards': sorted_rewards}), 200
@reward_api.route('/reward/<id>', methods=['DELETE'])
def delete_reward(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
RewardQuery = Query()
reward = reward_db.get(RewardQuery.id == id)
if not reward:
return jsonify({'error': 'Reward not found'}), 404
if reward.get('user_id') is None:
import logging
logging.warning(f"Forbidden delete attempt on system reward: id={id}, by user_id={user_id}")
return jsonify({'error': 'System rewards cannot be deleted.'}), 403
removed = reward_db.remove((RewardQuery.id == id) & (RewardQuery.user_id == user_id))
if removed:
# Cascade delete overrides for this reward
deleted_count = delete_overrides_for_entity(id)
if deleted_count > 0:
import logging
logging.info(f"Cascade deleted {deleted_count} overrides for reward {id}")
# remove the reward id from any child's reward list
ChildQuery = Query()
for child in child_db.all():
rewards = child.get('rewards', [])
if id in rewards:
rewards.remove(id)
child_db.update({'rewards': rewards}, ChildQuery.id == child.get('id'))
send_event_for_current_user(Event(EventType.CHILD_REWARDS_SET.value, ChildRewardsSet(id, rewards)))
send_event_for_current_user(Event(EventType.REWARD_MODIFIED.value, RewardModified(id, RewardModified.OPERATION_DELETE)))
return jsonify({'message': f'Reward {id} deleted.'}), 200
return jsonify({'error': 'Reward not found'}), 404
@reward_api.route('/reward/<id>/edit', methods=['PUT'])
def edit_reward(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
RewardQuery = Query()
existing = reward_db.get((RewardQuery.id == id) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None)))
if not existing:
return jsonify({'error': 'Reward not found'}), 404
reward = Reward.from_dict(existing)
is_dirty = False
data = request.get_json(force=True) or {}
if 'name' in data:
name = (data.get('name') or '').strip()
if not name:
return jsonify({'error': 'Name cannot be empty'}), 400
reward.name = name
is_dirty = True
if 'description' in data:
desc = (data.get('description') or '').strip()
if not desc:
return jsonify({'error': 'Description cannot be empty'}), 400
reward.description = desc
is_dirty = True
if 'cost' in data:
cost = data.get('cost')
if not isinstance(cost, int):
return jsonify({'error': 'Cost must be an integer'}), 400
if cost <= 0:
return jsonify({'error': 'Cost must be a positive integer'}), 400
reward.cost = cost
is_dirty = True
if 'image_id' in data:
reward.image_id = data.get('image_id', '')
is_dirty = True
if not is_dirty:
return jsonify({'error': 'No valid fields to update'}), 400
if reward.user_id is None: # public reward
new_reward = Reward(name=reward.name, description=reward.description, cost=reward.cost, image_id=reward.image_id, user_id=user_id)
reward_db.insert(new_reward.to_dict())
send_event_for_current_user(Event(EventType.REWARD_MODIFIED.value,
RewardModified(new_reward.id, RewardModified.OPERATION_ADD)))
return jsonify(new_reward.to_dict()), 200
reward_db.update(reward.to_dict(), (RewardQuery.id == id) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None)))
send_event_for_current_user(Event(EventType.REWARD_MODIFIED.value,
RewardModified(id, RewardModified.OPERATION_EDIT)))
return jsonify(reward.to_dict()), 200

178
backend/api/task_api.py Normal file
View File

@@ -0,0 +1,178 @@
from flask import Blueprint, request, jsonify
from tinydb import Query
from api.utils import send_event_for_current_user, get_validated_user_id
from events.types.child_tasks_set import ChildTasksSet
from db.db import task_db, child_db
from db.child_overrides import delete_overrides_for_entity
from events.types.event import Event
from events.types.event_types import EventType
from events.types.task_modified import TaskModified
from models.task import Task
task_api = Blueprint('task_api', __name__)
# Task endpoints
@task_api.route('/task/add', methods=['PUT'])
def add_task():
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
data = request.get_json()
name = data.get('name')
points = data.get('points')
is_good = data.get('is_good')
image = data.get('image_id', '')
if not name or points is None or is_good is None:
return jsonify({'error': 'Name, points, and is_good are required'}), 400
task = Task(name=name, points=points, is_good=is_good, image_id=image, user_id=user_id)
task_db.insert(task.to_dict())
send_event_for_current_user(Event(EventType.TASK_MODIFIED.value,
TaskModified(task.id, TaskModified.OPERATION_ADD)))
return jsonify({'message': f'Task {name} added.'}), 201
@task_api.route('/task/<id>', methods=['GET'])
def get_task(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
TaskQuery = Query()
result = task_db.search((TaskQuery.id == id) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)))
if not result:
return jsonify({'error': 'Task not found'}), 404
return jsonify(result[0]), 200
@task_api.route('/task/list', methods=['GET'])
def list_tasks():
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
ids_param = request.args.get('ids')
TaskQuery = Query()
tasks = task_db.search((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None))
if ids_param is not None:
if ids_param.strip() == '':
tasks = []
else:
ids = set(ids_param.split(','))
tasks = [task for task in tasks if task.get('id') in ids]
user_tasks = {t['name'].strip().lower(): t for t in tasks if t.get('user_id') == user_id}
filtered_tasks = []
for t in tasks:
if t.get('user_id') is None and t['name'].strip().lower() in user_tasks:
continue # Skip default if user version exists
filtered_tasks.append(t)
# Sort order:
# 1) good tasks first, then not-good tasks
# 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]
def sort_user_then_default(tasks_group):
user_created = sorted(
[t for t in tasks_group if t.get('user_id') == user_id],
key=lambda x: x['name'].lower(),
)
default_items = sorted(
[t for t in tasks_group if t.get('user_id') is None],
key=lambda x: x['name'].lower(),
)
return user_created + default_items
sorted_tasks = sort_user_then_default(good_tasks) + sort_user_then_default(not_good_tasks)
return jsonify({'tasks': sorted_tasks}), 200
@task_api.route('/task/<id>', methods=['DELETE'])
def delete_task(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
TaskQuery = Query()
task = task_db.get(TaskQuery.id == id)
if not task:
return jsonify({'error': 'Task not found'}), 404
if task.get('user_id') is None:
import logging
logging.warning(f"Forbidden delete attempt on system task: id={id}, by user_id={user_id}")
return jsonify({'error': 'System tasks cannot be deleted.'}), 403
removed = task_db.remove((TaskQuery.id == id) & (TaskQuery.user_id == user_id))
if removed:
# Cascade delete overrides for this task
deleted_count = delete_overrides_for_entity(id)
if deleted_count > 0:
import logging
logging.info(f"Cascade deleted {deleted_count} overrides for task {id}")
# remove the task id from any child's task list
ChildQuery = Query()
for child in child_db.all():
child_tasks = child.get('tasks', [])
if id in child_tasks:
child_tasks.remove(id)
child_db.update({'tasks': child_tasks}, ChildQuery.id == child.get('id'))
send_event_for_current_user(Event(EventType.CHILD_TASKS_SET.value, ChildTasksSet(id, child_tasks)))
send_event_for_current_user(Event(EventType.TASK_MODIFIED.value, TaskModified(id, TaskModified.OPERATION_DELETE)))
return jsonify({'message': f'Task {id} deleted.'}), 200
return jsonify({'error': 'Task not found'}), 404
@task_api.route('/task/<id>/edit', methods=['PUT'])
def edit_task(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
TaskQuery = Query()
existing = task_db.get((TaskQuery.id == id) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)))
if not existing:
return jsonify({'error': 'Task not found'}), 404
task = Task.from_dict(existing)
is_dirty = False
data = request.get_json(force=True) or {}
updates = {}
if 'name' in data:
name = data.get('name', '').strip()
if not name:
return jsonify({'error': 'Name cannot be empty'}), 400
task.name = name
is_dirty = True
if 'points' in data:
points = data.get('points')
if not isinstance(points, int):
return jsonify({'error': 'Points must be an integer'}), 400
if points <= 0:
return jsonify({'error': 'Points must be a positive integer'}), 400
task.points = points
is_dirty = True
if 'is_good' in data:
is_good = data.get('is_good')
if not isinstance(is_good, bool):
return jsonify({'error': 'is_good must be a boolean'}), 400
task.is_good = is_good
is_dirty = True
if 'image_id' in data:
task.image_id = data.get('image_id', '')
is_dirty = True
if not is_dirty:
return jsonify({'error': 'No valid fields to update'}), 400
if task.user_id is None: # public task
new_task = Task(name=task.name, points=task.points, is_good=task.is_good, 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

122
backend/api/tracking_api.py Normal file
View File

@@ -0,0 +1,122 @@
from flask import Blueprint, request, jsonify
from api.utils import get_validated_user_id
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():
"""
Admin endpoint to query tracking events with filters and pagination.
Query params:
- child_id: Filter by child ID (optional)
- user_id: Filter by user ID (optional, admin only)
- entity_type: Filter by entity type (task/reward/penalty) (optional)
- action: Filter by action type (activated/requested/redeemed/cancelled) (optional)
- limit: Max results (default 50, max 500)
- offset: Pagination offset (default 0)
"""
child_id = request.args.get('child_id')
filter_user_id = request.args.get('user_id')
entity_type = request.args.get('entity_type')
action = request.args.get('action')
limit = int(request.args.get('limit', 50))
offset = int(request.args.get('offset', 0))
# Validate limit
limit = min(max(limit, 1), 500)
offset = max(offset, 0)
# Validate filters
if entity_type and entity_type not in ['task', 'reward', 'penalty']:
return jsonify({'error': 'Invalid entity_type', 'code': 'INVALID_ENTITY_TYPE'}), 400
if action and action not in ['activated', 'requested', 'redeemed', 'cancelled']:
return jsonify({'error': 'Invalid action', 'code': 'INVALID_ACTION'}), 400
# Query tracking events
if child_id:
events, total = get_tracking_events_by_child(
child_id=child_id,
limit=limit,
offset=offset,
entity_type=entity_type,
action=action
)
elif filter_user_id:
events, total = get_tracking_events_by_user(
user_id=filter_user_id,
limit=limit,
offset=offset,
entity_type=entity_type
)
else:
return jsonify({
'error': 'Either child_id or user_id is required',
'code': 'MISSING_FILTER'
}), 400
# Convert to dict
events_data = [event.to_dict() for event in events]
return jsonify({
'tracking_events': events_data,
'total': total,
'limit': limit,
'offset': offset,
'count': len(events_data)
}), 200

246
backend/api/user_api.py Normal file
View File

@@ -0,0 +1,246 @@
from flask import Blueprint, request, jsonify, current_app
from events.types.user_modified import UserModified
from models.user import User
from tinydb import Query
from db.db import users_db
import jwt
import random
import string
import utils.email_sender as email_sender
from datetime import datetime, timedelta, timezone
from api.utils import get_validated_user_id, normalize_email, send_event_for_current_user
from api.error_codes import ACCOUNT_MARKED_FOR_DELETION, ALREADY_MARKED
from events.types.event_types import EventType
from events.types.event import Event
from events.types.profile_updated import ProfileUpdated
from utils.tracking_logger import log_tracking_event
from models.tracking_event import TrackingEvent
from db.tracking import insert_tracking_event
user_api = Blueprint('user_api', __name__)
UserQuery = Query()
def get_current_user():
token = request.cookies.get('token')
if not token:
return None
try:
payload = jwt.decode(token, current_app.config['SECRET_KEY'], algorithms=['HS256'])
user_id = payload.get('user_id')
user_dict = users_db.get(UserQuery.id == user_id)
return User.from_dict(user_dict) if user_dict else None
except Exception:
return None
@user_api.route('/user/profile', methods=['GET'])
def get_profile():
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
user = get_current_user()
if not user:
return jsonify({'error': 'Unauthorized'}), 401
return jsonify({
'first_name': user.first_name,
'last_name': user.last_name,
'email': user.email,
'image_id': user.image_id
}), 200
@user_api.route('/user/profile', methods=['PUT'])
def update_profile():
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
user = get_current_user()
if not user:
return jsonify({'error': 'Unauthorized'}), 401
data = request.get_json()
# Only allow first_name, last_name, image_id to be updated
first_name = data.get('first_name')
last_name = data.get('last_name')
image_id = data.get('image_id')
if first_name is not None:
user.first_name = first_name
if last_name is not None:
user.last_name = last_name
if image_id is not None:
user.image_id = image_id
users_db.update(user.to_dict(), UserQuery.email == user.email)
# Create tracking event
metadata = {}
if first_name is not None:
metadata['first_name_updated'] = True
if last_name is not None:
metadata['last_name_updated'] = True
if image_id is not None:
metadata['image_updated'] = True
tracking_event = TrackingEvent.create_event(
user_id=user_id,
child_id=None, # No child for user profile
entity_type='user',
entity_id=user.id,
action='updated',
points_before=0, # Not relevant
points_after=0,
metadata=metadata
)
insert_tracking_event(tracking_event)
log_tracking_event(tracking_event)
# Send SSE event
send_event_for_current_user(Event(EventType.PROFILE_UPDATED.value, ProfileUpdated(user.id)))
return jsonify({'message': 'Profile updated'}), 200
@user_api.route('/user/image', methods=['PUT'])
def update_image():
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
user = get_current_user()
if not user:
return jsonify({'error': 'Unauthorized'}), 401
data = request.get_json()
image_id = data.get('image_id')
if not image_id:
return jsonify({'error': 'Missing image_id'}), 400
user.image_id = image_id
users_db.update(user.to_dict(), UserQuery.email == user.email)
return jsonify({'message': 'Image updated', 'image_id': image_id}), 200
@user_api.route('/user/check-pin', methods=['POST'])
def check_pin():
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
user = get_current_user()
if not user:
return jsonify({'error': 'Unauthorized'}), 401
data = request.get_json()
pin = data.get('pin')
if not pin:
return jsonify({'error': 'Missing pin'}), 400
if user.pin and pin == user.pin:
return jsonify({'valid': True}), 200
return jsonify({'valid': False}), 200
@user_api.route('/user/has-pin', methods=['GET'])
def has_pin():
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
user = get_current_user()
if not user:
return jsonify({'error': 'Unauthorized'}), 401
return jsonify({'has_pin': bool(user.pin)}), 200
@user_api.route('/user/request-pin-setup', methods=['POST'])
def request_pin_setup():
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
user = get_current_user()
if not user or not user.verified:
return jsonify({'error': 'Unauthorized'}), 401
# Generate 6-digit/character code
code = ''.join(random.choices(string.ascii_uppercase + string.digits, k=6))
user.pin_setup_code = code
user.pin_setup_code_created = datetime.utcnow().isoformat()
users_db.update(user.to_dict(), UserQuery.email == user.email)
# Send email
send_pin_setup_email(user.email, code)
return jsonify({'message': 'Verification code sent to your email.'}), 200
def send_pin_setup_email(email, code):
# Use the reusable email sender
email_sender.send_pin_setup_email(email, code)
@user_api.route('/user/verify-pin-setup', methods=['POST'])
def verify_pin_setup():
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
user = get_current_user()
if not user or not user.verified:
return jsonify({'error': 'Unauthorized'}), 401
data = request.get_json()
code = data.get('code')
if not code:
return jsonify({'error': 'Missing code'}), 400
if not user.pin_setup_code or not user.pin_setup_code_created:
return jsonify({'error': 'No code requested'}), 400
# Check expiry (10 min)
created = datetime.fromisoformat(user.pin_setup_code_created)
if datetime.utcnow() > created + timedelta(minutes=10):
return jsonify({'error': 'Code expired'}), 400
if code.strip().upper() != user.pin_setup_code.upper():
return jsonify({'error': 'Invalid code'}), 400
return jsonify({'message': 'Code verified'}), 200
@user_api.route('/user/set-pin', methods=['POST'])
def set_pin():
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
user = get_current_user()
if not user or not user.verified:
return jsonify({'error': 'Unauthorized'}), 401
data = request.get_json()
pin = data.get('pin')
if not pin or not pin.isdigit() or not (4 <= len(pin) <= 6):
return jsonify({'error': 'PIN must be 4-6 digits'}), 400
# Only allow if code was recently verified
if not user.pin_setup_code or not user.pin_setup_code_created:
return jsonify({'error': 'No code verified'}), 400
created = datetime.fromisoformat(user.pin_setup_code_created)
if datetime.utcnow() > created + timedelta(minutes=10):
return jsonify({'error': 'Code expired'}), 400
# Set pin, clear code
user.pin = pin
user.pin_setup_code = ''
user.pin_setup_code_created = None
users_db.update(user.to_dict(), UserQuery.email == user.email)
return jsonify({'message': 'Parent PIN set'}), 200
@user_api.route('/user/mark-for-deletion', methods=['POST'])
def mark_for_deletion():
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
user = get_current_user()
if not user:
return jsonify({'error': 'Unauthorized'}), 401
# Validate email from request body
data = request.get_json()
email = data.get('email', '').strip()
if not email:
return jsonify({'error': 'Email is required', 'code': 'EMAIL_REQUIRED'}), 400
# Verify email matches the logged-in user - make sure to normalize the email address first
if normalize_email(email) != normalize_email(user.email):
return jsonify({'error': 'Email does not match your account', 'code': 'EMAIL_MISMATCH'}), 400
# Check if already marked
if user.marked_for_deletion:
return jsonify({'error': 'Account already marked for deletion', 'code': ALREADY_MARKED}), 400
# Mark for deletion
user.marked_for_deletion = True
user.marked_for_deletion_at = datetime.now(timezone.utc).isoformat()
# Invalidate any outstanding verification/reset tokens so they cannot be used after marking
user.verify_token = None
user.verify_token_created = None
user.reset_token = None
user.reset_token_created = None
users_db.update(user.to_dict(), UserQuery.id == user.id)
# Trigger SSE event
send_event_for_current_user(Event(EventType.USER_MARKED_FOR_DELETION.value, UserModified(user.id, UserModified.OPERATION_DELETE)))
return jsonify({'success': True}), 200

53
backend/api/utils.py Normal file
View File

@@ -0,0 +1,53 @@
import jwt
import re
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
def normalize_email(email: str) -> str:
"""Normalize email for uniqueness checks (Gmail: remove dots and +aliases)."""
email = email.strip().lower()
if '@' not in email:
return email
local, domain = email.split('@', 1)
if domain in ('gmail.com', 'googlemail.com'):
local = local.split('+', 1)[0].replace('.', '')
return f"{local}@{domain}"
def sanitize_email(email):
return email.replace('@', '_at_').replace('.', '_dot_')
def get_current_user_id():
token = request.cookies.get('token')
if not token:
return None
try:
payload = jwt.decode(token, current_app.config['SECRET_KEY'], algorithms=['HS256'])
user_id = payload.get('user_id')
if not user_id:
return None
token_version = payload.get('token_version', 0)
user = users_db.get(Query().id == user_id)
if not user:
return None
if token_version != user.get('token_version', 0):
return None
return user_id
except jwt.InvalidTokenError:
return None
def get_validated_user_id():
user_id = get_current_user_id()
if not user_id or not users_db.get(Query().id == user_id):
return None
return user_id
def send_event_for_current_user(event):
user_id = get_current_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized'}), 401
send_event_to_user(user_id, event)
return None

View File

@@ -0,0 +1,61 @@
import os
import logging
logger = logging.getLogger(__name__)
# Account deletion threshold in hours
# Default: 720 hours (30 days)
# Minimum: 24 hours (1 day)
# Maximum: 720 hours (30 days)
try:
ACCOUNT_DELETION_THRESHOLD_HOURS = int(os.getenv('ACCOUNT_DELETION_THRESHOLD_HOURS', '720'))
except ValueError as e:
raise ValueError(
f"ACCOUNT_DELETION_THRESHOLD_HOURS must be a valid integer. "
f"Invalid value: {os.getenv('ACCOUNT_DELETION_THRESHOLD_HOURS')}"
) from e
# Validation
MIN_THRESHOLD_HOURS = 24
MAX_THRESHOLD_HOURS = 720
def validate_threshold(threshold_hours=None):
"""
Validate the account deletion threshold.
Args:
threshold_hours: Optional threshold value to validate. If None, validates the module's global value.
Returns True if valid, raises ValueError if invalid.
"""
value = threshold_hours if threshold_hours is not None else ACCOUNT_DELETION_THRESHOLD_HOURS
if value < MIN_THRESHOLD_HOURS:
raise ValueError(
f"ACCOUNT_DELETION_THRESHOLD_HOURS must be at least {MIN_THRESHOLD_HOURS} hours. "
f"Current value: {value}"
)
if value > MAX_THRESHOLD_HOURS:
raise ValueError(
f"ACCOUNT_DELETION_THRESHOLD_HOURS must be at most {MAX_THRESHOLD_HOURS} hours. "
f"Current value: {value}"
)
# Warn if threshold is less than 7 days (168 hours)
if value < 168:
logger.warning(
f"Account deletion threshold is set to {value} hours, "
"which is below the recommended minimum of 7 days (168 hours). "
"Users will have limited time to recover their accounts."
)
if threshold_hours is None:
# Only log this when validating the module's global value
logger.info(f"Account deletion threshold: {ACCOUNT_DELETION_THRESHOLD_HOURS} hours")
return True
# Validate on module import
validate_threshold()

View File

@@ -9,19 +9,33 @@ TEST_DATA_DIR_NAME = 'test_data'
# Project root (two levels up from this file)
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def get_base_data_dir(data_env: str | None = None) -> str:
"""
Return the absolute base data directory path for the given env.
data_env: 'prod' uses `data`, anything else uses `test_data`.
"""
env = (data_env or os.environ.get('DATA_ENV', 'prod')).lower()
base_name = DATA_DIR_NAME if env == 'prod' else TEST_DATA_DIR_NAME
return os.path.join(PROJECT_ROOT, base_name)
def get_database_dir(db_env: str | None = None) -> str:
"""
Return the absolute base directory path for the given DB env.
db_env: 'prod' uses `data/db`, anything else uses `test_data/db`.
"""
env = (db_env or os.environ.get('DB_ENV', 'prod')).lower()
base_name = DATA_DIR_NAME if env == 'prod' else TEST_DATA_DIR_NAME
return os.path.join(PROJECT_ROOT, base_name, 'db')
return os.path.join(PROJECT_ROOT, get_base_data_dir(env), 'db')
def get_user_image_dir(username: str | None) -> str:
"""
Return the absolute directory path for storing images for a specific user.
"""
if username:
return os.path.join(PROJECT_ROOT, DATA_DIR_NAME, 'images', username)
return os.path.join(PROJECT_ROOT, get_base_data_dir(), 'images', username)
return os.path.join(PROJECT_ROOT, 'resources', 'images')
def get_logs_dir() -> str:
"""
Return the absolute directory path for application logs.
"""
return os.path.join(PROJECT_ROOT, 'logs')

View File

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

View File

@@ -0,0 +1,146 @@
"""Helper functions for child override database operations."""
import logging
from typing import Optional, List
from tinydb import Query
from db.db import child_overrides_db
from models.child_override import ChildOverride
logger = logging.getLogger(__name__)
def insert_override(override: ChildOverride) -> str:
"""
Insert or update an override. Only one override per (child_id, entity_id).
Args:
override: ChildOverride instance to insert or update
Returns:
The override ID
"""
try:
OverrideQuery = Query()
existing = child_overrides_db.get(
(OverrideQuery.child_id == override.child_id) &
(OverrideQuery.entity_id == override.entity_id)
)
if existing:
# Update existing override
override.touch() # Update timestamp
child_overrides_db.update(override.to_dict(), doc_ids=[existing.doc_id])
logger.info(f"Override updated: child={override.child_id}, entity={override.entity_id}, value={override.custom_value}")
else:
# Insert new override
child_overrides_db.insert(override.to_dict())
logger.info(f"Override created: child={override.child_id}, entity={override.entity_id}, value={override.custom_value}")
return override.id
except Exception as e:
logger.error(f"Failed to insert override: {e}")
raise
def get_override(child_id: str, entity_id: str) -> Optional[ChildOverride]:
"""
Get override for a specific child and entity.
Args:
child_id: Child ID
entity_id: Entity ID (task or reward)
Returns:
ChildOverride instance or None if not found
"""
OverrideQuery = Query()
result = child_overrides_db.get(
(OverrideQuery.child_id == child_id) &
(OverrideQuery.entity_id == entity_id)
)
return ChildOverride.from_dict(result) if result else None
def get_overrides_for_child(child_id: str) -> List[ChildOverride]:
"""
Get all overrides for a specific child.
Args:
child_id: Child ID
Returns:
List of ChildOverride instances
"""
OverrideQuery = Query()
results = child_overrides_db.search(OverrideQuery.child_id == child_id)
return [ChildOverride.from_dict(r) for r in results]
def delete_override(child_id: str, entity_id: str) -> bool:
"""
Delete a specific override.
Args:
child_id: Child ID
entity_id: Entity ID
Returns:
True if deleted, False if not found
"""
try:
OverrideQuery = Query()
deleted = child_overrides_db.remove(
(OverrideQuery.child_id == child_id) &
(OverrideQuery.entity_id == entity_id)
)
if deleted:
logger.info(f"Override deleted: child={child_id}, entity={entity_id}")
return True
return False
except Exception as e:
logger.error(f"Failed to delete override: {e}")
raise
def delete_overrides_for_child(child_id: str) -> int:
"""
Delete all overrides for a child.
Args:
child_id: Child ID
Returns:
Count of deleted overrides
"""
try:
OverrideQuery = Query()
deleted = child_overrides_db.remove(OverrideQuery.child_id == child_id)
count = len(deleted)
if count > 0:
logger.info(f"Overrides cascade deleted for child: child_id={child_id}, count={count}")
return count
except Exception as e:
logger.error(f"Failed to delete overrides for child: {e}")
raise
def delete_overrides_for_entity(entity_id: str) -> int:
"""
Delete all overrides for an entity.
Args:
entity_id: Entity ID (task or reward)
Returns:
Count of deleted overrides
"""
try:
OverrideQuery = Query()
deleted = child_overrides_db.remove(OverrideQuery.entity_id == entity_id)
count = len(deleted)
if count > 0:
logger.info(f"Overrides cascade deleted for entity: entity_id={entity_id}, count={count}")
return count
except Exception as e:
logger.error(f"Failed to delete overrides for entity: {e}")
raise

View File

@@ -72,6 +72,9 @@ 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')
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')
# Use separate TinyDB instances/files for each collection
_child_db = TinyDB(child_path, indent=2)
@@ -79,6 +82,9 @@ _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)
_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)
# Expose table objects wrapped with locking
child_db = LockedTable(_child_db)
@@ -86,6 +92,9 @@ task_db = LockedTable(_task_db)
reward_db = LockedTable(_reward_db)
image_db = LockedTable(_image_db)
pending_reward_db = LockedTable(_pending_rewards_db)
users_db = LockedTable(_users_db)
tracking_events_db = LockedTable(_tracking_events_db)
child_overrides_db = LockedTable(_child_overrides_db)
if os.environ.get('DB_ENV', 'prod') == 'test':
child_db.truncate()
@@ -93,4 +102,7 @@ if os.environ.get('DB_ENV', 'prod') == 'test':
reward_db.truncate()
image_db.truncate()
pending_reward_db.truncate()
users_db.truncate()
tracking_events_db.truncate()
child_overrides_db.truncate()

View File

@@ -2,6 +2,8 @@
# File: db/debug.py
from tinydb import Query
import os
import shutil
from api.image_api import IMAGE_TYPE_ICON, IMAGE_TYPE_PROFILE
from db.db import task_db, reward_db, image_db
@@ -119,7 +121,22 @@ def createDefaultRewards():
reward_db.insert(reward.to_dict())
def initializeImages():
"""Initialize the image database with default images if empty."""
"""Initialize the image database with default images if empty, and copy images to data/images/default."""
# Step 1: Create data/images/default directory if it doesn't exist
default_img_dir = os.path.join(os.path.dirname(__file__), '../data/images/default')
os.makedirs(default_img_dir, exist_ok=True)
# Step 2: Copy all image files from resources/images/ to data/images/default
src_img_dir = os.path.join(os.path.dirname(__file__), '../resources/images')
if os.path.exists(src_img_dir):
for fname in os.listdir(src_img_dir):
src_path = os.path.join(src_img_dir, fname)
dst_path = os.path.join(default_img_dir, fname)
if os.path.isfile(src_path):
shutil.copy2(src_path, dst_path)
# Original DB initialization logic
if len(image_db.all()) == 0:
image_defs = [
('boy01', IMAGE_TYPE_PROFILE, '.png', True),

125
backend/db/tracking.py Normal file
View File

@@ -0,0 +1,125 @@
"""Helper functions for tracking events database operations."""
import logging
from typing import Optional, List
from tinydb import Query
from db.db import tracking_events_db
from models.tracking_event import TrackingEvent, EntityType, ActionType
logger = logging.getLogger(__name__)
def insert_tracking_event(event: TrackingEvent) -> str:
"""
Insert a tracking event into the database.
Args:
event: TrackingEvent instance to insert
Returns:
The event ID
"""
try:
tracking_events_db.insert(event.to_dict())
logger.info(f"Tracking event created: {event.action} {event.entity_type} {event.entity_id} for child {event.child_id}")
return event.id
except Exception as e:
logger.error(f"Failed to insert tracking event: {e}")
raise
def get_tracking_events_by_child(
child_id: str,
limit: int = 50,
offset: int = 0,
entity_type: Optional[EntityType] = None,
action: Optional[ActionType] = None
) -> tuple[List[TrackingEvent], int]:
"""
Query tracking events for a specific child with optional filters.
Args:
child_id: Child ID to filter by
limit: Maximum number of results (default 50, max 500)
offset: Number of results to skip
entity_type: Optional filter by entity type
action: Optional filter by action type
Returns:
Tuple of (list of TrackingEvent instances, total count)
"""
limit = min(limit, 500)
TrackingQuery = Query()
query_condition = TrackingQuery.child_id == child_id
if entity_type:
query_condition &= TrackingQuery.entity_type == entity_type
if action:
query_condition &= TrackingQuery.action == action
all_results = tracking_events_db.search(query_condition)
total = len(all_results)
# Sort by occurred_at desc, then created_at desc
all_results.sort(key=lambda x: (x.get('occurred_at', ''), x.get('created_at', 0)), reverse=True)
paginated = all_results[offset:offset + limit]
events = [TrackingEvent.from_dict(r) for r in paginated]
return events, total
def get_tracking_events_by_user(
user_id: str,
limit: int = 50,
offset: int = 0,
entity_type: Optional[EntityType] = None
) -> tuple[List[TrackingEvent], int]:
"""
Query tracking events for a specific user.
Args:
user_id: User ID to filter by
limit: Maximum number of results
offset: Number of results to skip
entity_type: Optional filter by entity type
Returns:
Tuple of (list of TrackingEvent instances, total count)
"""
limit = min(limit, 500)
TrackingQuery = Query()
query_condition = TrackingQuery.user_id == user_id
if entity_type:
query_condition &= TrackingQuery.entity_type == entity_type
all_results = tracking_events_db.search(query_condition)
total = len(all_results)
all_results.sort(key=lambda x: (x.get('occurred_at', ''), x.get('created_at', 0)), reverse=True)
paginated = all_results[offset:offset + limit]
events = [TrackingEvent.from_dict(r) for r in paginated]
return events, total
def anonymize_tracking_events_for_user(user_id: str) -> int:
"""
Anonymize tracking events by setting user_id to None.
Called when a user is deleted.
Args:
user_id: User ID to anonymize
Returns:
Number of records anonymized
"""
TrackingQuery = Query()
result = tracking_events_db.update({'user_id': None}, TrackingQuery.user_id == user_id)
count = len(result) if result else 0
logger.info(f"Anonymized {count} tracking events for user {user_id}")
return count

View File

@@ -29,6 +29,7 @@ def get_queue(user_id: str, connection_id: str) -> queue.Queue:
def send_to_user(user_id: str, data: Dict[str, Any]):
"""Send data to all connections for a specific user."""
logger.info(f"Sending data to {user_id} user quesues are {user_queues.keys()}")
logger.info(f"Data: {data}")
if user_id in user_queues:
logger.info(f"Queued {user_id}")
# Format as SSE message once
@@ -37,8 +38,6 @@ def send_to_user(user_id: str, data: Dict[str, Any]):
# Send to all connections for this user
for connection_id, q in user_queues[user_id].items():
try:
logger.info(f"Sending message to {connection_id}")
q.put(message)
q.put(message, block=False)
except queue.Full:
# Skip if queue is full (connection might be dead)
@@ -61,7 +60,6 @@ def sse_response_for_user(user_id: str):
try:
while True:
# Get message from queue (blocks until available)
logger.info(f"blocking on get for {user_id} user")
message = user_queue.get()
yield message
except GeneratorExit:

View File

@@ -0,0 +1,22 @@
from events.types.payload import Payload
class ChildOverrideDeletedPayload(Payload):
def __init__(self, child_id: str, entity_id: str, entity_type: str):
super().__init__({
'child_id': child_id,
'entity_id': entity_id,
'entity_type': entity_type
})
@property
def child_id(self) -> str:
return self.get("child_id")
@property
def entity_id(self) -> str:
return self.get("entity_id")
@property
def entity_type(self) -> str:
return self.get("entity_type")

View File

@@ -0,0 +1,13 @@
from events.types.payload import Payload
from models.child_override import ChildOverride
class ChildOverrideSetPayload(Payload):
def __init__(self, override: ChildOverride):
super().__init__({
'override': override.to_dict()
})
@property
def override(self) -> dict:
return self.get("override")

View File

@@ -13,3 +13,13 @@ class EventType(Enum):
CHILD_REWARD_REQUEST = "child_reward_request"
CHILD_MODIFIED = "child_modified"
USER_MARKED_FOR_DELETION = "user_marked_for_deletion"
USER_DELETED = "user_deleted"
TRACKING_EVENT_CREATED = "tracking_event_created"
CHILD_OVERRIDE_SET = "child_override_set"
CHILD_OVERRIDE_DELETED = "child_override_deleted"
PROFILE_UPDATED = "profile_updated"

View File

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

View File

@@ -0,0 +1,27 @@
from events.types.payload import Payload
class TrackingEventCreated(Payload):
def __init__(self, tracking_event_id: str, child_id: str, entity_type: str, action: str):
super().__init__({
'tracking_event_id': tracking_event_id,
'child_id': child_id,
'entity_type': entity_type,
'action': action
})
@property
def tracking_event_id(self) -> str:
return self.get("tracking_event_id")
@property
def child_id(self) -> str:
return self.get("child_id")
@property
def entity_type(self) -> str:
return self.get("entity_type")
@property
def action(self) -> str:
return self.get("action")

View File

@@ -0,0 +1,26 @@
from events.types.payload import Payload
class UserDeleted(Payload):
"""
Event payload for when a user account is deleted.
This event is broadcast only to admin users.
"""
def __init__(self, user_id: str, email: str, deleted_at: str):
super().__init__({
'user_id': user_id,
'email': email,
'deleted_at': deleted_at,
})
@property
def user_id(self) -> str:
return self.get("user_id")
@property
def email(self) -> str:
return self.get("email")
@property
def deleted_at(self) -> str:
return self.get("deleted_at")

View File

@@ -0,0 +1,21 @@
from events.types.payload import Payload
class UserModified(Payload):
OPERATION_ADD = "ADD"
OPERATION_EDIT = "EDIT"
OPERATION_DELETE = "DELETE"
def __init__(self, user_id: str, operation: str):
super().__init__({
'user_id': user_id,
'operation': operation,
})
@property
def user_id(self) -> str:
return self.get("user_id")
@property
def operation(self) -> str:
return self.get("operation")

View File

@@ -1,16 +1,25 @@
import sys, logging, os
from config.paths import get_user_image_dir
import logging
import sys
import os
from flask import Flask, request, jsonify
from flask_cors import CORS
from api.admin_api import admin_api
from api.auth_api import auth_api
from api.child_api import child_api
from api.child_override_api import child_override_api
from api.image_api import image_api
from api.reward_api import reward_api
from api.task_api import task_api
from api.tracking_api import tracking_api
from api.user_api import user_api
from 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 db.default import initializeImages, createDefaultTasks, createDefaultRewards
from utils.account_deletion_scheduler import start_deletion_scheduler
# Configure logging once at application startup
logging.basicConfig(
@@ -24,10 +33,28 @@ logger = logging.getLogger(__name__)
app = Flask(__name__)
#CORS(app, resources={r"/api/*": {"origins": ["http://localhost:3000", "http://localhost:5173"]}})
#Todo - add prefix to all these routes instead of in each blueprint
app.register_blueprint(admin_api)
app.register_blueprint(child_api)
app.register_blueprint(child_override_api)
app.register_blueprint(reward_api)
app.register_blueprint(task_api)
app.register_blueprint(image_api)
app.register_blueprint(auth_api, url_prefix='/auth')
app.register_blueprint(user_api)
app.register_blueprint(tracking_api)
app.config.update(
MAIL_SERVER='smtp.gmail.com',
MAIL_PORT=587,
MAIL_USE_TLS=True,
MAIL_USERNAME='ryan.kegel@gmail.com',
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)
@app.route("/version")
@@ -61,12 +88,11 @@ def start_background_threads():
broadcaster.start()
# TODO: implement users
os.makedirs(get_user_image_dir("user123"), exist_ok=True)
initializeImages()
createDefaultTasks()
createDefaultRewards()
start_background_threads()
start_deletion_scheduler()
if __name__ == '__main__':
app.run(debug=False, host='0.0.0.0', port=5000, threaded=True)

View File

@@ -9,6 +9,7 @@ class Child(BaseModel):
rewards: list[str] = field(default_factory=list)
points: int = 0
image_id: str | None = None
user_id: str | None = None
@classmethod
def from_dict(cls, d: dict):
@@ -19,10 +20,10 @@ class Child(BaseModel):
rewards=d.get('rewards', []),
points=d.get('points', 0),
image_id=d.get('image_id'),
user_id=d.get('user_id'),
id=d.get('id'),
created_at=d.get('created_at'),
updated_at=d.get('updated_at')
)
def to_dict(self):
@@ -33,6 +34,7 @@ class Child(BaseModel):
'tasks': self.tasks,
'rewards': self.rewards,
'points': self.points,
'image_id': self.image_id
'image_id': self.image_id,
'user_id': self.user_id
})
return base

View File

@@ -0,0 +1,64 @@
from dataclasses import dataclass
from typing import Literal
from models.base import BaseModel
@dataclass
class ChildOverride(BaseModel):
"""
Stores per-child customized points/cost for tasks, penalties, and rewards.
Attributes:
child_id: ID of the child this override applies to
entity_id: ID of the task/penalty/reward being customized
entity_type: Type of entity ('task' or 'reward')
custom_value: Custom points (for tasks/penalties) or cost (for rewards)
"""
child_id: str
entity_id: str
entity_type: Literal['task', 'reward']
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'")
@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'),
custom_value=d.get('custom_value'),
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,
'custom_value': self.custom_value
})
return base
@staticmethod
def create_override(
child_id: str,
entity_id: str,
entity_type: Literal['task', 'reward'],
custom_value: int
) -> 'ChildOverride':
"""Factory method to create a new override."""
return ChildOverride(
child_id=child_id,
entity_id=entity_id,
entity_type=entity_type,
custom_value=custom_value
)

View File

@@ -2,12 +2,14 @@
from dataclasses import dataclass
from models.base import BaseModel
@dataclass
class Image(BaseModel):
type: int
extension: str
permanent: bool = False
user: str | None = None
user_id: str | None = None
@classmethod
def from_dict(cls, d: dict):
@@ -19,7 +21,7 @@ class Image(BaseModel):
id=d.get('id'),
created_at=d.get('created_at'),
updated_at=d.get('updated_at'),
user=d.get('user')
user_id=d.get('user_id') if 'user_id' in d else d.get('user')
)
def to_dict(self):
@@ -28,6 +30,6 @@ class Image(BaseModel):
'type': self.type,
'permanent': self.permanent,
'extension': self.extension,
'user': self.user
'user_id': self.user_id
})
return base

View File

@@ -5,6 +5,7 @@ from models.base import BaseModel
class PendingReward(BaseModel):
child_id: str
reward_id: str
user_id: str
status: str = "pending" # pending, approved, rejected
@classmethod
@@ -13,6 +14,7 @@ class PendingReward(BaseModel):
child_id=d.get('child_id'),
reward_id=d.get('reward_id'),
status=d.get('status', 'pending'),
user_id=d.get('user_id'),
id=d.get('id'),
created_at=d.get('created_at'),
updated_at=d.get('updated_at')
@@ -23,6 +25,7 @@ class PendingReward(BaseModel):
base.update({
'child_id': self.child_id,
'reward_id': self.reward_id,
'status': self.status
'status': self.status,
'user_id': self.user_id
})
return base

View File

@@ -8,6 +8,7 @@ class Reward(BaseModel):
description: str
cost: int
image_id: str | None = None
user_id: str | None = None
@classmethod
def from_dict(cls, d: dict):
@@ -17,6 +18,7 @@ class Reward(BaseModel):
description=d.get('description'),
cost=d.get('cost', 0),
image_id=d.get('image_id'),
user_id=d.get('user_id'),
id=d.get('id'),
created_at=d.get('created_at'),
updated_at=d.get('updated_at')
@@ -28,6 +30,7 @@ class Reward(BaseModel):
'name': self.name,
'description': self.description,
'cost': self.cost,
'image_id': self.image_id
'image_id': self.image_id,
'user_id': self.user_id
})
return base

View File

@@ -7,6 +7,7 @@ class Task(BaseModel):
points: int
is_good: bool
image_id: str | None = None
user_id: str | None = None
@classmethod
def from_dict(cls, d: dict):
@@ -15,6 +16,7 @@ class Task(BaseModel):
points=d.get('points', 0),
is_good=d.get('is_good', True),
image_id=d.get('image_id'),
user_id=d.get('user_id'),
id=d.get('id'),
created_at=d.get('created_at'),
updated_at=d.get('updated_at')
@@ -26,6 +28,7 @@ class Task(BaseModel):
'name': self.name,
'points': self.points,
'is_good': self.is_good,
'image_id': self.image_id
'image_id': self.image_id,
'user_id': self.user_id
})
return base

View File

@@ -0,0 +1,91 @@
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Literal, Optional
from models.base import BaseModel
EntityType = Literal['task', 'reward', 'penalty']
ActionType = Literal['activated', 'requested', 'redeemed', 'cancelled']
@dataclass
class TrackingEvent(BaseModel):
user_id: Optional[str]
child_id: str
entity_type: EntityType
entity_id: str
action: ActionType
points_before: int
points_after: int
delta: int
occurred_at: str # UTC ISO 8601 timestamp
metadata: Optional[dict] = None
def __post_init__(self):
"""Validate invariants after initialization."""
if self.delta != self.points_after - self.points_before:
raise ValueError(
f"Delta invariant violated: {self.delta} != {self.points_after} - {self.points_before}"
)
@classmethod
def from_dict(cls, d: dict):
return cls(
user_id=d.get('user_id'),
child_id=d.get('child_id'),
entity_type=d.get('entity_type'),
entity_id=d.get('entity_id'),
action=d.get('action'),
points_before=d.get('points_before'),
points_after=d.get('points_after'),
delta=d.get('delta'),
occurred_at=d.get('occurred_at'),
metadata=d.get('metadata'),
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({
'user_id': self.user_id,
'child_id': self.child_id,
'entity_type': self.entity_type,
'entity_id': self.entity_id,
'action': self.action,
'points_before': self.points_before,
'points_after': self.points_after,
'delta': self.delta,
'occurred_at': self.occurred_at,
'metadata': self.metadata
})
return base
@staticmethod
def create_event(
user_id: Optional[str],
child_id: str,
entity_type: EntityType,
entity_id: str,
action: ActionType,
points_before: int,
points_after: int,
metadata: Optional[dict] = None
) -> 'TrackingEvent':
"""Factory method to create a tracking event with server timestamp."""
delta = points_after - points_before
occurred_at = datetime.now(timezone.utc).isoformat()
return TrackingEvent(
user_id=user_id,
child_id=child_id,
entity_type=entity_type,
entity_id=entity_id,
action=action,
points_before=points_before,
points_after=points_after,
delta=delta,
occurred_at=occurred_at,
metadata=metadata
)

77
backend/models/user.py Normal file
View File

@@ -0,0 +1,77 @@
from dataclasses import dataclass, field
from models.base import BaseModel
@dataclass
class User(BaseModel):
first_name: str
last_name: str
email: str
password: str # In production, this should be hashed
verified: bool = False
verify_token: str | None = None
verify_token_created: str | None = None
reset_token: str | None = None
reset_token_created: str | None = None
image_id: str | None = None
pin: str = ''
pin_setup_code: str = ''
pin_setup_code_created: str | None = None
marked_for_deletion: bool = False
marked_for_deletion_at: str | None = None
deletion_in_progress: bool = False
deletion_attempted_at: str | None = None
role: str = 'user'
token_version: int = 0
@classmethod
def from_dict(cls, d: dict):
return cls(
first_name=d.get('first_name'),
last_name=d.get('last_name'),
email=d.get('email'),
password=d.get('password'),
verified=d.get('verified', False),
verify_token=d.get('verify_token'),
verify_token_created=d.get('verify_token_created'),
reset_token=d.get('reset_token'),
reset_token_created=d.get('reset_token_created'),
image_id=d.get('image_id'),
pin=d.get('pin', ''),
pin_setup_code=d.get('pin_setup_code', ''),
pin_setup_code_created=d.get('pin_setup_code_created'),
marked_for_deletion=d.get('marked_for_deletion', False),
marked_for_deletion_at=d.get('marked_for_deletion_at'),
deletion_in_progress=d.get('deletion_in_progress', False),
deletion_attempted_at=d.get('deletion_attempted_at'),
role=d.get('role', 'user'),
token_version=d.get('token_version', 0),
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({
'first_name': self.first_name,
'last_name': self.last_name,
'email': self.email,
'password': self.password,
'verified': self.verified,
'verify_token': self.verify_token,
'verify_token_created': self.verify_token_created,
'reset_token': self.reset_token,
'reset_token_created': self.reset_token_created,
'image_id': self.image_id,
'pin': self.pin,
'pin_setup_code': self.pin_setup_code,
'pin_setup_code_created': self.pin_setup_code_created,
'marked_for_deletion': self.marked_for_deletion,
'marked_for_deletion_at': self.marked_for_deletion_at,
'deletion_in_progress': self.deletion_in_progress,
'deletion_attempted_at': self.deletion_attempted_at,
'role': self.role,
'token_version': self.token_version,
})
return base

6
backend/package-lock.json generated Normal file
View File

@@ -0,0 +1,6 @@
{
"name": "backend",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

View File

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View File

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View File

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 41 KiB

View File

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 41 KiB

View File

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

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