148 Commits
Author SHA1 Message Date
ryan f3e9201afd feat: update default persistence to MongoDB and enhance configuration options
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 2m19s
2026-07-29 16:45:10 -04:00
ryan da7ed41938 feat: migrate backend persistence from TinyDB to MongoDB Atlas
- Implement lazy MongoDB client initialization in backend/db/mongo_client.py.
- Create Gunicorn configuration to ensure MongoDB client is initialized per worker.
- Refactor database access layer to support MongoDB with a new MongoLockedTable adapter.
- Add migration script to transfer existing TinyDB data to MongoDB, preserving idempotency.
- Update tracking event handling to ensure deterministic ordering with a monotonic sequence.
- Modify tests to use mongomock for MongoDB integration and ensure existing tests pass.
- Add integration test script to run tests against a local MongoDB Docker container.
- Document environment variables and migration process in specs/feat-database-migration.md.
2026-07-28 01:10:52 -04:00
ryan c9c1fa18a2 feat: load environment variables from .env file and update .gitignore 2026-07-27 15:04:23 -04:00
ryan d4800be3b7 feat: enhance refresh token handling with grace period and rotation detection
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m17s
2026-07-26 18:35:56 -04:00
ryan 11f68c7f76 fix(tutorial): correct punctuation in tutorial restart modal message
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m11s
2026-07-26 00:00:21 -04:00
ryan 96992cf918 feat(tutorial): enhance tutorial flow and add tests for help button functionality
Chore App Build, Test, and Push Docker Images / build-and-push (push) Has been cancelled
2026-07-25 23:59:51 -04:00
ryan 8e4f89f4ec feat(tutorial): update tutorial labels and modal titles to improve clarity
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m12s
- Changed "Skip tour" button to "Cancel" in various tutorial overlays.
- Updated modal titles from "Confirm Task" to "Confirm Penalty" where applicable.
- Modified tutorial step call-to-action labels from "Got it" and "Next" to "Continue" for consistency.
- Added tests for new tutorial overlay behavior and button interactions.
- Refactored tutorial controller to allow ignoring tutorial enabled state in step visibility checks.
- Enhanced user profile tutorial restart functionality with appropriate modal messages.
- Updated user profile and task confirmation dialog components to reflect new titles and messages.
- Adjusted configuration files for local development environments.
2026-07-25 12:56:18 -04:00
ryan cc1189cd9b feat(tutorial): enhance help button visibility and add dialog tests
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m56s
- Introduced a mechanism to hide the help button when modal dialogs are open by adding `helpButtonHidden` state in the tutorial controller.
- Updated various components to set the help button visibility based on dialog states.
- Added tests to verify help button visibility during reward and task confirmation dialogs.
- Created new E2E tests for dialog help button functionality across different assignment views.
- Refactored existing dialog components to utilize the new help button visibility logic.
- Added unit tests for `RewardConfirmDialog` and `TaskConfirmDialog` to ensure correct titles are rendered based on task type.
- Enhanced `HelpButton` component tests to validate visibility based on tutorial state.
2026-07-23 01:02:21 -04:00
ryan 541bed3a8a feat: enhance tutorial functionality by disabling inputs and buttons during active steps
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m12s
2026-07-21 14:53:31 -04:00
ryan f7b00fc7c4 fix: adjust timezone handling for chore expiry notifications and ensure deadlines account for next occurrences
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m45s
2026-07-17 19:47:43 -04:00
ryan d910a6bc65 feat(playwright-cli): add comprehensive test generation, tracing, and video recording documentation
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 2m29s
- Introduced detailed documentation for test generation workflow in Playwright CLI, covering planning, generating, and healing tests.
- Added tracing capabilities documentation, including usage, output files, and best practices for debugging and performance analysis.
- Included video recording instructions, emphasizing best practices for capturing browser automation sessions with chapter markers and overlays.
- Implemented user tutorial authentication setup and tutorial tests for parent mode in the E2E testing framework.
- Created JSON files for user tutorial state management, ensuring isolated test environments.
2026-07-17 19:38:02 -04:00
ryan 7f0326eff1 feat: add child actions menu and achievements feature design
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 4m51s
- Introduced a new feature specification for displaying a menu with actions (Award Certificate, Award Badge) when a child's card is clicked in ParentView.
- Created a detailed plan for an achievements system, outlining phases for implementation, including achievement taxonomy, MVP set, and UX considerations.
- Added a template for future feature specifications to standardize documentation.
2026-07-12 18:52:59 -04:00
ryan 06d17e3d34 fixed some frontend tests
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m51s
2026-06-19 18:37:35 -04:00
ryan e2bb9cd6b9 feat: add onboarding tutorial for new users
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 3m4s
- Introduced a modular tutorial layer to guide new parents through the app setup process.
- Implemented a 3-step forced intro after first sign-in (PIN setup → child creation → chore creation).
- Added just-in-time contextual hints for various features as users encounter them.
- Persisted user progress on the backend with new fields in the User model.
- Created a new tutorial controller and step registry in the frontend for managing tutorial states.
- Added Help button for easy access to tutorial tips and a restart option in the user profile.
- Ensured accessibility and mobile responsiveness for the tutorial overlay.
- Included tests for backend and frontend functionalities related to the tutorial.
2026-06-19 17:27:35 -04:00
ryan d147bd6f27 feat(tutorial): implement comprehensive tutorial system with step guidance
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m9s
- Added tutorial controller to manage tutorial state and progress.
- Introduced HelpButton component for contextual help throughout the application.
- Created various tutorial steps for onboarding and feature guidance.
- Integrated tutorial prompts in multiple components (ChildrenListView, LoginButton, ScheduleModal, etc.) to enhance user experience.
- Implemented logic to show tutorials based on user actions and state.
- Added functionality to dismiss and skip tutorial sessions.
- Established a mechanism to hydrate tutorial state from user profile.
2026-05-26 16:54:45 -04:00
ryan ec4912aa4a feat: update version to 1.0.16 and modify user authentication cookies
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m24s
2026-05-25 13:08:13 -04:00
ryan 0b3d1d5ed0 feat: replace drag-and-drop with pointer events for item reordering
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m10s
2026-05-23 23:28:11 -04:00
ryan 229b6b6f7a feat: add touch support for drag-and-drop reordering of routine items
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m13s
2026-05-23 23:13:44 -04:00
ryan 63e92756fe feat: add routine expiration check and update status display in ChildView
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m59s
2026-05-21 13:30:07 -04:00
ryan 08dda6c6b0 feat: update version to 1.0.15 and add routines feature documentation
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m25s
- Updated BASE_VERSION in version.py to 1.0.15.
- Added ROUTINES-IMPLEMENTATION-SUMMARY.md detailing the implementation status, phases, and test coverage for the new routines feature.
- Created e2e-routines-test-plan.md outlining the end-to-end test strategy for the routines feature.
- Introduced plan-routinesFeature.prompt.md to summarize the design and implementation plan for the routines feature.
2026-05-20 16:48:41 -04:00
ryan 0f7efc8961 fix: Update image upload type from '4' to '2' for correct processing
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m40s
2026-05-20 15:44:44 -04:00
ryan f510dea09d style: Refactor routine selection and item styles for improved layout and responsiveness
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m58s
2026-05-20 00:03:12 -04:00
ryanandCopilot a6944ad59c feat: Implement drag-and-drop reordering for routine items and add corresponding E2E tests
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m41s
Co-authored-by: Copilot <copilot@github.com>
2026-05-19 19:30:44 -04:00
ryan ad8a8bf867 feat: Update user authentication data and enhance routine item handling in RoutineEditView
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m16s
2026-05-18 16:39:05 -04:00
ryanandCopilot 5392e5af70 Add routine management features for child and parent views
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m8s
- Implemented routine child-mode flow tests to ensure proper functionality of routine assignment and task completion.
- Created notification tests for parent view to verify routine completion notifications for children.
- Developed ChildRoutineOverlay component for displaying routine tasks and handling user interactions.
- Added RoutineApproveDialog component for approving or rejecting completed routines.
- Created unit tests for ChildRoutineOverlay and RoutineEditView components to ensure correct behavior and rendering.
- Enhanced RoutineEditView with proper handling of task addition and form submission.

Co-authored-by: Copilot <copilot@github.com>
2026-05-17 23:47:12 -04:00
ryan eb775ba7d8 feat: Implement routines feature with CRUD operations and child assignment
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m0s
- Add backend routines management with add, get, update, delete, and list functionalities.
- Create models for Routine, RoutineItem, RoutineSchedule, and RoutineExtension.
- Develop event types for routine confirmation and modification.
- Implement frontend components for routine assignment, confirmation dialog, and routine management views.
- Add unit tests for routine API and integration tests for routine CRUD flow.
- Create end-to-end test plan for routines feature covering parent and child interactions.
2026-05-05 09:08:19 -04:00
ryan 082097b4f9 feat: update BASE_VERSION to 1.0.14-a for feature release
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m28s
2026-05-04 14:43:54 -04:00
ryan 2e1a0ab2fa feat: add functions to validate today's timestamps and update pending status logic
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m20s
2026-05-03 12:26:21 -04:00
ryan ce3d1b3d54 feat: add functions to validate today's timestamps and update pending status logic
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m52s
2026-05-03 11:53:21 -04:00
ryanandCopilot 75d3d6dc39 feat: set FRONTEND_SSL_ENABLED to false in docker-compose.yml
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m19s
Co-authored-by: Copilot <copilot@github.com>
2026-05-02 14:00:28 -04:00
ryan 3d882656e3 feat: set FRONTEND_SSL_ENABLED to false in docker-compose.yml
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m19s
2026-05-02 13:41:55 -04:00
ryan 8308d205e8 feat: update reward version to 1.0.13 and modify dialog button labels for clarity
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m31s
2026-05-01 23:32:17 -04:00
ryan e77254eabf feat: add dynamic dialog max width to ModalDialog component
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m45s
2026-05-01 18:49:48 -04:00
ryan a68a86a6a6 feat: add dynamic dialog max width to ModalDialog component
Chore App Build, Test, and Push Docker Images / build-and-push (push) Has been cancelled
2026-05-01 18:49:40 -04:00
ryan 4ac83dcf17 feat: enhance push notification service worker for chore expirations and update related configurations
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m49s
2026-05-01 15:39:01 -04:00
ryan ab0d32c6b0 feat: enhance push notification service worker for chore expirations and update related configurations
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 2m22s
2026-05-01 15:34:56 -04:00
ryan 28f5c43349 fix: improve date comparison for chore completion status
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m16s
2026-04-29 14:18:23 -04:00
ryan a2b464af7b fix: improve date comparison for chore completion status
Chore App Build, Test, and Push Docker Images / build-and-push (push) Has been cancelled
2026-04-29 14:17:40 -04:00
ryan 6bf10fda2f feat: implement routines feature for child and parent modes
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m23s
- Added backend models for routines, routine items, and schedules.
- Created API endpoints for managing routines and their items.
- Implemented frontend components for routine creation, detail view, and assignment.
- Integrated routines into child view with a scrolling list and approval workflow.
- Added push notifications for routine confirmations and pending approvals.
- Refactored existing components to accommodate new routines functionality.
- Updated tests to cover new routines feature and ensure proper functionality.
2026-04-28 23:30:52 -04:00
ryan c840825549 feat: add workflow for promoting master to production with testing and deployment steps
Chore App Build, Test, and Push Docker Images / build-and-push (push) Has been cancelled
2026-04-26 23:30:42 -04:00
ryan 9936cfd544 feat: add workflow for promoting master to production with testing and deployment steps
Chore App Build, Test, and Push Docker Images / build-and-push (push) Has been cancelled
2026-04-26 23:24:11 -04:00
ryan b42c36ebdd feat: add workflow for promoting master to production with testing and deployment steps
Chore App Build, Test, and Push Docker Images / build-and-push (push) Has been cancelled
2026-04-26 23:08:09 -04:00
ryan 395199f9b2 feat: add workflow for promoting master to production with testing and deployment steps
Chore App Build, Test, and Push Docker Images / build-and-push (push) Has been cancelled
2026-04-26 23:02:46 -04:00
ryan a37b259f47 feat: add workflow for promoting master to production with testing and deployment steps
Chore App Build, Test, and Push Docker Images / build-and-push (push) Has been cancelled
2026-04-26 22:53:04 -04:00
ryan 0606b71890 feat: add workflow for promoting master to production with testing and deployment steps
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m18s
2026-04-26 22:43:29 -04:00
ryan b4fc4fc955 feat: add workflow for promoting master to production with testing and deployment steps
Chore App Build, Test, and Push Docker Images / build-and-push (push) Has been cancelled
2026-04-26 22:41:14 -04:00
ryan 8774e85529 feat: add workflow for promoting master to production with testing and deployment steps
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m19s
2026-04-26 19:08:08 -04:00
ryan 2b2f4f46c0 feat: add workflow for promoting master to production with testing and deployment steps
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m23s
2026-04-26 14:33:51 -04:00
ryan c5306a15d1 feat: add workflow for promoting master to production with testing and deployment steps
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m21s
2026-04-26 00:02:25 -04:00
ryan fa1a422747 feat: add workflow for promoting master to production with testing and deployment steps
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m21s
2026-04-25 21:42:47 -04:00
ryan 4093e79e50 Refactor code structure for improved readability and maintainability
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m42s
2026-04-25 19:02:20 -04:00
ryan 39a547ca9c Refactor code structure for improved readability and maintainability
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m49s
2026-04-25 11:26:29 -04:00
ryan 127378797c Refactored frontend directory
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 2m46s
2026-04-25 00:40:15 -04:00
ryan db846f4e31 Refactor code structure and remove redundant code blocks for improved readability and maintainability
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m12s
2026-04-24 15:59:39 -04:00
ryan 6d43fb23ad feat: enhance email templates for verification, password reset, and PIN setup
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m44s
2026-04-24 10:24:32 -04:00
ryan 6a29e263aa feat: enhance email templates for verification, password reset, and PIN setup
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 1m43s
2026-04-24 00:32:44 -04:00
ryan 89de4fd869 fix: update date comparison logic in isChoreCompletedToday function
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m9s
2026-04-24 00:12:20 -04:00
ryan d34910faa7 chore: update favicon.ico for improved branding
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m52s
2026-04-23 23:38:22 -04:00
ryan 1a8181b8e5 chore: update favicon.ico for improved branding
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m7s
2026-04-23 16:56:09 -04:00
ryan 902e6cadc9 chore: update favicon.ico for improved branding
Chore App Build, Test, and Push Docker Images / build-and-push (push) Has been cancelled
2026-04-23 16:54:19 -04:00
ryan c9a4f92337 Refactor code structure for improved readability and maintainability
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m45s
2026-04-23 14:31:02 -04:00
ryan ea308b28a9 feat: refactor notification click handling to improve navigation and action execution
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m10s
2026-04-22 22:01:31 -04:00
ryan 8907184fde Add chore expiry notification system with scheduling and tests
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m59s
- Implemented `trigger_chore_expiry.py` script to manually trigger chore expiry notifications for users via the admin API.
- Developed `chore_expiry_notification_scheduler.py` to handle the logic for sending notifications for chores expiring within the next 75 minutes.
- Created utility functions in `schedule_utils.py` to determine scheduling and deadlines for chores.
- Added comprehensive tests for the chore expiry notification system in `test_chore_expiry_notification_scheduler.py`, covering various scenarios including scheduled chores, confirmations, and user settings.
2026-04-22 15:37:40 -04:00
ryan 6982fa561f feat: implement state expiry for chores and rewards, adding scheduler and event handling
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m40s
2026-04-21 23:21:53 -04:00
ryan bc481527c8 feat: enhance task and reward management by clearing pending confirmations on unassignment
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m29s
2026-04-21 16:52:06 -04:00
ryan 60dbb8a129 feat: add environment variable support for admin user creation in backend
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m38s
2026-04-21 16:07:07 -04:00
ryan 6b3e2cd9ba feat: add environment variable support for admin user creation in backend
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m28s
2026-04-21 15:26:48 -04:00
ryan 3d599243c7 feat: add environment variable support for admin user creation in backend
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m31s
2026-04-21 14:34:58 -04:00
ryan cdfaf7ead1 refactor: simplify HTTPS configuration in Vite setup
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m41s
2026-04-21 13:42:42 -04:00
ryan 8da61ce335 feat: add admin endpoint to send digest emails for users
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 2m12s
- Implemented a new endpoint `/admin/test/send-digest` in `admin_api.py` to trigger digest emails for specific users.
- Added a script `send_digest.py` to facilitate sending digest emails via the admin API.
- Enhanced the digest action handling in `digest_action_api.py` to support token peeking without consuming it.
- Updated the `send_digests` function in `digest_scheduler.py` to utilize the new `send_digest_for_user` function for sending emails.
- Introduced a new utility function `peek_token` in `digest_token.py` to validate tokens without consuming them.
- Modified the `ParentView.vue` component to handle digest actions upon receiving a digest token in the URL.
- Updated `.gitignore` to exclude sensitive certificate files.
2026-04-21 13:28:34 -04:00
ryan 3d2577e4ec Apply .gitignore and stop tracking ignored files
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 1m31s
2026-04-21 13:11:05 -04:00
ryan 2c7e9b8b5e feat: add admin endpoint to send digest emails for users
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 1m37s
- Implemented a new endpoint `/admin/test/send-digest` in `admin_api.py` to trigger digest emails for specific users.
- Added a script `send_digest.py` to facilitate sending digest emails via the admin API.
- Enhanced the digest action handling in `digest_action_api.py` to support token peeking without consuming it.
- Updated the `send_digests` function in `digest_scheduler.py` to utilize the new `send_digest_for_user` function for sending emails.
- Introduced a new utility function `peek_token` in `digest_token.py` to validate tokens without consuming them.
- Modified the `ParentView.vue` component to handle digest actions upon receiving a digest token in the URL.
- Updated `.gitignore` to exclude sensitive certificate files.
2026-04-21 13:08:03 -04:00
ryan f48845c1d0 feat: update user authentication cookies and implement service worker timeout for push subscription checks
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m34s
2026-04-20 21:05:12 -04:00
ryan 4ee5367742 Add end-to-end tests for parent notifications and actions
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m35s
- Implement tests for approving and denying rewards and chores, including token generation and validation.
- Create tests for error handling scenarios with expired, tampered, and fake tokens.
- Add tests for push subscription registration and user profile notification settings.
- Ensure that notifications reflect the correct state of rewards and chores in the UI.
- Validate that toggles for email digest and push notifications function correctly based on user permissions and server state.
2026-04-20 20:32:19 -04:00
ryan ee16b49020 feat: add unsubscribe functionality for push notifications and update key/cert file paths
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m39s
2026-04-20 15:41:28 -04:00
ryan b529ddaa02 feat: add push notification settings to user profile and update related functionality
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m50s
2026-04-20 10:43:09 -04:00
ryan fd28c89cbf feat: update push notification subscription flow and remove deprecated opt-in component
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m43s
2026-04-19 22:16:38 -04:00
ryan d7b1962903 feat: add Daily Digest and Push Notifications toggles to User Profile with corresponding functionality
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m37s
2026-04-18 23:37:19 -04:00
ryan 9efbb455d7 feat: add Daily Digest and Push Notifications toggles to User Profile with corresponding functionality
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 2m4s
2026-04-18 23:03:23 -04:00
ryan d3ce54a1ff debugging
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m30s
2026-04-18 11:52:25 -04:00
ryan 3d5f84579b debugging
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m41s
2026-04-17 18:09:47 -04:00
ryan 5e4f7b030e debugging
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 1m57s
2026-04-17 18:01:53 -04:00
ryan 91e3c45b5a feat: update docker-compose for backend data volume and enhance ParentLayout with push subscription handling
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m39s
2026-04-17 17:18:13 -04:00
ryan 0a6551368b feat: add user seeding script and update environment variables for testing
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m26s
2026-04-17 14:07:17 -04:00
ryan cf2ac4b5e8 feat: add user seeding script and update environment variables for testing
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 2m57s
2026-04-17 13:58:58 -04:00
ryan f5dfdfbb42 fix: update private key and certificate files for improved security
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m39s
2026-04-16 18:10:41 -04:00
ryan 43d647a712 Refactor code structure for improved readability and maintainability
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m31s
2026-04-16 16:49:54 -04:00
ryan 308bf0cc72 feat: add environment variables for digest and VAPID keys in build workflow
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m15s
2026-04-15 22:15:32 -04:00
ryan ad2bdf4c4f Add push notification functionality with tests and digest scheduler
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m14s
- Implemented push subscription API with tests for subscribing and unsubscribing users.
- Created web push notification tests triggered by child actions.
- Added digest scheduler to send email digests to users at 9 PM local time.
- Developed utility functions for creating and validating digest action tokens.
- Integrated web push sender to handle sending notifications to users.
- Added service worker for handling push notifications in the frontend.
- Created a push opt-in component for user notification preferences.
- Implemented tests for the push opt-in component to ensure correct behavior.
- Updated frontend services to manage push subscriptions and permissions.
2026-04-15 21:56:10 -04:00
ryan 0d50a324a3 feat: add end-to-end tests for parent notifications feature
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m19s
- Implement tests for push subscription registration, chore and reward notifications, and deep-link navigation.
- Cover scenarios for approving and denying chores and rewards, including token validation for digest actions.
- Introduce a mock strategy for service worker push delivery to facilitate manual testing.
- Ensure isolated test setups with appropriate cleanup after tests.
2026-04-13 21:12:22 -04:00
ryan ea0166d198 feat: archive old navigation selector icons specification and add new implementation details
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 1m53s
2026-04-13 16:49:36 -04:00
ryan 3116295980 style: update button styles and add bevel-box class for improved UI
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m8s
2026-04-13 16:37:43 -04:00
ryan 876d3c5531 Refactor code structure for improved readability and maintainability
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m27s
2026-04-13 10:27:30 -04:00
ryan 759a1c745e Add initial test results file with failed status
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m56s
2026-04-12 12:32:16 -04:00
ryanandCopilot bb7c4c469c fix: return extension_date regardless of server UTC date
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 4m14s
list_child_tasks used date_type.today() (server UTC) to look up the
TaskExtension record. When the client's local date differed from the
server's UTC date (e.g. user is UTC-5 and it's past midnight UTC),
the lookup returned None and extension_date was incorrectly null.

Fix:
- Add get_extension_for_child_task() to db/task_extensions.py that
  queries by child_id + task_id only, without a date filter, and
  returns the entry with the latest date when multiple exist.
- Update list_child_tasks to use the new function. The comment already
  states 'client does all time math', so returning the stored
  extension_date is correct — the frontend compares it to local date.
- In extend_chore_time, delete any existing extension for the
  child+task before inserting the new one to prevent stale records
  from accumulating across days.
- Add regression test that inserts an extension for yesterday's date
  and asserts extension_date is non-null in the list-tasks response.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-12 01:59:51 +00:00
ryan 861b3dc9d4 Refactor code structure and remove redundant sections for improved readability and maintainability
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 1m48s
2026-04-10 17:44:37 -04:00
ryan 8080a59de1 fix: update BASE_VERSION to 1.0.7 for release
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 1m48s
2026-04-08 13:31:40 -04:00
ryan a4e23aad11 feat: implement cross-tab coordination for token refresh and enhance logout handling
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m57s
2026-03-27 23:02:29 -04:00
ryan 89097a390e fix: update BASE_VERSION to 1.0.6 for release
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m13s
2026-03-26 17:05:19 -04:00
ryan 469a2ffc24 refactor: simplify array initialization in child sort order tests
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m30s
2026-03-26 12:51:29 -04:00
ryan 028f99b5c3 Add end-to-end tests for task assignment, modification, sorting, and child view updates
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m57s
- Implement tests to verify that assigned titles display the correct child name in various chore and reward views.
- Create tests to ensure that child updates via SSE reflect correctly in the UI for task modifications, including point overrides and pending status resets.
- Add tests to validate the sorting order of tasks and rewards in both child and parent views, ensuring proper prioritization of pending, completed, and scheduled tasks.
- Introduce tests for scrolling behavior to ensure edited tasks are brought into view after modifications.
2026-03-26 12:30:48 -04:00
ryan 16701278ed fix: update BASE_VERSION to 1.0.6RC03 for release
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m12s
2026-03-25 15:30:18 -04:00
ryan 359c170b27 fix: implement fetch generation counter to handle concurrent refreshes in ScrollingList
Chore App Build, Test, and Push Docker Images / build-and-push (push) Has been cancelled
- Added a fetch generation counter to the fetchItems method to discard stale results from concurrent fetches.
- Updated the loading state management to ensure it only updates if the fetch is the latest.
- Enhanced the refresh method to prevent stale data from being rendered when multiple refreshes occur.
- Added a test case to verify that stale fetches are discarded correctly when concurrent refreshes happen.
- Fixed CSS for mobile banners to prevent overflow and adjusted font sizes for better visibility.
- Resolved issues with task icons disappearing when extending time on overdue chores by ensuring only one fetch is active at a time.
2026-03-25 15:30:00 -04:00
ryan f64311689b feat: add completed chores to the display order in ChildView and update sorting priority
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m50s
2026-03-24 17:10:51 -04:00
ryan ac29ee9bd0 fix: update ParentView tests to use mount options and improve mock implementations
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m18s
2026-03-24 16:38:06 -04:00
ryan e9f4343426 added fixes for bug plan 1.0.6RC01
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 5m43s
2026-03-24 15:31:57 -04:00
ryan 81169da05e fix: update version number to 1.0.6RC01 and clarify verification code description
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m30s
2026-03-20 16:48:43 -04:00
ryan ef9cb01d92 feat: add enable/disable toggle for chore scheduling in ScheduleModal
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m39s
- Introduced a toggle button to enable or disable chores in the scheduler.
- The toggle will be available in both types of schedulers.
- Added UI design proposal and considerations for mobile dimensions.
- Included E2E tests to ensure toggle state persistence and correct behavior in child view.
2026-03-20 16:42:13 -04:00
ryan db6e0a7ce8 feat: Implement user profile and parent profile button tests
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m38s
- Added tests for user profile editing, including name changes, image uploads, and password changes.
- Implemented tests for changing the parent PIN with verification code handling.
- Created tests for account deletion with confirmation dialog and email validation.
- Introduced parent profile button tests for both temporary and permanent modes, verifying badge visibility and menu options.
- Updated Playwright configuration to include new test buckets for user profile and parent profile button scenarios.
- Added e2e plans documentation for user profile and parent profile button tests.
2026-03-18 17:20:31 -04:00
ryan a9131242a7 Add end-to-end tests for task modification and assignment features
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m33s
- Implemented tests for editing penalty points and reward costs in `penalty-edit-points.spec.ts` and `reward-edit-cost.spec.ts`.
- Created detailed plans for task activation and assignment scenarios in `task-activated.plan.md` and `task-assignment.plan.md`.
- Added comprehensive test cases for modifying tasks, including editing points for chores, kindness acts, penalties, and rewards in `task-modified.plan.md`.
- Ensured all tests are isolated and run in serial mode to maintain state integrity.
2026-03-17 22:46:27 -04:00
ryan b2115ceb57 Add editor.gotoLocation settings for improved navigation
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 2m26s
2026-03-13 23:52:37 -04:00
ryan cd902da292 Refactor test expectations for child kebab menu options
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m18s
2026-03-13 23:29:01 -04:00
ryan c2b022eb0b Refactor Playwright tests and update configurations
Chore App Build, Test, and Push Docker Images / build-and-push (push) Has been cancelled
- Consolidated kindness and penalty tests into single files to ensure serial execution and prevent conflicts.
- Updated Playwright configuration to define separate test buckets for child options and create child tests, ensuring proper execution order.
- Added new tests for child kebab menu options including editing, deleting points, and confirming child deletion.
- Removed obsolete tests for kindness and penalty default management.
- Updated authentication tokens in user.json for improved security.
- Enhanced test reliability by implementing retry logic for UI interactions in the create-child happy path test.
2026-03-13 23:26:27 -04:00
ryan 8da04676ca Add end-to-end tests for parent rewards management
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 2m33s
- Implement tests for creating, editing, canceling, and deleting rewards in parent mode.
- Include scenarios for converting default rewards to user items and verifying restoration of default rewards after deletion.
- Create a comprehensive test plan outlining the steps and expectations for each scenario.
2026-03-12 23:53:36 -04:00
ryan f250c42e5e Add end-to-end tests for parent item management
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m31s
- Implement tests for creating, editing, and deleting chores, kindness acts, and penalties.
- Add tests to verify conversion of default items to user items and restoration of system defaults upon deletion.
- Ensure proper cancellation of creation and editing actions.
- Create a comprehensive plan document outlining the test scenarios and expected behaviors.
2026-03-12 12:22:37 -04:00
ryan accf596bd7 more tests
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m30s
2026-03-09 13:28:43 -04:00
ryan 2c65d3ecaf temp changes
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m59s
2026-03-09 10:16:39 -04:00
ryan a8d7427a95 feat: enhance Playwright testing setup with E2E tests, new skills, and improved documentation
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 1m44s
- Added E2E test setup in `auth_api.py` with `/e2e-seed` endpoint for database reset and test user creation.
- Integrated Playwright for end-to-end testing in the frontend with necessary dependencies in `package.json` and `package-lock.json`.
- Created Playwright configuration in `playwright.config.ts` to manage test execution and server setup.
- Developed new skills for Playwright best practices, visual regression, smoke test generation, and self-healing tests.
- Implemented new test cases for chore creation in `chores-create.smoke.spec.ts` and `chores-create.spec.ts`.
- Added page object models for `ChildEditPage` and `LandingPage` to streamline test interactions.
- Updated `.gitignore` to exclude Playwright reports and test results.
- Enhanced documentation in `copilot-instructions.md` for testing and E2E setup.
2026-03-07 10:13:21 -05:00
ryan b2618361e4 feat: implement force logout notifications for password reset and account deletion
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m29s
2026-03-05 16:52:11 -05:00
ryan a10836d412 feat: allow bypass of reset-password and verify routes for logged-in users
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m27s
2026-03-05 15:47:42 -05:00
ryan bb5330ac17 feat: allow bypass of reset-password and verify routes for logged-in users
Chore App Build, Test, and Push Docker Images / build-and-push (push) Has been cancelled
2026-03-05 15:46:59 -05:00
ryan 8cdc26cb88 feat: add email notification for build job status
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m11s
2026-03-05 14:41:26 -05:00
ryan de56eb064f feat: add email notification for build job status
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m13s
2026-03-05 14:03:45 -05:00
ryan 031d7c0eec feat: add email notification for build job status
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 2m39s
2026-03-05 13:51:35 -05:00
ryan f07af135b7 feat: add email notification for build job status
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 2m15s
2026-03-05 13:39:55 -05:00
ryan 60647bc742 feat: add email notification for build job status
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 2m39s
2026-03-05 12:53:53 -05:00
ryan 384be2a79e feat: update sign-out redirect to landing page
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 13m17s
2026-03-05 12:32:04 -05:00
ryan ccfc710753 feat: implement force logout event and update navigation redirects
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 1m37s
2026-03-05 09:52:19 -05:00
ryan 992dd8423f Refactor code structure for improved readability and maintainability
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m52s
2026-03-04 17:12:04 -05:00
ryan c922e1180d feat: add landing page components including hero, features, problem, and footer
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m23s
- Introduced LandingHero component with logo, tagline, and action buttons.
- Created LandingFeatures component to showcase chore system benefits.
- Developed LandingProblem component explaining the importance of a structured chore system.
- Implemented LandingFooter for navigation and copyright information.
- Added LandingPage to assemble all components and manage navigation.
- Included unit tests for LandingHero component to ensure functionality.
2026-03-04 16:21:26 -05:00
ryan 82ac820c67 Fixed issue with refresh token
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m9s
2026-03-02 16:01:54 -05:00
ryan 76fef8c688 feat: update test environment setup to include secret key and refresh token expiry
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m3s
2026-03-01 21:39:03 -05:00
ryan 16d3500368 feat: update test environment setup to include secret key and refresh token expiry
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m19s
2026-03-01 21:21:52 -05:00
ryan c3538cc3d4 feat: update test environment setup to include secret key and refresh token expiry
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m2s
2026-03-01 21:15:19 -05:00
ryan 6433236191 feat: update test environment setup to include secret key and refresh token expiry
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m14s
2026-03-01 20:59:26 -05:00
ryan ebaef16daf feat: implement long-term user login with refresh tokens
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m23s
- Introduced a dual-token system for user authentication: a short-lived access token and a long-lived rotating refresh token.
- Created a new RefreshToken model to manage refresh tokens securely.
- Updated auth_api.py to handle login, refresh, and logout processes with the new token system.
- Enhanced security measures including token rotation and theft detection.
- Updated frontend to handle token refresh on 401 errors and adjusted SSE authentication.
- Removed CORS middleware as it's unnecessary behind the nginx proxy.
- Added tests to ensure functionality and security of the new token system.
2026-03-01 19:27:25 -05:00
ryan d7316bb00a feat: add chore, kindness, and penalty management components
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m34s
- Implemented ChoreAssignView for assigning chores to children.
- Created ChoreConfirmDialog for confirming chore completion.
- Developed KindnessAssignView for assigning kindness acts.
- Added PenaltyAssignView for assigning penalties.
- Introduced ChoreEditView and ChoreView for editing and viewing chores.
- Created KindnessEditView and KindnessView for managing kindness acts.
- Developed PenaltyEditView and PenaltyView for managing penalties.
- Added TaskSubNav for navigation between chores, kindness acts, and penalties.
2026-02-28 11:25:56 -05:00
ryan 65e987ceb6 feat: add delay before showing dialogs and enhance item card styles for better user feedback
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 1m11s
2026-02-27 14:05:09 -05:00
ryan f12940dc11 fix: improve formatting and readability in ScheduleModal component
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m19s
2026-02-27 10:43:10 -05:00
ryan 1777700cc8 feat: add default_has_deadline to ChoreSchedule and update related components for deadline management
Chore App Build, Test, and Push Docker Images / build-and-push (push) Has been cancelled
2026-02-27 10:42:43 -05:00
ryan f5a752d873 fix: reset ready state on outside click and prevent task trigger on ignored clicks
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m30s
2026-02-26 16:44:22 -05:00
ryan a197f8e206 feat: Refactor ScheduleModal to support interval scheduling with date input and deadline toggle
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 2m30s
- Updated ChoreSchedule model to include anchor_date and interval_has_deadline.
- Refactored interval scheduling logic in scheduleUtils to use anchor_date.
- Introduced DateInputField component for selecting anchor dates in ScheduleModal.
- Enhanced ScheduleModal to include a stepper for interval days and a toggle for deadline.
- Updated tests for ScheduleModal and scheduleUtils to reflect new interval scheduling logic.
- Added DateInputField tests to ensure proper functionality and prop handling.
2026-02-26 15:16:46 -05:00
ryan 2403daa3f7 feat: add detailed specifications for Daily chore scheduler refactor phase 2
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 57s
2026-02-25 21:06:57 -05:00
ryan 91a52c1973 Refactor Time Selector and Scheduler UI; Implement TimePickerPopover Component
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 1m5s
- Updated TimeSelector.vue styles for smaller dimensions and font sizes.
- Added new API proxy for '/events' in vite.config.ts.
- Created bug specifications for various UI issues and fixes in bugs-1.0.5-001.md and bugs-1.0.5-002.md.
- Introduced TimePickerPopover.vue for a new time selection interface in the chore scheduler.
- Refactored ScheduleModal.vue to replace checkbox rows with a chip-based design for selecting specific days.
- Enhanced chore scheduling logic to ensure proper handling of time extensions and UI updates.
2026-02-25 19:45:31 -05:00
ryan a41a357f50 feat: update Vite configuration to load environment variables for backend host
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 1m1s
2026-02-23 16:52:22 -05:00
ryan 234adbe05f Add TimeSelector and ScheduleModal components with tests
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m45s
- Implemented TimeSelector component for selecting time with AM/PM toggle and minute/hour increment/decrement functionality.
- Created ScheduleModal component for scheduling chores with options for specific days or intervals.
- Added utility functions for scheduling logic in scheduleUtils.ts.
- Developed comprehensive tests for TimeSelector and scheduleUtils functions to ensure correct behavior.
2026-02-23 15:44:55 -05:00
ryan d8822b44be feat: add child actions menu specification for ParentView
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 1m37s
2026-02-22 16:24:26 -05:00
ryan d68272bb57 feat: add feature specification for scheduling chores on calendar days
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 4m30s
2026-02-22 10:10:56 -05:00
527 changed files with 55191 additions and 3841 deletions
+54 -9
View File
@@ -44,15 +44,15 @@ jobs:
with: with:
node-version: "20.19.0" node-version: "20.19.0"
cache: "npm" cache: "npm"
cache-dependency-path: frontend/vue-app/package-lock.json cache-dependency-path: frontend/package-lock.json
- name: Install frontend dependencies - name: Install frontend dependencies
run: npm ci run: npm ci
working-directory: frontend/vue-app working-directory: frontend
- name: Run frontend unit tests - name: Run frontend unit tests
run: npm run test:unit --if-present run: npm run test:unit --if-present
working-directory: frontend/vue-app working-directory: frontend
- name: Build Backend Docker Image - name: Build Backend Docker Image
run: | run: |
@@ -60,7 +60,7 @@ jobs:
- name: Build Frontend Docker Image - name: Build Frontend Docker Image
run: | run: |
docker build -t git.ryankegel.com:3000/kegel/chores/frontend:${{ steps.vars.outputs.tag }} ./frontend/vue-app docker build -t git.ryankegel.com:3000/kegel/chores/frontend:${{ steps.vars.outputs.tag }} ./frontend
- name: Log in to Registry - name: Log in to Registry
uses: docker/login-action@v2 uses: docker/login-action@v2
@@ -119,24 +119,69 @@ jobs:
- name: Deploy Test Environment - name: Deploy Test Environment
if: gitea.ref == 'refs/heads/next' if: gitea.ref == 'refs/heads/next'
uses: appleboy/ssh-action@v1.0.3 # Or equivalent Gitea action; adjust version if needed uses: appleboy/ssh-action@v1.0.3
with: with:
host: ${{ secrets.DEPLOY_TEST_HOST }} host: ${{ secrets.DEPLOY_TEST_HOST }}
username: ${{ secrets.DEPLOY_TEST_USER }} username: ${{ secrets.DEPLOY_TEST_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }} key: ${{ secrets.SSH_PRIVATE_KEY }}
port: 22 # Default SSH port; change if different port: 22
script: | script: |
cd /tmp cd /tmp
# Pull the repository to get the latest docker-compose.dev.yml
if [ -d "chore" ]; then if [ -d "chore" ]; then
cd chore cd chore
git pull origin next || true # Pull latest changes; ignore if it fails (e.g., first run) git pull origin next || true
else else
git clone --branch next https://git.ryankegel.com/ryan/chore.git git clone --branch next https://git.ryankegel.com/ryan/chore.git
cd chore cd chore
fi fi
# Write .env file — docker-compose automatically reads this
cat > .env << EOF
SECRET_KEY=${{ secrets.SECRET_KEY }}
REFRESH_TOKEN_EXPIRY_DAYS=1
DIGEST_TOKEN_SECRET=${{ secrets.DIGEST_TOKEN_SECRET }}
VAPID_PUBLIC_KEY=${{ secrets.VAPID_PUBLIC_KEY }}
VAPID_PRIVATE_KEY=${{ secrets.VAPID_PRIVATE_KEY }}
SEED_EMAIL=${{ secrets.SEED_EMAIL }}
SEED_PASSWORD=${{ secrets.SEED_PASSWORD }}
SEED_PIN=${{ secrets.SEED_PIN }}
SEED_FIRST_NAME=${{ secrets.SEED_FIRST_NAME }}
SEED_LAST_NAME=${{ secrets.SEED_LAST_NAME }}
ADMIN_EMAIL=${{ secrets.ADMIN_EMAIL }}
ADMIN_PASSWORD=${{ secrets.ADMIN_PASSWORD }}
ADMIN_FIRST_NAME=${{ secrets.ADMIN_FIRST_NAME }}
ADMIN_LAST_NAME=${{ secrets.ADMIN_LAST_NAME }}
EOF
echo "SECRET_KEY is set: $(grep -q 'SECRET_KEY=' .env && echo YES || echo NO)"
echo "Bringing down previous test environment..." echo "Bringing down previous test environment..."
docker-compose -f docker-compose.test.yml down --volumes --remove-orphans || true docker-compose -f docker-compose.test.yml down --volumes --remove-orphans || true
echo "Starting new test environment..." echo "Starting new test environment..."
docker-compose -f docker-compose.test.yml pull # Ensure latest images are pulled docker-compose -f docker-compose.test.yml pull
docker-compose -f docker-compose.test.yml up -d docker-compose -f docker-compose.test.yml up -d
echo "Waiting for backend to be ready..."
sleep 10
echo "Seeding test user..."
docker-compose -f docker-compose.test.yml exec -T chores-test-app-backend python scripts/seed_test_user.py
echo "Creating admin user..."
docker-compose -f docker-compose.test.yml exec -T chores-test-app-backend python scripts/create_admin.py
- name: Send mail
if: always() # Runs on success or failure
uses: dawidd6/action-send-mail@v3
with:
server_address: smtp.gmail.com
server_port: 465
username: ${{ secrets.MAIL_USER }}
password: ${{ secrets.MAIL_PASSWORD }}
secure: true
to: ${{ secrets.MAIL_TO }}
from: Gitea <git@git.ryankegel.com>
subject: ${{ github.repository }} - Job ${{ job.status }}
convert_markdown: true
html_body: |
### Job ${{ job.status }}
${{ github.repository }}: [${{ github.ref }}@${{ github.sha }}](${{ vars.GIT_SERVER }}/${{ github.repository }}/actions)
+503
View File
@@ -0,0 +1,503 @@
name: Promote Master to Production
run-name: ${{ gitea.actor }} promoting ${{ github.event.inputs.target_ref || 'master' }} [${{ gitea.sha }}]
on:
workflow_dispatch:
inputs:
target_ref:
description: "Git ref/branch to promote"
required: true
default: "master"
run_backend_tests:
description: "Run backend pytest gate"
required: true
default: "true"
run_frontend_tests:
description: "Run frontend unit test gate"
required: true
default: "true"
run_playwright_tests:
description: "Run Playwright E2E gate"
required: true
default: "true"
skip_tests:
description: "Skip the entire tests stage"
required: true
default: "false"
deploy:
description: "Run production deploy over SSH"
required: true
default: "true"
create_tag:
description: "Create and push git release tag after successful deploy"
required: true
default: "true"
require_manual_approval:
description: "Require approval token for deploy job"
required: true
default: "false"
approval_token:
description: "Approval token value when manual approval is required"
required: false
default: ""
rollback_on_failed_healthcheck:
description: "Auto-rollback if post-deploy health checks fail"
required: true
default: "false"
concurrency:
group: promotion-production
cancel-in-progress: false
jobs:
prepare:
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
target_ref: ${{ steps.resolve.outputs.target_ref }}
version: ${{ steps.version.outputs.version }}
release_tag: ${{ steps.version.outputs.release_tag }}
commit_sha: ${{ steps.commit.outputs.commit_sha }}
steps:
- name: Resolve target ref
id: resolve
run: |
target_ref="${{ github.event.inputs.target_ref }}"
if [ -z "$target_ref" ]; then
target_ref="master"
fi
echo "target_ref=$target_ref" >> "$GITHUB_OUTPUT"
- name: Check out repository code
uses: actions/checkout@v3
with:
ref: ${{ steps.resolve.outputs.target_ref }}
fetch-depth: 0
- name: Resolve version and release tag
id: version
run: |
version=$(python -c "import sys; sys.path.append('./backend'); from config.version import BASE_VERSION; print(BASE_VERSION)")
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "release_tag=v$version" >> "$GITHUB_OUTPUT"
- name: Resolve promoted commit SHA
id: commit
run: |
echo "commit_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Guard against duplicate release tags
if: ${{ github.event.inputs.create_tag != 'false' }}
run: |
release_tag="${{ steps.version.outputs.release_tag }}"
if git ls-remote --exit-code --tags origin "refs/tags/${release_tag}" >/dev/null 2>&1; then
echo "Release tag ${release_tag} already exists on origin; aborting promotion."
exit 1
fi
tests:
runs-on: ubuntu-latest
timeout-minutes: 120
needs: prepare
steps:
- name: Skip tests stage
if: ${{ github.event.inputs.skip_tests == 'true' }}
run: echo "Skipping tests stage because skip_tests=true"
- name: Check out promoted ref
if: ${{ github.event.inputs.skip_tests != 'true' }}
uses: actions/checkout@v3
with:
ref: ${{ needs.prepare.outputs.target_ref }}
- name: Set up Python for backend tests
if: ${{ github.event.inputs.skip_tests != 'true' && (github.event.inputs.run_backend_tests != 'false' || github.event.inputs.run_playwright_tests != 'false') }}
uses: actions/setup-python@v4
with:
python-version: "3.11"
- name: Install backend dependencies (runner python)
if: ${{ github.event.inputs.skip_tests != 'true' && github.event.inputs.run_backend_tests != 'false' }}
run: |
python -m pip install --upgrade pip
pip install -r backend/requirements.txt
- name: Run backend unit tests
if: ${{ github.event.inputs.skip_tests != 'true' && github.event.inputs.run_backend_tests != 'false' }}
run: |
cd backend
pytest -q
- name: Set up Node.js
if: ${{ github.event.inputs.skip_tests != 'true' && (github.event.inputs.run_frontend_tests != 'false' || github.event.inputs.run_playwright_tests != 'false') }}
uses: actions/setup-node@v4
with:
node-version: "20.19.0"
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Install frontend dependencies
if: ${{ github.event.inputs.skip_tests != 'true' && (github.event.inputs.run_frontend_tests != 'false' || github.event.inputs.run_playwright_tests != 'false') }}
run: npm ci
working-directory: frontend
- name: Run frontend unit tests
if: ${{ github.event.inputs.skip_tests != 'true' && github.event.inputs.run_frontend_tests != 'false' }}
run: npm run test:unit --if-present
working-directory: frontend
- name: Create backend venv for Playwright webServer
if: ${{ github.event.inputs.skip_tests != 'true' && github.event.inputs.run_playwright_tests != 'false' }}
run: |
python -m venv backend/.venv
backend/.venv/bin/python -m pip install --upgrade pip
backend/.venv/bin/python -m pip install -r backend/requirements.txt
- name: Install Playwright browsers
if: ${{ github.event.inputs.skip_tests != 'true' && github.event.inputs.run_playwright_tests != 'false' }}
run: npx playwright install --with-deps
working-directory: frontend
- name: Run Playwright tests
if: ${{ github.event.inputs.skip_tests != 'true' && github.event.inputs.run_playwright_tests != 'false' }}
run: npx playwright test --reporter=line
working-directory: frontend
env:
CI: "true"
PLAYWRIGHT_BASE_URL: "https://localhost:5173"
E2E_ACCESS_TOKEN_EXPIRY_MINUTES: "180"
deploy:
runs-on: ubuntu-latest
timeout-minutes: 45
needs:
- prepare
- tests
if: ${{ github.event.inputs.deploy != 'false' }}
steps:
- name: Validate manual approval token
if: ${{ github.event.inputs.require_manual_approval != 'false' }}
run: |
if [ -z "${{ secrets.PROD_APPROVAL_TOKEN }}" ]; then
echo "PROD_APPROVAL_TOKEN secret is required when manual approval is enabled."
exit 1
fi
if [ -z "${{ github.event.inputs.approval_token }}" ]; then
echo "approval_token input is required when manual approval is enabled."
exit 1
fi
if [ "${{ github.event.inputs.approval_token }}" != "${{ secrets.PROD_APPROVAL_TOKEN }}" ]; then
echo "Approval token does not match PROD_APPROVAL_TOKEN."
exit 1
fi
- name: Deploy to production homeserver
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.DEPLOY_PROD_HOST }}
username: ${{ secrets.DEPLOY_PROD_USER }}
key: ${{ secrets.PROD_SSH_PRIVATE_KEY }}
port: 22
script: |
set -euo pipefail
repo_dir="${{ secrets.DEPLOY_PROD_PATH }}"
if [ -z "$repo_dir" ]; then
repo_dir="$HOME/chore"
fi
repo_parent=$(dirname "$repo_dir")
if [ ! -d "$repo_parent" ]; then
mkdir -p "$repo_parent"
fi
if [ ! -w "$repo_parent" ]; then
echo "Deploy user $(whoami) cannot write to ${repo_parent}."
echo "Set DEPLOY_PROD_PATH to a writable location or grant write access before rerunning promotion."
exit 1
fi
if [ -d "$repo_dir/.git" ]; then
cd "$repo_dir"
git fetch --all --tags
else
git clone --branch "${{ needs.prepare.outputs.target_ref }}" https://git.ryankegel.com/ryan/chore.git "$repo_dir"
cd "$repo_dir"
fi
git checkout "${{ needs.prepare.outputs.target_ref }}"
git reset --hard "origin/${{ needs.prepare.outputs.target_ref }}"
timestamp=$(date +%Y%m%d-%H%M%S)
mkdir -p backups
# Capture currently running images for rollback.
backend_prev_id=""
frontend_prev_id=""
if docker ps --format '{{.Names}}' | grep -q '^chores-app-backend-prod$'; then
backend_prev_id=$(docker inspect -f '{{.Image}}' chores-app-backend-prod)
docker tag "$backend_prev_id" git.ryankegel.com:3000/kegel/chores/backend:predeploy-backup || true
fi
if docker ps --format '{{.Names}}' | grep -q '^chores-app-frontend-prod$'; then
frontend_prev_id=$(docker inspect -f '{{.Image}}' chores-app-frontend-prod)
docker tag "$frontend_prev_id" git.ryankegel.com:3000/kegel/chores/frontend:predeploy-backup || true
fi
cat > .rollback-meta << EOF
BACKEND_PREV_ID=${backend_prev_id}
FRONTEND_PREV_ID=${frontend_prev_id}
CREATED_AT=${timestamp}
PROMOTED_SHA=${{ needs.prepare.outputs.commit_sha }}
RELEASE_TAG=${{ needs.prepare.outputs.release_tag }}
EOF
# Backup TinyDB volume before replacement.
docker run --rm \
-v chores-app-backend-data:/data \
-v "$PWD/backups:/backup" \
alpine sh -c "tar czf /backup/chores-backend-data-${timestamp}.tar.gz -C /data ." || true
# Keep long-lived cryptographic values stable; fall back to previous .env if secrets are absent.
PREV_SECRET_KEY=""
PREV_DIGEST_TOKEN_SECRET=""
PREV_VAPID_PUBLIC_KEY=""
PREV_VAPID_PRIVATE_KEY=""
PREV_REFRESH_TOKEN_EXPIRY_DAYS=""
if [ -f .env ]; then
PREV_SECRET_KEY=$(grep '^SECRET_KEY=' .env | head -n1 | cut -d '=' -f2- || true)
PREV_DIGEST_TOKEN_SECRET=$(grep '^DIGEST_TOKEN_SECRET=' .env | head -n1 | cut -d '=' -f2- || true)
PREV_VAPID_PUBLIC_KEY=$(grep '^VAPID_PUBLIC_KEY=' .env | head -n1 | cut -d '=' -f2- || true)
PREV_VAPID_PRIVATE_KEY=$(grep '^VAPID_PRIVATE_KEY=' .env | head -n1 | cut -d '=' -f2- || true)
PREV_REFRESH_TOKEN_EXPIRY_DAYS=$(grep '^REFRESH_TOKEN_EXPIRY_DAYS=' .env | head -n1 | cut -d '=' -f2- || true)
fi
SECRET_KEY_VALUE="${{ secrets.PROD_SECRET_KEY }}"
DIGEST_TOKEN_SECRET_VALUE="${{ secrets.PROD_DIGEST_TOKEN_SECRET }}"
VAPID_PUBLIC_KEY_VALUE="${{ secrets.PROD_VAPID_PUBLIC_KEY }}"
VAPID_PRIVATE_KEY_VALUE="${{ secrets.PROD_VAPID_PRIVATE_KEY }}"
REFRESH_TOKEN_EXPIRY_DAYS_VALUE="${{ secrets.PROD_REFRESH_TOKEN_EXPIRY_DAYS }}"
if [ -z "$SECRET_KEY_VALUE" ]; then
if [ -n "$PREV_SECRET_KEY" ]; then
SECRET_KEY_VALUE="$PREV_SECRET_KEY"
else
SECRET_KEY_VALUE=$(openssl rand -hex 32)
fi
fi
if [ -z "$DIGEST_TOKEN_SECRET_VALUE" ]; then
if [ -n "$PREV_DIGEST_TOKEN_SECRET" ]; then
DIGEST_TOKEN_SECRET_VALUE="$PREV_DIGEST_TOKEN_SECRET"
else
DIGEST_TOKEN_SECRET_VALUE=$(openssl rand -hex 32)
fi
fi
if [ -z "$VAPID_PUBLIC_KEY_VALUE" ] && [ -n "$PREV_VAPID_PUBLIC_KEY" ]; then
VAPID_PUBLIC_KEY_VALUE="$PREV_VAPID_PUBLIC_KEY"
fi
if [ -z "$VAPID_PRIVATE_KEY_VALUE" ] && [ -n "$PREV_VAPID_PRIVATE_KEY" ]; then
VAPID_PRIVATE_KEY_VALUE="$PREV_VAPID_PRIVATE_KEY"
fi
if [ -z "$VAPID_PUBLIC_KEY_VALUE" ] || [ -z "$VAPID_PRIVATE_KEY_VALUE" ]; then
echo "VAPID keys are required (set PROD_VAPID_PUBLIC_KEY/PROD_VAPID_PRIVATE_KEY or keep previous .env values)."
exit 1
fi
if [ -z "$REFRESH_TOKEN_EXPIRY_DAYS_VALUE" ]; then
if [ -n "$PREV_REFRESH_TOKEN_EXPIRY_DAYS" ]; then
REFRESH_TOKEN_EXPIRY_DAYS_VALUE="$PREV_REFRESH_TOKEN_EXPIRY_DAYS"
else
REFRESH_TOKEN_EXPIRY_DAYS_VALUE="90"
fi
fi
if ! printf '%s' "$REFRESH_TOKEN_EXPIRY_DAYS_VALUE" | grep -Eq '^[0-9]+$'; then
echo "REFRESH_TOKEN_EXPIRY_DAYS must be a positive integer, got: $REFRESH_TOKEN_EXPIRY_DAYS_VALUE"
exit 1
fi
cat > .env << EOF
FRONTEND_URL=${{ secrets.PROD_FRONTEND_URL }}
BACKEND_HOST_PORT=${{ secrets.PROD_BACKEND_HOST_PORT }}
FRONTEND_HOST_PORT=${{ secrets.PROD_FRONTEND_HOST_PORT }}
SECRET_KEY=${SECRET_KEY_VALUE}
REFRESH_TOKEN_EXPIRY_DAYS=${REFRESH_TOKEN_EXPIRY_DAYS_VALUE}
DIGEST_TOKEN_SECRET=${DIGEST_TOKEN_SECRET_VALUE}
VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY_VALUE}
VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY_VALUE}
ADMIN_EMAIL=${{ secrets.PROD_ADMIN_EMAIL }}
ADMIN_PASSWORD=${{ secrets.PROD_ADMIN_PASSWORD }}
ADMIN_FIRST_NAME=${{ secrets.PROD_ADMIN_FIRST_NAME }}
ADMIN_LAST_NAME=${{ secrets.PROD_ADMIN_LAST_NAME }}
EOF
chmod 600 .env .rollback-meta
backend_host_port=$(grep '^BACKEND_HOST_PORT=' .env | head -n1 | cut -d '=' -f2- || true)
frontend_host_port=$(grep '^FRONTEND_HOST_PORT=' .env | head -n1 | cut -d '=' -f2- || true)
if [ -z "$backend_host_port" ]; then
backend_host_port="5001"
fi
if [ -z "$frontend_host_port" ]; then
frontend_host_port="4601"
fi
docker-compose down
wait_for_container_removal() {
container_name="$1"
attempts=0
while docker ps -a --format '{{.Names}}' | grep -qx "$container_name"; do
attempts=$((attempts + 1))
if [ "$attempts" -ge 12 ]; then
echo "Container ${container_name} still exists after waiting for teardown."
docker ps -a --filter "name=^${container_name}$"
return 1
fi
echo "Waiting for ${container_name} to be removed..."
sleep 5
done
}
wait_for_port_release() {
port="$1"
attempts=0
while docker ps -a --format '{{.ID}} {{.Names}} {{.Ports}} {{.Status}}' | grep -E "(^|[[:space:]])0\.0\.0\.0:${port}->|:::${port}->" >/dev/null 2>&1; do
attempts=$((attempts + 1))
if [ "$attempts" -ge 12 ]; then
echo "Port ${port} is still allocated after teardown. Blocking container(s):"
docker ps -a --format '{{.ID}} {{.Names}} {{.Ports}} {{.Status}}' | grep -E "(^|[[:space:]])0\.0\.0\.0:${port}->|:::${port}->" || true
return 1
fi
echo "Waiting for port ${port} to be released..."
sleep 5
done
}
wait_for_container_removal chores-app-frontend-prod
wait_for_container_removal chores-app-backend-prod
wait_for_port_release "$frontend_host_port"
wait_for_port_release "$backend_host_port"
docker-compose pull
docker-compose up -d
sleep 20
backend_running=$(docker inspect -f '{{.State.Running}}' chores-app-backend-prod 2>/dev/null || echo false)
frontend_running=$(docker inspect -f '{{.State.Running}}' chores-app-frontend-prod 2>/dev/null || echo false)
backend_ok=false
if curl -fsS "http://localhost:${backend_host_port}/version" >/dev/null 2>&1; then
backend_ok=true
fi
frontend_ok=false
if curl -fsS "http://localhost:${frontend_host_port}/" >/dev/null 2>&1; then
frontend_ok=true
fi
if [ "$backend_running" != "true" ] || [ "$frontend_running" != "true" ] || [ "$backend_ok" != "true" ] || [ "$frontend_ok" != "true" ]; then
echo "Post-deploy health check failed."
docker-compose ps
echo "--- Backend logs (last 200 lines) ---"
docker logs --tail 200 chores-app-backend-prod || true
echo "--- Frontend logs (last 120 lines) ---"
docker logs --tail 120 chores-app-frontend-prod || true
if [ "${{ github.event.inputs.rollback_on_failed_healthcheck }}" = "true" ]; then
echo "Attempting rollback using predeploy-backup image tags..."
docker tag git.ryankegel.com:3000/kegel/chores/backend:predeploy-backup git.ryankegel.com:3000/kegel/chores/backend:latest || true
docker tag git.ryankegel.com:3000/kegel/chores/frontend:predeploy-backup git.ryankegel.com:3000/kegel/chores/frontend:latest || true
docker-compose up -d
sleep 20
curl -fsS "http://localhost:${backend_host_port}/version" >/dev/null
fi
exit 1
fi
docker-compose ps
create-release-tag:
runs-on: ubuntu-latest
timeout-minutes: 10
needs:
- prepare
- tests
- deploy
if: ${{ github.event.inputs.create_tag != 'false' && github.event.inputs.deploy != 'false' }}
steps:
- name: Check out promoted ref
uses: actions/checkout@v3
with:
ref: ${{ needs.prepare.outputs.target_ref }}
fetch-depth: 0
- name: Create and push annotated release tag
run: |
tag_name="${{ needs.prepare.outputs.release_tag }}"
git config user.name "gitea-actions"
git config user.email "gitea-actions@local"
git tag -a "$tag_name" -m "Release $tag_name"
git push origin "$tag_name"
notify-on-failure:
runs-on: ubuntu-latest
needs:
- prepare
- tests
- deploy
- create-release-tag
if: ${{ always() }}
steps:
- name: Send email when promotion fails
if: ${{ needs.prepare.result == 'failure' || needs.tests.result == 'failure' || needs.deploy.result == 'failure' || needs.create-release-tag.result == 'failure' }}
uses: dawidd6/action-send-mail@v3
with:
server_address: smtp.gmail.com
server_port: 465
username: ${{ secrets.MAIL_USER }}
password: ${{ secrets.MAIL_PASSWORD }}
secure: true
to: ${{ secrets.MAIL_TO }}
from: Gitea <git@git.ryankegel.com>
subject: Promotion failed - ${{ gitea.repository }} [${{ needs.prepare.outputs.target_ref }}@${{ needs.prepare.outputs.commit_sha }}]
convert_markdown: true
html_body: |
### Production promotion failed
- Repository: ${{ gitea.repository }}
- Ref: ${{ needs.prepare.outputs.target_ref }}
- Commit: ${{ needs.prepare.outputs.commit_sha }}
- Intended tag: ${{ needs.prepare.outputs.release_tag }}
- prepare: ${{ needs.prepare.result }}
- tests: ${{ needs.tests.result }}
- deploy: ${{ needs.deploy.result }}
- tag: ${{ needs.create-release-tag.result }}
- name: Send email when promotion succeeds
if: ${{ needs.prepare.result == 'success' && needs.tests.result == 'success' && (needs.deploy.result == 'success' || needs.deploy.result == 'skipped') && (needs.create-release-tag.result == 'success' || needs.create-release-tag.result == 'skipped') }}
uses: dawidd6/action-send-mail@v3
with:
server_address: smtp.gmail.com
server_port: 465
username: ${{ secrets.MAIL_USER }}
password: ${{ secrets.MAIL_PASSWORD }}
secure: true
to: ${{ secrets.MAIL_TO }}
from: Gitea <git@git.ryankegel.com>
subject: Promotion succeeded - ${{ gitea.repository }} [${{ needs.prepare.outputs.target_ref }}@${{ needs.prepare.outputs.commit_sha }}]
convert_markdown: true
html_body: |
### Production promotion succeeded
- Repository: ${{ gitea.repository }}
- Ref: ${{ needs.prepare.outputs.target_ref }}
- Commit: ${{ needs.prepare.outputs.commit_sha }}
- Release tag: ${{ needs.prepare.outputs.release_tag }}
- prepare: ${{ needs.prepare.result }}
- tests: ${{ needs.tests.result }}
- deploy: ${{ needs.deploy.result }}
- tag: ${{ needs.create-release-tag.result }}
@@ -0,0 +1,29 @@
---
name: playwright-implementation
description: Converts plans into code and performs self-healing verification.
tools: [execute, read, edit, search, "playwright-test/*"]
---
# Role: Senior QA Automation Engineer
You are a Playwright expert. Your goal is to create robust, flake-free E2E tests.
# Test Implementation & Healing Workflow
When you receive a test plan:
1. **Implement**: Generate the `.spec.ts` files in `/tests` using standard Playwright patterns.
2. **Verify**: Once files are written, execute the following command in the terminal:
`npx playwright test --agent=healer`
3. **Analyze & Repair**:
- If the Healer Agent proposes a patch, review it.
- If the test still fails after healing, check the **Flask backend logs** to see if it's an API error rather than a UI error.
4. **Final Check**: Only mark the task as "Complete" once `npx playwright test` returns a clean pass.
## Rules of Engagement
1. **Locators:** Prioritize `getByRole`, `getByLabel`, and `getByText`. Avoid CSS selectors unless necessary.
2. **Page Objects:** Always use the Page Object Model (POM). Check `tests/pages/` for existing objects before creating new ones.
3. **Environment:** The app runs at `https://localhost:5173` (HTTPS — self-signed cert). The backend runs at `http://localhost:5000`.
4. **Authentication:** Auth is handled globally via `storageState`. Do NOT navigate to `/auth/login` in any test — you are already logged in. Never hardcode credentials; import `E2E_EMAIL` and `E2E_PASSWORD` from `tests/global-setup.ts` if needed.
@@ -0,0 +1,30 @@
---
name: playwright-research
description: Scans codebase and explores URLs to create Playwright test plans.
#argument-hint: The inputs this agent expects, e.g., "a task to implement" or "a question to answer".
tools: ["read", "search", "playwright/*", "web"]
handoffs:
- label: Start Implementation
agent: playwright-implementation
prompt: Implement the test plan
send: true
# tools: ['vscode', 'execute', 'read', 'agent', 'edit', 'search', 'web', 'todo'] # specify the tools this agent can use. If not set, all enabled tools are allowed.
---
# Test Architect Persona
You are a Senior QA Architect. Your goal is to analyze the user's codebase and
live application to identify high-value test cases.
### Your Workflow:
1. **Scan**: Use `read` and `search` to understand existing project structure and components.
2. **Explore**: Use `playwright/navigate` and `playwright/screenshot` to explore the live UI.
3. **Analyze**: Identify edge cases, happy paths, and critical user journeys.
4. **Present**: Output a structured Markdown Test Plan.
### Hard Constraints:
- **DO NOT** write any `.spec.ts` or `.js` files.
- **DO NOT** modify existing code.
- **ONLY** present the plan and wait for feedback.
@@ -0,0 +1,93 @@
---
name: playwright-test-generator
description: 'Use this agent when you need to create automated browser tests using Playwright Examples: <example>Context: User wants to generate a test for the test plan item. <test-suite><!-- Verbatim name of the test spec group w/o ordinal like "Multiplication tests" --></test-suite> <test-name><!-- Name of the test case without the ordinal like "should add two numbers" --></test-name> <test-file><!-- Name of the file to save the test into, like tests/multiplication/should-add-two-numbers.spec.ts --></test-file> <seed-file><!-- Seed file path from test plan --></seed-file> <body><!-- Test case content including steps and expectations --></body></example>'
tools:
- search
- playwright-test/browser_click
- playwright-test/browser_drag
- playwright-test/browser_evaluate
- playwright-test/browser_file_upload
- playwright-test/browser_handle_dialog
- playwright-test/browser_hover
- playwright-test/browser_navigate
- playwright-test/browser_press_key
- playwright-test/browser_select_option
- playwright-test/browser_snapshot
- playwright-test/browser_type
- playwright-test/browser_verify_element_visible
- playwright-test/browser_verify_list_visible
- playwright-test/browser_verify_text_visible
- playwright-test/browser_verify_value
- playwright-test/browser_wait_for
- playwright-test/generator_read_log
- playwright-test/generator_setup_page
- playwright-test/generator_write_test
model: Claude Sonnet 4.6
mcp-servers:
playwright-test:
type: stdio
command: npx
args:
- playwright
- run-test-mcp-server
tools:
- "*"
---
You are a Playwright Test Generator, an expert in browser automation and end-to-end testing.
Your specialty is creating robust, reliable Playwright tests that accurately simulate user interactions and validate
application behavior.
# For each test you generate
- Obtain the test plan with all the steps and verification specification
- Run the `generator_setup_page` tool to set up page for the scenario
- For each step and verification in the scenario, do the following:
- Use Playwright tool to manually execute it in real-time.
- Use the step description as the intent for each Playwright tool call.
- Retrieve generator log via `generator_read_log`
- Immediately after reading the test log, invoke `generator_write_test` with the generated source code
- File should contain single test
- File name must be fs-friendly scenario name
- Test must be placed in a describe matching the top-level test plan item
- Test title must match the scenario name
- Includes a comment with the step text before each step execution. Do not duplicate comments if step requires
multiple actions.
- Always use best practices from the log when generating tests.
<example-generation>
For following plan:
```markdown file=specs/plan.md
### 1. Adding New Todos
**Seed:** `tests/seed.spec.ts`
#### 1.1 Add Valid Todo
**Steps:**
1. Click in the "What needs to be done?" input field
#### 1.2 Add Multiple Todos
...
```
Following file is generated:
```ts file=add-valid-todo.spec.ts
// spec: specs/plan.md
// seed: tests/seed.spec.ts
test.describe('Adding New Todos', () => {
test('Add Valid Todo', async { page } => {
// 1. Click in the "What needs to be done?" input field
await page.click(...);
...
});
});
```
</example-generation>
@@ -0,0 +1,63 @@
---
name: playwright-test-healer
description: Use this agent when you need to debug and fix failing Playwright tests
tools:
- search
- edit
- playwright-test/browser_console_messages
- playwright-test/browser_evaluate
- playwright-test/browser_generate_locator
- playwright-test/browser_network_requests
- playwright-test/browser_snapshot
- playwright-test/test_debug
- playwright-test/test_list
- playwright-test/test_run
model: Claude Sonnet 4
mcp-servers:
playwright-test:
type: stdio
command: npx
args:
- playwright
- run-test-mcp-server
tools:
- "*"
---
You are the Playwright Test Healer, an expert test automation engineer specializing in debugging and
resolving Playwright test failures. Your mission is to systematically identify, diagnose, and fix
broken Playwright tests using a methodical approach.
Your workflow:
1. **Initial Execution**: Run all tests using `test_run` tool to identify failing tests
2. **Debug failed tests**: For each failing test run `test_debug`.
3. **Error Investigation**: When the test pauses on errors, use available Playwright MCP tools to:
- Examine the error details
- Capture page snapshot to understand the context
- Analyze selectors, timing issues, or assertion failures
4. **Root Cause Analysis**: Determine the underlying cause of the failure by examining:
- Element selectors that may have changed
- Timing and synchronization issues
- Data dependencies or test environment problems
- Application changes that broke test assumptions
5. **Code Remediation**: Edit the test code to address identified issues, focusing on:
- Updating selectors to match current application state
- Fixing assertions and expected values
- Improving test reliability and maintainability
- For inherently dynamic data, utilize regular expressions to produce resilient locators
6. **Verification**: Restart the test after each fix to validate the changes
7. **Iteration**: Repeat the investigation and fixing process until the test passes cleanly
Key principles:
- Be systematic and thorough in your debugging approach
- Document your findings and reasoning for each fix
- Prefer robust, maintainable solutions over quick hacks
- Use Playwright best practices for reliable test automation
- If multiple errors exist, fix them one at a time and retest
- Provide clear explanations of what was broken and how you fixed it
- You will continue this process until the test runs successfully without any failures or errors.
- If the error persists and you have high level of confidence that the test is correct, mark this test as test.fixme()
so that it is skipped during the execution. Add a comment before the failing step explaining what is happening instead
of the expected behavior.
- Do not ask user questions, you are not interactive tool, do the most reasonable thing possible to pass the test.
- Never wait for networkidle or use other discouraged or deprecated apis
@@ -0,0 +1,82 @@
---
name: playwright-test-planner
description: Use this agent when you need to create comprehensive test plan for a web application or website
tools:
- search
- playwright-test/browser_click
- playwright-test/browser_close
- playwright-test/browser_console_messages
- playwright-test/browser_drag
- playwright-test/browser_evaluate
- playwright-test/browser_file_upload
- playwright-test/browser_handle_dialog
- playwright-test/browser_hover
- playwright-test/browser_navigate
- playwright-test/browser_navigate_back
- playwright-test/browser_network_requests
- playwright-test/browser_press_key
- playwright-test/browser_run_code
- playwright-test/browser_select_option
- playwright-test/browser_snapshot
- playwright-test/browser_take_screenshot
- playwright-test/browser_type
- playwright-test/browser_wait_for
- playwright-test/planner_setup_page
- playwright-test/planner_save_plan
model: Claude Sonnet 4.6
mcp-servers:
playwright-test:
type: stdio
command: npx
args:
- playwright
- run-test-mcp-server
tools:
- "*"
---
You are an expert web test planner with extensive experience in quality assurance, user experience testing, and test
scenario design. Your expertise includes functional testing, edge case identification, and comprehensive test coverage
planning.
You will:
1. **Navigate and Explore**
- Invoke the `planner_setup_page` tool once to set up page before using any other tools
- Explore the browser snapshot
- Do not take screenshots unless absolutely necessary
- Use `browser_*` tools to navigate and discover interface
- Thoroughly explore the interface, identifying all interactive elements, forms, navigation paths, and functionality
2. **Analyze User Flows**
- Map out the primary user journeys and identify critical paths through the application
- Consider different user types and their typical behaviors
3. **Design Comprehensive Scenarios**
Create detailed test scenarios that cover:
- Happy path scenarios (normal user behavior)
- Edge cases and boundary conditions
- Error handling and validation
4. **Structure Test Plans**
Each scenario must include:
- Clear, descriptive title
- Detailed step-by-step instructions
- Expected outcomes where appropriate
- Assumptions about starting state (always assume blank/fresh state)
- Success criteria and failure conditions
5. **Create Documentation**
Submit your test plan using `planner_save_plan` tool.
**Quality Standards**:
- Write steps that are specific enough for any tester to follow
- Include negative testing scenarios
- Ensure scenarios are independent and can be run in any order
**Output Format**: Always save the complete test plan as a markdown file with clear headings, numbered steps, and
professional formatting suitable for sharing with development and QA teams.
+25
View File
@@ -0,0 +1,25 @@
---
name: playwright
description: Expert in end-to-end testing using Playwright and TypeScript.
tools: [runCommands, readFile, editFiles, fetchWebpage, codebase, findTestFiles]
---
# Role: Senior QA Automation Engineer
You are a Playwright expert. Your goal is to create robust, flake-free E2E tests.
## Rules of Engagement
1. **Locators:** Prioritize `getByRole`, `getByLabel`, and `getByText`. Avoid CSS selectors unless necessary.
2. **Page Objects:** Always use the Page Object Model (POM). Check `tests/pages/` for existing objects before creating new ones.
3. **Execution:** After writing a test, run it using `npx playwright test` from `frontend/vue-app/`. If it fails, read the trace and fix the test immediately.
4. **Authentication:** Auth is handled globally via `storageState`. Do NOT navigate to `/auth/login` in any test — you are already logged in. Never hardcode credentials; import `E2E_EMAIL` and `E2E_PASSWORD` from `tests/global-setup.ts` if needed.
5. **Test Naming:** Test files must match the pattern `*.smoke.spec.ts` to be picked up by the `smoke` project in `playwright.config.ts`.
## Workflow
- **Step 1:** Read the relevant source code or component file.
- **Step 2:** Generate a plan for the test steps.
- **Step 3:** Create/Update Page Objects in `tests/pages/`.
- **Step 4:** Write the test file in `tests/` using the `*.smoke.spec.ts` naming convention.
- **Step 5:** Run the test and verify success.
+25
View File
@@ -0,0 +1,25 @@
---
name: playwright
description: Expert in end-to-end testing using Playwright and TypeScript.
---
# Role: Senior QA Automation Engineer
You are a Playwright expert. Your goal is to create robust, flake-free E2E tests.
## Rules of Engagement
1. **Locators:** Prioritize `getByRole`, `getByLabel`, and `getByText`. Avoid CSS selectors unless necessary.
2. **Page Objects:** Always use the Page Object Model (POM). Check `tests/pages/` for existing objects before creating new ones.
3. **Execution:** After writing a test, run it using `npx playwright test` from `frontend/vue-app/`. If it fails, read the trace and fix the test immediately.
4. **Environment:** The app runs at `https://localhost:5173` (HTTPS — self-signed cert). The backend runs at `http://localhost:5000`. Both must be running before tests execute. Use the `flask-backend` skill (sets `DB_ENV=e2e DATA_ENV=e2e`) and `vue-frontend` skill to start them.
5. **Authentication:** Auth is handled globally via `storageState`. Do NOT navigate to `/auth/login` in any test — you are already logged in. Never hardcode credentials; import `E2E_EMAIL` and `E2E_PASSWORD` from `tests/global-setup.ts` if needed.
6. **Test Naming:** Test files must match the pattern `*.smoke.spec.ts` to be picked up by the `smoke` project in `playwright.config.ts`.
## Workflow
- **Step 1:** Read the relevant source code or component file.
- **Step 2:** Generate a plan for the test steps.
- **Step 3:** Create/Update Page Objects in `tests/pages/`.
- **Step 4:** Write the test file in `tests/` using the `*.smoke.spec.ts` naming convention.
- **Step 5:** Run the test and verify success.
+73
View File
@@ -0,0 +1,73 @@
---
name: ui-planner
description: Expert UI/UX design review and creative alternative layouts for Vue 3 components. Use when asking for design critiques, layout ideas, visual polish, animation suggestions, or CSS improvements on existing .vue files.
argument-hint: review this design
tools:
- read
- search
- edit
- vscode
- web
- todo
---
You are a Senior UI/UX Architect specializing in clean, highly reactive web applications. Your goal is to provide expert design critiques and creative layout alternatives that are technically compatible with Vue 3 and this project's conventions.
## 🏗 Project Context
This is the **Reward** app — a family chore/reward tracker. Vue 3 (Composition API / `<script setup lang="ts">`) frontend. Key files:
- Theme tokens: `frontend/src/assets/colors.css`**always read this first** when reviewing a component.
- Layout wrappers: `ParentLayout` (admin views) and `ChildLayout` (child dashboard views).
- All `.vue` files use `<style scoped>`. Child component styling uses `:deep()` selectors.
- File order within `.vue` files: `<template>`, then `<script>`, then `<style>`.
### Available Design Tokens (from `colors.css`)
**Brand:** `--primary` (#667eea), `--secondary` (#7257b3), `--accent` (#cbd5e1)
**Header:** `--header-bg` (gradient primary → secondary)
**Buttons:** `--btn-primary`, `--btn-primary-hover`, `--btn-secondary`, `--btn-secondary-hover`, `--btn-secondary-text`, `--btn-danger`, `--btn-danger-hover`, `--btn-green`, `--btn-green-hover`
**List items:** `--list-item-bg`, `--list-item-bg-good` (#8dabfd), `--list-item-bg-bad` (#f98a8a), `--list-item-bg-reward` (#4ed271), `--list-item-border-good`, `--list-item-border-bad`, `--list-item-border-reward`
**Forms:** `--form-bg`, `--form-shadow`, `--form-heading`, `--form-label`, `--form-input-bg`, `--form-input-border`
**Modals/Cards:** `--modal-bg`, `--modal-shadow`, `--card-bg`, `--card-shadow`, `--card-title`
**Feedback:** `--error`, `--error-bg`, `--loading-color`, `--list-loading-color`
**FAB:** `--fab-bg`, `--fab-hover-bg`, `--fab-active-bg`
## 🛠 Technical Constraints
- **No utility frameworks.** No Tailwind, no Bootstrap.
- **Layout:** Flexbox and CSS Grid only.
- **Animations:** Vue `<Transition>` / `<TransitionGroup>` for state changes; `@keyframes` for loaders.
- **Theming:** Only use `var(--token)` from `colors.css` for any color, shadow, or spacing token. Never hardcode hex values.
- **Component-first:** Consider how changes affect child component slots and reusability.
---
## Workflow
When a component is shared, always **read the file** plus `colors.css` before responding.
### Step 1 — Design Audit
Critique the current UI concisely:
1. **Visual Hierarchy:** Does the most important information catch the eye first?
2. **State Clarity:** Are loading / empty / pending / success / error states visually distinct?
3. **Consistency:** Does spacing, typography, and color usage feel cohesive with the rest of the app?
### Step 2 — Creative Exploration
Offer a range of design directions — not a fixed number, but varied **perspectives**:
- **Refinement:** Polish what exists for better clarity or polish.
- **Layout Shift:** Reorganize spatial relationships of elements.
- **Visual Metaphor:** New ways to represent status (icons vs. text vs. color-coding vs. progress indicators).
- **Interactivity:** Transitions, micro-animations, or hover states that make the UI feel alive.
### Step 3 — Implementation Spec
For any direction the user wants to pursue, produce a complete spec:
- **Template:** Semantic HTML using existing project components and slot patterns.
- **CSS:** Complete `<style scoped>` rules using only `var(--token)` design tokens.
- **Logic:** TypeScript / Vue reactivity changes needed (computed props, refs, transitions).
+9 -8
View File
@@ -5,7 +5,7 @@
- **Stack**: Flask (Python, backend) + Vue 3 (TypeScript, frontend) + TinyDB (JSON, thread-safe, see `db/`). - **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. - **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`. - **Nginx Proxy**: Frontend nginx proxies `/api/*` to backend, stripping the `/api` prefix. Backend endpoints should NOT include `/api` in their route definitions. Example: Backend defines `@app.route('/user')`, frontend calls `/api/user`.
- **Models**: Maintain strict 1:1 mapping between Python `@dataclass`es (`backend/models/`) and TypeScript interfaces (`frontend/vue-app/src/common/models.ts`). - **Models**: Maintain strict 1:1 mapping between Python `@dataclass`es (`backend/models/`) and TypeScript interfaces (`frontend/src/common/models.ts`).
- **Database**: Use TinyDB with `from_dict()`/`to_dict()` for serialization. All logic should operate on model instances, not raw dicts. - **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/`). - **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. - **Changes**: Do not use comments to replace code. All changes must be reflected in both backend and frontend files as needed.
@@ -25,7 +25,7 @@
## 🚦 Frontend Logic & Event Bus ## 🚦 Frontend Logic & Event Bus
- **SSE Event Management**: Register listeners in `onMounted`, clean up in `onUnmounted`. Listen for events like `child_task_triggered`, `child_reward_request`, `task_modified`, etc. See `frontend/vue-app/src/common/backendEvents.ts` and `components/BackendEventsListener.vue`. - **SSE Event Management**: Register listeners in `onMounted`, clean up in `onUnmounted`. Listen for events like `child_task_triggered`, `child_reward_request`, `task_modified`, etc. See `frontend/src/common/backendEvents.ts` and `components/BackendEventsListener.vue`.
- **Layout Hierarchy**: Use `ParentLayout` for admin/management, `ChildLayout` for dashboard/focus views. - **Layout Hierarchy**: Use `ParentLayout` for admin/management, `ChildLayout` for dashboard/focus views.
## ⚖️ Business Logic & Safeguards ## ⚖️ Business Logic & Safeguards
@@ -37,8 +37,9 @@
- **Backend**: Run Flask with `python -m flask run --host=0.0.0.0 --port=5000` from the `backend/` directory. Main entry: `backend/main.py`. - **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/`. - **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`. - **Frontend**: From `frontend/`, run `npm install` then `npm run dev`.
- **Tests**: Run backend tests with `pytest` in `backend/tests/`. Frontend component tests: `npm run test` in `frontend/vue-app/components/__tests__/`. - **Tests**: Run backend tests with `pytest` in `backend/tests/`. Frontend component tests: `npm run test` in `frontend/components/__tests__/`. E2E tests: `npx playwright test` from `frontend/` — requires both servers running (use the `flask-backend` and `vue-frontend` skills).
- **E2E Setup**: Playwright config is at `frontend/playwright.config.ts`. Tests live in `frontend/tests/`. The `globalSetup` in `playwright.config.ts` seeds the database and logs in once; all tests receive a pre-authenticated session via `storageState` — do NOT navigate to `/auth/login` in tests. Import `E2E_EMAIL` and `E2E_PASSWORD` from `tests/global-setup.ts` rather than hardcoding credentials. The backend must be started with `DB_ENV=e2e DATA_ENV=e2e` (the `flask-backend` skill does this) so test data goes to `backend/test_data/` and never touches production data.
- **Debugging**: Use VS Code launch configs or run Flask/Vue dev servers directly. For SSE, use browser dev tools to inspect event streams. - **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 ## 📁 Key Files & Directories
@@ -47,10 +48,10 @@
- `backend/models/` — Python dataclasses (business logic, serialization) - `backend/models/` — Python dataclasses (business logic, serialization)
- `backend/db/` — TinyDB setup and helpers - `backend/db/` — TinyDB setup and helpers
- `backend/events/` — SSE event types, broadcaster, payloads - `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 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/src/common/models.ts` — TypeScript interfaces (mirror Python models)
- `frontend/vue-app/src/common/api.ts` — API helpers, error parsing, validation - `frontend/src/common/api.ts` — API helpers, error parsing, validation
- `frontend/vue-app/src/common/backendEvents.ts` — SSE event types and handlers - `frontend/src/common/backendEvents.ts` — SSE event types and handlers
## 🧠 Integration & Cross-Component Patterns ## 🧠 Integration & Cross-Component Patterns
+24
View File
@@ -0,0 +1,24 @@
---
name: flask-backend
description: Starts the Flask backend using the local virtual environment.
disable-model-invocation: true
---
# Instructions
1. **Locate Environment:** Check for a virtual environment folder (usually `.venv` or `venv`) inside the `/backend` directory.
2. **Activation Logic:**
- If on **Windows**: Use `backend\.venv\Scripts\activate`
- If on **macOS/Linux**: Use `source backend/.venv/bin/activate`
3. **Set Environment Variables:**
- `FLASK_APP`: `main.py`
- `FLASK_DEBUG`: `1`
- `DB_ENV`: `e2e`
- `DATA_ENV`: `e2e`
- `SECRET_KEY`: `dev-secret-key-change-in-production`
- `REFRESH_TOKEN_EXPIRY_DAYS`: `90`
4. **Command:** Execute the following via the `terminal` tool:
`flask run --host=0.0.0.0 --port=5000 --no-debugger --no-reload`
5. **Execution:** Run `python -m flask run --host=0.0.0.0 --port=5000`
_Note: Using `python -m flask` ensures the version inside the venv is used._
6. **Verification:** After running, check the terminal output for "Running on http://0.0.0.0:5000". If it fails, check if port 5000 is already in use.
@@ -0,0 +1,12 @@
---
name: playwright-best-practices
description: Enforces stable, maintainable, and high-performance Playwright test code.
---
# Playwright Best Practices
When generating or refactoring tests, you must adhere to these standards:
1. **User-Visible Locators**: Prioritize `page.getByRole()`, `page.getByText()`, and `page.getByLabel()`. Never use fragile CSS selectors (e.g., `.btn-primary`) or XPath unless no other option exists.
2. **Web-First Assertions**: Use the `expect(locator).to...` pattern (e.g., `toBeVisible()`, `toHaveText()`) to leverage Playwright's built-in auto-waiting and retry logic.
3. **Test Isolation**: Every test must be independent. Use `test.beforeEach` for setup (like navigation or login) rather than relying on the state of a previous test.
4. **Avoid Logic in Tests**: Keep tests declarative. Use Page Object Models (POM) if the logic for finding an element requires more than one line of code.
5. **Network Reliability**: Mock third-party APIs using `page.route()` to prevent external flakiness.
@@ -0,0 +1,27 @@
---
name: playwright-default
description: Provides Playwright test generation and analysis for E2E testing.
---
# Role: Senior QA Automation Engineer
You are a Playwright expert. Your goal is to create robust, flake-free E2E tests.
# Test Implementation & Healing Workflow
When you receive a test plan:
1. **Implement**: Generate the `.spec.ts` files in `/tests` using standard Playwright patterns.
2. **Verify**: Once files are written, execute the following command in the terminal:
`npx playwright test`
3. **Analyze & Repair**:
- If the playwright-healer skill proposes a patch, review it.
- If the test still fails after healing, check the **Flask backend logs** to see if it's an API error rather than a UI error.
4. **Final Check**: Only mark the task as "Complete" once `npx playwright test` returns a clean pass.
## Rules of Engagement
1. **Locators:** Prioritize `getByRole`, `getByLabel`, and `getByText`. Avoid CSS selectors unless necessary.
2. **Page Objects:** Always use the Page Object Model (POM). Check `tests/pages/` for existing objects before creating new ones.
3. **Environment:** The app runs at `https://localhost:5173` (HTTPS — self-signed cert). The backend runs at `http://localhost:5000`.
4. **Authentication:** Auth is handled globally via `storageState`. Do NOT navigate to `/auth/login` in any test — you are already logged in. Never hardcode credentials; import `E2E_EMAIL` and `E2E_PASSWORD` from `tests/global-setup.ts` if needed.
+14
View File
@@ -0,0 +1,14 @@
---
name: playwright-healer
description: Analyzes failing Playwright tests and suggests automated fixes based on UI changes.
---
# Playwright Self-Healing Instructions
When a user asks to "fix" or "heal" a test:
1. **Analyze the Trace**: Use the Playwright MCP or CLI to open the latest trace file in `.playwright-cli/traces/`.
2. **Compare Snapshots**: If a locator failed, take a fresh `snapshot` of the page. Identify if the element moved, changed its ARIA role, or had its text updated.
3. **Propose the Patch**:
- If the UI changed, suggest the updated locator.
- If it's a timing issue, suggest adding an `expect(locator).toBeVisible()` wait.
- If it's a data issue, check the mock definitions.
4. **Verify**: Run the patched test once before presenting the final code to the user.
@@ -0,0 +1,11 @@
---
name: playwright-smoke-gen
description: Generates high-level smoke tests by exploring a running web application.
---
# Playwright Smoke Test Instructions
When this skill is active, follow these rules:
1. **Explore First**: Use the Playwright MCP `snapshot` tool to understand the page structure before writing code.
2. **Web-First Assertions**: Always use `expect(locator).toBeVisible()` or `toBeEnabled()`.
3. **Naming Convention**: Save tests in `tests/smoke/[feature].spec.ts`.
4. **Setup/Teardown**: Use `test.beforeEach` for repeated actions like navigating to the base URL.
5. **No Hardcoded Secrets**: If a login is required, use `process.env.TEST_USER` placeholders.
@@ -0,0 +1,12 @@
---
name: playwright-visual-reg
description: Generates and manages visual regression snapshots for UI consistency.
---
# Playwright Visual Regression Standards
When creating visual tests:
1. **Standard Assertion**: Use `await expect(page).toHaveScreenshot('name.png');`.
2. **Masking**: Automatically mask dynamic content (dates, usernames, or random IDs) using the `mask` option:
`await expect(page).toHaveScreenshot({ mask: [page.locator('.dynamic-id')] });`
3. **Consistency**: Set `animations: 'disabled'` and `timezoneId: 'UTC'` in the generated test metadata to prevent false positives.
4. **Update Strategy**: Instruct the user to run `npx playwright test --update-snapshots` if they intentionally changed the UI.
+21
View File
@@ -0,0 +1,21 @@
---
name: vue-frontend
description: Starts the Vue development server for the frontend application.
disable-model-invocation: true
---
# Instructions
Use this skill when the user wants to "start the frontend," "run vue," or "launch the dev server."
1. **Verify Directory:** Navigate to `./frontend`.
- _Self-Correction:_ If the directory doesn't exist, search the workspace for `package.json` files and ask for clarification.
2. **Check Dependencies:** - Before running, check if `node_modules` exists in `./frontend`.
- If missing, ask the user: "Should I run `npm install` first?"
3. **Execution:** - Run the command: `npm run dev`
- This script is configured in `package.json` to start the Vite/Vue dev server.
4. **Success Criteria:** - Monitor the terminal output for a local URL (typically `http://localhost:5173` or similar).
- Once the server is "Ready," notify the user.
+34
View File
@@ -0,0 +1,34 @@
name: "Copilot Setup Steps"
on:
workflow_dispatch:
push:
paths:
- .github/workflows/copilot-setup-steps.yml
pull_request:
paths:
- .github/workflows/copilot-setup-steps.yml
jobs:
copilot-setup-steps:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: lts/*
- name: Install dependencies
run: npm ci
- name: Install Playwright Browsers
run: npx playwright install --with-deps
# Customize this step as needed
- name: Build application
run: npx run build
+13 -8
View File
@@ -1,9 +1,14 @@
.env .env
backend/test_data/db/children.json backend/test_data/
backend/test_data/db/images.json logs/
backend/test_data/db/pending_rewards.json frontend/resources/
backend/test_data/db/rewards.json frontend/playwright-report/
backend/test_data/db/tasks.json frontend/test-results/
backend/test_data/db/users.json backend/test-results/
logs/account_deletion.log .vscode/keybindings.json
backend/test_data/db/tracking_events.json .DS_Store
**/.DS_Store
frontend/cert.pem
frontend/key.pem
tmp/
backend/.env*
+98
View File
@@ -0,0 +1,98 @@
---
name: architect
description: "Defines system requirements, data contracts, and architectural blueprints."
mode: subagent
model: "deepseek/deepseek-v4-pro"
thinking: "enabled"
permission:
edit: allow
bash: deny
---
You are the Lead Systems Architect. You are responsible for ensuring all subagents work from a shared technical specification. Understand the codebase deeply, identify and ask about underspecified details, design elegant architectures
## Core Responsibilities
- **Specification:** Create and maintain `specs/` markdown files for new features.
- **Clarity:** Understand before acting — Read and comprehend existing code patterns first.
- **Contracts:** Define API payload shapes (JSON schemas), Python type hints, and Vue prop interfaces before any code is written.
- **Decision Log:** Maintain a `decisions.md` file to track _why_ certain architectural choices were made (e.g., why you chose a specific Vue state management pattern).
## Working discipline
These bias toward caution over speed — use judgment on trivial tasks.
- **Think before acting** — state assumptions; if the request has more than one reading, surface them instead of silently choosing; if a simpler path exists, say so.
- **Simplicity first** — the minimum that solves the problem; no speculative features, abstractions, configurability, or handling of impossible cases.
- **Surgical changes** — touch only what the task needs; do not refactor or restyle adjacent code; match existing style; clean up only the orphans your change created, and mention unrelated dead code rather than deleting it.
- **Goal-driven** — turn the task into a concrete success check and iterate until it passes.
## Phase 1: Discovery
Goal: Understand what needs to be built.
1. Create a todo list covering all seven phases.
2. If the feature is unclear, ask the user:
- What problem are they solving?
- What should the feature do?
- Any constraints or requirements?
3. Summarize your understanding and confirm with the user before proceeding.
## Phase 2: Codebase exploration
Goal: Understand relevant existing code at both high and low levels.
1. Dispatch 23 `code-explorer` sub-tasks in parallel. Each should:
- Trace through the code comprehensively, focusing on abstractions, architecture, and control flow.
- Target a different aspect (similar features, high-level architecture, UX, extension points).
- Return a list of 510 key files to read.
2. After they return, read every file they identified to build deep understanding.
3. Present a comprehensive summary of findings and patterns to the user.
## Phase 3: Clarifying questions
Goal: Fill gaps and resolve ambiguities before designing.
**This is one of the most important phases. Do not skip.**
1. Review the codebase findings and the original feature request.
2. Identify underspecified aspects: edge cases, error handling, integration points, scope boundaries, design preferences, backward compatibility, performance.
3. Present all questions to the user as a clear, organized list.
4. **Wait for answers** before moving to architecture.
If the user says "whatever you think is best", make your recommendation explicit and get confirmation.
## Phase 4: Architecture design
Goal: Design multiple implementation approaches with different trade-offs.
1. Dispatch 23 `code-architect` sub-tasks in parallel, each with a different focus:
- **Minimal changes** — smallest diff, maximum reuse of existing code.
- **Clean architecture** — maintainability, elegant abstractions.
- **Pragmatic balance** — speed plus quality.
2. Review all approaches and form an opinion on which fits best for this task. Consider scope (small fix vs. large feature), urgency, complexity, and team context.
3. Present to the user: a brief summary of each approach, a trade-offs comparison, your recommendation with reasoning, and concrete differences in implementation.
4. **Ask the user which approach they prefer.**
## Phase 5: Create Spec
Goal: Build the spec.
**Do not start without explicit user approval.**
1. Wait for approval.
2. Re-read all relevant files identified earlier.
3. Spec following the chosen architecture. We are not writing code, just the specification.
4. Strictly follow codebase conventions (naming, style, error-handling patterns).
5. Update todos as you progress.
## Phase 6: Summary
Goal: Document what was accomplished.
1. Mark all todos complete.
2. Save spec to specs/[feature-name].md
3. Summarize:
- What was built
- Key decisions made
- Files modified
- Suggest running the @feature-pipeline skill to begin implementation
+28
View File
@@ -0,0 +1,28 @@
---
name: Developer
description: Implements core application features across Python backends and Vue frontends.
mode: subagent
model: moonshotai/kimi-k2.7-code
temperature: 0.2
maxSteps: 50
permission:
edit: allow
bash: allow
options:
reasoningEffort: high
thinking:
type: enabled
---
You are an expert full-stack developer focused on building clean, modular features.
### Technical Stack Focus
- **Backend:** Python. Prioritize clean architecture, explicit type hinting, and robust error/exception handling.
- **Frontend:** Vue 3. Utilize the Composition API, structured reactive state management, and semantic components.
### Core Instructions
1. Maintain a strong separation of concerns between business logic and the transport layer.
2. Match the established formatting, design tokens, and architectural conventions of the existing codebase.
3. Avoid pulling in heavy external dependencies when clean native implementations are straightforward.
+29
View File
@@ -0,0 +1,29 @@
---
name: reviewer
description: "Performs read-only code reviews, security audits, and architectural soundness checks on Python/Vue code."
mode: "subagent"
model: deepseek/deepseek-v4-pro
temperature: 0.2
maxSteps: 50
permission:
edit: deny
bash: allow
options:
reasoningEffort: max
thinking:
type: enabled
---
You are a specialized code reviewer subagent.
### Strict Constraints
- **Read-Only Context:** Your role is to analyze, critique, and guide. Do not use `write`, `edit`, or patch tools to modify the workspace files directly.
### Audit Focus Areas
- Ensure asynchronous tasks in your Vue components balance resource utilization correctly.
- Catch containerization bottlenecks or environment sync gaps in Docker configurations.
- Verify strict typing boundaries between backend Python data models and frontend components.
Provide feedback by explicitly noting the file, logical block, and detected issue.
+32
View File
@@ -0,0 +1,32 @@
---
name: tester
description: "Holistic QA: Manages unit, integration, and writes and auto-repairs E2E test suites."
mode: "subagent"
model: "moonshotai/kimi-k2.7-code"
permission:
edit: allow
bash:
"pytest *": "allow"
"npx playwright *": "allow"
"playwright-cli *": "allow"
"npm *": "ask"
---
You are a comprehensive QA Engineer. You own the quality of the entire repository.
## Operational Directives
- **Unit Testing:** Audit the Developer's unit tests. If you identify missing coverage for edge cases, write the additional unit tests yourself.
- **Front End Testing:** Audit the Developers frontend tests. If you identify missing coverage for edge cases, write the additional unit tests yourself.
- **E2E Ownership:** Author and maintain all Playwright E2E suites using the playwright-cli skills. Prioritize user-facing locators (`getByRole`, `getByLabel`).
- **Gatekeeping:** Before any task is considered "Done," run the full suite (unit + E2E). If a test fails, you own the investigation.
- **Verification:** When a failure occurs, do not just notify. Trace the stack trace, identify the breaking commit or configuration change, and suggest a fix.
## Autonomous Repair Protocol
When executing tests (especially `playwright`):
1. **Analyze Failure:** If a test fails, do not just report. Parse the stack trace, identifying if the error is a `locator` issue, a `timeout` issue, or a `logic` error.
2. **The "Application Bug" Check:** If the failure indicates that the _application code_ is incorrect (rather than the test locator), **STOP**. Ask the user if you should fix the application logic or if the test is wrong.
3. **Looping:** You have permission to fix the test (e.g., update a locator) and re-run.
4. **Safety Limit:** Do not run more than 3 repair attempts per test file. If it fails 3 times, stop, output the logs, and ask for help.
+24
View File
@@ -0,0 +1,24 @@
---
description: "Drafts and updates technical documentation, architecture guides, and API specs."
mode: "subagent"
model: "deepseek/deepseek/deepseek-v4-flash"
permission:
edit: allow
bash: deny
options:
thinking:
type: disabled
---
You are a technical writer who communicates complex software architectures with pinpoint precision.
### Deliverables
- Clear, architectural READMEs, system setup guides, and internal team runbooks.
- Clean API documentation maps outlining payload shapes, status codes, and endpoint routing.
### Style Guide
1. Keep prose technical, precise, and highly scannable.
2. Avoid generic corporate or marketing phrases. Lead with the technical details immediately.
3. Maximize the use of Markdown tables, bulleted structural lists, and code blocks for readability.
+58
View File
@@ -0,0 +1,58 @@
---
description: Design a feature architecture by analyzing existing codebase patterns and conventions, then provide a comprehensive implementation blueprint with specific files to create or modify, component designs, data flows, and a build sequence. Use this skill when the user asks for an architecture design, an implementation plan for a non-trivial feature, or when dispatched as a sub-task during feature-dev architecture phase.
---
# Code Architect
You are a senior software architect who delivers comprehensive, actionable architecture blueprints by deeply understanding codebases and making confident architectural decisions.
## Working discipline
These bias toward caution over speed — use judgment on trivial tasks.
- **Think before acting** — state assumptions; if the request has more than one reading, surface them instead of silently choosing; if a simpler path exists, say so.
- **Simplicity first** — the minimum that solves the problem; no speculative features, abstractions, configurability, or handling of impossible cases.
- **Surgical changes** — touch only what the task needs; do not refactor or restyle adjacent code; match existing style; clean up only the orphans your change created, and mention unrelated dead code rather than deleting it.
- **Goal-driven** — turn the task into a concrete success check and iterate until it passes.
## Core process
### 1. Codebase pattern analysis
Extract existing patterns, conventions, and architectural decisions. Identify:
- The technology stack
- Module boundaries and abstraction layers
- Project guidelines (`CLAUDE.md` / `AGENTS.md`)
- Similar features already implemented — how were they structured?
- Key abstractions the codebase already provides
### 2. Architecture design
Based on patterns found, design the complete feature architecture:
- Make decisive choices. Pick one approach and commit to it.
- Ensure seamless integration with existing code.
- Design for testability, performance, and maintainability.
### 3. Complete implementation blueprint
Specify every file to create or modify, component responsibilities, integration points, and data flow. Break the implementation into clear phases.
## Output
Deliver a decisive, complete architecture blueprint. Include:
- **Patterns & conventions found** — list existing patterns with `file:line` references, similar features, and key abstractions to leverage.
- **Architecture decision** — your chosen approach with rationale and trade-offs.
- **Component design** — each component with its file path, responsibilities, dependencies, and interfaces.
- **Implementation map** — specific files to create or modify, with detailed change descriptions.
- **Data flow** — complete flow from entry points through transformations to outputs.
- **Build sequence** — phased implementation steps as a checklist.
- **Critical details** — error handling, state management, testing approach, performance, security.
Make confident architectural choices. Be specific and actionable: provide file paths, function names, and concrete steps. Avoid presenting multiple equally-weighted options unless the user specifically asked for trade-off analysis.
---
**User arguments:** $ARGUMENTS
+58
View File
@@ -0,0 +1,58 @@
---
description: Deeply analyze an existing codebase feature by tracing execution paths, mapping architecture layers, understanding patterns and abstractions, and documenting dependencies. Use this skill when you need to understand how a feature works before modifying or extending it, when dispatched as a sub-task during feature-dev exploration, or when the user asks "how does X work in this codebase".
---
# Code Explorer
You are an expert code analyst specializing in tracing and understanding feature implementations across codebases.
## Core mission
Provide a complete understanding of how a specific feature works by tracing its implementation from entry points to data storage, through all abstraction layers.
## Analysis approach
### 1. Feature discovery
- Find entry points: APIs, UI components, CLI commands.
- Locate core implementation files.
- Map feature boundaries and configuration surface.
### 2. Code-flow tracing
- Follow call chains from entry to output.
- Trace data transformations at each step.
- Identify all dependencies and integrations.
- Document state changes and side effects.
### 3. Architecture analysis
- Map abstraction layers: presentation → business logic → data.
- Identify design patterns and architectural decisions.
- Document interfaces between components.
- Note cross-cutting concerns: auth, logging, caching, observability.
### 4. Implementation details
- Key algorithms and data structures.
- Error handling and edge cases.
- Performance considerations.
- Technical debt or improvement areas.
## Output
Deliver a comprehensive analysis that helps developers understand the feature deeply enough to modify or extend it. Always include:
- **Entry points** with `file:line` references
- **Step-by-step execution flow** with data transformations
- **Key components** and their responsibilities
- **Architecture insights** — patterns, layers, design decisions
- **Dependencies** — internal and external
- **Observations** about strengths, issues, or opportunities
- **Essential files list** — the files a developer absolutely must read to understand this topic
Structure the response for maximum clarity and usefulness. Always cite specific file paths and line numbers.
---
**User arguments:** $ARGUMENTS
+27
View File
@@ -0,0 +1,27 @@
---
name: bugfix
description: "Executes a bugfix pipeline on one or more gitea issues: Developer -> Tester -> Reviewer"
---
## What I do
I orchestrate a sequential bugfix and verification pipeline - I will retrieve issues(s) from Gitea (title, body, images, comments, etc...). I will then forward information from the issues to the respective subagents.
Use gitea-mcp-server to interact with Gitea. Verify that the server is running and accessible.
If an issue is not provided, ask the user for the issue number(s).
1. **Developer**: Provides a code fix for each issue.
2. **Reviewer**: Audits the code and architectural soundness.
3. **Tester**: Runs tests related to the bugfix and determines if new unit tests, integration tests, or end-to-end tests are needed. If so, implement. Verify by running the test suite.
## Execution Rules
- Stop and ask the user for clarification if a step fails or is ambiguous.
- Use the `@` mention to trigger the respective subagents sequentially.
- Pass the context from the previous stage to the next stage to ensure consistency.
- Use multiple subagents to handle different aspects of the bugfix process if it will help.
## When to use me
Invoke me when you are ready to fix a Gitea issue or multiple issues.
+22
View File
@@ -0,0 +1,22 @@
---
name: e2e-repair
description: "Runs playwright tests, captures errors, and triggers auto-repair."
---
## Logic
1. Execute: `npx playwright test [test_file]`
2. If Success:
- Report success.
- Exit.
3. If Failure:
- Capture output.
- Pass logs to @tester agent.
- @tester analyzes error and edits file.
- Repeat until success or max_retries reached.
## Safety Guardrails
- Make use of playwright-cli skills for test execution and repair.
- Max Retries: 3 per file.
- If the error persists after 3 retries, report: "Repair exhausted: Please review logs."
@@ -0,0 +1,23 @@
---
name: feature-pipeline
description: "Executes the full dev-to-docs pipeline: Developer -> Tester -> Reviewer -> Writer."
---
## What I do
I orchestrate a sequential feature implementation and verification pipeline:
1. **Developer**: Implements the feature based on the spec.
2. **Reviewer**: Audits the code and architectural soundness.
3. **Tester**: Runs full unit/E2E test suites; repairs failures if found.
4. **Writer**: Updates README and API docs based on verified code.
## Execution Rules
- Stop and ask the user for clarification if a step fails or is ambiguous.
- Use the `@` mention to trigger the respective subagents sequentially.
- Pass the context from the previous stage to the next stage to ensure consistency.
## When to use me
Invoke me when you are ready to begin a new feature or when the Architect has finished a specification.
+420
View File
@@ -0,0 +1,420 @@
---
name: playwright-cli
description: Automate browser interactions, test web pages and work with Playwright tests.
allowed-tools: Bash(playwright-cli:*) Bash(npx:*) Bash(npm:*)
---
# Browser Automation with playwright-cli
## Quick start
```bash
# open new browser
playwright-cli open
# navigate to a page
playwright-cli goto https://playwright.dev
# interact with the page using refs from the snapshot
playwright-cli click e15
playwright-cli type "page.click"
playwright-cli press Enter
# take a screenshot (rarely used, as snapshot is more common)
playwright-cli screenshot
# close the browser
playwright-cli close
```
## Commands
### Core
```bash
playwright-cli open
# open and navigate right away
playwright-cli open https://example.com/
playwright-cli goto https://playwright.dev
playwright-cli type "search query"
playwright-cli click e3
playwright-cli dblclick e7
# --submit presses Enter after filling the element
playwright-cli fill e5 "user@example.com" --submit
playwright-cli drag e2 e8
# drop files or data onto an element (from outside the page)
playwright-cli drop e4 --path=./image.png
playwright-cli drop e4 --data="text/plain=hello world"
playwright-cli hover e4
playwright-cli select e9 "option-value"
playwright-cli upload ./document.pdf
playwright-cli check e12
playwright-cli uncheck e12
playwright-cli snapshot
# search the snapshot for text or a regexp, returns matching nodes with surrounding context
playwright-cli find "Sign in"
playwright-cli find --regex "Sign (in|up)"
# wrap the regexp in slashes to add flags, e.g. /i for case-insensitive
playwright-cli find --regex "/sign (in|up)/i"
playwright-cli eval "document.title"
playwright-cli eval "el => el.textContent" e5
# get element id, class, or any attribute not visible in the snapshot
playwright-cli eval "el => el.id" e5
playwright-cli eval "el => el.getAttribute('data-testid')" e5
playwright-cli dialog-accept
playwright-cli dialog-accept "confirmation text"
playwright-cli dialog-dismiss
playwright-cli resize 1920 1080
playwright-cli close
```
### Navigation
```bash
playwright-cli go-back
playwright-cli go-forward
playwright-cli reload
```
### Keyboard
```bash
playwright-cli press Enter
playwright-cli press ArrowDown
playwright-cli keydown Shift
playwright-cli keyup Shift
```
### Mouse
```bash
playwright-cli mousemove 150 300
playwright-cli mousedown
playwright-cli mousedown right
playwright-cli mouseup
playwright-cli mouseup right
playwright-cli mousewheel 0 100
```
### Save as
```bash
playwright-cli screenshot
playwright-cli screenshot e5
playwright-cli screenshot --filename=page.png
playwright-cli screenshot --hires
playwright-cli pdf --filename=page.pdf
```
### Tabs
```bash
playwright-cli tab-list
playwright-cli tab-new
playwright-cli tab-new https://example.com/page
playwright-cli tab-close
playwright-cli tab-close 2
playwright-cli tab-select 0
```
### Storage
```bash
playwright-cli state-save
playwright-cli state-save auth.json
playwright-cli state-load auth.json
# Cookies
playwright-cli cookie-list
playwright-cli cookie-list --domain=example.com
playwright-cli cookie-get session_id
playwright-cli cookie-set session_id abc123
playwright-cli cookie-set session_id abc123 --domain=example.com --httpOnly --secure
playwright-cli cookie-delete session_id
playwright-cli cookie-clear
# LocalStorage
playwright-cli localstorage-list
playwright-cli localstorage-get theme
playwright-cli localstorage-set theme dark
playwright-cli localstorage-delete theme
playwright-cli localstorage-clear
# SessionStorage
playwright-cli sessionstorage-list
playwright-cli sessionstorage-get step
playwright-cli sessionstorage-set step 3
playwright-cli sessionstorage-delete step
playwright-cli sessionstorage-clear
```
### Network
```bash
playwright-cli route "**/*.jpg" --status=404
playwright-cli route "https://api.example.com/**" --body='{"mock": true}'
playwright-cli route-list
playwright-cli unroute "**/*.jpg"
playwright-cli unroute
```
### DevTools
```bash
playwright-cli console
playwright-cli console warning
playwright-cli requests
playwright-cli request 5
playwright-cli run-code "async page => await page.context().grantPermissions(['geolocation'])"
playwright-cli run-code --filename=script.js
playwright-cli tracing-start
playwright-cli tracing-stop
playwright-cli video-start video.webm
playwright-cli video-chapter "Chapter Title" --description="Details" --duration=2000
playwright-cli video-stop
# annotate each subsequent action (click, type, ...) with a callout naming the action and highlighting the target
playwright-cli video-show-actions --duration=600 --position=top-right
playwright-cli video-hide-actions
# launch the dashboard for UI review / design feedback — user annotates the page, you receive the annotated screenshot, snapshot, and notes
playwright-cli show --annotate
# generate a Playwright locator for an element from its ref or selector
playwright-cli generate-locator e5 --raw
# show a persistent highlight overlay for an element, optionally with a custom style
playwright-cli highlight e5
playwright-cli highlight e5 --style="outline: 3px dashed red"
# hide a single element highlight, or all page highlights when no target is given
playwright-cli highlight e5 --hide
playwright-cli highlight --hide
```
## Raw output
The global `--raw` option strips page status, generated code, and snapshot sections from the output, returning only the result value. Use it to pipe command output into other tools. Commands that don't produce output return nothing.
```bash
playwright-cli --raw eval "JSON.stringify(performance.timing)" | jq '.loadEventEnd - .navigationStart'
playwright-cli --raw eval "JSON.stringify([...document.querySelectorAll('a')].map(a => a.href))" > links.json
playwright-cli --raw snapshot > before.yml
playwright-cli click e5
playwright-cli --raw snapshot > after.yml
diff before.yml after.yml
TOKEN=$(playwright-cli --raw cookie-get session_id)
playwright-cli --raw localstorage-get theme
```
For structured output wrapping every reply as JSON, pass --json
```bash
playwright-cli list --json
```
## Open parameters
```bash
# Use specific browser when creating session
playwright-cli open --browser=chrome
playwright-cli open --browser=firefox
playwright-cli open --browser=webkit
playwright-cli open --browser=msedge
# Emulate a generic mobile device (Pixel 10 for Chromium, iPhone 17 for WebKit).
# Prefer this when a mobile layout is acceptable: mobile pages are usually
# lighter, so snapshots are smaller and cheaper.
playwright-cli open --mobile
playwright-cli open --device="iPhone 15"
# Use persistent profile (by default profile is in-memory)
playwright-cli open --persistent
# Use persistent profile with custom directory
playwright-cli open --profile=/path/to/profile
# Connect to browser via Playwright Extension
playwright-cli attach --extension=chrome
# Connect to a running Chrome or Edge by channel name
playwright-cli attach --cdp=chrome
playwright-cli attach --cdp=msedge
# Connect to a running browser via CDP endpoint
playwright-cli attach --cdp=http://localhost:9222
# Start with config file
playwright-cli open --config=my-config.json
# Close the browser
playwright-cli close
# Detach from an attached browser (leaves the external browser running)
playwright-cli -s=msedge detach
# Delete user data for the default session
playwright-cli delete-data
```
## URLs with `&` on Windows
On Windows, `cmd.exe` and PowerShell treat `&` as a command separator, so URLs with multiple query parameters get truncated before `playwright-cli` runs. Escape `&` with `^&` in `cmd.exe`, or use `--%` in PowerShell:
```batch
playwright-cli goto "https://example.com/?a=1^&b=2"
```
```powershell
playwright-cli --% goto "https://example.com/?a=1&b=2"
```
## Snapshots
After each command, playwright-cli provides a snapshot of the current browser state.
```bash
> playwright-cli goto https://example.com
### Page
- Page URL: https://example.com/
- Page Title: Example Domain
### Snapshot
[Snapshot](.playwright-cli/page-2026-02-14T19-22-42-679Z.yml)
```
You can also take a snapshot on demand using `playwright-cli snapshot` command. All the options below can be combined as needed.
```bash
# default - save to a file with timestamp-based name
playwright-cli snapshot
# save to file, use when snapshot is a part of the workflow result
playwright-cli snapshot --filename=after-click.yaml
# snapshot an element instead of the whole page
playwright-cli snapshot "#main"
# limit snapshot depth for efficiency, take a partial snapshot afterwards
playwright-cli snapshot --depth=4
playwright-cli snapshot e34
# include each element's bounding box as [box=x,y,width,height]
playwright-cli snapshot --boxes
# search a large snapshot instead of capturing it all — returns matching nodes
# with 3 lines of context around each match (like grep -C)
playwright-cli find "Add to cart"
playwright-cli find --regex "\\$[0-9]+\\.[0-9]{2}"
```
## Targeting elements
By default, use refs from the snapshot to interact with page elements.
```bash
# get snapshot with refs
playwright-cli snapshot
# interact using a ref
playwright-cli click e15
```
You can also use css selectors or Playwright locators.
```bash
# css selector
playwright-cli click "#main > button.submit"
# role locator
playwright-cli click "getByRole('button', { name: 'Submit' })"
# test id
playwright-cli click "getByTestId('submit-button')"
```
## Browser Sessions
```bash
# create new browser session named "mysession" with persistent profile
playwright-cli -s=mysession open example.com --persistent
# same with manually specified profile directory (use when requested explicitly)
playwright-cli -s=mysession open example.com --profile=/path/to/profile
playwright-cli -s=mysession click e6
playwright-cli -s=mysession close # stop a named browser
playwright-cli -s=mysession delete-data # delete user data for persistent session
playwright-cli list
# Close all browsers
playwright-cli close-all
# Forcefully kill all browser processes
playwright-cli kill-all
```
## Installation
If global `playwright-cli` command is not available, try a local version via `npx playwright cli`:
```bash
npx --no-install playwright --version
```
When local version is available, use `npx playwright cli` in all commands. Otherwise, install `playwright-cli` as a global command:
```bash
npm install -g @playwright/cli@latest
```
## Example: Form submission
```bash
playwright-cli open https://example.com/form
playwright-cli snapshot
playwright-cli fill e1 "user@example.com"
playwright-cli fill e2 "password123"
playwright-cli click e3
playwright-cli snapshot
playwright-cli close
```
## Example: Multi-tab workflow
```bash
playwright-cli open https://example.com
playwright-cli tab-new https://example.com/other
playwright-cli tab-list
playwright-cli tab-select 0
playwright-cli snapshot
playwright-cli close
```
## Example: Debugging with DevTools
```bash
playwright-cli open https://example.com
playwright-cli click e4
playwright-cli fill e7 "test"
playwright-cli console
playwright-cli requests
playwright-cli close
```
```bash
playwright-cli open https://example.com
playwright-cli tracing-start
playwright-cli click e4
playwright-cli fill e7 "test"
playwright-cli tracing-stop
playwright-cli close
```
## Example: Interactive session
Ask the user for UI review or design feedback. The user draws boxes on the live page and types comments; you receive the annotated screenshot, the snapshot of the marked region, and the user's notes. Use this whenever the user asks for "UI review", "design feedback", or to "ask the user what they think / want / mean":
```bash
playwright-cli open https://example.com
playwright-cli show --annotate
```
## Specific tasks
* **Running and Debugging Playwright tests** [references/playwright-tests.md](references/playwright-tests.md)
* **Request mocking** [references/request-mocking.md](references/request-mocking.md)
* **Running Playwright code** [references/running-code.md](references/running-code.md)
* **Browser session management** [references/session-management.md](references/session-management.md)
* **Storage state (cookies, localStorage)** [references/storage-state.md](references/storage-state.md)
* **Test generation (plan / generate / heal)** [references/test-generation.md](references/test-generation.md)
* **Tracing** [references/tracing.md](references/tracing.md)
* **Video recording** [references/video-recording.md](references/video-recording.md)
* **Inspecting element attributes** [references/element-attributes.md](references/element-attributes.md)
@@ -0,0 +1,23 @@
# Inspecting Element Attributes
When the snapshot doesn't show an element's `id`, `class`, `data-*` attributes, or other DOM properties, use `eval` to inspect them.
## Examples
```bash
playwright-cli snapshot
# snapshot shows a button as e7 but doesn't reveal its id or data attributes
# get the element's id
playwright-cli eval "el => el.id" e7
# get all CSS classes
playwright-cli eval "el => el.className" e7
# get a specific attribute
playwright-cli eval "el => el.getAttribute('data-testid')" e7
playwright-cli eval "el => el.getAttribute('aria-label')" e7
# get a computed style property
playwright-cli eval "el => getComputedStyle(el).display" e7
```
@@ -0,0 +1,39 @@
# Running Playwright Tests
To run Playwright tests, use the `npx playwright test` command, or a package manager script. To avoid opening the interactive html report, use `PLAYWRIGHT_HTML_OPEN=never` environment variable.
```bash
# Run all tests
PLAYWRIGHT_HTML_OPEN=never npx playwright test
# Run all tests through a custom npm script
PLAYWRIGHT_HTML_OPEN=never npm run special-test-command
```
# Debugging Playwright Tests
To debug a failing Playwright test, run it with `--debug=cli` option. This command will pause the test at the start and print the debugging instructions.
**IMPORTANT**: run the command in the background and check the output until "Debugging Instructions" is printed. Make sure to stop the command after you have finished.
Once instructions containing a session name are printed, use `playwright-cli` to attach the session and explore the page.
```bash
# Run the test
PLAYWRIGHT_HTML_OPEN=never npx playwright test --debug=cli
# ...
# ... debugging instructions for "tw-abcdef" session ...
# ...
# Attach to the test
playwright-cli attach tw-abcdef
```
Keep the test running in the background while you explore and look for a fix.
The test is paused at the start, so you should step over or pause at a particular location
where the problem is most likely to be.
Every action you perform with `playwright-cli` generates corresponding Playwright TypeScript code.
This code appears in the output and can be copied directly into the test. Most of the time, a specific locator or an expectation should be updated, but it could also be a bug in the app. Use your judgement.
After fixing the test, stop the background test run. Rerun to check that test passes.
@@ -0,0 +1,87 @@
# Request Mocking
Intercept, mock, modify, and block network requests.
## CLI Route Commands
```bash
# Mock with custom status
playwright-cli route "**/*.jpg" --status=404
# Mock with JSON body
playwright-cli route "**/api/users" --body='[{"id":1,"name":"Alice"}]' --content-type=application/json
# Mock with custom headers
playwright-cli route "**/api/data" --body='{"ok":true}' --header="X-Custom: value"
# Remove headers from requests
playwright-cli route "**/*" --remove-header=cookie,authorization
# List active routes
playwright-cli route-list
# Remove a route or all routes
playwright-cli unroute "**/*.jpg"
playwright-cli unroute
```
## URL Patterns
```
**/api/users - Exact path match
**/api/*/details - Wildcard in path
**/*.{png,jpg,jpeg} - Match file extensions
**/search?q=* - Match query parameters
```
## Advanced Mocking with run-code
For conditional responses, request body inspection, response modification, or delays:
### Conditional Response Based on Request
```bash
playwright-cli run-code "async page => {
await page.route('**/api/login', route => {
const body = route.request().postDataJSON();
if (body.username === 'admin') {
route.fulfill({ body: JSON.stringify({ token: 'mock-token' }) });
} else {
route.fulfill({ status: 401, body: JSON.stringify({ error: 'Invalid' }) });
}
});
}"
```
### Modify Real Response
```bash
playwright-cli run-code "async page => {
await page.route('**/api/user', async route => {
const response = await route.fetch();
const json = await response.json();
json.isPremium = true;
await route.fulfill({ response, json });
});
}"
```
### Simulate Network Failures
```bash
playwright-cli run-code "async page => {
await page.route('**/api/offline', route => route.abort('internetdisconnected'));
}"
# Options: connectionrefused, timedout, connectionreset, internetdisconnected
```
### Delayed Response
```bash
playwright-cli run-code "async page => {
await page.route('**/api/slow', async route => {
await new Promise(r => setTimeout(r, 3000));
route.fulfill({ body: JSON.stringify({ data: 'loaded' }) });
});
}"
```
@@ -0,0 +1,241 @@
# Running Custom Playwright Code
Use `run-code` to execute arbitrary Playwright code for advanced scenarios not covered by CLI commands.
## Syntax
```bash
playwright-cli run-code "async page => {
// Your Playwright code here
// Access page.context() for browser context operations
}"
```
You can also load the function from a file:
```bash
playwright-cli run-code --filename=./my-script.js
```
The code must be a single function expression, it is wrapped in `(...)` and evaluated.
import/export/require syntax is not supported.
## Geolocation
```bash
# Grant geolocation permission and set location
playwright-cli run-code "async page => {
await page.context().grantPermissions(['geolocation']);
await page.context().setGeolocation({ latitude: 37.7749, longitude: -122.4194 });
}"
# Set location to London
playwright-cli run-code "async page => {
await page.context().grantPermissions(['geolocation']);
await page.context().setGeolocation({ latitude: 51.5074, longitude: -0.1278 });
}"
# Clear geolocation override
playwright-cli run-code "async page => {
await page.context().clearPermissions();
}"
```
## Permissions
```bash
# Grant multiple permissions
playwright-cli run-code "async page => {
await page.context().grantPermissions([
'geolocation',
'notifications',
'camera',
'microphone'
]);
}"
# Grant permissions for specific origin
playwright-cli run-code "async page => {
await page.context().grantPermissions(['clipboard-read'], {
origin: 'https://example.com'
});
}"
```
## Media Emulation
```bash
# Emulate dark color scheme
playwright-cli run-code "async page => {
await page.emulateMedia({ colorScheme: 'dark' });
}"
# Emulate light color scheme
playwright-cli run-code "async page => {
await page.emulateMedia({ colorScheme: 'light' });
}"
# Emulate reduced motion
playwright-cli run-code "async page => {
await page.emulateMedia({ reducedMotion: 'reduce' });
}"
# Emulate print media
playwright-cli run-code "async page => {
await page.emulateMedia({ media: 'print' });
}"
```
## Wait Strategies
```bash
# Wait for network idle
playwright-cli run-code "async page => {
await page.waitForLoadState('networkidle');
}"
# Wait for specific element
playwright-cli run-code "async page => {
await page.locator('.loading').waitFor({ state: 'hidden' });
}"
# Wait for function to return true
playwright-cli run-code "async page => {
await page.waitForFunction(() => window.appReady === true);
}"
# Wait with timeout
playwright-cli run-code "async page => {
await page.locator('.result').waitFor({ timeout: 10000 });
}"
```
## Frames and Iframes
```bash
# Work with iframe
playwright-cli run-code "async page => {
const frame = page.locator('iframe#my-iframe').contentFrame();
await frame.locator('button').click();
}"
# Get all frames
playwright-cli run-code "async page => {
const frames = page.frames();
return frames.map(f => f.url());
}"
```
## File Downloads
```bash
# Handle file download
playwright-cli run-code "async page => {
const downloadPromise = page.waitForEvent('download');
await page.getByRole('link', { name: 'Download' }).click();
const download = await downloadPromise;
await download.saveAs('./downloaded-file.pdf');
return download.suggestedFilename();
}"
```
## Clipboard
```bash
# Read clipboard (requires permission)
playwright-cli run-code "async page => {
await page.context().grantPermissions(['clipboard-read']);
return await page.evaluate(() => navigator.clipboard.readText());
}"
# Write to clipboard
playwright-cli run-code "async page => {
await page.evaluate(text => navigator.clipboard.writeText(text), 'Hello clipboard!');
}"
```
## Page Information
```bash
# Get page title
playwright-cli run-code "async page => {
return await page.title();
}"
# Get current URL
playwright-cli run-code "async page => {
return page.url();
}"
# Get page content
playwright-cli run-code "async page => {
return await page.content();
}"
# Get viewport size
playwright-cli run-code "async page => {
return page.viewportSize();
}"
```
## JavaScript Execution
```bash
# Execute JavaScript and return result
playwright-cli run-code "async page => {
return await page.evaluate(() => {
return {
userAgent: navigator.userAgent,
language: navigator.language,
cookiesEnabled: navigator.cookieEnabled
};
});
}"
# Pass arguments to evaluate
playwright-cli run-code "async page => {
const multiplier = 5;
return await page.evaluate(m => document.querySelectorAll('li').length * m, multiplier);
}"
```
## Error Handling
```bash
# Try-catch in run-code
playwright-cli run-code "async page => {
try {
await page.getByRole('button', { name: 'Submit' }).click({ timeout: 1000 });
return 'clicked';
} catch (e) {
return 'element not found';
}
}"
```
## Complex Workflows
```bash
# Login and save state
playwright-cli run-code "async page => {
await page.goto('https://example.com/login');
await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com');
await page.getByRole('textbox', { name: 'Password' }).fill('secret');
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('**/dashboard');
await page.context().storageState({ path: 'auth.json' });
return 'Login successful';
}"
# Scrape data from multiple pages
playwright-cli run-code "async page => {
const results = [];
for (let i = 1; i <= 3; i++) {
await page.goto(\`https://example.com/page/\${i}\`);
const items = await page.locator('.item').allTextContents();
results.push(...items);
}
return results;
}"
```
@@ -0,0 +1,225 @@
# Browser Session Management
Run multiple isolated browser sessions concurrently with state persistence.
## Named Browser Sessions
Use `-s` flag to isolate browser contexts:
```bash
# Browser 1: Authentication flow
playwright-cli -s=auth open https://app.example.com/login
# Browser 2: Public browsing (separate cookies, storage)
playwright-cli -s=public open https://example.com
# Commands are isolated by browser session
playwright-cli -s=auth fill e1 "user@example.com"
playwright-cli -s=public snapshot
```
## Browser Session Isolation Properties
Each browser session has independent:
- Cookies
- LocalStorage / SessionStorage
- IndexedDB
- Cache
- Browsing history
- Open tabs
## Browser Session Commands
```bash
# List all browser sessions
playwright-cli list
# Stop a browser session (close the browser)
playwright-cli close # stop the default browser
playwright-cli -s=mysession close # stop a named browser
# Stop all browser sessions
playwright-cli close-all
# Forcefully kill all daemon processes (for stale/zombie processes)
playwright-cli kill-all
# Delete browser session user data (profile directory)
playwright-cli delete-data # delete default browser data
playwright-cli -s=mysession delete-data # delete named browser data
```
## Environment Variable
Set a default browser session name via environment variable:
```bash
export PLAYWRIGHT_CLI_SESSION="mysession"
playwright-cli open example.com # Uses "mysession" automatically
```
## Common Patterns
### Concurrent Scraping
```bash
#!/bin/bash
# Scrape multiple sites concurrently
# Start all browsers
playwright-cli -s=site1 open https://site1.com &
playwright-cli -s=site2 open https://site2.com &
playwright-cli -s=site3 open https://site3.com &
wait
# Take snapshots from each
playwright-cli -s=site1 snapshot
playwright-cli -s=site2 snapshot
playwright-cli -s=site3 snapshot
# Cleanup
playwright-cli close-all
```
### A/B Testing Sessions
```bash
# Test different user experiences
playwright-cli -s=variant-a open "https://app.com?variant=a"
playwright-cli -s=variant-b open "https://app.com?variant=b"
# Compare
playwright-cli -s=variant-a screenshot
playwright-cli -s=variant-b screenshot
```
### Persistent Profile
By default, browser profile is kept in memory only. Use `--persistent` flag on `open` to persist the browser profile to disk:
```bash
# Use persistent profile (auto-generated location)
playwright-cli open https://example.com --persistent
# Use persistent profile with custom directory
playwright-cli open https://example.com --profile=/path/to/profile
```
## Attaching to a Running Browser
Use `attach` to connect to a browser that is already running, instead of launching a new one.
### Attach by channel name
Connect to a running Chrome or Edge instance by its channel name. The browser must have remote debugging enabled — navigate to `chrome://inspect/#remote-debugging` in the target browser and check "Allow remote debugging for this browser instance".
```bash
# Attach to Chrome
playwright-cli attach --cdp=chrome
# Attach to Chrome Canary
playwright-cli attach --cdp=chrome-canary
# Attach to Microsoft Edge
playwright-cli attach --cdp=msedge
# Attach to Edge Dev
playwright-cli attach --cdp=msedge-dev
```
Supported channels: `chrome`, `chrome-beta`, `chrome-dev`, `chrome-canary`, `msedge`, `msedge-beta`, `msedge-dev`, `msedge-canary`.
When `--session` is not provided, the session is named after the channel (e.g. `--cdp=msedge` creates a session called `msedge`), so parallel attaches to Chrome and Edge don't collide on `default`. Pass `--session=<name>` to override.
### Attach via CDP endpoint
Connect to a browser that exposes a Chrome DevTools Protocol endpoint:
```bash
playwright-cli attach --cdp=http://localhost:9222
```
### Attach via browser extension
Connect to a browser with the Playwright extension installed:
```bash
playwright-cli attach --extension
```
### Detach
Tear down an attached session without affecting the external browser:
```bash
# Detach the default attached session
playwright-cli detach
# Detach a specific attached session
playwright-cli -s=msedge detach
```
`detach` only works on sessions created via `attach`. For sessions created via `open`, use `close`.
## Default Browser Session
When `-s` is omitted, commands use the default browser session:
```bash
# These use the same default browser session
playwright-cli open https://example.com
playwright-cli snapshot
playwright-cli close # Stops default browser
```
## Browser Session Configuration
Configure a browser session with specific settings when opening:
```bash
# Open with config file
playwright-cli open https://example.com --config=.playwright/my-cli.json
# Open with specific browser
playwright-cli open https://example.com --browser=firefox
# Open in headed mode
playwright-cli open https://example.com --headed
# Open with persistent profile
playwright-cli open https://example.com --persistent
```
## Best Practices
### 1. Name Browser Sessions Semantically
```bash
# GOOD: Clear purpose
playwright-cli -s=github-auth open https://github.com
playwright-cli -s=docs-scrape open https://docs.example.com
# AVOID: Generic names
playwright-cli -s=s1 open https://github.com
```
### 2. Always Clean Up
```bash
# Stop browsers when done
playwright-cli -s=auth close
playwright-cli -s=scrape close
# Or stop all at once
playwright-cli close-all
# If browsers become unresponsive or zombie processes remain
playwright-cli kill-all
```
### 3. Delete Stale Browser Data
```bash
# Remove old browser data to free disk space
playwright-cli -s=oldsession delete-data
```
@@ -0,0 +1,275 @@
# Storage Management
Manage cookies, localStorage, sessionStorage, and browser storage state.
## Storage State
Save and restore complete browser state including cookies and storage.
### Save Storage State
```bash
# Save to auto-generated filename (storage-state-{timestamp}.json)
playwright-cli state-save
# Save to specific filename
playwright-cli state-save my-auth-state.json
```
### Restore Storage State
```bash
# Load storage state from file
playwright-cli state-load my-auth-state.json
# Reload page to apply cookies
playwright-cli open https://example.com
```
### Storage State File Format
The saved file contains:
```json
{
"cookies": [
{
"name": "session_id",
"value": "abc123",
"domain": "example.com",
"path": "/",
"expires": 1893456000,
"httpOnly": true,
"secure": true,
"sameSite": "Lax"
}
],
"origins": [
{
"origin": "https://example.com",
"localStorage": [
{ "name": "theme", "value": "dark" },
{ "name": "user_id", "value": "12345" }
]
}
]
}
```
## Cookies
### List All Cookies
```bash
playwright-cli cookie-list
```
### Filter Cookies by Domain
```bash
playwright-cli cookie-list --domain=example.com
```
### Filter Cookies by Path
```bash
playwright-cli cookie-list --path=/api
```
### Get Specific Cookie
```bash
playwright-cli cookie-get session_id
```
### Set a Cookie
```bash
# Basic cookie
playwright-cli cookie-set session abc123
# Cookie with options
playwright-cli cookie-set session abc123 --domain=example.com --path=/ --httpOnly --secure --sameSite=Lax
# Cookie with expiration (Unix timestamp)
playwright-cli cookie-set remember_me token123 --expires=1893456000
```
### Delete a Cookie
```bash
playwright-cli cookie-delete session_id
```
### Clear All Cookies
```bash
playwright-cli cookie-clear
```
### Advanced: Multiple Cookies or Custom Options
For complex scenarios like adding multiple cookies at once, use `run-code`:
```bash
playwright-cli run-code "async page => {
await page.context().addCookies([
{ name: 'session_id', value: 'sess_abc123', domain: 'example.com', path: '/', httpOnly: true },
{ name: 'preferences', value: JSON.stringify({ theme: 'dark' }), domain: 'example.com', path: '/' }
]);
}"
```
## Local Storage
### List All localStorage Items
```bash
playwright-cli localstorage-list
```
### Get Single Value
```bash
playwright-cli localstorage-get token
```
### Set Value
```bash
playwright-cli localstorage-set theme dark
```
### Set JSON Value
```bash
playwright-cli localstorage-set user_settings '{"theme":"dark","language":"en"}'
```
### Delete Single Item
```bash
playwright-cli localstorage-delete token
```
### Clear All localStorage
```bash
playwright-cli localstorage-clear
```
### Advanced: Multiple Operations
For complex scenarios like setting multiple values at once, use `run-code`:
```bash
playwright-cli run-code "async page => {
await page.evaluate(() => {
localStorage.setItem('token', 'jwt_abc123');
localStorage.setItem('user_id', '12345');
localStorage.setItem('expires_at', Date.now() + 3600000);
});
}"
```
## Session Storage
### List All sessionStorage Items
```bash
playwright-cli sessionstorage-list
```
### Get Single Value
```bash
playwright-cli sessionstorage-get form_data
```
### Set Value
```bash
playwright-cli sessionstorage-set step 3
```
### Delete Single Item
```bash
playwright-cli sessionstorage-delete step
```
### Clear sessionStorage
```bash
playwright-cli sessionstorage-clear
```
## IndexedDB
### List Databases
```bash
playwright-cli run-code "async page => {
return await page.evaluate(async () => {
const databases = await indexedDB.databases();
return databases;
});
}"
```
### Delete Database
```bash
playwright-cli run-code "async page => {
await page.evaluate(() => {
indexedDB.deleteDatabase('myDatabase');
});
}"
```
## Common Patterns
### Authentication State Reuse
```bash
# Step 1: Login and save state
playwright-cli open https://app.example.com/login
playwright-cli snapshot
playwright-cli fill e1 "user@example.com"
playwright-cli fill e2 "password123"
playwright-cli click e3
# Save the authenticated state
playwright-cli state-save auth.json
# Step 2: Later, restore state and skip login
playwright-cli state-load auth.json
playwright-cli open https://app.example.com/dashboard
# Already logged in!
```
### Save and Restore Roundtrip
```bash
# Set up authentication state
playwright-cli open https://example.com
playwright-cli eval "() => { document.cookie = 'session=abc123'; localStorage.setItem('user', 'john'); }"
# Save state to file
playwright-cli state-save my-session.json
# ... later, in a new session ...
# Restore state
playwright-cli state-load my-session.json
playwright-cli open https://example.com
# Cookies and localStorage are restored!
```
## Security Notes
- Never commit storage state files containing auth tokens
- Add `*.auth-state.json` to `.gitignore`
- Delete state files after automation completes
- Use environment variables for sensitive data
- By default, sessions run in-memory mode which is safer for sensitive operations
@@ -0,0 +1,433 @@
# Test generation (plan → generate → heal)
End-to-end workflow for authoring and maintaining Playwright tests with `playwright-cli`. Every `playwright-cli` action emits the equivalent Playwright TypeScript, and that generated code is the raw material for every test. The sections below can be used independently:
- **How generation works** — the core mechanic everything else relies on: actions become TypeScript, plus how to add assertions.
- **Plan** — explore the app, produce a spec file describing what to test.
- **Generate** — turn a spec into Playwright test files. Update the spec if it's vague or stale.
- **Heal** — diagnose failing tests, fix the code, reconcile the spec with reality.
Plan / generate / heal lean on the same mechanic: run `npx playwright test --debug=cli` in the background, then `playwright-cli attach tw-XXXX` to drive the paused page interactively. See [playwright-tests.md](playwright-tests.md) for the debug/attach mechanics.
---
## 0. How generation works
Every action you perform with `playwright-cli` generates corresponding Playwright TypeScript code. This code appears in the output and can be copied directly into your test files.
```bash
# Start a session
playwright-cli open https://example.com/login
# Take a snapshot to see elements
playwright-cli snapshot
# Output shows: e1 [textbox "Email"], e2 [textbox "Password"], e3 [button "Sign In"]
# Fill form fields - generates code automatically
playwright-cli fill e1 "user@example.com"
# Ran Playwright code:
# await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com');
playwright-cli fill e2 "password123"
# Ran Playwright code:
# await page.getByRole('textbox', { name: 'Password' }).fill('password123');
playwright-cli click e3
# Ran Playwright code:
# await page.getByRole('button', { name: 'Sign In' }).click();
```
### Building a test file
Collect the generated code into a Playwright test:
```typescript
import { test, expect } from '@playwright/test';
test('login flow', async ({ page }) => {
// Generated code from playwright-cli session:
await page.goto('https://example.com/login');
await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com');
await page.getByRole('textbox', { name: 'Password' }).fill('password123');
await page.getByRole('button', { name: 'Sign In' }).click();
// Add assertions
await expect(page).toHaveURL(/.*dashboard/);
});
```
### Use semantic locators
The generated code uses role-based locators when possible, which are more resilient:
```typescript
// Generated (good - semantic)
await page.getByRole('button', { name: 'Submit' }).click();
// Avoid (fragile - CSS selectors)
await page.locator('#submit-btn').click();
```
### Explore before recording
Take snapshots to understand the page structure before recording actions:
```bash
playwright-cli open https://example.com
playwright-cli snapshot
# Review the element structure
playwright-cli click e5
```
### Add assertions manually
Generated code captures actions but not assertions. Add expectations in your test using one of the recommended matchers:
- `toBeVisible()` — element is rendered and visible
- `toHaveText(text)` — element text content matches
- `toHaveValue(value) / toBeEmpty()` — input/select value matches
- `toBeChecked() / toBeUnchecked()` — checkbox state matches
- `toMatchAriaSnapshot(snapshot)` — page (or locator) matches a partial accessibility snapshot
Use `playwright-cli generate-locator <target>` to produce the locator expression for the assertion, and the snapshot/eval commands to capture the expected value.
When asserting text content, make sure that generated locator does not contain text from the element itself. `getByTestId()` or `getByLabel()` usually work well with asserting text. When locator is text-based, prefer `toBeVisible()` instead.
Snapshot to be matched does not have to contain all the information - only capture what's necessary for the assertion. You can use regular expressions for unstable values.
```bash
# Get a stable locator for an element ref to use in the assertion
playwright-cli --raw generate-locator e5
# getByRole('button', { name: 'Submit' })
# Capture expected text content for toHaveText
playwright-cli --raw eval "el => el.textContent" e5
# Capture expected input value for toHaveValue/toBeEmpty
playwright-cli --raw eval "el => el.value" e5
# Capture expected aria snapshot for toMatchAriaSnapshot/toBeChecked
# (whole page, or use a ref to scope to a region)
playwright-cli --raw snapshot
playwright-cli --raw snapshot e5
```
```typescript
// Generated action
await page.getByRole('button', { name: 'Submit' }).click();
// Manual assertions using the outputs above:
await expect(page.getByRole('alert', { name: 'Success' })).toBeVisible();
await expect(page.getByTestId('main-header')).toHaveText('Welcome, user');
await expect(page.getByRole('textbox', { name: 'Email' })).toHaveValue('user@example.com');
await expect(page.getByRole('checkbox', { name: 'Enable notifications' })).toBeChecked();
// toMatchAriaSnapshot on the whole page, finds a matching region
await expect(page).toMatchAriaSnapshot(`
- heading "Welcome, user"
- link /\\d+ new messages?/
- button "Sign out"
`);
// toMatchAriaSnapshot scoped to a region
await expect(page.getByRole('navigation')).toMatchAriaSnapshot(`
- link "Home"
- link /\\d+ new messages?/
- link "Profile"
`);
```
---
## 1. Planning
Goal: produce a spec file (e.g. `specs/<feature>.plan.md`) that enumerates the scenarios to test. **Always** write the spec to a file.
### 1.1 Prerequisite: workspace
Check the workspace has Playwright installed before anything else:
```bash
# Either of these confirms a workspace:
test -f playwright.config.ts || test -f playwright.config.js
npx --no-install playwright --version
```
If there is no Playwright install, bootstrap one and let the user pick the defaults:
```bash
npm init playwright@latest
```
### 1.2 Prerequisite: seed test
A **seed test** is a minimal test that lands the page in the state every scenario starts from: navigation to the app, any required login, feature flags, etc. Scenarios assume a fresh start *after* the seed. `--debug=cli` pauses *inside* this test, so the seed is where every planning and generation session begins.
Minimum viable seed:
```ts
// tests/seed.spec.ts
import { test } from '@playwright/test';
test('seed', async ({ page }) => {
await page.goto('https://example.com/');
});
```
Preferred — push navigation into a fixture so scenario tests reuse it:
```ts
// tests/fixtures.ts
import { test as baseTest } from '@playwright/test';
export { expect } from '@playwright/test';
export const test = baseTest.extend({
page: async ({ page }, use) => {
await page.goto('https://example.com/');
await use(page);
},
});
```
```ts
// tests/seed.spec.ts
import { test } from './fixtures';
test('seed', async ({ page }) => {
// Fixture already navigates. This empty body tells agents where to start.
});
```
If no seed exists, create one that at least navigates to the app.
### 1.3 Explore the app
Launch the app via the seed in the background and attach:
```bash
PLAYWRIGHT_HTML_OPEN=never npx playwright test tests/seed.spec.ts --debug=cli
# wait for "Debugging Instructions" and the session name tw-XXXX
playwright-cli attach tw-XXXX
```
Resume so the seed runs, then probe the app:
```bash
playwright-cli resume # resume so that seed test runs fully
playwright-cli snapshot # inventory of interactive elements
playwright-cli click e5 # follow a flow
playwright-cli eval "location.href" # read URL / state
playwright-cli show --annotate # ask the user to point at something
```
Map out:
- Interactive surfaces (forms, buttons, lists, filters, modals).
- Primary user journeys end-to-end.
- Edge cases: empty states, validation errors, very long input, boundary values.
- Persistence: reload, local/session storage, URL fragments.
- Navigation: which controls change the URL, back/forward behaviour.
**Important**: Do not just open the app url with playwright-cli, always go through the test to capture any custom setup done there.
**Important**: Stop the background test when done exploring.
### 1.4 Write the spec file
Save under `specs/<feature>.plan.md`. Use this structure:
```markdown
# <Feature> Test Plan
## Application Overview
<One paragraph describing what the feature does and why it matters.>
## Test Scenarios
### 1. <Group Name>
**Seed:** `tests/seed.spec.ts`
#### 1.1. <kebab-case-scenario-name>
**File:** `tests/<group>/<kebab-case-scenario-name>.spec.ts`
**Steps:**
1. <Concrete user step>
- expect: <observable outcome>
- expect: <another observable outcome>
2. <Next step>
- expect: <outcome>
#### 1.2. <next-scenario>
...
### 2. <Next Group>
**Seed:** `tests/seed.spec.ts`
...
```
Guidelines:
- Each scenario is independent and starts from the seed's fresh state — never chain scenarios.
- Scenario names are kebab-case and match the test file name (`should-add-single-todo``should-add-single-todo.spec.ts`).
- Cover happy path, edge cases, validation, negative flows, persistence.
- Write steps at the user level ("Type 'Buy milk' into the input"), not the API level ("call `fill`").
- Put observable outcomes in `- expect:` bullets; each becomes an assertion during generation.
---
## 2. Generate
Goal: take a spec file and produce Playwright test files. Optionally update the spec if it has drifted.
### 2.1 Inputs
- **Spec file**, e.g. `specs/basic-operations.plan.md`.
- **Target**: either a single scenario (e.g. `1.2`), a whole group (`1`), or all.
- **Seed file**, read from the `**Seed:**` line of the scenario's group.
### 2.2 Generate one scenario
For each target scenario, in sequence (never in parallel — scenarios share the seed session):
```bash
PLAYWRIGHT_HTML_OPEN=never npx playwright test <seed-file> --debug=cli # background
playwright-cli attach tw-XXXX
# resume
```
**Do not** just open the app url with playwright-cli, always go through the test to capture any custom setup done there.
Walk the scenario's `Steps:` one by one with `playwright-cli`, treating the spec as the plan and the live app as the source of truth. If a step is vague ("click the button" — which button?), references an element that no longer exists, or contradicts the app's actual behaviour, use your judgement: update the spec to match what the app really does, then keep going. Editing the spec mid-generation is expected.
Every action prints the equivalent Playwright TypeScript (see [How generation works](#0-how-generation-works)):
```bash
playwright-cli snapshot # find refs
playwright-cli fill e3 "John Doe" # -> page.getByRole('textbox', {...}).fill(...)
playwright-cli press Enter
playwright-cli click e7
```
For each `- expect:` bullet, add an explicit assertion. See [How generation works](#0-how-generation-works) for details.
Collect the generated code and write the test file at the path given in the spec:
```ts
// spec: specs/basic-operations.plan.md
// seed: tests/seed.spec.ts
import { test, expect } from './fixtures'; // or '@playwright/test' if no fixtures file
test.describe('Signing in and out', () => {
test('should sign in', async ({ page }) => {
// 1. Navigate to the application
// (handled by the seed fixture)
// 2. Type 'John Doe' into the username field
await page.getByRole('textbox', { name: 'username' }).fill('John Doe');
// 3. Type password
await page.getByRole('textbox', { name: 'password' }).fill('TestPassword');
// 4. Press Enter to submit
await page.getByRole('textbox', { name: 'password' }).press('Enter');
await expect(page.getByRole('heading')).toContainText('Welcome, John Doe!');
});
});
```
Rules:
- **One test per file.** File path, describe name, and test name come verbatim from the spec (minus the ordinal).
- Prefix each numbered step with a `// N. <step text>` comment before its actions.
- Use the describe group name verbatim from the spec (no `1.` ordinal).
- Import from `./fixtures` if the project has one; otherwise `@playwright/test`.
- **Important**: close the CLI session and stop the background test before moving to the next scenario.
### 2.3 Generate multiple scenarios
Loop 2.2 over the targeted scenarios one at a time, restarting the seed between each so every test starts from a clean page. This is safe to parallelise due to unique generated session names - just make sure each test run is stopped.
### 2.4 Run generated tests
After generation, run the new tests once:
```bash
PLAYWRIGHT_HTML_OPEN=never npx playwright test tests/<group>/<scenario>.spec.ts
```
Any failure goes to Section 3.
---
## 3. Heal
Goal: fix failing tests, and update the spec if the app's intended behaviour changed.
### 3.1 Find failing tests
```bash
PLAYWRIGHT_HTML_OPEN=never npx playwright test
```
Record the list of failing `<file>:<line>` entries and process them one at a time. Do not attempt parallel fixes — shared state and the single CLI session make that fragile.
### 3.2 Debug one failure
Run the single failing test in debug mode in the background, then attach:
```bash
PLAYWRIGHT_HTML_OPEN=never npx playwright test tests/<group>/<scenario>.spec.ts:<line> --debug=cli
# wait for "Debugging Instructions" and the tw-XXXX session name
playwright-cli attach tw-XXXX
```
The test is paused at the start. Step forward or run to until just before the failing action or assertion, then diagnose:
```bash
playwright-cli snapshot # did the element change / move / rename?
playwright-cli console # app-side errors?
playwright-cli requests # failed request? wrong payload?
playwright-cli show --annotate # ask the user to point somewhere
```
Common causes: selector drift, new wrapper element, label/ARIA rename, timing (transition, async load), assertion text updated in the app, test data leaking between runs.
Rehearse the corrected interaction with `playwright-cli` — the generated code in the output is what you paste back into the test.
### 3.3 Apply the fix
Edit the test file: update the locator, assertion, step order, or inputs to match the corrected behaviour. Stop the background debug run. Rerun the single test to confirm green.
Never skip hooks or add sleeps as a fix. Never use `networkidle`.
### 3.4 Reconcile with the spec
Open the spec referenced by the `// spec:` header in the test file and locate the scenario that matches the test.
- **Fix was purely technical** (locator drift, better assertion shape) and the spec's user-level behaviour still matches the app → leave the spec alone.
- **Fix changed user-visible steps, inputs, order, or expected outcomes** that the spec describes → update the spec to match reality. Keep the scenario id and file path stable; only the step / expect lines change.
- **Unclear whether the app change is intentional** (spec is stale) **or a regression** (test was right, app is wrong) → **stop and ask the user**. Provide:
- the scenario id (e.g. `2.3`),
- the spec lines that no longer match,
- the observed app behaviour (quote a snapshot excerpt or a concrete outcome).
Only after the user answers, either update the spec (intentional change) or file/flag the test as covering a bug (regression).
### 3.5 Iteration and giving up
- Fix failures one at a time; rerun after each.
- If after thorough investigation you are confident the test is correct but the app is wrong *and* the user has confirmed it's a bug: mark the test `test.fixme(...)` with a comment pointing at the user's decision or issue link. Never silently skip.
---
## Cross-references
| For... | See |
|---|---|
| `--debug=cli` / attach mechanics | [playwright-tests.md](playwright-tests.md) |
| Mocking requests during exploration/generation | [request-mocking.md](request-mocking.md) |
| Managing the CLI browser session | [session-management.md](session-management.md) |
@@ -0,0 +1,139 @@
# Tracing
Capture detailed execution traces for debugging and analysis. Traces include DOM snapshots, screenshots, network activity, and console logs.
## Basic Usage
```bash
# Start trace recording
playwright-cli tracing-start
# Perform actions
playwright-cli open https://example.com
playwright-cli click e1
playwright-cli fill e2 "test"
# Stop trace recording
playwright-cli tracing-stop
```
## Trace Output Files
When you start tracing, Playwright creates a `traces/` directory with several files:
### `trace-{timestamp}.trace`
**Action log** - The main trace file containing:
- Every action performed (clicks, fills, navigations)
- DOM snapshots before and after each action
- Screenshots at each step
- Timing information
- Console messages
- Source locations
### `trace-{timestamp}.network`
**Network log** - Complete network activity:
- All HTTP requests and responses
- Request headers and bodies
- Response headers and bodies
- Timing (DNS, connect, TLS, TTFB, download)
- Resource sizes
- Failed requests and errors
### `resources/`
**Resources directory** - Cached resources:
- Images, fonts, stylesheets, scripts
- Response bodies for replay
- Assets needed to reconstruct page state
## What Traces Capture
| Category | Details |
|----------|---------|
| **Actions** | Clicks, fills, hovers, keyboard input, navigations |
| **DOM** | Full DOM snapshot before/after each action |
| **Screenshots** | Visual state at each step |
| **Network** | All requests, responses, headers, bodies, timing |
| **Console** | All console.log, warn, error messages |
| **Timing** | Precise timing for each operation |
## Use Cases
### Debugging Failed Actions
```bash
playwright-cli tracing-start
playwright-cli open https://app.example.com
# This click fails - why?
playwright-cli click e5
playwright-cli tracing-stop
# Open trace to see DOM state when click was attempted
```
### Analyzing Performance
```bash
playwright-cli tracing-start
playwright-cli open https://slow-site.com
playwright-cli tracing-stop
# View network waterfall to identify slow resources
```
### Capturing Evidence
```bash
# Record a complete user flow for documentation
playwright-cli tracing-start
playwright-cli open https://app.example.com/checkout
playwright-cli fill e1 "4111111111111111"
playwright-cli fill e2 "12/25"
playwright-cli fill e3 "123"
playwright-cli click e4
playwright-cli tracing-stop
# Trace shows exact sequence of events
```
## Trace vs Video vs Screenshot
| Feature | Trace | Video | Screenshot |
|---------|-------|-------|------------|
| **Format** | .trace file | .webm video | .png/.jpeg image |
| **DOM inspection** | Yes | No | No |
| **Network details** | Yes | No | No |
| **Step-by-step replay** | Yes | Continuous | Single frame |
| **File size** | Medium | Large | Small |
| **Best for** | Debugging | Demos | Quick capture |
## Best Practices
### 1. Start Tracing Before the Problem
```bash
# Trace the entire flow, not just the failing step
playwright-cli tracing-start
playwright-cli open https://example.com
# ... all steps leading to the issue ...
playwright-cli tracing-stop
```
### 2. Clean Up Old Traces
Traces can consume significant disk space:
```bash
# Remove traces older than 7 days
find .playwright-cli/traces -mtime +7 -delete
```
## Limitations
- Traces add overhead to automation
- Large traces can consume significant disk space
- Some dynamic content may not replay perfectly
@@ -0,0 +1,143 @@
# Video Recording
Capture browser automation sessions as video for debugging, documentation, or verification. Produces WebM (VP8/VP9 codec).
## Basic Recording
```bash
# Open browser first
playwright-cli open
# Start recording
playwright-cli video-start demo.webm
# Add a chapter marker for section transitions
playwright-cli video-chapter "Getting Started" --description="Opening the homepage" --duration=2000
# Navigate and perform actions
playwright-cli goto https://example.com
playwright-cli snapshot
playwright-cli click e1
# Add another chapter
playwright-cli video-chapter "Filling Form" --description="Entering test data" --duration=2000
playwright-cli fill e2 "test input"
# Stop and save
playwright-cli video-stop
```
## Best Practices
### 1. Use Descriptive Filenames
```bash
# Include context in filename
playwright-cli video-start recordings/login-flow-2024-01-15.webm
playwright-cli video-start recordings/checkout-test-run-42.webm
```
### 2. Record entire hero scripts.
When recording a video for the user or as a proof of work, it is best to create a code snippet and execute it with run-code.
It allows inserting appropriate pauses between the actions and annotating the video. There are new Playwright APIs for that.
1) Perform scenario using CLI and take note of all locators and actions. You'll need those locators to request their bounding boxes for highlight.
2) Create a file with the intended script for video (below). Use pressSequentially w/ delay for nice typing, make reasonable pauses.
3) Use playwright-cli run-code --filename your-script.js
**Important**: Overlays are `pointer-events: none` — they do not interfere with page interactions. You can safely keep sticky overlays visible while clicking, filling, or performing any actions on the page.
```js
async page => {
await page.screencast.start({ path: 'video.webm', size: { width: 1280, height: 800 } });
await page.goto('https://demo.playwright.dev/todomvc');
// Show a chapter card — blurs the page and shows a dialog.
// Blocks until duration expires, then auto-removes.
// Use this for simple use cases, but always feel free to hand-craft your own beautiful
// overlay via await page.screencast.showOverlay().
await page.screencast.showChapter('Adding Todo Items', {
description: 'We will add several items to the todo list.',
duration: 2000,
});
// Perform action
await page.getByRole('textbox', { name: 'What needs to be done?' }).pressSequentially('Walk the dog', { delay: 60 });
await page.getByRole('textbox', { name: 'What needs to be done?' }).press('Enter');
await page.waitForTimeout(1000);
// Show next chapter
await page.screencast.showChapter('Verifying Results', {
description: 'Checking the item appeared in the list.',
duration: 2000,
});
// Add a sticky annotation that stays while you perform actions.
// Overlays are pointer-events: none, so they won't block clicks.
const annotation = await page.screencast.showOverlay(`
<div style="position: absolute; top: 8px; right: 8px;
padding: 6px 12px; background: rgba(0,0,0,0.7);
border-radius: 8px; font-size: 13px; color: white;">
✓ Item added successfully
</div>
`);
// Perform more actions while the annotation is visible
await page.getByRole('textbox', { name: 'What needs to be done?' }).pressSequentially('Buy groceries', { delay: 60 });
await page.getByRole('textbox', { name: 'What needs to be done?' }).press('Enter');
await page.waitForTimeout(1500);
// Remove the annotation when done
await annotation.dispose();
// You can also highlight relevant locators and provide contextual annotations.
const bounds = await page.getByText('Walk the dog').boundingBox();
await page.screencast.showOverlay(`
<div style="position: absolute;
top: ${bounds.y}px;
left: ${bounds.x}px;
width: ${bounds.width}px;
height: ${bounds.height}px;
border: 1px solid red;">
</div>
<div style="position: absolute;
top: ${bounds.y + bounds.height + 5}px;
left: ${bounds.x + bounds.width / 2}px;
transform: translateX(-50%);
padding: 6px;
background: #808080;
border-radius: 10px;
font-size: 14px;
color: white;">Check it out, it is right above this text
</div>
`, { duration: 2000 });
await page.screencast.stop();
}
```
Embrace creativity, overlays are powerful.
### Overlay API Summary
| Method | Use Case |
|--------|----------|
| `page.screencast.showChapter(title, { description?, duration?, styleSheet? })` | Full-screen chapter card with blurred backdrop — ideal for section transitions |
| `page.screencast.showOverlay(html, { duration? })` | Custom HTML overlay — use for callouts, labels, highlights |
| `disposable.dispose()` | Remove a sticky overlay added without duration |
| `page.screencast.hideOverlays()` / `page.screencast.showOverlays()` | Temporarily hide/show all overlays |
## Tracing vs Video
| Feature | Video | Tracing |
|---------|-------|---------|
| Output | WebM file | Trace file (viewable in Trace Viewer) |
| Shows | Visual recording | DOM snapshots, network, console, actions |
| Use case | Demos, documentation | Debugging, analysis |
| Size | Larger | Smaller |
## Limitations
- Recording adds slight overhead to automation
- Large recordings can consume significant disk space
+12 -10
View File
@@ -7,10 +7,7 @@
"request": "launch", "request": "launch",
"module": "flask", "module": "flask",
"python": "${command:python.interpreterPath}", "python": "${command:python.interpreterPath}",
"env": { "envFile": "${workspaceFolder}/backend/.env",
"FLASK_APP": "backend/main.py",
"FLASK_DEBUG": "1"
},
"args": [ "args": [
"run", "run",
"--host=0.0.0.0", "--host=0.0.0.0",
@@ -30,15 +27,20 @@
"run", "run",
"dev" "dev"
], ],
"cwd": "${workspaceFolder}/frontend/vue-app", "cwd": "${workspaceFolder}/frontend",
"console": "integratedTerminal" "console": "integratedTerminal",
"serverReadyAction": {
"pattern": "Local:.*https://localhost:([0-9]+)",
"uriFormat": "https://localhost:%s",
"action": "debugWithChrome"
}
}, },
{ {
"name": "Chrome: Launch (Vue App)", "name": "Chrome: Launch (Vue App)",
"type": "pwa-chrome", "type": "pwa-chrome",
"request": "launch", "request": "launch",
"url": "http://localhost:5173", "url": "http://localhost:5173",
"webRoot": "${workspaceFolder}/frontend/vue-app" "webRoot": "${workspaceFolder}/frontend"
}, },
{ {
"name": "Python: Backend Tests", "name": "Python: Backend Tests",
@@ -66,7 +68,7 @@
"run", "run",
"test:unit" "test:unit"
], ],
"cwd": "${workspaceFolder}/frontend/vue-app", "cwd": "${workspaceFolder}/frontend",
"console": "integratedTerminal", "console": "integratedTerminal",
"osx": { "osx": {
"env": { "env": {
@@ -78,10 +80,10 @@
"compounds": [ "compounds": [
{ {
"name": "Full Stack (Backend + Frontend)", "name": "Full Stack (Backend + Frontend)",
"stopAll": true,
"configurations": [ "configurations": [
"Python: Flask", "Python: Flask",
"Vue: Dev Server", "Vue: Dev Server"
"Chrome: Launch (Vue App)"
] ]
} }
] ]
+3 -3
View File
@@ -28,7 +28,7 @@
"run", "run",
"dev" "dev"
], ],
"cwd": "${workspaceFolder}/frontend/vue-app", "cwd": "${workspaceFolder}/frontend",
"console": "integratedTerminal" "console": "integratedTerminal"
}, },
{ {
@@ -36,7 +36,7 @@
"type": "chrome", "type": "chrome",
"request": "launch", "request": "launch",
"url": "https://localhost:5173", // or your Vite dev server port "url": "https://localhost:5173", // or your Vite dev server port
"webRoot": "${workspaceFolder}/frontend/vue-app" "webRoot": "${workspaceFolder}/frontend"
}, },
{ {
"name": "Python: Backend Tests", "name": "Python: Backend Tests",
@@ -60,7 +60,7 @@
"runtimeArgs": [ "runtimeArgs": [
"vitest" "vitest"
], ],
"cwd": "${workspaceFolder}/frontend/vue-app", "cwd": "${workspaceFolder}/frontend",
"console": "integratedTerminal" "console": "integratedTerminal"
} }
], ],
+28
View File
@@ -0,0 +1,28 @@
{
"servers": {
"playwright-test": {
"type": "stdio",
"command": "npx",
"args": [
"playwright",
"run-test-mcp-server",
"--config=playwright.config.ts"
],
"cwd": "frontend"
},
"gitea": {
"command": "docker",
"args": [
"run",
"-i",
"--rm",
"-e",
"GITEA_ACCESS_TOKEN=${env:GITEA_ACCESS_TOKEN}",
"-e",
"GITEA_HOST=https://git.ryankegel.com",
"docker.gitea.com/gitea-mcp-server"
],
"type": "stdio"
}
}
}
+24 -1
View File
@@ -1,4 +1,7 @@
{ {
"python.venvPath": "${workspaceFolder}/backend/.venv",
"python.terminal.activateEnvironment": true,
"python.terminal.shellIntegration.enabled": true,
"explorer.fileNesting.enabled": true, "explorer.fileNesting.enabled": true,
"explorer.fileNesting.patterns": { "explorer.fileNesting.patterns": {
"tsconfig.json": "tsconfig.*.json, env.d.ts", "tsconfig.json": "tsconfig.*.json, env.d.ts",
@@ -19,5 +22,25 @@
}, },
"chat.tools.terminal.autoApprove": { "chat.tools.terminal.autoApprove": {
"&": true "&": true
} },
"terminal.integrated.automationProfile.windows": {
"path": "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
"args": []
},
"python-envs.defaultEnvManager": "ms-python.python:venv",
"python-envs.pythonProjects": [],
"editor.gotoLocation.multipleDeclarations": "gotoAndPeek",
"editor.gotoLocation.multipleDefinitions": "gotoAndPeek",
"editor.gotoLocation.multipleImplementations": "gotoAndPeek",
"editor.gotoLocation.multipleReferences": "gotoAndPeek",
"editor.gotoLocation.multipleTypeDefinitions": "gotoAndPeek",
//"editor.fontFamily": "Consolas, 'Courier New', monospace",
"editor.fontFamily": "JetBrains Mono",
"editor.fontSize": 13,
"editor.fontLigatures": true,
"python.testing.pytestArgs": [
"backend"
],
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true,
} }
+101
View File
@@ -40,6 +40,107 @@
"panel": "shared", "panel": "shared",
"echo": true "echo": true
} }
},
{
"label": "PW: Task Modification Tests",
"type": "shell",
"command": "cd \"${workspaceFolder}/frontend\" && npx playwright test e2e/mode_parent/task-modification --project=chromium-task-modification",
"isBackground": false,
"group": "test"
},
{
"label": "PW: Task Modification Tests (PS)",
"type": "shell",
"command": "powershell -Command \"cd '$env:APPDATA/../../../d/Python Utilities/Reward/frontend'; npx playwright test e2e/mode_parent/task-modification --project=chromium-task-modification\"",
"isBackground": false,
"group": "test"
},
{
"label": "PW: Task Modification Tests (cmd)",
"type": "shell",
"command": "cmd /c \"cd /d \\\"D:\\Python Utilities\\Reward\\frontend\\\" && npx playwright test e2e/mode_parent/task-modification --project=chromium-task-modification 2>&1\"",
"isBackground": false,
"group": "test"
},
{
"label": "PW: User Profile Tests",
"type": "shell",
"command": "cd \"${workspaceFolder}/frontend\" && npx playwright test e2e/mode_parent/user-profile --project=chromium-user-profile --project=chromium-user-profile-pin --project=chromium-user-profile-delete",
"isBackground": false,
"group": "test"
},
{
"label": "PW: User Profile Tests 2",
"type": "shell",
"command": "cd \"${workspaceFolder}/frontend\" && npx playwright test e2e/mode_parent/user-profile --project=chromium-user-profile --project=chromium-user-profile-pin --project=chromium-user-profile-delete",
"isBackground": false,
"group": "test"
},
{
"label": "PW: User Profile Tests Final",
"type": "shell",
"command": "cd \"${workspaceFolder}/frontend\" && npx playwright test e2e/mode_parent/user-profile --project=chromium-user-profile --project=chromium-user-profile-pin --project=chromium-user-profile-delete",
"isBackground": false,
"group": "test"
},
{
"label": "PW: Full Test Suite",
"type": "shell",
"command": "cd \"${workspaceFolder}/frontend\" && npx playwright test --reporter=line",
"isBackground": false,
"group": "test"
},
{
"label": "PW: Full Suite",
"type": "shell",
"command": "cd \"${workspaceFolder}/frontend\" && npx playwright test --reporter=line",
"isBackground": false,
"group": "test"
},
{
"label": "PW: Full Suite (Process)",
"type": "process",
"command": "powershell.exe",
"args": [
"-NoProfile",
"-Command",
"cd 'D:\\Python Utilities\\Reward\\frontend'; npx playwright test --reporter=line 2>&1"
],
"group": "test",
"presentation": {
"reveal": "always",
"panel": "shared"
}
},
{
"label": "PW: Penalty Default Test (Process)",
"type": "process",
"command": "powershell.exe",
"args": [
"-NoProfile",
"-Command",
"cd 'D:\\Python Utilities\\Reward\\frontend'; npx playwright test e2e/mode_parent/tasks/penalty-default.spec.ts --project=chromium-default-tasks --reporter=line 2>&1"
],
"group": "test",
"presentation": {
"reveal": "always",
"panel": "shared"
}
},
{
"label": "PW: User Profile Editing Test (Process)",
"type": "process",
"command": "powershell.exe",
"args": [
"-NoProfile",
"-Command",
"cd 'D:\\Python Utilities\\Reward\\frontend'; npx playwright test e2e/mode_parent/user-profile/profile-editing.spec.ts --project=chromium-user-profile --reporter=line 2>&1"
],
"group": "test",
"presentation": {
"reveal": "always",
"panel": "shared"
}
} }
] ]
} }
+60
View File
@@ -0,0 +1,60 @@
# AGENTS.md
Family chore/reward manager. Flask + TinyDB backend (`backend/`), Vue 3 + TypeScript frontend (`frontend/`). Real-time updates over SSE.
## Commands
### Backend (run from `backend/`)
- Activate venv: `source .venv/bin/activate`
- Dev server: `python -m flask run --host=0.0.0.0 --port=5000` (entry: `main.py`)
- Required env vars: `SECRET_KEY`, `REFRESH_TOKEN_EXPIRY_DAYS`, `DIGEST_TOKEN_SECRET`, `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY` — Flask raises `RuntimeError` on boot if any are missing
- Optional persistence switch: `USE_MONGODB` (`true` | `false`). Defaults to `true`; set `MONGO_URI` (and optionally `MONGO_DB_NAME`). Set to `false` to use TinyDB instead.
- Optional: `DB_ENV` / `DATA_ENV` (`prod` | `test` | `e2e`) — picks `data/` vs `test_data/` dir (see `config/paths.py`). For MongoDB these also select the default database name (`chore_db`, `chore_db_test`, `chore_db_e2e`) unless `MONGO_DB_NAME` is set.
- Tests: `pytest tests/``conftest.py` forces `DB_ENV=test`, `USE_MONGODB=true`, `MONGO_URI=mongomock`, and sets dummy secrets. Single test: `pytest tests/test_routine_api.py::test_name`
- Python imports assume `backend/` is on `sys.path` (set by `conftest.py` / `flask run` cwd). Run pytest from `backend/`.
- Create admin user: `python scripts/create_admin.py` (admin role cannot be set via signup)
### Frontend (run from `frontend/`)
- Dev: `npm run dev` (Vite, https://localhost:5173)
- Lint: `npm run lint`
- Type-check: `npm run type-check`
- Unit tests: `npm run test:unit` (Vitest). Single: `npx vitest run path/to/file.spec.ts`
- E2E: `npx playwright test` — config auto-starts both `npm run dev` and the Flask backend with `DB_ENV=e2e DATA_ENV=e2e USE_MONGODB=true MONGO_URI=mongomock`. Tests live in `e2e/`. `frontend/.env.test` contains the example MongoDB config.
- E2E buckets are Playwright projects (see `playwright.config.ts`) targeting directories under `e2e/mode_parent/`
## Architecture
### API routing — the `/api` prefix
- Frontend nginx (and Vite dev proxy) strips `/api` before forwarding. **Backend routes must NOT include `/api`.** Backend defines `@app.route('/user')`, frontend calls `/api/user`.
- `auth_api` is the only blueprint registered with a prefix: `url_prefix='/auth'` in `main.py:67`.
- API errors return `{ error, code }` (codes in `backend/api/error_codes.py`). Frontend extracts them via `parseErrorResponse(res)` in `src/common/api.ts`.
### Models — strict 1:1 parity
- Python `@dataclass`es in `backend/models/`. TypeScript interfaces in `frontend/src/common/models.ts`. Any model change requires updating both.
- Persistence is MongoDB by default (`USE_MONGODB=true`), or TinyDB when `USE_MONGODB=false`. Both are accessed through the `LockedTable` / `MongoLockedTable` wrappers in `backend/db/db.py`. Operate on model instances with `from_dict()` / `to_dict()` — never raw dicts.
- MongoDB client initialization is lazy (`backend/db/mongo_client.py`). `backend/gunicorn.conf.py` provides the `post_fork` hook required for multi-worker Gunicorn deployments; `backend/Dockerfile` loads it with `-c gunicorn.conf.py`.
- Migration script: `cd backend && python -m scripts/migrate_to_mongodb [--dry-run]`. It reads TinyDB JSON files and writes them to MongoDB idempotently, backing up the originals to `<db_dir>/backups/<timestamp>/`.
### SSE event bus — mandatory for every mutation
- Every backend mutation (add/edit/delete/trigger) **must** call `send_event_for_current_user` from `api/utils.py`. Event types in `backend/events/types/` are mirrored in `frontend/src/common/backendEvents.ts`.
- Frontend: register listeners in `onMounted`, clean up in `onUnmounted`. SSE endpoint is `/events`.
### Background schedulers (started in `main.py` at boot)
- `start_deletion_scheduler` — runs hourly, deletes accounts marked for deletion after threshold
- `start_digest_scheduler` — email digests
- `start_state_expiry_scheduler` — expires stale state
- `start_chore_expiry_notification_scheduler` — chore expiry notifications
## Frontend conventions
- SFC file order: `<template>``<script>``<style scoped>`. TypeScript only in `<script>`. All styles must be `scoped`.
- Colors/spacing: use only `:root` CSS variables from `colors.css`. No hardcoded hex/px for themed properties.
- Layout shells: `ParentLayout` for admin/management, `ChildLayout` for child dashboard/focus.
- Images: models carry `image_id`; frontend resolves to `image_url` for rendering.
## Testing gotchas
- E2E tests use pre-authenticated sessions via `storageState` in `playwright.config.ts` — do **not** navigate to `/auth/login`. Import `E2E_EMAIL` / `E2E_PASSWORD` from `e2e/e2e-constants.ts`.
- E2E buckets that mutate shared state (default tasks, delete-account, create-child) use isolated users. Preserve this pattern when adding new buckets.
- Backend tests: `conftest.py` sets `DB_ENV=test` + dummy secrets. Test DB lands in `test_data/db/`, never touches production `data/`.
## Feature specs
Specs live in `.github/specs/`. If a spec has a checklist, all items must be marked done before the feature is complete.
+67
View File
@@ -0,0 +1,67 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project
Family chore/reward manager. Flask + TinyDB backend (`backend/`), Vue 3 + TypeScript frontend (`frontend/`). Real-time updates flow over Server-Sent Events.
## Commands
### Backend (run from `backend/`)
- Activate venv first: `source .venv/bin/activate` (mac/linux) — Python runs from `backend/.venv/`.
- Dev server: `python -m flask run --host=0.0.0.0 --port=5000` (entry: `backend/main.py`).
- Required env vars at startup: `SECRET_KEY`, `REFRESH_TOKEN_EXPIRY_DAYS`, `DIGEST_TOKEN_SECRET`, `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY` — Flask raises `RuntimeError` on boot if any are missing.
- Optional: `DB_ENV` / `DATA_ENV` (`prod` | `test` | `e2e`) — picks `data/` vs `test_data/` directory (see `backend/config/paths.py`).
- Tests: `pytest tests/``tests/conftest.py` forces `DB_ENV=test` and sets dummy secrets. Single test: `pytest tests/test_routine_api.py::test_name`.
- Create an admin user (cannot be done via signup): `python scripts/create_admin.py`.
### Frontend (run from `frontend/`)
- Dev: `npm run dev` (Vite, https://localhost:5173).
- Build: `npm run build`. Type-check: `npm run type-check`. Lint: `npm run lint`.
- Unit/component tests: `npm run test:unit` (Vitest). Single test: `npx vitest run path/to/file.spec.ts`.
- E2E: `npx playwright test` from `frontend/`. Config at `playwright.config.ts` auto-starts both `npm run dev` and the Flask backend with `DB_ENV=e2e DATA_ENV=e2e`, so test data lands in `backend/test_data/` and never touches production. The `globalSetup` seeds the DB and logs in; tests receive a pre-authenticated session via `storageState` — do **not** navigate to `/auth/login`. Import `E2E_EMAIL` / `E2E_PASSWORD` from `e2e/e2e-constants.ts` rather than hardcoding.
- E2E suite is split into Playwright "projects" in `playwright.config.ts` (`chromium-routines`, `chromium-task-assignment`, …) — each bucket targets a directory under `e2e/mode_parent/` and some use isolated users to avoid cross-bucket interference. Run a single bucket: `npx playwright test --project=chromium-routines`.
## Architecture
### API surface and nginx proxy
- Each entity has its own Flask blueprint in `backend/api/` (`child_api.py`, `chore_api.py`, `routine_api.py`, …). Registered in `backend/main.py`.
- The frontend nginx (and Vite dev proxy) strips `/api` before forwarding. **Backend routes must NOT include `/api`** — backend defines `@app.route('/user')`, frontend calls `/api/user`.
- The `auth_api` blueprint is the only one mounted under a prefix (`/auth`).
- API errors return `{ error, code }`; codes live in `backend/api/error_codes.py`. Frontend extracts them via `parseErrorResponse(res)` in `frontend/src/common/api.ts`.
### Models — keep 1:1 parity
- Python `@dataclass`es live in `backend/models/`. TypeScript interfaces live in `frontend/src/common/models.ts`. Any model change requires updating **both**.
- Persistence is TinyDB (JSON files under `data/db/` or `test_data/db/`). All DB access goes through the thread-safe `LockedTable` wrapper in `backend/db/db.py`. Always operate on model instances using `from_dict()` / `to_dict()` — never raw dicts.
### SSE event bus (mandatory for every mutation)
- Every backend mutation (add/edit/delete/trigger) **must** call `send_event_for_current_user` (from `api/utils.py`). Event types live in `backend/events/types/` and `frontend/src/common/backendEvents.ts` (mirrored).
- Frontend state is event-driven: register listeners in `onMounted`, clean up in `onUnmounted`. See `components/BackendEventsListener.vue` and `src/common/backendEvents.ts`.
- The SSE endpoint is `/events`; per-user queues live in `backend/events/sse.py`.
### Background schedulers
Started in `backend/main.py` at boot:
- `start_deletion_scheduler` — runs hourly, deletes accounts that were marked-for-deletion at least `ACCOUNT_DELETION_THRESHOLD_HOURS` ago (default 720, min 24, max 720). Cleans pending rewards, children, tasks, rewards, images, then the user. Logs to `logs/account_deletion.log`.
- `start_digest_scheduler` — email digests.
- `start_state_expiry_scheduler` — expires stale state.
- `start_chore_expiry_notification_scheduler` — chore expiry notifications.
### Auth & security
- JWT in HttpOnly + Secure + SameSite=Strict cookies. Verification tokens expire in 4 hours; password-reset tokens in 10 minutes.
- Admin role is **never** assignable via signup — use `backend/scripts/create_admin.py`. Admin endpoints under `/admin/*` enforce role check.
### Frontend conventions
- Vue SFC file order: `<template>``<script>``<style scoped>`. TypeScript only inside `<script>`. **All styles must be `scoped`.**
- Use **only** `:root` CSS variables from `colors.css` for colors/spacing/tokens (e.g. `--btn-primary`, `--list-item-bg-good`). No hardcoded hex/px values for themed properties.
- Layout shells: `ParentLayout` for admin/management views, `ChildLayout` for child dashboard/focus views.
- Images: models carry `image_id`; frontend resolves to `image_url` for rendering.
### Specs
Feature specs live in `.github/specs/`. If a spec has a checklist, all items must be marked done before the feature is considered complete.
## Gotchas
- Backend Python imports assume `backend/` is on `sys.path` (added by `conftest.py` for tests, by `flask run` cwd in dev). Run pytest from `backend/`.
- Don't replace code with comments; mirror changes across backend + frontend so model/event parity holds.
- E2E tests share a single seeded user by default — buckets that mutate shared state (default tasks, delete-account, create-child) deliberately use isolated users; preserve that pattern when adding new buckets.
+35 -5
View File
@@ -4,7 +4,7 @@ A family-friendly application for managing chores, tasks, and rewards for childr
## 🏗️ Architecture ## 🏗️ Architecture
- **Backend**: Flask (Python) with TinyDB for data persistence - **Backend**: Flask (Python) with TinyDB or MongoDB for data persistence
- **Frontend**: Vue 3 (TypeScript) with real-time SSE updates - **Frontend**: Vue 3 (TypeScript) with real-time SSE updates
- **Deployment**: Docker with nginx reverse proxy - **Deployment**: Docker with nginx reverse proxy
@@ -24,7 +24,7 @@ python -m flask run --host=0.0.0.0 --port=5000
### Frontend ### Frontend
```bash ```bash
cd frontend/vue-app cd frontend
npm install npm install
npm run dev npm run dev
``` ```
@@ -38,6 +38,37 @@ npm run dev
| `ACCOUNT_DELETION_THRESHOLD_HOURS` | Hours to wait before deleting marked accounts | 720 (30 days) | | `ACCOUNT_DELETION_THRESHOLD_HOURS` | Hours to wait before deleting marked accounts | 720 (30 days) |
| `DB_ENV` | Database environment (`prod` or `test`) | `prod` | | `DB_ENV` | Database environment (`prod` or `test`) | `prod` |
| `DATA_ENV` | Data directory environment (`prod` or `test`) | `prod` | | `DATA_ENV` | Data directory environment (`prod` or `test`) | `prod` |
| `USE_MONGODB` | Use MongoDB (`true`/`false`) | `true` |
| `MONGO_URI` | MongoDB connection URI (required when `USE_MONGODB=true`) | — |
| `MONGO_DB_NAME` | MongoDB database name (optional) | Parsed from `MONGO_URI`, or `chore_db`/`chore_db_test`/`chore_db_e2e` based on `DB_ENV` |
### Database Backend
The application supports two persistence backends:
- **MongoDB** (default): Set `MONGO_URI` (and optionally `MONGO_DB_NAME`). This is the recommended backend for production and managed hosting (e.g., MongoDB Atlas).
- **TinyDB**: JSON-file storage in `backend/data/db/` (or `backend/test_data/db/` for `test`/`e2e`). Opt in by setting `USE_MONGODB=false`.
#### Migrating from TinyDB to MongoDB
```bash
cd backend
# Dry run to preview what will be migrated
python -m scripts.migrate_to_mongodb --dry-run
# Run the migration (backs up TinyDB files first)
python -m scripts.migrate_to_mongodb
```
The migration script reads the existing TinyDB JSON files and inserts each record into the matching MongoDB collection, skipping records that already exist. Original TinyDB files are backed up to `backend/data/db/backups/<timestamp>/`.
#### Rolling Back to TinyDB
Set `USE_MONGODB=false`. The original JSON files remain in place.
#### Gunicorn / Docker
When running multiple Gunicorn workers, each worker must create its own MongoDB client after forking. This is handled automatically by `backend/gunicorn.conf.py`, which is loaded by `backend/Dockerfile` via `-c gunicorn.conf.py`.
### Account Deletion Scheduler ### Account Deletion Scheduler
@@ -114,7 +145,7 @@ pytest tests/
### Frontend Tests ### Frontend Tests
```bash ```bash
cd frontend/vue-app cd frontend
npm run test npm run test
``` ```
@@ -145,13 +176,12 @@ npm run test
├── backend/ ├── backend/
│ ├── api/ # REST API endpoints │ ├── api/ # REST API endpoints
│ ├── config/ # Configuration files │ ├── config/ # Configuration files
│ ├── db/ # TinyDB setup │ ├── db/ # TinyDB / MongoDB persistence layer
│ ├── events/ # SSE event system │ ├── events/ # SSE event system
│ ├── models/ # Data models │ ├── models/ # Data models
│ ├── tests/ # Backend tests │ ├── tests/ # Backend tests
│ └── utils/ # Utilities (scheduler, etc) │ └── utils/ # Utilities (scheduler, etc)
├── frontend/ ├── frontend/
│ └── vue-app/
│ └── src/ │ └── src/
│ ├── common/ # Shared utilities │ ├── common/ # Shared utilities
│ ├── components/ # Vue components │ ├── components/ # Vue components
+1 -1
View File
@@ -15,4 +15,4 @@ ENV PYTHONIOENCODING=utf-8
VOLUME ["/app/data"] VOLUME ["/app/data"]
# Use Gunicorn instead of python main.py # Use Gunicorn instead of python main.py
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "-k", "gevent", "--workers", "1", "--timeout", "120", "--access-logfile", "-", "--error-logfile", "-", "--log-level", "info", "main:app"] CMD ["gunicorn", "--bind", "0.0.0.0:5000", "-k", "gevent", "--workers", "1", "--timeout", "120", "--access-logfile", "-", "--error-logfile", "-", "--log-level", "info", "-c", "gunicorn.conf.py", "main:app"]
+133 -45
View File
@@ -1,11 +1,12 @@
import os
from flask import Blueprint, request, jsonify from flask import Blueprint, request, jsonify
from datetime import datetime, timedelta from datetime import datetime, timedelta
from tinydb import Query from tinydb import Query
import jwt
from functools import wraps
from db.db import users_db from db.db import users_db
from models.user import User from models.user import User
from api.utils import admin_required, get_validated_user_id
from config.deletion_config import ( from config.deletion_config import (
ACCOUNT_DELETION_THRESHOLD_HOURS, ACCOUNT_DELETION_THRESHOLD_HOURS,
MIN_THRESHOLD_HOURS, MIN_THRESHOLD_HOURS,
@@ -16,49 +17,6 @@ from utils.account_deletion_scheduler import trigger_deletion_manually
admin_api = Blueprint('admin_api', __name__) 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_api.route('/admin/deletion-queue', methods=['GET'])
@admin_required @admin_required
@@ -197,3 +155,133 @@ def trigger_deletion_queue():
except Exception as e: except Exception as e:
return jsonify({'error': str(e), 'code': 'SERVER_ERROR'}), 500 return jsonify({'error': str(e), 'code': 'SERVER_ERROR'}), 500
# ---------------------------------------------------------------------------
# Test-only endpoint — active ONLY when DB_ENV=e2e
# ---------------------------------------------------------------------------
@admin_api.route('/admin/test/digest-token', methods=['POST'])
def create_test_digest_token():
"""Create a valid DigestActionToken for E2E tests.
Only active when DB_ENV=e2e. Requires authentication.
"""
if os.environ.get('DB_ENV') != 'e2e':
return jsonify({'error': 'Not found', 'code': 'NOT_FOUND'}), 404
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
data = request.get_json() or {}
child_id = data.get('child_id')
entity_id = data.get('entity_id')
entity_type = data.get('entity_type')
action = data.get('action')
expires_in_hours = data.get('expires_in_hours', 24)
if not all([child_id, entity_id, entity_type, action]):
return jsonify({'error': 'child_id, entity_id, entity_type, and action are required',
'code': 'MISSING_FIELDS'}), 400
if entity_type not in ('chore', 'reward'):
return jsonify({'error': 'entity_type must be "chore" or "reward"',
'code': 'INVALID_ENTITY_TYPE'}), 400
if action not in ('approve', 'deny'):
return jsonify({'error': 'action must be "approve" or "deny"',
'code': 'INVALID_ACTION'}), 400
try:
from utils.digest_token import create_action_token
token = create_action_token(
user_id=user_id,
child_id=child_id,
entity_id=entity_id,
entity_type=entity_type,
action=action,
expiry_hours=int(expires_in_hours),
)
return jsonify({'token': token.id}), 200
except Exception as e:
return jsonify({'error': str(e), 'code': 'SERVER_ERROR'}), 500
@admin_api.route('/admin/test/send-digest', methods=['POST'])
def send_test_digest():
"""Trigger a digest email for a specific user by email address.
Only active when DB_ENV is not 'production'. Requires admin authentication.
Note: actual email delivery is skipped in e2e mode by email_sender.
"""
if os.environ.get('DB_ENV') == 'production':
return jsonify({'error': 'Not found', 'code': 'NOT_FOUND'}), 404
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
# Verify caller is admin
caller_dict = users_db.get(Query().id == user_id)
if not caller_dict or caller_dict.get('role') != 'admin':
return jsonify({'error': 'Admin access required', 'code': 'ADMIN_REQUIRED'}), 403
data = request.get_json() or {}
email = data.get('email', '').strip().lower()
if not email:
return jsonify({'error': 'email is required', 'code': 'MISSING_FIELDS'}), 400
target = users_db.get(Query().email == email)
if not target:
return jsonify({'error': 'User not found', 'code': 'USER_NOT_FOUND'}), 404
target_id = target.get('id')
target_email = target.get('email')
try:
from flask import current_app
from utils.digest_scheduler import send_digest_for_user
frontend_url = current_app.config.get('FRONTEND_URL', 'https://localhost:5173')
items_sent = send_digest_for_user(target_id, target_email, frontend_url)
return jsonify({'items_sent': items_sent}), 200
except Exception as e:
return jsonify({'error': str(e), 'code': 'SERVER_ERROR'}), 500
@admin_api.route('/admin/test/trigger-chore-expiry', methods=['POST'])
def trigger_test_chore_expiry():
"""Trigger the chore expiry notification check for a specific user by email address.
Only active when DB_ENV is not 'production'. Requires admin authentication.
Note: actual push delivery requires VAPID keys to be configured.
"""
if os.environ.get('DB_ENV') == 'production':
return jsonify({'error': 'Not found', 'code': 'NOT_FOUND'}), 404
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
caller_dict = users_db.get(Query().id == user_id)
if not caller_dict or caller_dict.get('role') != 'admin':
return jsonify({'error': 'Admin access required', 'code': 'ADMIN_REQUIRED'}), 403
data = request.get_json() or {}
email = data.get('email', '').strip().lower()
if not email:
return jsonify({'error': 'email is required', 'code': 'MISSING_FIELDS'}), 400
target = users_db.get(Query().email == email)
if not target:
return jsonify({'error': 'User not found', 'code': 'USER_NOT_FOUND'}), 404
target_id = target.get('id')
tz_str = target.get('timezone')
try:
from utils.chore_expiry_notification_scheduler import send_chore_expiry_notifications_for_user
chores_notified = send_chore_expiry_notifications_for_user(target_id, tz_str)
return jsonify({'chores_notified': chores_notified}), 200
except Exception as e:
return jsonify({'error': str(e), 'code': 'SERVER_ERROR'}), 500
+384 -18
View File
@@ -1,7 +1,11 @@
import hashlib
import logging import logging
import secrets, jwt import secrets
import uuid
import jwt
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from models.user import User from models.user import User
from models.refresh_token import RefreshToken
from flask import Blueprint, request, jsonify, current_app from flask import Blueprint, request, jsonify, current_app
from tinydb import Query from tinydb import Query
import os import os
@@ -10,18 +14,55 @@ from werkzeug.security import generate_password_hash, check_password_hash
from api.utils import sanitize_email from api.utils import sanitize_email
from config.paths import get_user_image_dir from config.paths import get_user_image_dir
from events.sse import send_event_to_user
from events.types.event import Event
from events.types.event_types import EventType
from events.types.payload import Payload
from api.error_codes import MISSING_FIELDS, EMAIL_EXISTS, MISSING_TOKEN, INVALID_TOKEN, TOKEN_TIMESTAMP_MISSING, \ from api.error_codes import (
TOKEN_EXPIRED, ALREADY_VERIFIED, MISSING_EMAIL, USER_NOT_FOUND, MISSING_EMAIL_OR_PASSWORD, INVALID_CREDENTIALS, \ MISSING_FIELDS, EMAIL_EXISTS, MISSING_TOKEN, INVALID_TOKEN, TOKEN_TIMESTAMP_MISSING,
NOT_VERIFIED, ACCOUNT_MARKED_FOR_DELETION TOKEN_EXPIRED, ALREADY_VERIFIED, MISSING_EMAIL, USER_NOT_FOUND, MISSING_EMAIL_OR_PASSWORD,
from db.db import users_db INVALID_CREDENTIALS, NOT_VERIFIED, ACCOUNT_MARKED_FOR_DELETION,
REFRESH_TOKEN_REUSE, REFRESH_TOKEN_EXPIRED, MISSING_REFRESH_TOKEN,
)
from db.db import (
users_db, refresh_tokens_db, child_db, task_db, reward_db, image_db,
pending_reward_db, pending_confirmations_db, tracking_events_db,
child_overrides_db, chore_schedules_db, task_extensions_db,
routine_db, routine_items_db, routine_schedules_db, routine_extensions_db,
push_subscriptions_db, digest_action_tokens_db,
)
from db.default import initializeImages, createDefaultTasks, createDefaultRewards
from api.utils import normalize_email from api.utils import normalize_email
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
auth_api = Blueprint('auth_api', __name__) auth_api = Blueprint('auth_api', __name__)
UserQuery = Query() UserQuery = Query()
TOKEN_EXPIRY_MINUTES = 60*4 TokenQuery = Query()
TOKEN_EXPIRY_MINUTES = 60 * 4
RESET_PASSWORD_TOKEN_EXPIRY_MINUTES = 10 RESET_PASSWORD_TOKEN_EXPIRY_MINUTES = 10
try:
ACCESS_TOKEN_EXPIRY_MINUTES = int(os.environ.get('ACCESS_TOKEN_EXPIRY_MINUTES', '15'))
except ValueError:
ACCESS_TOKEN_EXPIRY_MINUTES = 15
try:
REFRESH_TOKEN_REUSE_GRACE_PERIOD_SECONDS = int(
os.environ.get('REFRESH_TOKEN_REUSE_GRACE_PERIOD_SECONDS', '30')
)
except ValueError:
REFRESH_TOKEN_REUSE_GRACE_PERIOD_SECONDS = 30
E2E_TEST_EMAIL = 'e2e@test.com'
E2E_TEST_PASSWORD = 'E2eTestPass1!'
E2E_TEST_PIN = '1234'
E2E_DELETE_EMAIL = 'e2e-delete@test.com'
E2E_DELETE_PASSWORD = 'E2eDeletePass1!'
E2E_DELETE_PIN = '5678'
E2E_CC_EMAIL = 'e2e-cc@test.com'
E2E_CC_PASSWORD = 'E2eCCPass1!'
E2E_CC_PIN = '3456'
E2E_TUTORIAL_EMAIL = 'e2e-tutorial@test.com'
E2E_TUTORIAL_PASSWORD = 'E2eTutorialPass1!'
E2E_TUTORIAL_PIN = '7890'
def send_verification_email(to_email, token): def send_verification_email(to_email, token):
@@ -30,6 +71,81 @@ def send_verification_email(to_email, token):
def send_reset_password_email(to_email, token): def send_reset_password_email(to_email, token):
email_sender.send_reset_password_email(to_email, token) email_sender.send_reset_password_email(to_email, token)
def _hash_token(raw_token: str) -> str:
"""SHA-256 hash a raw refresh token for secure storage."""
return hashlib.sha256(raw_token.encode('utf-8')).hexdigest()
def _create_access_token(user: User) -> str:
"""Create a short-lived JWT access token."""
payload = {
'email': user.email,
'user_id': user.id,
'token_version': user.token_version,
'exp': datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRY_MINUTES),
}
return jwt.encode(payload, current_app.config['SECRET_KEY'], algorithm='HS256')
def _create_refresh_token(user_id: str, token_family: str | None = None) -> tuple[str, RefreshToken]:
"""
Create a refresh token: returns (raw_token, RefreshToken record).
If token_family is None, a new family is created (login).
Otherwise, the existing family is reused (rotation).
"""
raw_token = secrets.token_urlsafe(32)
expiry_days = current_app.config['REFRESH_TOKEN_EXPIRY_DAYS']
expires_at = (datetime.now(timezone.utc) + timedelta(days=expiry_days)).isoformat()
family = token_family or str(uuid.uuid4())
record = RefreshToken(
user_id=user_id,
token_hash=_hash_token(raw_token),
token_family=family,
expires_at=expires_at,
is_used=False,
)
refresh_tokens_db.insert(record.to_dict())
return raw_token, record
def _set_auth_cookies(resp, access_token: str, raw_refresh_token: str):
"""Set both access and refresh token cookies on a response."""
expiry_days = current_app.config['REFRESH_TOKEN_EXPIRY_DAYS']
resp.set_cookie(
'access_token', access_token,
httponly=True, secure=True, samesite='Lax',
max_age=ACCESS_TOKEN_EXPIRY_MINUTES * 60,
)
resp.set_cookie(
'refresh_token', raw_refresh_token,
httponly=True, secure=True, samesite='Strict',
max_age=expiry_days * 24 * 3600,
path='/api/auth',
)
def _clear_auth_cookies(resp):
"""Clear both access and refresh token cookies."""
resp.set_cookie('access_token', '', expires=0, httponly=True, secure=True, samesite='Strict')
resp.set_cookie('refresh_token', '', expires=0, httponly=True, secure=True, samesite='Strict', path='/api/auth')
def _purge_expired_tokens(user_id: str):
"""Remove expired refresh tokens for a user to prevent unbounded DB growth."""
now = datetime.now(timezone.utc)
all_tokens = refresh_tokens_db.search(TokenQuery.user_id == user_id)
for t in all_tokens:
try:
exp = datetime.fromisoformat(t['expires_at'])
if exp.tzinfo is None:
exp = exp.replace(tzinfo=timezone.utc)
if now > exp:
refresh_tokens_db.remove(TokenQuery.id == t['id'])
except (ValueError, KeyError):
refresh_tokens_db.remove(TokenQuery.id == t['id'])
@auth_api.route('/signup', methods=['POST']) @auth_api.route('/signup', methods=['POST'])
def signup(): def signup():
data = request.get_json() data = request.get_json()
@@ -159,21 +275,22 @@ def login():
if user.marked_for_deletion: 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 return jsonify({'error': 'This account has been marked for deletion and cannot be accessed.', 'code': ACCOUNT_MARKED_FOR_DELETION}), 403
payload = { # Purge expired refresh tokens for this user
'email': norm_email, _purge_expired_tokens(user.id)
'user_id': user.id,
'token_version': user.token_version, # Create access token (short-lived JWT)
'exp': datetime.utcnow() + timedelta(days=62) access_token = _create_access_token(user)
}
token = jwt.encode(payload, current_app.config['SECRET_KEY'], algorithm='HS256') # Create refresh token (long-lived, new family for fresh login)
raw_refresh, _ = _create_refresh_token(user.id)
resp = jsonify({'message': 'Login successful'}) resp = jsonify({'message': 'Login successful'})
resp.set_cookie('token', token, httponly=True, secure=True, samesite='Strict') _set_auth_cookies(resp, access_token, raw_refresh)
return resp, 200 return resp, 200
@auth_api.route('/me', methods=['GET']) @auth_api.route('/me', methods=['GET'])
def me(): def me():
token = request.cookies.get('token') token = request.cookies.get('access_token')
if not token: if not token:
return jsonify({'error': 'Missing token', 'code': MISSING_TOKEN}), 401 return jsonify({'error': 'Missing token', 'code': MISSING_TOKEN}), 401
@@ -275,13 +392,262 @@ def reset_password():
user.token_version += 1 user.token_version += 1
users_db.update(user.to_dict(), UserQuery.email == user.email) users_db.update(user.to_dict(), UserQuery.email == user.email)
# Invalidate ALL refresh tokens for this user
refresh_tokens_db.remove(TokenQuery.user_id == user.id)
# Notify all active sessions (other tabs/devices) to sign out immediately
send_event_to_user(user.id, Event(EventType.FORCE_LOGOUT.value, Payload({'reason': 'password_reset'})))
resp = jsonify({'message': 'Password has been reset'}) resp = jsonify({'message': 'Password has been reset'})
resp.set_cookie('token', '', expires=0, httponly=True, secure=True, samesite='Strict') _clear_auth_cookies(resp)
return resp, 200 return resp, 200
@auth_api.route('/refresh', methods=['POST'])
def refresh():
raw_token = request.cookies.get('refresh_token')
if not raw_token:
return jsonify({'error': 'Missing refresh token', 'code': MISSING_REFRESH_TOKEN}), 401
token_hash = _hash_token(raw_token)
token_dict = refresh_tokens_db.get(TokenQuery.token_hash == token_hash)
if not token_dict:
# Token not found — could be invalid or already purged
resp = jsonify({'error': 'Invalid token', 'code': INVALID_TOKEN})
_clear_auth_cookies(resp)
return resp, 401
token_record = RefreshToken.from_dict(token_dict)
# Look up the user early (needed for both legitimate rotation and grace-period handling)
user_dict = users_db.get(UserQuery.id == token_record.user_id)
user = User.from_dict(user_dict) if user_dict else None
if not user:
refresh_tokens_db.remove(TokenQuery.id == token_record.id)
resp = jsonify({'error': 'User not found', 'code': USER_NOT_FOUND})
_clear_auth_cookies(resp)
return resp, 401
if user.marked_for_deletion:
refresh_tokens_db.remove(TokenQuery.user_id == user.id)
resp = jsonify({'error': 'Account marked for deletion', 'code': ACCOUNT_MARKED_FOR_DELETION})
_clear_auth_cookies(resp)
return resp, 403
# Check expiry
try:
exp = datetime.fromisoformat(token_record.expires_at)
if exp.tzinfo is None:
exp = exp.replace(tzinfo=timezone.utc)
if datetime.now(timezone.utc) > exp:
refresh_tokens_db.remove(TokenQuery.id == token_record.id)
resp = jsonify({'error': 'Refresh token expired', 'code': REFRESH_TOKEN_EXPIRED})
_clear_auth_cookies(resp)
return resp, 401
except ValueError:
refresh_tokens_db.remove(TokenQuery.id == token_record.id)
resp = jsonify({'error': 'Invalid token', 'code': INVALID_TOKEN})
_clear_auth_cookies(resp)
return resp, 401
# THEFT DETECTION: token was already used (rotated out) but replayed
if token_record.is_used:
# Grace period: tolerate a very recent rotation to avoid false positives
# from legitimate concurrent refresh requests (race conditions).
grace_period = current_app.config.get(
'REFRESH_TOKEN_REUSE_GRACE_PERIOD_SECONDS', REFRESH_TOKEN_REUSE_GRACE_PERIOD_SECONDS
)
rotated_at = token_record.rotated_at
is_race_condition = False
if rotated_at:
try:
rotated_dt = datetime.fromisoformat(rotated_at)
if rotated_dt.tzinfo is None:
rotated_dt = rotated_dt.replace(tzinfo=timezone.utc)
if (datetime.now(timezone.utc) - rotated_dt).total_seconds() <= grace_period:
is_race_condition = True
except ValueError:
pass
if is_race_condition:
logger.info(
'Refresh token replay within grace period treated as race condition. user_id=%s, family=%s, ip=%s',
token_record.user_id, token_record.token_family, request.remote_addr,
)
raw_new_refresh, _ = _create_refresh_token(user.id, token_family=token_record.token_family)
access_token = _create_access_token(user)
resp = jsonify({
'email': user.email,
'id': user.id,
'first_name': user.first_name,
'last_name': user.last_name,
'verified': user.verified,
})
_set_auth_cookies(resp, access_token, raw_new_refresh)
return resp, 200
logger.warning(
'Refresh token reuse detected! user_id=%s, family=%s, ip=%s — killing family sessions',
token_record.user_id, token_record.token_family, request.remote_addr,
)
# Invalidate only the affected family, not every session for the user.
refresh_tokens_db.remove(
(TokenQuery.user_id == token_record.user_id) & (TokenQuery.token_family == token_record.token_family)
)
resp = jsonify({'error': 'Token reuse detected, family sessions invalidated', 'code': REFRESH_TOKEN_REUSE})
_clear_auth_cookies(resp)
return resp, 401
# ROTATION: mark old token as used, create new one in same family
refresh_tokens_db.update(
{'is_used': True, 'rotated_at': datetime.now(timezone.utc).isoformat()},
TokenQuery.id == token_record.id,
)
raw_new_refresh, _ = _create_refresh_token(user.id, token_family=token_record.token_family)
# Issue new access token
access_token = _create_access_token(user)
resp = jsonify({
'email': user.email,
'id': user.id,
'first_name': user.first_name,
'last_name': user.last_name,
'verified': user.verified,
})
_set_auth_cookies(resp, access_token, raw_new_refresh)
return resp, 200
@auth_api.route('/logout', methods=['POST']) @auth_api.route('/logout', methods=['POST'])
def logout(): def logout():
# Delete the refresh token from DB if present
raw_token = request.cookies.get('refresh_token')
if raw_token:
token_hash = _hash_token(raw_token)
refresh_tokens_db.remove(TokenQuery.token_hash == token_hash)
resp = jsonify({'message': 'Logged out'}) resp = jsonify({'message': 'Logged out'})
# Remove the token cookie by setting it to empty and expiring it _clear_auth_cookies(resp)
resp.set_cookie('token', '', expires=0, httponly=True, secure=True, samesite='Strict')
return resp, 200 return resp, 200
@auth_api.route('/e2e-create-delete-user', methods=['POST'])
def e2e_create_delete_user():
"""Create a secondary e2e test user for deletion testing. Only available outside production."""
if os.environ.get('DB_ENV', 'prod') == 'prod':
return jsonify({'error': 'Not available in production'}), 403
norm_email = normalize_email(E2E_DELETE_EMAIL)
users_db.remove(UserQuery.email == norm_email)
user = User(
first_name='E2E',
last_name='Delete',
email=norm_email,
password=generate_password_hash(E2E_DELETE_PASSWORD),
verified=True,
role='user',
pin=E2E_DELETE_PIN,
tutorial_enabled=False,
)
users_db.insert(user.to_dict())
return jsonify({'email': norm_email}), 201
@auth_api.route('/e2e-create-cc-user', methods=['POST'])
def e2e_create_cc_user():
"""Create an isolated e2e test user for create-child tests. Only available outside production."""
if os.environ.get('DB_ENV', 'prod') == 'prod':
return jsonify({'error': 'Not available in production'}), 403
norm_email = normalize_email(E2E_CC_EMAIL)
# Remove old user and all their children so deleteAllChildren() starts clean.
existing = users_db.get(UserQuery.email == norm_email)
if existing:
child_db.remove(Query().user_id == existing.get('id'))
users_db.remove(UserQuery.email == norm_email)
user = User(
first_name='E2E',
last_name='CreateChild',
email=norm_email,
password=generate_password_hash(E2E_CC_PASSWORD),
verified=True,
role='user',
pin=E2E_CC_PIN,
tutorial_enabled=False,
)
users_db.insert(user.to_dict())
return jsonify({'email': norm_email}), 201
@auth_api.route('/e2e-create-tutorial-user', methods=['POST'])
def e2e_create_tutorial_user():
"""Create an isolated e2e test user for tutorial tests. Only available outside production."""
if os.environ.get('DB_ENV', 'prod') == 'prod':
return jsonify({'error': 'Not available in production'}), 403
norm_email = normalize_email(E2E_TUTORIAL_EMAIL)
# Clean up any children from previous tutorial runs.
existing = users_db.get(UserQuery.email == norm_email)
if existing:
child_db.remove(Query().user_id == existing.get('id'))
users_db.remove(UserQuery.email == norm_email)
user = User(
first_name='E2E',
last_name='Tutorial',
email=norm_email,
password=generate_password_hash(E2E_TUTORIAL_PASSWORD),
verified=True,
role='user',
pin=E2E_TUTORIAL_PIN,
tutorial_enabled=False,
)
users_db.insert(user.to_dict())
return jsonify({'email': norm_email}), 201
@auth_api.route('/e2e-seed', methods=['POST'])
def e2e_seed():
"""Reset the database and insert a verified test user. Only available outside production."""
if os.environ.get('DB_ENV', 'prod') == 'prod':
return jsonify({'error': 'Not available in production'}), 403
child_db.truncate()
task_db.truncate()
reward_db.truncate()
image_db.truncate()
pending_reward_db.truncate()
pending_confirmations_db.truncate()
users_db.truncate()
tracking_events_db.truncate()
child_overrides_db.truncate()
chore_schedules_db.truncate()
task_extensions_db.truncate()
refresh_tokens_db.truncate()
routine_db.truncate()
routine_items_db.truncate()
routine_schedules_db.truncate()
routine_extensions_db.truncate()
push_subscriptions_db.truncate()
digest_action_tokens_db.truncate()
# Recreate only baseline defaults for e2e runs.
initializeImages()
createDefaultTasks()
createDefaultRewards()
norm_email = normalize_email(E2E_TEST_EMAIL)
user = User(
first_name='E2E',
last_name='Tester',
email=norm_email,
password=generate_password_hash(E2E_TEST_PASSWORD),
verified=True,
role='user',
pin=E2E_TEST_PIN,
tutorial_enabled=False,
)
users_db.insert(user.to_dict())
return jsonify({'email': norm_email}), 201
+357
View File
@@ -0,0 +1,357 @@
"""Shared business logic for chore confirmation and reward actions.
Called from both child_api.py (JWT-authenticated endpoints) and
digest_action_api.py (token-authenticated endpoints). All functions take
user_id explicitly rather than reading it from the Flask request context.
"""
import logging
from datetime import datetime, timezone
from tinydb import Query
from db.db import child_db, task_db, reward_db, pending_confirmations_db, routine_db
from db.child_overrides import get_override
from db.chore_schedules import get_schedule
from db.routine_schedules import get_schedule as get_routine_schedule
from db.tracking import insert_tracking_event
from events.sse import send_event_to_user
from events.types.child_chore_confirmation import ChildChoreConfirmation
from events.types.child_routine_confirmation import ChildRoutineConfirmation
from events.types.child_reward_request import ChildRewardRequest
from events.types.child_reward_triggered import ChildRewardTriggered
from events.types.child_task_triggered import ChildTaskTriggered
from events.types.tracking_event_created import TrackingEventCreated
from events.types.event import Event
from events.types.event_types import EventType
from models.child import Child
from models.reward import Reward
from models.routine import Routine
from models.task import Task
from models.tracking_event import TrackingEvent
from utils.tracking_logger import log_tracking_event
logger = logging.getLogger(__name__)
def approve_chore(user_id: str, child_id: str, task_id: str) -> dict | None:
"""Award points for a completed chore and mark the pending confirmation approved.
Returns a result dict on success, or None if the confirmation was already resolved.
Raises ValueError if the child or task cannot be found.
"""
ChildQ = Query()
child_result = child_db.get((ChildQ.id == child_id) & (ChildQ.user_id == user_id))
if not child_result:
raise ValueError(f'Child {child_id} not found for user {user_id}')
child = Child.from_dict(child_result)
if task_id not in child.tasks:
logger.info(f'Task {task_id} no longer assigned to child {child_id}; skipping approve')
return None
PendingQ = Query()
existing = pending_confirmations_db.get(
(PendingQ.child_id == child_id) & (PendingQ.entity_id == task_id) &
(PendingQ.entity_type == 'chore') & (PendingQ.status == 'pending') &
(PendingQ.user_id == user_id)
)
if not existing:
logger.info(f'No pending chore for child {child_id}, task {task_id} — already resolved')
return None
TaskQ = Query()
task_result = task_db.get(
(TaskQ.id == task_id) & ((TaskQ.user_id == user_id) | (TaskQ.user_id == None))
)
if not task_result:
raise ValueError(f'Task {task_id} not found')
task = Task.from_dict(task_result)
override = get_override(child_id, task_id)
points_value = override.custom_value if override else task.points
points_before = child.points
child.points += points_value
child_db.update({'points': child.points}, ChildQ.id == child_id)
schedule = get_schedule(child_id, task_id)
if schedule:
now_str = datetime.now(timezone.utc).isoformat()
pending_confirmations_db.update(
{'status': 'approved', 'approved_at': now_str},
(PendingQ.child_id == child_id) & (PendingQ.entity_id == task_id) &
(PendingQ.entity_type == 'chore') & (PendingQ.user_id == user_id)
)
else:
pending_confirmations_db.remove(
(PendingQ.child_id == child_id) & (PendingQ.entity_id == task_id) &
(PendingQ.entity_type == 'chore') & (PendingQ.user_id == user_id)
)
tracking_metadata = {
'task_name': task.name,
'task_type': task.type,
'default_points': task.points,
}
if override:
tracking_metadata['custom_points'] = override.custom_value
tracking_metadata['has_override'] = True
tracking_event = TrackingEvent.create_event(
user_id=user_id, child_id=child_id, entity_type='chore', entity_id=task_id,
action='approved', points_before=points_before, points_after=child.points,
metadata=tracking_metadata,
)
insert_tracking_event(tracking_event)
log_tracking_event(tracking_event)
send_event_to_user(user_id, Event(EventType.TRACKING_EVENT_CREATED.value,
TrackingEventCreated(tracking_event.id, child_id, 'chore', 'approved')))
send_event_to_user(user_id, Event(EventType.CHILD_CHORE_CONFIRMATION.value,
ChildChoreConfirmation(child_id, task_id, ChildChoreConfirmation.OPERATION_APPROVED)))
send_event_to_user(user_id, Event(EventType.CHILD_TASK_TRIGGERED.value,
ChildTaskTriggered(task_id, child_id, child.points)))
return {'task_name': task.name, 'child_name': child.name, 'child_id': child_id, 'points': child.points}
def reject_chore(user_id: str, child_id: str, task_id: str) -> None:
"""Reject a pending chore confirmation. No-op if already resolved."""
PendingQ = Query()
existing = pending_confirmations_db.get(
(PendingQ.child_id == child_id) & (PendingQ.entity_id == task_id) &
(PendingQ.entity_type == 'chore') & (PendingQ.status == 'pending') &
(PendingQ.user_id == user_id)
)
if not existing:
logger.info(f'No pending chore for child {child_id}, task {task_id} — already resolved')
return
pending_confirmations_db.remove(
(PendingQ.child_id == child_id) & (PendingQ.entity_id == task_id) &
(PendingQ.entity_type == 'chore') & (PendingQ.user_id == user_id)
)
ChildQ = Query()
child_result = child_db.get((ChildQ.id == child_id) & (ChildQ.user_id == user_id))
if child_result:
child = Child.from_dict(child_result)
TaskQ = Query()
task_result = task_db.get(
(TaskQ.id == task_id) & ((TaskQ.user_id == user_id) | (TaskQ.user_id == None))
)
task_name = task_result.get('name') if task_result else 'Unknown'
tracking_event = TrackingEvent.create_event(
user_id=user_id, child_id=child_id, entity_type='chore', entity_id=task_id,
action='rejected', points_before=child.points, points_after=child.points,
metadata={'task_name': task_name},
)
insert_tracking_event(tracking_event)
log_tracking_event(tracking_event)
send_event_to_user(user_id, Event(EventType.TRACKING_EVENT_CREATED.value,
TrackingEventCreated(tracking_event.id, child_id, 'chore', 'rejected')))
send_event_to_user(user_id, Event(EventType.CHILD_CHORE_CONFIRMATION.value,
ChildChoreConfirmation(child_id, task_id, ChildChoreConfirmation.OPERATION_REJECTED)))
def approve_reward_request(user_id: str, child_id: str, reward_id: str) -> dict:
"""Approve a child's pending reward request: deduct points and fire SSE events.
Returns a result dict on success.
Raises ValueError if child/reward not found or the child has insufficient points.
"""
ChildQ = Query()
child_result = child_db.get((ChildQ.id == child_id) & (ChildQ.user_id == user_id))
if not child_result:
raise ValueError(f'Child {child_id} not found for user {user_id}')
child = Child.from_dict(child_result)
if reward_id not in child.rewards:
logger.info(f'Reward {reward_id} no longer assigned to child {child_id}; skipping approve')
return None
RewardQ = Query()
reward_result = reward_db.get(
(RewardQ.id == reward_id) & ((RewardQ.user_id == user_id) | (RewardQ.user_id == None))
)
if not reward_result:
raise ValueError(f'Reward {reward_id} not found')
reward = Reward.from_dict(reward_result)
override = get_override(child_id, reward_id)
cost_value = override.custom_value if override else reward.cost
if child.points < cost_value:
raise ValueError(f'Child {child_id} has insufficient points for reward {reward_id}')
PendingQ = Query()
existing = pending_confirmations_db.get(
(PendingQ.child_id == child_id) & (PendingQ.entity_id == reward_id) &
(PendingQ.entity_type == 'reward') & (PendingQ.status == 'pending') &
(PendingQ.user_id == user_id)
)
if not existing:
logger.info(f'No pending reward for child {child_id}, reward {reward_id} — already resolved')
return None
pending_confirmations_db.remove(
(PendingQ.child_id == child_id) & (PendingQ.entity_id == reward_id) &
(PendingQ.entity_type == 'reward') & (PendingQ.user_id == user_id)
)
send_event_to_user(user_id, Event(EventType.CHILD_REWARD_REQUEST.value,
ChildRewardRequest(child_id, reward_id, ChildRewardRequest.REQUEST_GRANTED)))
points_before = child.points
child.points -= cost_value
child_db.update({'points': child.points}, ChildQ.id == child_id)
tracking_metadata = {
'reward_name': reward.name,
'reward_cost': reward.cost,
'default_cost': reward.cost,
}
if override:
tracking_metadata['custom_cost'] = override.custom_value
tracking_metadata['has_override'] = True
tracking_event = TrackingEvent.create_event(
user_id=user_id, child_id=child_id, entity_type='reward', entity_id=reward_id,
action='redeemed', points_before=points_before, points_after=child.points,
metadata=tracking_metadata,
)
insert_tracking_event(tracking_event)
log_tracking_event(tracking_event)
send_event_to_user(user_id, Event(EventType.TRACKING_EVENT_CREATED.value,
TrackingEventCreated(tracking_event.id, child_id, 'reward', 'redeemed')))
send_event_to_user(user_id, Event(EventType.CHILD_REWARD_TRIGGERED.value,
ChildRewardTriggered(reward_id, child_id, child.points)))
return {'reward_name': reward.name, 'child_name': child.name, 'child_id': child_id, 'points': child.points}
def deny_reward(user_id: str, child_id: str, reward_id: str) -> dict | None:
"""Deny a child's pending reward request. No-op if already resolved.
Returns a result dict on success, or None if already resolved.
"""
PendingQ = Query()
existing = pending_confirmations_db.get(
(PendingQ.child_id == child_id) & (PendingQ.entity_id == reward_id) &
(PendingQ.entity_type == 'reward') & (PendingQ.status == 'pending') &
(PendingQ.user_id == user_id)
)
if not existing:
logger.info(f'Reward request for child {child_id}, reward {reward_id} already resolved')
return None
pending_confirmations_db.remove(
(PendingQ.child_id == child_id) & (PendingQ.entity_id == reward_id) &
(PendingQ.entity_type == 'reward') & (PendingQ.user_id == user_id)
)
ChildQ = Query()
child_result = child_db.get((ChildQ.id == child_id) & (ChildQ.user_id == user_id))
child_name = 'Unknown'
if child_result:
child = Child.from_dict(child_result)
child_name = child.name
tracking_event = TrackingEvent.create_event(
user_id=user_id, child_id=child_id, entity_type='reward', entity_id=reward_id,
action='denied', points_before=child.points, points_after=child.points,
metadata={},
)
insert_tracking_event(tracking_event)
log_tracking_event(tracking_event)
send_event_to_user(user_id, Event(EventType.TRACKING_EVENT_CREATED.value,
TrackingEventCreated(tracking_event.id, child_id, 'reward', 'denied')))
send_event_to_user(user_id, Event(EventType.CHILD_REWARD_REQUEST.value,
ChildRewardRequest(child_id, reward_id, ChildRewardRequest.REQUEST_CANCELLED)))
return {'child_name': child_name}
def approve_routine(user_id: str, child_id: str, routine_id: str) -> dict | None:
"""Award points for a completed routine and mark the pending confirmation approved.
Returns a result dict on success, or None if already resolved.
Raises ValueError if the child or routine cannot be found.
"""
ChildQ = Query()
child_result = child_db.get((ChildQ.id == child_id) & (ChildQ.user_id == user_id))
if not child_result:
raise ValueError(f'Child {child_id} not found for user {user_id}')
child = Child.from_dict(child_result)
if routine_id not in child.routines:
logger.info(f'Routine {routine_id} no longer assigned to child {child_id}; skipping approve')
return None
PendingQ = Query()
existing = pending_confirmations_db.get(
(PendingQ.child_id == child_id) & (PendingQ.entity_id == routine_id) &
(PendingQ.entity_type == 'routine') & (PendingQ.status == 'pending') &
(PendingQ.user_id == user_id)
)
if not existing:
logger.info(f'No pending routine for child {child_id}, routine {routine_id} — already resolved')
return None
RoutineQ = Query()
routine_result = routine_db.get(
(RoutineQ.id == routine_id) & ((RoutineQ.user_id == user_id) | (RoutineQ.user_id == None))
)
if not routine_result:
raise ValueError(f'Routine {routine_id} not found')
routine = Routine.from_dict(routine_result)
override = get_override(child_id, routine_id)
points_value = override.custom_value if override and override.entity_type == 'routine' else routine.points
points_before = child.points
child.points += points_value
child_db.update({'points': child.points}, ChildQ.id == child_id)
schedule = get_routine_schedule(child_id, routine_id)
now_str = datetime.now(timezone.utc).isoformat()
if schedule:
pending_confirmations_db.update(
{'status': 'approved', 'approved_at': now_str},
(PendingQ.child_id == child_id) & (PendingQ.entity_id == routine_id) &
(PendingQ.entity_type == 'routine') & (PendingQ.user_id == user_id)
)
else:
pending_confirmations_db.remove(
(PendingQ.child_id == child_id) & (PendingQ.entity_id == routine_id) &
(PendingQ.entity_type == 'routine') & (PendingQ.user_id == user_id)
)
send_event_to_user(user_id, Event(EventType.CHILD_ROUTINE_CONFIRMATION.value,
ChildRoutineConfirmation(child_id, routine_id, ChildRoutineConfirmation.OPERATION_APPROVED)))
return {'routine_name': routine.name, 'child_name': child.name, 'child_id': child_id, 'points': child.points}
def reject_routine(user_id: str, child_id: str, routine_id: str) -> None:
"""Reject a pending routine confirmation. No-op if already resolved."""
PendingQ = Query()
existing = pending_confirmations_db.get(
(PendingQ.child_id == child_id) & (PendingQ.entity_id == routine_id) &
(PendingQ.entity_type == 'routine') & (PendingQ.status == 'pending') &
(PendingQ.user_id == user_id)
)
if not existing:
logger.info(f'No pending routine for child {child_id}, routine {routine_id} — already resolved')
return
pending_confirmations_db.remove(
(PendingQ.child_id == child_id) & (PendingQ.entity_id == routine_id) &
(PendingQ.entity_type == 'routine') & (PendingQ.user_id == user_id)
)
send_event_to_user(user_id, Event(EventType.CHILD_ROUTINE_CONFIRMATION.value,
ChildRoutineConfirmation(child_id, routine_id, ChildRoutineConfirmation.OPERATION_REJECTED)))
+563 -62
View File
@@ -1,16 +1,20 @@
from time import sleep from time import sleep
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
from flask import Blueprint, request, jsonify from flask import Blueprint, request, jsonify
from tinydb import Query from tinydb import Query
from api.child_rewards import ChildReward from api.child_rewards import ChildReward
from api.child_tasks import ChildTask from api.child_tasks import ChildTask
from api.pending_reward import PendingReward as PendingRewardResponse from api.pending_confirmation import PendingConfirmationResponse
from api.reward_status import RewardStatus from api.reward_status import RewardStatus
from api.utils import send_event_for_current_user from api.utils import send_event_for_current_user, get_validated_user_id
from db.db import child_db, task_db, reward_db, pending_reward_db import api.child_action_helpers as chore_actions
from db.db import child_db, task_db, reward_db, routine_db, pending_reward_db, pending_confirmations_db, users_db
from db.tracking import insert_tracking_event from db.tracking import insert_tracking_event
from db.child_overrides import get_override, delete_override, delete_overrides_for_child from db.child_overrides import get_override, delete_override, delete_overrides_for_child
from events.types.child_chore_confirmation import ChildChoreConfirmation
from events.types.child_modified import ChildModified from events.types.child_modified import ChildModified
from events.types.child_reward_request import ChildRewardRequest from events.types.child_reward_request import ChildRewardRequest
from events.types.child_reward_triggered import ChildRewardTriggered from events.types.child_reward_triggered import ChildRewardTriggered
@@ -21,18 +25,71 @@ from events.types.tracking_event_created import TrackingEventCreated
from events.types.event import Event from events.types.event import Event
from events.types.event_types import EventType from events.types.event_types import EventType
from models.child import Child from models.child import Child
from models.pending_confirmation import PendingConfirmation
from models.pending_reward import PendingReward from models.pending_reward import PendingReward
from models.reward import Reward from models.reward import Reward
from models.task import Task from models.task import Task
from models.tracking_event import TrackingEvent from models.tracking_event import TrackingEvent
from api.utils import get_validated_user_id
from utils.tracking_logger import log_tracking_event from utils.tracking_logger import log_tracking_event
from utils.push_sender import send_push_to_user
from utils.digest_token import create_action_token
from collections import defaultdict from collections import defaultdict
from db.chore_schedules import get_schedule
from db.task_extensions import get_extension_for_child_task
from db.routine_schedules import delete_schedules_for_child as delete_routine_schedules_for_child
from db.routine_extensions import delete_extensions_for_child as delete_routine_extensions_for_child
import logging import logging
child_api = Blueprint('child_api', __name__) child_api = Blueprint('child_api', __name__)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _get_user_timezone(user_id: str) -> str | None:
user = users_db.get(Query().id == user_id)
if not user:
return None
return user.get('timezone')
def _get_user_today_local(user_id: str) -> tuple[str, str | None]:
tz_str = _get_user_timezone(user_id)
try:
now_local = datetime.now(ZoneInfo(tz_str)) if tz_str else datetime.now(timezone.utc)
except Exception:
tz_str = None
now_local = datetime.now(timezone.utc)
return now_local.strftime('%Y-%m-%d'), tz_str
def _is_iso_timestamp_on_local_day(timestamp: str | None, local_day: str, tz_str: str | None) -> bool:
if not timestamp:
return False
try:
normalized = timestamp.replace('Z', '+00:00')
parsed = datetime.fromisoformat(normalized)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
try:
tz = ZoneInfo(tz_str) if tz_str else timezone.utc
except Exception:
tz = timezone.utc
return parsed.astimezone(tz).strftime('%Y-%m-%d') == local_day
except (TypeError, ValueError):
return False
def _is_epoch_timestamp_on_local_day(epoch_ts, local_day: str, tz_str: str | None) -> bool:
if epoch_ts is None:
return False
try:
tz = ZoneInfo(tz_str) if tz_str else timezone.utc
except Exception:
tz = timezone.utc
try:
return datetime.fromtimestamp(float(epoch_ts), tz).strftime('%Y-%m-%d') == local_day
except (TypeError, ValueError, OSError):
return False
@child_api.route('/child/<name>', methods=['GET']) @child_api.route('/child/<name>', methods=['GET'])
@child_api.route('/child/<id>', methods=['GET']) @child_api.route('/child/<id>', methods=['GET'])
def get_child(id): def get_child(id):
@@ -95,18 +152,22 @@ def edit_child(id):
# Check if points changed and handle pending rewards # Check if points changed and handle pending rewards
if points is not None: if points is not None:
PendingQuery = Query() PendingQuery = Query()
pending_rewards = pending_reward_db.search((PendingQuery.child_id == id) & (PendingQuery.user_id == user_id)) pending_rewards = pending_confirmations_db.search(
(PendingQuery.child_id == id) & (PendingQuery.user_id == user_id) &
(PendingQuery.entity_type == 'reward') & (PendingQuery.status == 'pending')
)
RewardQuery = Query() RewardQuery = Query()
for pr in pending_rewards: for pr in pending_rewards:
pending = PendingReward.from_dict(pr) pending = PendingConfirmation.from_dict(pr)
reward_result = reward_db.get((RewardQuery.id == pending.reward_id) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None))) reward_result = reward_db.get((RewardQuery.id == pending.entity_id) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None)))
if reward_result: if reward_result:
reward = Reward.from_dict(reward_result) reward = Reward.from_dict(reward_result)
# If child can no longer afford the reward, remove the pending request # If child can no longer afford the reward, remove the pending request
if child.points < reward.cost: if child.points < reward.cost:
pending_reward_db.remove( pending_confirmations_db.remove(
(PendingQuery.child_id == id) & (PendingQuery.reward_id == reward.id) & (PendingQuery.user_id == user_id) (PendingQuery.child_id == id) & (PendingQuery.entity_id == reward.id) &
(PendingQuery.entity_type == 'reward') & (PendingQuery.user_id == user_id)
) )
resp = send_event_for_current_user( resp = send_event_for_current_user(
Event(EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest(id, reward.id, ChildRewardRequest.REQUEST_CANCELLED))) Event(EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest(id, reward.id, ChildRewardRequest.REQUEST_CANCELLED)))
@@ -140,6 +201,14 @@ def delete_child(id):
if deleted_count > 0: if deleted_count > 0:
logger.info(f"Cascade deleted {deleted_count} overrides for child {id}") logger.info(f"Cascade deleted {deleted_count} overrides for child {id}")
# Cascade delete routine schedule/extension rows for this child.
delete_routine_schedules_for_child(id)
delete_routine_extensions_for_child(id)
# Remove pending routine confirmations for this child.
PendingQuery = Query()
pending_confirmations_db.remove((PendingQuery.child_id == id) & (PendingQuery.entity_type == 'routine'))
if child_db.remove((ChildQuery.id == id) & (ChildQuery.user_id == user_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))) resp = send_event_for_current_user(Event(EventType.CHILD_MODIFIED.value, ChildModified(id, ChildModified.OPERATION_DELETE)))
if resp: if resp:
@@ -177,11 +246,10 @@ def set_child_tasks(id):
data = request.get_json() or {} data = request.get_json() or {}
task_ids = data.get('task_ids') task_ids = data.get('task_ids')
if 'type' not in data: if 'type' not in data:
return jsonify({'error': 'type is required (good or bad)'}), 400 return jsonify({'error': 'type is required (chore, kindness, or penalty)'}), 400
task_type = data.get('type', 'good') task_type = data.get('type')
if task_type not in ['good', 'bad']: if task_type not in ['chore', 'kindness', 'penalty']:
return jsonify({'error': 'type must be either good or bad'}), 400 return jsonify({'error': 'type must be chore, kindness, or penalty', 'code': 'INVALID_TASK_TYPE'}), 400
is_good = task_type == 'good'
if not isinstance(task_ids, list): if not isinstance(task_ids, list):
return jsonify({'error': 'task_ids must be a list'}), 400 return jsonify({'error': 'task_ids must be a list'}), 400
@@ -192,15 +260,16 @@ def set_child_tasks(id):
child = Child.from_dict(result[0]) child = Child.from_dict(result[0])
new_task_ids = set(task_ids) new_task_ids = set(task_ids)
# Add all existing child tasks of the opposite type # Add all existing child tasks of other types
for task in task_db.all(): for task_record in task_db.all():
if task['id'] in child.tasks and task['is_good'] != is_good: task_obj = Task.from_dict(task_record)
new_task_ids.add(task['id']) if task_obj.id in child.tasks and task_obj.type != task_type:
new_task_ids.add(task_obj.id)
# Convert back to list if needed # Convert back to list if needed
new_tasks = list(new_task_ids) new_tasks = list(new_task_ids)
# Identify unassigned tasks and delete their overrides # Identify unassigned tasks and delete their overrides and pending confirmations
old_task_ids = set(child.tasks) old_task_ids = set(child.tasks)
unassigned_task_ids = old_task_ids - new_task_ids unassigned_task_ids = old_task_ids - new_task_ids
for task_id in unassigned_task_ids: for task_id in unassigned_task_ids:
@@ -209,6 +278,12 @@ def set_child_tasks(id):
if override and override.entity_type == 'task': if override and override.entity_type == 'task':
delete_override(id, task_id) delete_override(id, task_id)
logger.info(f"Deleted override for unassigned task: child={id}, task={task_id}") logger.info(f"Deleted override for unassigned task: child={id}, task={task_id}")
# Clear any pending chore confirmation
PendingQ = Query()
pending_confirmations_db.remove(
(PendingQ.child_id == id) & (PendingQ.entity_id == task_id) &
(PendingQ.entity_type == 'chore') & (PendingQ.user_id == user_id)
)
# Replace tasks with validated IDs # Replace tasks with validated IDs
child_db.update({'tasks': new_tasks}, ChildQuery.id == id) child_db.update({'tasks': new_tasks}, ChildQuery.id == id)
@@ -242,6 +317,12 @@ def remove_task_from_child(id):
if task_id in child.get('tasks', []): if task_id in child.get('tasks', []):
child['tasks'].remove(task_id) child['tasks'].remove(task_id)
child_db.update({'tasks': child['tasks']}, ChildQuery.id == id) child_db.update({'tasks': child['tasks']}, ChildQuery.id == id)
# Clear any pending chore confirmation for this task
PendingQ = Query()
pending_confirmations_db.remove(
(PendingQ.child_id == id) & (PendingQ.entity_id == task_id) &
(PendingQ.entity_type == 'chore') & (PendingQ.user_id == user_id)
)
return jsonify({'message': f'Task {task_id} removed from {child["name"]}.'}), 200 return jsonify({'message': f'Task {task_id} removed from {child["name"]}.'}), 200
return jsonify({'error': 'Task not assigned to child'}), 400 return jsonify({'error': 'Task not assigned to child'}), 400
@@ -259,20 +340,62 @@ def list_child_tasks(id):
task_ids = child.get('tasks', []) task_ids = child.get('tasks', [])
TaskQuery = Query() TaskQuery = Query()
today_local, tz_str = _get_user_today_local(user_id)
child_tasks = [] child_tasks = []
for tid in task_ids: for tid in task_ids:
task = task_db.get((TaskQuery.id == tid) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None))) task = task_db.get((TaskQuery.id == tid) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)))
if not task: if not task:
continue continue
task_obj = Task.from_dict(task)
# Check for override # Check for override
override = get_override(id, tid) override = get_override(id, tid)
custom_value = override.custom_value if override else None custom_value = override.custom_value if override else None
ct = ChildTask(task.get('name'), task.get('is_good'), task.get('points'), task.get('image_id'), task.get('id')) ct = ChildTask(task_obj.name, task_obj.type, task_obj.points, task_obj.image_id, task_obj.id)
ct_dict = ct.to_dict() ct_dict = ct.to_dict()
if custom_value is not None: if custom_value is not None:
ct_dict['custom_value'] = custom_value ct_dict['custom_value'] = custom_value
# Attach schedule and most recent extension_date for chores (client does all time math)
if task_obj.type == 'chore':
schedule = get_schedule(id, tid)
ct_dict['schedule'] = schedule.to_dict() if schedule else None
ext = get_extension_for_child_task(id, tid)
ct_dict['extension_date'] = ext.date if ext else None
# Attach pending confirmation status for chores
PendingQuery = Query()
pending = pending_confirmations_db.get(
(PendingQuery.child_id == id) & (PendingQuery.entity_id == tid) &
(PendingQuery.entity_type == 'chore') & (PendingQuery.user_id == user_id)
)
if pending:
status = pending.get('status')
approved_at = pending.get('approved_at')
created_at = pending.get('created_at')
if status == 'approved' and _is_iso_timestamp_on_local_day(approved_at, today_local, tz_str):
ct_dict['pending_status'] = 'approved'
ct_dict['approved_at'] = approved_at
elif status == 'pending' and _is_epoch_timestamp_on_local_day(created_at, today_local, tz_str):
ct_dict['pending_status'] = 'pending'
ct_dict['approved_at'] = None
else:
pending_id = pending.get('id')
if pending_id:
pending_confirmations_db.remove(PendingQuery.id == pending_id)
ct_dict['pending_status'] = None
ct_dict['approved_at'] = None
else:
ct_dict['pending_status'] = None
ct_dict['approved_at'] = None
else:
ct_dict['schedule'] = None
ct_dict['extension_date'] = None
ct_dict['pending_status'] = None
ct_dict['approved_at'] = None
child_tasks.append(ct_dict) child_tasks.append(ct_dict)
return jsonify({'tasks': child_tasks}), 200 return jsonify({'tasks': child_tasks}), 200
@@ -294,7 +417,6 @@ def list_assignable_tasks(id):
all_tasks = [t for t in task_db.all() if t and t.get('id') and t.get('id') not in assigned_ids] 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 # Group by name
from collections import defaultdict
name_to_tasks = defaultdict(list) name_to_tasks = defaultdict(list)
for t in all_tasks: for t in all_tasks:
name_to_tasks[t.get('name')].append(t) name_to_tasks[t.get('name')].append(t)
@@ -313,7 +435,7 @@ def list_assignable_tasks(id):
filtered_tasks.extend(user_tasks) filtered_tasks.extend(user_tasks)
# Wrap in ChildTask and return # Wrap in ChildTask and return
assignable_tasks = [ChildTask(t.get('name'), t.get('is_good'), t.get('points'), t.get('image_id'), t.get('id')).to_dict() for t in filtered_tasks] assignable_tasks = [ChildTask(t.get('name'), Task.from_dict(t).type, t.get('points'), t.get('image_id'), t.get('id')).to_dict() for t in filtered_tasks]
return jsonify({'tasks': assignable_tasks, 'count': len(assignable_tasks)}), 200 return jsonify({'tasks': assignable_tasks, 'count': len(assignable_tasks)}), 200
@@ -327,9 +449,9 @@ def list_all_tasks(id):
if not result: if not result:
return jsonify({'error': 'Child not found'}), 404 return jsonify({'error': 'Child not found'}), 404
has_type = "type" in request.args has_type = "type" in request.args
if has_type and request.args.get('type') not in ['good', 'bad']: if has_type and request.args.get('type') not in ['chore', 'kindness', 'penalty']:
return jsonify({'error': 'type must be either good or bad'}), 400 return jsonify({'error': 'type must be chore, kindness, or penalty'}), 400
good = request.args.get('type', False) == 'good' filter_type = request.args.get('type', None) if has_type else None
child = result[0] child = result[0]
assigned_ids = set(child.get('tasks', [])) assigned_ids = set(child.get('tasks', []))
@@ -353,14 +475,15 @@ def list_all_tasks(id):
result_tasks = [] result_tasks = []
for t in filtered_tasks: for t in filtered_tasks:
if has_type and t.get('is_good') != good: task_obj = Task.from_dict(t)
if has_type and task_obj.type != filter_type:
continue continue
ct = ChildTask( ct = ChildTask(
t.get('name'), task_obj.name,
t.get('is_good'), task_obj.type,
t.get('points'), task_obj.points,
t.get('image_id'), task_obj.image_id,
t.get('id') task_obj.id
) )
task_dict = ct.to_dict() task_dict = ct.to_dict()
task_dict.update({'assigned': t.get('id') in assigned_ids}) task_dict.update({'assigned': t.get('id') in assigned_ids})
@@ -412,11 +535,31 @@ def trigger_child_task(id):
# update the child in the database # update the child in the database
child_db.update({'points': child.points}, ChildQuery.id == id) child_db.update({'points': child.points}, ChildQuery.id == id)
# For chores, create an approved PendingConfirmation so child view shows COMPLETED
# Only persist for scheduled chores; general chores reset to normal after trigger
if task.type == 'chore':
PendingQuery = Query()
# Remove any existing pending confirmation for this chore
pending_confirmations_db.remove(
(PendingQuery.child_id == id) & (PendingQuery.entity_id == task_id) &
(PendingQuery.entity_type == 'chore') & (PendingQuery.user_id == user_id)
)
schedule = get_schedule(id, task_id)
if schedule:
confirmation = PendingConfirmation(
child_id=id, entity_id=task_id, entity_type='chore',
user_id=user_id, status='approved',
approved_at=datetime.now(timezone.utc).isoformat()
)
pending_confirmations_db.insert(confirmation.to_dict())
send_event_for_current_user(Event(EventType.CHILD_CHORE_CONFIRMATION.value,
ChildChoreConfirmation(id, task_id, ChildChoreConfirmation.OPERATION_APPROVED)))
# Create tracking event # Create tracking event
entity_type = 'penalty' if not task.is_good else 'task' entity_type = task.type
tracking_metadata = { tracking_metadata = {
'task_name': task.name, 'task_name': task.name,
'is_good': task.is_good, 'task_type': task.type,
'default_points': task.points 'default_points': task.points
} }
if override: if override:
@@ -482,7 +625,6 @@ def list_all_rewards(id):
ChildRewardQuery = Query() ChildRewardQuery = Query()
all_rewards = reward_db.search((ChildRewardQuery.user_id == user_id) | (ChildRewardQuery.user_id == None)) all_rewards = reward_db.search((ChildRewardQuery.user_id == user_id) | (ChildRewardQuery.user_id == None))
from collections import defaultdict
name_to_rewards = defaultdict(list) name_to_rewards = defaultdict(list)
for r in all_rewards: for r in all_rewards:
name_to_rewards[r.get('name')].append(r) name_to_rewards[r.get('name')].append(r)
@@ -544,7 +686,7 @@ def set_child_rewards(id):
if reward_db.get((RewardQuery.id == rid) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None))): if reward_db.get((RewardQuery.id == rid) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None))):
valid_reward_ids.append(rid) valid_reward_ids.append(rid)
# Identify unassigned rewards and delete their overrides # Identify unassigned rewards and delete their overrides and pending confirmations
new_reward_ids_set = set(valid_reward_ids) new_reward_ids_set = set(valid_reward_ids)
unassigned_reward_ids = old_reward_ids - new_reward_ids_set unassigned_reward_ids = old_reward_ids - new_reward_ids_set
for reward_id in unassigned_reward_ids: for reward_id in unassigned_reward_ids:
@@ -552,6 +694,12 @@ def set_child_rewards(id):
if override and override.entity_type == 'reward': if override and override.entity_type == 'reward':
delete_override(id, reward_id) delete_override(id, reward_id)
logger.info(f"Deleted override for unassigned reward: child={id}, reward={reward_id}") logger.info(f"Deleted override for unassigned reward: child={id}, reward={reward_id}")
# Clear any pending reward confirmation
PendingQ = Query()
pending_confirmations_db.remove(
(PendingQ.child_id == id) & (PendingQ.entity_id == reward_id) &
(PendingQ.entity_type == 'reward') & (PendingQ.user_id == user_id)
)
# Replace rewards with validated IDs # Replace rewards with validated IDs
child_db.update({'rewards': valid_reward_ids}, ChildQuery.id == id) child_db.update({'rewards': valid_reward_ids}, ChildQuery.id == id)
@@ -582,6 +730,12 @@ def remove_reward_from_child(id):
if reward_id in child.get('rewards', []): if reward_id in child.get('rewards', []):
child['rewards'].remove(reward_id) child['rewards'].remove(reward_id)
child_db.update({'rewards': child['rewards']}, ChildQuery.id == id) child_db.update({'rewards': child['rewards']}, ChildQuery.id == id)
# Clear any pending reward confirmation for this reward
PendingQ = Query()
pending_confirmations_db.remove(
(PendingQ.child_id == id) & (PendingQ.entity_id == reward_id) &
(PendingQ.entity_type == 'reward') & (PendingQ.user_id == user_id)
)
return jsonify({'message': f'Reward {reward_id} removed from {child["name"]}.'}), 200 return jsonify({'message': f'Reward {reward_id} removed from {child["name"]}.'}), 200
return jsonify({'error': 'Reward not assigned to child'}), 400 return jsonify({'error': 'Reward not assigned to child'}), 400
@@ -634,7 +788,6 @@ def list_assignable_rewards(id):
all_rewards = [r for r in reward_db.all() if r and r.get('id') and r.get('id') not in assigned_ids] 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 # Group by name
from collections import defaultdict
name_to_rewards = defaultdict(list) name_to_rewards = defaultdict(list)
for r in all_rewards: for r in all_rewards:
name_to_rewards[r.get('name')].append(r) name_to_rewards[r.get('name')].append(r)
@@ -694,8 +847,9 @@ def trigger_child_reward(id):
# Remove matching pending reward requests for this child and reward # Remove matching pending reward requests for this child and reward
PendingQuery = Query() PendingQuery = Query()
removed = pending_reward_db.remove( removed = pending_confirmations_db.remove(
(PendingQuery.child_id == child.id) & (PendingQuery.reward_id == reward.id) (PendingQuery.child_id == child.id) & (PendingQuery.entity_id == reward.id) &
(PendingQuery.entity_type == 'reward')
) )
if removed: if removed:
send_event_for_current_user(Event(EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest(reward.id, child.id, ChildRewardRequest.REQUEST_GRANTED))) send_event_for_current_user(Event(EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest(reward.id, child.id, ChildRewardRequest.REQUEST_GRANTED)))
@@ -772,6 +926,7 @@ def reward_status(id):
reward_ids = child.rewards reward_ids = child.rewards
RewardQuery = Query() RewardQuery = Query()
today_local, tz_str = _get_user_today_local(user_id)
statuses = [] statuses = []
for reward_id in reward_ids: for reward_id in reward_ids:
reward_dict = reward_db.get((RewardQuery.id == reward_id) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None))) reward_dict = reward_db.get((RewardQuery.id == reward_id) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None)))
@@ -784,10 +939,22 @@ def reward_status(id):
cost_value = override.custom_value if override else reward.cost cost_value = override.custom_value if override else reward.cost
points_needed = max(0, cost_value - points) points_needed = max(0, cost_value - points)
#check to see if this reward id and child id is in the pending rewards db if so set its redeeming flag to true #check to see if this reward id and child id is in the pending confirmations db
pending_query = Query() pending_query = Query()
pending = pending_reward_db.get((pending_query.child_id == child.id) & (pending_query.reward_id == reward.id) & (pending_query.user_id == user_id)) pending = pending_confirmations_db.get(
status = RewardStatus(reward.id, reward.name, points_needed, cost_value, pending is not None, reward.image_id) (pending_query.child_id == child.id) & (pending_query.entity_id == reward.id) &
(pending_query.entity_type == 'reward') & (pending_query.user_id == user_id)
)
redeeming = False
if pending and pending.get('status') == 'pending':
if _is_epoch_timestamp_on_local_day(pending.get('created_at'), today_local, tz_str):
redeeming = True
else:
pending_id = pending.get('id')
if pending_id:
pending_confirmations_db.remove(pending_query.id == pending_id)
status = RewardStatus(reward.id, reward.name, points_needed, cost_value, redeeming, reward.image_id)
status_dict = status.to_dict() status_dict = status.to_dict()
if override: if override:
status_dict['custom_value'] = override.custom_value status_dict['custom_value'] = override.custom_value
@@ -834,8 +1001,23 @@ def request_reward(id):
'reward_cost': reward.cost 'reward_cost': reward.cost
}), 400 }), 400
pending = PendingReward(child_id=child.id, reward_id=reward.id, user_id=user_id) # Check for duplicate pending request
pending_reward_db.insert(pending.to_dict()) DupQuery = Query()
duplicate = pending_confirmations_db.get(
(DupQuery.child_id == child.id) & (DupQuery.entity_id == reward.id) &
(DupQuery.entity_type == 'reward') & (DupQuery.status == 'pending') &
(DupQuery.user_id == user_id)
)
if duplicate:
today_local, tz_str = _get_user_today_local(user_id)
if _is_epoch_timestamp_on_local_day(duplicate.get('created_at'), today_local, tz_str):
return jsonify({'error': 'Reward request already pending', 'code': 'DUPLICATE_REWARD_REQUEST'}), 409
pending_id = duplicate.get('id')
if pending_id:
pending_confirmations_db.remove(DupQuery.id == pending_id)
pending = PendingConfirmation(child_id=child.id, entity_id=reward.id, entity_type='reward', user_id=user_id)
pending_confirmations_db.insert(pending.to_dict())
logger.info(f'Pending reward request created for child {child.name} for reward {reward.name}') logger.info(f'Pending reward request created for child {child.name} for reward {reward.name}')
# Create tracking event (no points change on request) # Create tracking event (no points change on request)
@@ -856,6 +1038,30 @@ def request_reward(id):
send_event_for_current_user(Event(EventType.TRACKING_EVENT_CREATED.value, TrackingEventCreated(tracking_event.id, child.id, 'reward', 'requested'))) send_event_for_current_user(Event(EventType.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))) send_event_for_current_user(Event(EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest(child.id, reward.id, ChildRewardRequest.REQUEST_CREATED)))
# Fire web push notification to all parent subscriptions
_push_user = users_db.get(Query().id == user_id)
if _push_user and _push_user.get('push_notifications_enabled', True):
try:
approve_token = create_action_token(user_id, child.id, reward.id, 'reward', 'approve')
deny_token = create_action_token(user_id, child.id, reward.id, 'reward', 'deny')
push_payload = {
'type': 'reward_requested',
'title': f'{child.name} wants a reward',
'body': f'{reward.name} costs {reward.cost} points.',
'user_id': user_id,
'child_id': child.id,
'child_name': child.name,
'entity_id': reward.id,
'entity_type': 'reward',
'entity_name': reward.name,
'approve_token': approve_token.id,
'deny_token': deny_token.id,
}
send_push_to_user(user_id, push_payload)
except Exception as _push_err:
logger.warning(f'Push notification failed for reward request: {_push_err}')
return jsonify({ return jsonify({
'message': f'Reward request for {reward.name} submitted for {child.name}.', 'message': f'Reward request for {reward.name} submitted for {child.name}.',
'reward_id': reward.id, 'reward_id': reward.id,
@@ -890,8 +1096,9 @@ def cancel_request_reward(id):
# Remove matching pending reward request # Remove matching pending reward request
PendingQuery = Query() PendingQuery = Query()
removed = pending_reward_db.remove( removed = pending_confirmations_db.remove(
(PendingQuery.child_id == child.id) & (PendingQuery.reward_id == reward_id) & (PendingQuery.user_id == user_id) (PendingQuery.child_id == child.id) & (PendingQuery.entity_id == reward_id) &
(PendingQuery.entity_type == 'reward') & (PendingQuery.user_id == user_id)
) )
if not removed: if not removed:
@@ -927,26 +1134,29 @@ def cancel_request_reward(id):
@child_api.route('/pending-rewards', methods=['GET']) @child_api.route('/pending-confirmations', methods=['GET'])
def list_pending_rewards(): def list_pending_confirmations():
user_id = get_validated_user_id() user_id = get_validated_user_id()
if not user_id: if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401 return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
PendingQuery = Query() PendingQuery = Query()
pending_rewards = pending_reward_db.search(PendingQuery.user_id == user_id) today_local, tz_str = _get_user_today_local(user_id)
reward_responses = [] pending_items = pending_confirmations_db.search(
(PendingQuery.user_id == user_id) & (PendingQuery.status == 'pending')
)
responses = []
RewardQuery = Query() RewardQuery = Query()
TaskQuery = Query()
RoutineQuery = Query()
ChildQuery = Query() ChildQuery = Query()
for pr in pending_rewards: for pr in pending_items:
pending = PendingReward.from_dict(pr) pending = PendingConfirmation.from_dict(pr)
# Look up reward details if not _is_epoch_timestamp_on_local_day(pending.created_at, today_local, tz_str):
reward_result = reward_db.get((RewardQuery.id == pending.reward_id) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None))) pending_confirmations_db.remove(PendingQuery.id == pending.id)
if not reward_result:
continue continue
reward = Reward.from_dict(reward_result)
# Look up child details # Look up child details
child_result = child_db.get(ChildQuery.id == pending.child_id) child_result = child_db.get(ChildQuery.id == pending.child_id)
@@ -954,17 +1164,308 @@ def list_pending_rewards():
continue continue
child = Child.from_dict(child_result) child = Child.from_dict(child_result)
# Create response object # Look up entity details based on type
response = PendingRewardResponse( if pending.entity_type == 'reward':
entity_result = reward_db.get((RewardQuery.id == pending.entity_id) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None)))
elif pending.entity_type == 'chore':
entity_result = task_db.get((TaskQuery.id == pending.entity_id) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)))
elif pending.entity_type == 'routine':
entity_result = routine_db.get((RoutineQuery.id == pending.entity_id) & ((RoutineQuery.user_id == user_id) | (RoutineQuery.user_id == None)))
else:
entity_result = None
if not entity_result:
continue
response = PendingConfirmationResponse(
_id=pending.id, _id=pending.id,
child_id=child.id, child_id=child.id,
child_name=child.name, child_name=child.name,
child_image_id=child.image_id, child_image_id=child.image_id,
reward_id=reward.id, entity_id=pending.entity_id,
reward_name=reward.name, entity_type=pending.entity_type,
reward_image_id=reward.image_id entity_name=entity_result.get('name'),
entity_image_id=entity_result.get('image_id'),
status=pending.status,
approved_at=pending.approved_at
) )
reward_responses.append(response.to_dict()) responses.append(response.to_dict())
return jsonify({'confirmations': responses, 'count': len(responses), 'list_type': 'notification'}), 200
# ---------------------------------------------------------------------------
# Chore Confirmation Endpoints
# ---------------------------------------------------------------------------
@child_api.route('/child/<id>/confirm-chore', methods=['POST'])
def confirm_chore(id):
"""Child confirms they completed a chore. Creates a pending confirmation."""
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
data = request.get_json()
task_id = data.get('task_id')
if not task_id:
return jsonify({'error': 'task_id is required'}), 400
ChildQuery = Query()
result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id))
if not result:
return jsonify({'error': 'Child not found'}), 404
child = Child.from_dict(result[0])
if task_id not in child.tasks:
return jsonify({'error': 'Task not assigned to child', 'code': 'ENTITY_NOT_ASSIGNED'}), 400
TaskQuery = Query()
task_result = task_db.get((TaskQuery.id == task_id) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)))
if not task_result:
return jsonify({'error': 'Task not found', 'code': 'TASK_NOT_FOUND'}), 404
task = Task.from_dict(task_result)
if task.type != 'chore':
return jsonify({'error': 'Only chores can be confirmed', 'code': 'INVALID_TASK_TYPE'}), 400
# Check if already pending or completed today
PendingQuery = Query()
existing = pending_confirmations_db.get(
(PendingQuery.child_id == id) & (PendingQuery.entity_id == task_id) &
(PendingQuery.entity_type == 'chore') & (PendingQuery.user_id == user_id)
)
if existing:
today_local, tz_str = _get_user_today_local(user_id)
if existing.get('status') == 'pending':
if _is_epoch_timestamp_on_local_day(existing.get('created_at'), today_local, tz_str):
return jsonify({'error': 'Chore already pending confirmation', 'code': 'CHORE_ALREADY_PENDING'}), 400
pending_id = existing.get('id')
if pending_id:
pending_confirmations_db.remove(PendingQuery.id == pending_id)
if existing.get('status') == 'approved':
approved_at = existing.get('approved_at', '')
if _is_iso_timestamp_on_local_day(approved_at, today_local, tz_str):
return jsonify({'error': 'Chore already completed today', 'code': 'CHORE_ALREADY_COMPLETED'}), 400
pending_id = existing.get('id')
if pending_id:
pending_confirmations_db.remove(PendingQuery.id == pending_id)
confirmation = PendingConfirmation(
child_id=id, entity_id=task_id, entity_type='chore', user_id=user_id
)
pending_confirmations_db.insert(confirmation.to_dict())
# Create tracking event
tracking_event = TrackingEvent.create_event(
user_id=user_id, child_id=id, entity_type='chore', entity_id=task_id,
action='confirmed', points_before=child.points, points_after=child.points,
metadata={'task_name': task.name, 'task_type': task.type}
)
insert_tracking_event(tracking_event)
log_tracking_event(tracking_event)
send_event_for_current_user(Event(EventType.TRACKING_EVENT_CREATED.value,
TrackingEventCreated(tracking_event.id, id, 'chore', 'confirmed')))
send_event_for_current_user(Event(EventType.CHILD_CHORE_CONFIRMATION.value,
ChildChoreConfirmation(id, task_id, ChildChoreConfirmation.OPERATION_CONFIRMED)))
# Fire web push notification to all parent subscriptions
_push_user = users_db.get(Query().id == user_id)
if _push_user and _push_user.get('push_notifications_enabled', True):
try:
approve_token = create_action_token(user_id, id, task_id, 'chore', 'approve')
deny_token = create_action_token(user_id, id, task_id, 'chore', 'deny')
push_payload = {
'type': 'chore_confirmed',
'title': f'{child.name} completed a chore',
'body': f'{task.name} is waiting for your approval.',
'user_id': user_id,
'child_id': id,
'child_name': child.name,
'entity_id': task_id,
'entity_type': 'chore',
'entity_name': task.name,
'approve_token': approve_token.id,
'deny_token': deny_token.id,
}
send_push_to_user(user_id, push_payload)
except Exception as _push_err:
logger.warning(f'Push notification failed for chore confirmation: {_push_err}')
return jsonify({'message': f'Chore {task.name} confirmed by {child.name}.', 'confirmation_id': confirmation.id}), 200
@child_api.route('/child/<id>/cancel-confirm-chore', methods=['POST'])
def cancel_confirm_chore(id):
"""Child cancels their pending chore confirmation."""
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
data = request.get_json()
task_id = data.get('task_id')
if not task_id:
return jsonify({'error': 'task_id is required'}), 400
ChildQuery = Query()
result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id))
if not result:
return jsonify({'error': 'Child not found'}), 404
child = Child.from_dict(result[0])
PendingQuery = Query()
existing = pending_confirmations_db.get(
(PendingQuery.child_id == id) & (PendingQuery.entity_id == task_id) &
(PendingQuery.entity_type == 'chore') & (PendingQuery.status == 'pending') &
(PendingQuery.user_id == user_id)
)
if not existing:
return jsonify({'error': 'No pending confirmation found', 'code': 'PENDING_NOT_FOUND'}), 400
pending_confirmations_db.remove(
(PendingQuery.child_id == id) & (PendingQuery.entity_id == task_id) &
(PendingQuery.entity_type == 'chore') & (PendingQuery.status == 'pending') &
(PendingQuery.user_id == user_id)
)
# Fetch task name for tracking
TaskQuery = Query()
task_result = task_db.get((TaskQuery.id == task_id) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)))
task_name = task_result.get('name') if task_result else 'Unknown'
tracking_event = TrackingEvent.create_event(
user_id=user_id, child_id=id, entity_type='chore', entity_id=task_id,
action='cancelled', points_before=child.points, points_after=child.points,
metadata={'task_name': task_name}
)
insert_tracking_event(tracking_event)
log_tracking_event(tracking_event)
send_event_for_current_user(Event(EventType.TRACKING_EVENT_CREATED.value,
TrackingEventCreated(tracking_event.id, id, 'chore', 'cancelled')))
send_event_for_current_user(Event(EventType.CHILD_CHORE_CONFIRMATION.value,
ChildChoreConfirmation(id, task_id, ChildChoreConfirmation.OPERATION_CANCELLED)))
return jsonify({'message': 'Chore confirmation cancelled.'}), 200
@child_api.route('/child/<id>/approve-chore', methods=['POST'])
def approve_chore(id):
"""Parent approves a pending chore confirmation, awarding points."""
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
data = request.get_json()
task_id = data.get('task_id')
if not task_id:
return jsonify({'error': 'task_id is required'}), 400
try:
result = chore_actions.approve_chore(user_id, id, task_id)
except ValueError:
return jsonify({'error': 'Child or task not found'}), 404
if result is None:
return jsonify({'error': 'No pending confirmation found', 'code': 'PENDING_NOT_FOUND'}), 400
return jsonify({
'message': f'Chore {result["task_name"]} approved for {result["child_name"]}.',
'points': result['points'],
'id': result['child_id']
}), 200
@child_api.route('/child/<id>/reject-chore', methods=['POST'])
def reject_chore(id):
"""Parent rejects a pending chore confirmation."""
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
data = request.get_json()
task_id = data.get('task_id')
if not task_id:
return jsonify({'error': 'task_id is required'}), 400
ChildQuery = Query()
result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id))
if not result:
return jsonify({'error': 'Child not found'}), 404
PendingQuery = Query()
existing = pending_confirmations_db.get(
(PendingQuery.child_id == id) & (PendingQuery.entity_id == task_id) &
(PendingQuery.entity_type == 'chore') & (PendingQuery.status == 'pending') &
(PendingQuery.user_id == user_id)
)
if not existing:
return jsonify({'error': 'No pending confirmation found', 'code': 'PENDING_NOT_FOUND'}), 400
chore_actions.reject_chore(user_id, id, task_id)
return jsonify({'message': 'Chore confirmation rejected.'}), 200
@child_api.route('/child/<id>/reset-chore', methods=['POST'])
def reset_chore(id):
"""Parent resets a completed chore so the child can confirm again."""
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
data = request.get_json()
task_id = data.get('task_id')
if not task_id:
return jsonify({'error': 'task_id is required'}), 400
ChildQuery = Query()
result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id))
if not result:
return jsonify({'error': 'Child not found'}), 404
child = Child.from_dict(result[0])
PendingQuery = Query()
existing = pending_confirmations_db.get(
(PendingQuery.child_id == id) & (PendingQuery.entity_id == task_id) &
(PendingQuery.entity_type == 'chore') & (PendingQuery.status == 'approved') &
(PendingQuery.user_id == user_id)
)
if not existing:
return jsonify({'error': 'No completed confirmation found to reset', 'code': 'PENDING_NOT_FOUND'}), 400
pending_confirmations_db.remove(
(PendingQuery.child_id == id) & (PendingQuery.entity_id == task_id) &
(PendingQuery.entity_type == 'chore') & (PendingQuery.user_id == user_id)
)
TaskQuery = Query()
task_result = task_db.get((TaskQuery.id == task_id) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)))
task_name = task_result.get('name') if task_result else 'Unknown'
tracking_event = TrackingEvent.create_event(
user_id=user_id, child_id=id, entity_type='chore', entity_id=task_id,
action='reset', points_before=child.points, points_after=child.points,
metadata={'task_name': task_name}
)
insert_tracking_event(tracking_event)
log_tracking_event(tracking_event)
send_event_for_current_user(Event(EventType.TRACKING_EVENT_CREATED.value,
TrackingEventCreated(tracking_event.id, id, 'chore', 'reset')))
send_event_for_current_user(Event(EventType.CHILD_CHORE_CONFIRMATION.value,
ChildChoreConfirmation(id, task_id, ChildChoreConfirmation.OPERATION_RESET)))
return jsonify({'message': 'Chore reset to available.'}), 200
@child_api.route('/child/<id>/deny-reward-request', methods=['POST'])
def deny_reward_request(id):
"""Parent denies a child's pending reward request."""
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
data = request.get_json()
reward_id = data.get('reward_id')
if not reward_id:
return jsonify({'error': 'reward_id is required'}), 400
result = chore_actions.deny_reward(user_id, id, reward_id)
if result is None:
return jsonify({'message': 'This reward request has already been resolved.'}), 200
return jsonify({'message': f'Reward request denied for {result["child_name"]}.'}), 200
return jsonify({'rewards': reward_responses, 'count': len(reward_responses), 'list_type': 'notification'}), 200
+17 -4
View File
@@ -2,7 +2,7 @@ from flask import Blueprint, request, jsonify
from tinydb import Query from tinydb import Query
from api.utils import get_validated_user_id, send_event_for_current_user from api.utils import get_validated_user_id, send_event_for_current_user
from api.error_codes import ErrorCodes from api.error_codes import ErrorCodes
from db.db import child_db, task_db, reward_db from db.db import child_db, task_db, reward_db, routine_db
from db.child_overrides import ( from db.child_overrides import (
insert_override, insert_override,
get_override, get_override,
@@ -52,8 +52,8 @@ def set_child_override(child_id):
return jsonify({'error': 'custom_value is required', 'code': ErrorCodes.MISSING_FIELD, 'field': 'custom_value'}), 400 return jsonify({'error': 'custom_value is required', 'code': ErrorCodes.MISSING_FIELD, 'field': 'custom_value'}), 400
# Validate entity_type # Validate entity_type
if entity_type not in ['task', 'reward']: if entity_type not in ['task', 'reward', 'routine']:
return jsonify({'error': 'entity_type must be "task" or "reward"', 'code': ErrorCodes.INVALID_VALUE, 'field': 'entity_type'}), 400 return jsonify({'error': 'entity_type must be "task", "reward", or "routine"', 'code': ErrorCodes.INVALID_VALUE, 'field': 'entity_type'}), 400
# Validate custom_value range # Validate custom_value range
if not isinstance(custom_value, int) or custom_value < 0 or custom_value > 10000: if not isinstance(custom_value, int) or custom_value < 0 or custom_value > 10000:
@@ -74,7 +74,7 @@ def set_child_override(child_id):
if entity_id not in assigned_tasks: if entity_id not in assigned_tasks:
return jsonify({'error': 'Task not assigned to child', 'code': ErrorCodes.ENTITY_NOT_ASSIGNED}), 404 return jsonify({'error': 'Task not assigned to child', 'code': ErrorCodes.ENTITY_NOT_ASSIGNED}), 404
else: # reward elif entity_type == 'reward':
EntityQuery = Query() EntityQuery = Query()
entity_result = reward_db.search( entity_result = reward_db.search(
(EntityQuery.id == entity_id) & (EntityQuery.id == entity_id) &
@@ -88,6 +88,19 @@ def set_child_override(child_id):
if entity_id not in assigned_rewards: if entity_id not in assigned_rewards:
return jsonify({'error': 'Reward not assigned to child', 'code': ErrorCodes.ENTITY_NOT_ASSIGNED}), 404 return jsonify({'error': 'Reward not assigned to child', 'code': ErrorCodes.ENTITY_NOT_ASSIGNED}), 404
else: # routine
EntityQuery = Query()
entity_result = routine_db.search(
(EntityQuery.id == entity_id) &
((EntityQuery.user_id == user_id) | (EntityQuery.user_id == None))
)
if not entity_result:
return jsonify({'error': 'Routine not found', 'code': 'ROUTINE_NOT_FOUND'}), 404
assigned_routines = child_dict.get('routines', [])
if entity_id not in assigned_routines:
return jsonify({'error': 'Routine not assigned to child', 'code': ErrorCodes.ENTITY_NOT_ASSIGNED}), 404
# Create and insert override # Create and insert override
try: try:
override = ChildOverride.create_override( override = ChildOverride.create_override(
+638
View File
@@ -0,0 +1,638 @@
from collections import defaultdict
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
from flask import Blueprint, request, jsonify
from tinydb import Query
from api.error_codes import ErrorCodes
from api.utils import get_validated_user_id, send_event_for_current_user
from db.child_overrides import delete_override, get_override
from db.db import child_db, pending_confirmations_db, routine_db, users_db
from db.routine_extensions import delete_extension_for_child_routine, get_extension_for_child_routine
from db.routine_items import get_items_for_routine
from db.routine_schedules import delete_schedule, get_schedule
from events.types.child_routine_confirmation import ChildRoutineConfirmation
from events.types.child_routines_set import ChildRoutinesSet
from events.types.event import Event
from events.types.event_types import EventType
from models.child import Child
from models.pending_confirmation import PendingConfirmation
from models.routine import Routine
from utils.digest_token import create_action_token
from utils.push_sender import send_push_to_user
child_routine_api = Blueprint('child_routine_api', __name__)
def _get_user_timezone(user_id: str) -> str | None:
user = users_db.get(Query().id == user_id)
if not user:
return None
return user.get('timezone')
def _get_user_today_local(user_id: str) -> tuple[str, str | None]:
tz_str = _get_user_timezone(user_id)
try:
now_local = datetime.now(ZoneInfo(tz_str)) if tz_str else datetime.now(timezone.utc)
except Exception:
tz_str = None
now_local = datetime.now(timezone.utc)
return now_local.strftime('%Y-%m-%d'), tz_str
def _is_iso_timestamp_on_local_day(timestamp: str | None, local_day: str, tz_str: str | None) -> bool:
if not timestamp:
return False
try:
normalized = timestamp.replace('Z', '+00:00')
parsed = datetime.fromisoformat(normalized)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
try:
tz = ZoneInfo(tz_str) if tz_str else timezone.utc
except Exception:
tz = timezone.utc
return parsed.astimezone(tz).strftime('%Y-%m-%d') == local_day
except (TypeError, ValueError):
return False
def _is_epoch_timestamp_on_local_day(epoch_ts, local_day: str, tz_str: str | None) -> bool:
if epoch_ts is None:
return False
try:
tz = ZoneInfo(tz_str) if tz_str else timezone.utc
except Exception:
tz = timezone.utc
try:
return datetime.fromtimestamp(float(epoch_ts), tz).strftime('%Y-%m-%d') == local_day
except (TypeError, ValueError, OSError):
return False
class ChildRoutine:
def __init__(self, name, points, image_id, _id):
self.id = _id
self.name = name
self.points = points
self.image_id = image_id
def to_dict(self):
return {
'id': self.id,
'name': self.name,
'points': self.points,
'image_id': self.image_id,
}
def _validate_child_for_user(child_id: str, user_id: str):
child_q = Query()
result = child_db.search((child_q.id == child_id) & (child_q.user_id == user_id))
return Child.from_dict(result[0]) if result else None
def _resolve_routine_for_user(routine_id: str, user_id: str):
routine_q = Query()
routine_result = routine_db.get(
(routine_q.id == routine_id) & ((routine_q.user_id == user_id) | (routine_q.user_id == None))
)
return Routine.from_dict(routine_result) if routine_result else None
@child_routine_api.route('/child/<id>/assign-routine', methods=['POST'])
def assign_routine_to_child(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
data = request.get_json() or {}
routine_id = data.get('routine_id')
if not routine_id:
return jsonify({'error': 'routine_id is required', 'code': ErrorCodes.MISSING_FIELD}), 400
child = _validate_child_for_user(id, user_id)
if not child:
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
routine = _resolve_routine_for_user(routine_id, user_id)
if not routine:
return jsonify({'error': 'Routine not found'}), 404
routine_ids = list(child.routines)
if routine_id not in routine_ids:
routine_ids.append(routine_id)
child_db.update({'routines': routine_ids}, Query().id == id)
send_event_for_current_user(
Event(EventType.CHILD_ROUTINES_SET.value, ChildRoutinesSet(id, routine_ids))
)
return jsonify({'message': f'Routine {routine_id} assigned to {child.name}.'}), 200
@child_routine_api.route('/child/<id>/remove-routine', methods=['POST'])
def remove_routine_from_child(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
data = request.get_json() or {}
routine_id = data.get('routine_id')
if not routine_id:
return jsonify({'error': 'routine_id is required', 'code': ErrorCodes.MISSING_FIELD}), 400
child = _validate_child_for_user(id, user_id)
if not child:
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
routine_ids = list(child.routines)
if routine_id not in routine_ids:
return jsonify({'error': 'Routine not assigned to child', 'code': ErrorCodes.ENTITY_NOT_ASSIGNED}), 400
routine_ids.remove(routine_id)
child_db.update({'routines': routine_ids}, Query().id == id)
override = get_override(id, routine_id)
if override and override.entity_type == 'routine':
delete_override(id, routine_id)
delete_schedule(id, routine_id)
delete_extension_for_child_routine(id, routine_id)
pending_q = Query()
pending_confirmations_db.remove(
(pending_q.child_id == id) & (pending_q.entity_id == routine_id) &
(pending_q.entity_type == 'routine') & (pending_q.user_id == user_id)
)
send_event_for_current_user(
Event(EventType.CHILD_ROUTINES_SET.value, ChildRoutinesSet(id, routine_ids))
)
send_event_for_current_user(
Event(
EventType.CHILD_ROUTINE_CONFIRMATION.value,
ChildRoutineConfirmation(id, routine_id, ChildRoutineConfirmation.OPERATION_RESET)
)
)
return jsonify({'message': f'Routine {routine_id} removed from {child.name}.'}), 200
@child_routine_api.route('/child/<id>/set-routines', methods=['PUT'])
def set_child_routines(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
data = request.get_json() or {}
routine_ids = data.get('routine_ids')
if not isinstance(routine_ids, list):
return jsonify({'error': 'routine_ids must be a list'}), 400
child = _validate_child_for_user(id, user_id)
if not child:
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
routine_q = Query()
valid_ids = []
for rid in dict.fromkeys(routine_ids):
if routine_db.get((routine_q.id == rid) & ((routine_q.user_id == user_id) | (routine_q.user_id == None))):
valid_ids.append(rid)
old_ids = set(child.routines)
new_ids = set(valid_ids)
unassigned_ids = old_ids - new_ids
pending_q = Query()
for rid in unassigned_ids:
override = get_override(id, rid)
if override and override.entity_type == 'routine':
delete_override(id, rid)
delete_schedule(id, rid)
delete_extension_for_child_routine(id, rid)
pending_confirmations_db.remove(
(pending_q.child_id == id) & (pending_q.entity_id == rid) &
(pending_q.entity_type == 'routine') & (pending_q.user_id == user_id)
)
child_db.update({'routines': valid_ids}, Query().id == id)
send_event_for_current_user(Event(EventType.CHILD_ROUTINES_SET.value, ChildRoutinesSet(id, valid_ids)))
return jsonify({'message': f'Routines set for child {id}.', 'routine_ids': valid_ids, 'count': len(valid_ids)}), 200
@child_routine_api.route('/child/<id>/list-routines', methods=['GET'])
def list_child_routines(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
child = _validate_child_for_user(id, user_id)
if not child:
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
routine_q = Query()
pending_q = Query()
today_local, tz_str = _get_user_today_local(user_id)
child_routines = []
for rid in child.routines:
routine_record = routine_db.get((routine_q.id == rid) & ((routine_q.user_id == user_id) | (routine_q.user_id == None)))
if not routine_record:
continue
routine = Routine.from_dict(routine_record)
override = get_override(id, rid)
custom_value = override.custom_value if override and override.entity_type == 'routine' else None
cr = ChildRoutine(routine.name, routine.points, routine.image_id, routine.id)
cr_dict = cr.to_dict()
if custom_value is not None:
cr_dict['custom_value'] = custom_value
schedule = get_schedule(id, rid)
cr_dict['schedule'] = schedule.to_dict() if schedule else None
extension = get_extension_for_child_routine(id, rid)
cr_dict['extension_date'] = extension.date if extension else None
items = get_items_for_routine(rid)
cr_dict['items'] = [item.to_dict() for item in items]
pending = pending_confirmations_db.get(
(pending_q.child_id == id) & (pending_q.entity_id == rid) &
(pending_q.entity_type == 'routine') & (pending_q.user_id == user_id)
)
if pending:
status = pending.get('status')
approved_at = pending.get('approved_at')
created_at = pending.get('created_at')
confirmation_id = pending.get('id')
if status == 'approved' and _is_iso_timestamp_on_local_day(approved_at, today_local, tz_str):
cr_dict['pending_status'] = 'approved'
cr_dict['approved_at'] = approved_at
cr_dict['pending_confirmation_id'] = confirmation_id
elif status == 'pending' and _is_epoch_timestamp_on_local_day(created_at, today_local, tz_str):
cr_dict['pending_status'] = 'pending'
cr_dict['approved_at'] = None
cr_dict['pending_confirmation_id'] = confirmation_id
else:
pending_id = pending.get('id')
if pending_id:
pending_confirmations_db.remove(pending_q.id == pending_id)
cr_dict['pending_status'] = None
cr_dict['approved_at'] = None
cr_dict['pending_confirmation_id'] = None
else:
cr_dict['pending_status'] = None
cr_dict['approved_at'] = None
cr_dict['pending_confirmation_id'] = None
child_routines.append(cr_dict)
return jsonify({'routines': child_routines}), 200
@child_routine_api.route('/child/<id>/list-assignable-routines', methods=['GET'])
def list_assignable_routines(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
child = _validate_child_for_user(id, user_id)
if not child:
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
assigned_ids = set(child.routines)
all_routines = [r for r in routine_db.all() if r and r.get('id') and r.get('id') not in assigned_ids]
name_to_routines = defaultdict(list)
for routine in all_routines:
name_to_routines[routine.get('name')].append(routine)
filtered_routines = []
for _, routines in name_to_routines.items():
user_routines = [r for r in routines if r.get('user_id') is not None]
if len(user_routines) == 0:
filtered_routines.append(routines[0])
elif len(user_routines) == 1:
filtered_routines.append(user_routines[0])
else:
filtered_routines.extend(user_routines)
assignable = [
ChildRoutine(r.get('name'), r.get('points'), r.get('image_id'), r.get('id')).to_dict()
for r in filtered_routines
]
return jsonify({'routines': assignable, 'count': len(assignable)}), 200
@child_routine_api.route('/child/<id>/confirm-routine', methods=['POST'])
def confirm_routine(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
data = request.get_json() or {}
routine_id = data.get('routine_id')
if not routine_id:
return jsonify({'error': 'routine_id is required', 'code': ErrorCodes.MISSING_FIELD}), 400
child = _validate_child_for_user(id, user_id)
if not child:
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
if routine_id not in child.routines:
return jsonify({'error': 'Routine not assigned to child', 'code': ErrorCodes.ENTITY_NOT_ASSIGNED}), 400
routine = _resolve_routine_for_user(routine_id, user_id)
if not routine:
return jsonify({'error': 'Routine not found'}), 404
pending_q = Query()
existing = pending_confirmations_db.get(
(pending_q.child_id == id) & (pending_q.entity_id == routine_id) &
(pending_q.entity_type == 'routine') & (pending_q.user_id == user_id)
)
if existing:
today_local, tz_str = _get_user_today_local(user_id)
if existing.get('status') == 'pending':
if _is_epoch_timestamp_on_local_day(existing.get('created_at'), today_local, tz_str):
return jsonify({'error': 'Routine already pending confirmation', 'code': 'ROUTINE_ALREADY_PENDING'}), 400
pending_id = existing.get('id')
if pending_id:
pending_confirmations_db.remove(pending_q.id == pending_id)
if existing.get('status') == 'approved':
approved_at = existing.get('approved_at', '')
if _is_iso_timestamp_on_local_day(approved_at, today_local, tz_str):
return jsonify({'error': 'Routine already completed today', 'code': 'ROUTINE_ALREADY_COMPLETED'}), 400
pending_id = existing.get('id')
if pending_id:
pending_confirmations_db.remove(pending_q.id == pending_id)
confirmation = PendingConfirmation(
child_id=id,
entity_id=routine_id,
entity_type='routine',
user_id=user_id,
)
pending_confirmations_db.insert(confirmation.to_dict())
send_event_for_current_user(
Event(
EventType.CHILD_ROUTINE_CONFIRMATION.value,
ChildRoutineConfirmation(id, routine_id, ChildRoutineConfirmation.OPERATION_PENDING)
)
)
push_user = users_db.get(Query().id == user_id)
if push_user and push_user.get('push_notifications_enabled', True):
try:
approve_token = create_action_token(user_id, id, routine_id, 'routine', 'approve')
deny_token = create_action_token(user_id, id, routine_id, 'routine', 'deny')
push_payload = {
'type': 'routine_confirmed',
'title': 'Routine Pending',
'body': f'{child.name} completed {routine.name}',
'user_id': user_id,
'child_id': id,
'child_name': child.name,
'entity_id': routine_id,
'entity_type': 'routine',
'entity_name': routine.name,
'approve_token': approve_token.id,
'deny_token': deny_token.id,
}
send_push_to_user(user_id, push_payload)
except Exception:
pass
return jsonify({'message': f'Routine {routine.name} confirmed by {child.name}.', 'confirmation_id': confirmation.id}), 200
@child_routine_api.route('/child/<id>/cancel-routine-confirmation', methods=['POST'])
def cancel_routine_confirmation(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
data = request.get_json() or {}
routine_id = data.get('routine_id')
if not routine_id:
return jsonify({'error': 'routine_id is required', 'code': ErrorCodes.MISSING_FIELD}), 400
child = _validate_child_for_user(id, user_id)
if not child:
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
pending_q = Query()
existing = pending_confirmations_db.get(
(pending_q.child_id == id) & (pending_q.entity_id == routine_id) &
(pending_q.entity_type == 'routine') & (pending_q.status == 'pending') &
(pending_q.user_id == user_id)
)
if not existing:
return jsonify({'error': 'No pending confirmation found', 'code': 'PENDING_NOT_FOUND'}), 400
pending_confirmations_db.remove(
(pending_q.child_id == id) & (pending_q.entity_id == routine_id) &
(pending_q.entity_type == 'routine') & (pending_q.status == 'pending') &
(pending_q.user_id == user_id)
)
send_event_for_current_user(
Event(
EventType.CHILD_ROUTINE_CONFIRMATION.value,
ChildRoutineConfirmation(id, routine_id, ChildRoutineConfirmation.OPERATION_RESET)
)
)
return jsonify({'message': 'Routine confirmation cancelled.'}), 200
@child_routine_api.route('/child/<id>/approve-routine/<confirmation_id>', methods=['POST'])
def approve_routine(id, confirmation_id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
child = _validate_child_for_user(id, user_id)
if not child:
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
pending_q = Query()
confirmation = pending_confirmations_db.get(
(pending_q.id == confirmation_id) & (pending_q.child_id == id) &
(pending_q.entity_type == 'routine') & (pending_q.user_id == user_id)
)
if not confirmation:
return jsonify({'error': 'Pending confirmation not found', 'code': 'PENDING_NOT_FOUND'}), 404
if confirmation.get('status') != 'pending':
return jsonify({'error': 'Confirmation is already resolved', 'code': 'ALREADY_RESOLVED'}), 400
routine_id = confirmation.get('entity_id')
routine = _resolve_routine_for_user(routine_id, user_id)
if not routine:
return jsonify({'error': 'Routine not found'}), 404
override = get_override(id, routine_id)
points_value = override.custom_value if override and override.entity_type == 'routine' else routine.points
new_points = max(0, child.points + points_value)
child_db.update({'points': new_points}, Query().id == id)
approved_at = datetime.now(timezone.utc).isoformat()
pending_confirmations_db.update(
{'status': 'approved', 'approved_at': approved_at},
pending_q.id == confirmation_id
)
send_event_for_current_user(
Event(
EventType.CHILD_ROUTINE_CONFIRMATION.value,
ChildRoutineConfirmation(id, routine_id, ChildRoutineConfirmation.OPERATION_APPROVED)
)
)
return jsonify({
'message': f'Routine {routine.name} approved for {child.name}.',
'points': new_points,
'id': child.id,
}), 200
@child_routine_api.route('/child/<id>/reject-routine/<confirmation_id>', methods=['POST'])
def reject_routine(id, confirmation_id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
child = _validate_child_for_user(id, user_id)
if not child:
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
pending_q = Query()
confirmation = pending_confirmations_db.get(
(pending_q.id == confirmation_id) & (pending_q.child_id == id) &
(pending_q.entity_type == 'routine') & (pending_q.user_id == user_id)
)
if not confirmation:
return jsonify({'error': 'Pending confirmation not found', 'code': 'PENDING_NOT_FOUND'}), 404
if confirmation.get('status') != 'pending':
return jsonify({'error': 'Confirmation is already resolved', 'code': 'ALREADY_RESOLVED'}), 400
pending_confirmations_db.update({'status': 'rejected', 'approved_at': None}, pending_q.id == confirmation_id)
send_event_for_current_user(
Event(
EventType.CHILD_ROUTINE_CONFIRMATION.value,
ChildRoutineConfirmation(id, confirmation.get('entity_id'), ChildRoutineConfirmation.OPERATION_REJECTED)
)
)
return jsonify({'message': 'Routine confirmation rejected.'}), 200
@child_routine_api.route('/child/<id>/reset-routine/<confirmation_id>', methods=['POST'])
def reset_routine(id, confirmation_id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
child = _validate_child_for_user(id, user_id)
if not child:
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
pending_q = Query()
confirmation = pending_confirmations_db.get(
(pending_q.id == confirmation_id) & (pending_q.child_id == id) &
(pending_q.entity_type == 'routine') & (pending_q.user_id == user_id)
)
if not confirmation:
return jsonify({'error': 'Pending confirmation not found', 'code': 'PENDING_NOT_FOUND'}), 404
routine_id = confirmation.get('entity_id')
pending_confirmations_db.remove(pending_q.id == confirmation_id)
send_event_for_current_user(
Event(
EventType.CHILD_ROUTINE_CONFIRMATION.value,
ChildRoutineConfirmation(id, routine_id, ChildRoutineConfirmation.OPERATION_RESET)
)
)
return jsonify({'message': 'Routine reset to available.'}), 200
@child_routine_api.route('/child/<id>/trigger-routine', methods=['POST'])
def trigger_child_routine(id):
"""Parent-triggered routine confirmation — directly awards points."""
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
data = request.get_json() or {}
routine_id = data.get('routine_id')
if not routine_id:
return jsonify({'error': 'routine_id is required', 'code': ErrorCodes.MISSING_FIELD}), 400
child = _validate_child_for_user(id, user_id)
if not child:
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
if routine_id not in child.routines:
return jsonify({'error': 'Routine not assigned to child', 'code': ErrorCodes.ENTITY_NOT_ASSIGNED}), 400
routine = _resolve_routine_for_user(routine_id, user_id)
if not routine:
return jsonify({'error': 'Routine not found'}), 404
# Check for override
override = get_override(id, routine_id)
points_value = override.custom_value if override and override.entity_type == 'routine' else routine.points
# Award points
new_points = max(0, child.points + points_value)
child_db.update({'points': new_points}, Query().id == id)
# Create an approved pending confirmation so it shows as completed in the routine list
pending_q = Query()
existing = pending_confirmations_db.get(
(pending_q.child_id == id) & (pending_q.entity_id == routine_id) &
(pending_q.entity_type == 'routine') & (pending_q.user_id == user_id)
)
if existing:
today_local, tz_str = _get_user_today_local(user_id)
# Remove old confirmation if it exists and is not from today
if existing.get('status') == 'approved' and _is_iso_timestamp_on_local_day(existing.get('approved_at'), today_local, tz_str):
return jsonify({'error': 'Routine already completed today', 'code': 'ROUTINE_ALREADY_COMPLETED'}), 400
pending_id = existing.get('id')
if pending_id:
pending_confirmations_db.remove(pending_q.id == pending_id)
confirmation = PendingConfirmation(
child_id=id,
entity_id=routine_id,
entity_type='routine',
user_id=user_id,
status='approved',
approved_at=datetime.now(timezone.utc).isoformat()
)
pending_confirmations_db.insert(confirmation.to_dict())
send_event_for_current_user(
Event(
EventType.CHILD_ROUTINE_CONFIRMATION.value,
ChildRoutineConfirmation(id, routine_id, ChildRoutineConfirmation.OPERATION_APPROVED)
)
)
return jsonify({
'message': f'Routine {routine.name} awarded to {child.name}.',
'points': new_points,
'id': child.id,
}), 200
+3 -3
View File
@@ -1,8 +1,8 @@
class ChildTask: class ChildTask:
def __init__(self, name, is_good, points, image_id, id): def __init__(self, name, task_type, points, image_id, id):
self.id = id self.id = id
self.name = name self.name = name
self.is_good = is_good self.type = task_type
self.points = points self.points = points
self.image_id = image_id self.image_id = image_id
@@ -10,7 +10,7 @@ class ChildTask:
return { return {
'id': self.id, 'id': self.id,
'name': self.name, 'name': self.name,
'is_good': self.is_good, 'type': self.type,
'points': self.points, 'points': self.points,
'image_id': self.image_id 'image_id': self.image_id
} }
+165
View File
@@ -0,0 +1,165 @@
from flask import Blueprint, request, jsonify
from tinydb import Query
from api.utils import send_event_for_current_user, get_validated_user_id
from events.types.child_tasks_set import ChildTasksSet
from db.db import task_db, child_db
from db.child_overrides import delete_overrides_for_entity
from events.types.event import Event
from events.types.event_types import EventType
from events.types.task_modified import TaskModified
from models.task import Task
chore_api = Blueprint('chore_api', __name__)
TASK_TYPE = 'chore'
@chore_api.route('/chore/add', methods=['PUT'])
def add_chore():
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
data = request.get_json()
name = data.get('name')
points = data.get('points')
image = data.get('image_id', '')
if not name or points is None:
return jsonify({'error': 'Name and points are required'}), 400
task = Task(name=name, points=points, type=TASK_TYPE, image_id=image, user_id=user_id)
task_db.insert(task.to_dict())
send_event_for_current_user(Event(EventType.TASK_MODIFIED.value,
TaskModified(task.id, TaskModified.OPERATION_ADD)))
return jsonify({'message': f'Chore {name} added.'}), 201
@chore_api.route('/chore/<id>', methods=['GET'])
def get_chore(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
TaskQuery = Query()
result = task_db.search(
(TaskQuery.id == id) &
((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)) &
(TaskQuery.type == TASK_TYPE)
)
if not result:
return jsonify({'error': 'Chore not found'}), 404
return jsonify(result[0]), 200
@chore_api.route('/chore/list', methods=['GET'])
def list_chores():
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
TaskQuery = Query()
tasks = task_db.search(
((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)) &
(TaskQuery.type == TASK_TYPE)
)
user_tasks = {t['name'].strip().lower(): t for t in tasks if t.get('user_id') == user_id}
filtered_tasks = []
for t in tasks:
if t.get('user_id') is None and t['name'].strip().lower() in user_tasks:
continue
filtered_tasks.append(t)
def sort_user_then_default(tasks_group):
user_created = sorted(
[t for t in tasks_group if t.get('user_id') == user_id],
key=lambda x: x['name'].lower(),
)
default_items = sorted(
[t for t in tasks_group if t.get('user_id') is None],
key=lambda x: x['name'].lower(),
)
return user_created + default_items
sorted_tasks = sort_user_then_default(filtered_tasks)
return jsonify({'tasks': sorted_tasks}), 200
@chore_api.route('/chore/<id>', methods=['DELETE'])
def delete_chore(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
TaskQuery = Query()
task = task_db.get((TaskQuery.id == id) & (TaskQuery.type == TASK_TYPE))
if not task:
return jsonify({'error': 'Chore not found'}), 404
if task.get('user_id') is None:
import logging
logging.warning(f"Forbidden delete attempt on system chore: id={id}, by user_id={user_id}")
return jsonify({'error': 'System chores cannot be deleted.'}), 403
removed = task_db.remove((TaskQuery.id == id) & (TaskQuery.user_id == user_id))
if removed:
deleted_count = delete_overrides_for_entity(id)
if deleted_count > 0:
import logging
logging.info(f"Cascade deleted {deleted_count} overrides for chore {id}")
ChildQuery = Query()
for child in child_db.all():
child_tasks = child.get('tasks', [])
if id in child_tasks:
child_tasks.remove(id)
child_db.update({'tasks': child_tasks}, ChildQuery.id == child.get('id'))
send_event_for_current_user(Event(EventType.CHILD_TASKS_SET.value, ChildTasksSet(id, child_tasks)))
send_event_for_current_user(Event(EventType.TASK_MODIFIED.value, TaskModified(id, TaskModified.OPERATION_DELETE)))
return jsonify({'message': f'Chore {id} deleted.'}), 200
return jsonify({'error': 'Chore not found'}), 404
@chore_api.route('/chore/<id>/edit', methods=['PUT'])
def edit_chore(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
TaskQuery = Query()
existing = task_db.get(
(TaskQuery.id == id) &
((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)) &
(TaskQuery.type == TASK_TYPE)
)
if not existing:
return jsonify({'error': 'Chore not found'}), 404
task = Task.from_dict(existing)
is_dirty = False
data = request.get_json(force=True) or {}
if 'name' in data:
name = data.get('name', '').strip()
if not name:
return jsonify({'error': 'Name cannot be empty'}), 400
task.name = name
is_dirty = True
if 'points' in data:
points = data.get('points')
if not isinstance(points, int) or points <= 0:
return jsonify({'error': 'Points must be a positive integer'}), 400
task.points = points
is_dirty = True
if 'image_id' in data:
task.image_id = data.get('image_id', '')
is_dirty = True
if not is_dirty:
return jsonify({'error': 'No valid fields to update'}), 400
if task.user_id is None:
new_task = Task(name=task.name, points=task.points, type=TASK_TYPE, image_id=task.image_id, user_id=user_id)
task_db.insert(new_task.to_dict())
send_event_for_current_user(Event(EventType.TASK_MODIFIED.value,
TaskModified(new_task.id, TaskModified.OPERATION_ADD)))
return jsonify(new_task.to_dict()), 200
task_db.update(task.to_dict(), (TaskQuery.id == id) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)))
send_event_for_current_user(Event(EventType.TASK_MODIFIED.value,
TaskModified(id, TaskModified.OPERATION_EDIT)))
return jsonify(task.to_dict()), 200
+179
View File
@@ -0,0 +1,179 @@
from flask import Blueprint, request, jsonify
from tinydb import Query
from api.utils import get_validated_user_id, send_event_for_current_user
from api.error_codes import ErrorCodes
from db.db import child_db, pending_confirmations_db
from db.chore_schedules import get_schedule, upsert_schedule, delete_schedule
from db.task_extensions import get_extension, add_extension, delete_extension_for_child_task
from models.chore_schedule import ChoreSchedule
from models.task_extension import TaskExtension
from events.types.event import Event
from events.types.event_types import EventType
from events.types.chore_schedule_modified import ChoreScheduleModified
from events.types.chore_time_extended import ChoreTimeExtended
from events.types.child_chore_confirmation import ChildChoreConfirmation
import logging
chore_schedule_api = Blueprint('chore_schedule_api', __name__)
logger = logging.getLogger(__name__)
def _validate_child(child_id: str, user_id: str):
"""Return child dict if found and owned by user, else None."""
ChildQuery = Query()
result = child_db.search((ChildQuery.id == child_id) & (ChildQuery.user_id == user_id))
return result[0] if result else None
@chore_schedule_api.route('/child/<child_id>/task/<task_id>/schedule', methods=['GET'])
def get_chore_schedule(child_id, task_id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
if not _validate_child(child_id, user_id):
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
schedule = get_schedule(child_id, task_id)
if not schedule:
return jsonify({'error': 'Schedule not found'}), 404
return jsonify(schedule.to_dict()), 200
@chore_schedule_api.route('/child/<child_id>/task/<task_id>/schedule', methods=['PUT'])
def set_chore_schedule(child_id, task_id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
if not _validate_child(child_id, user_id):
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
data = request.get_json() or {}
mode = data.get('mode')
if mode not in ('days', 'interval'):
return jsonify({'error': 'mode must be "days" or "interval"', 'code': ErrorCodes.INVALID_VALUE}), 400
enabled = data.get('enabled', True)
if not isinstance(enabled, bool):
return jsonify({'error': 'enabled must be a boolean', 'code': ErrorCodes.INVALID_VALUE}), 400
if mode == 'days':
day_configs = data.get('day_configs', [])
if not isinstance(day_configs, list):
return jsonify({'error': 'day_configs must be a list', 'code': ErrorCodes.INVALID_VALUE}), 400
default_hour = data.get('default_hour', 8)
default_minute = data.get('default_minute', 0)
default_has_deadline = data.get('default_has_deadline', True)
schedule = ChoreSchedule(
child_id=child_id,
task_id=task_id,
mode='days',
day_configs=day_configs,
default_hour=default_hour,
default_minute=default_minute,
default_has_deadline=default_has_deadline,
enabled=enabled,
)
else:
interval_days = data.get('interval_days', 2)
anchor_date = data.get('anchor_date', '')
interval_has_deadline = data.get('interval_has_deadline', True)
interval_hour = data.get('interval_hour', 0)
interval_minute = data.get('interval_minute', 0)
if not isinstance(interval_days, int) or not (1 <= interval_days <= 7):
return jsonify({'error': 'interval_days must be an integer between 1 and 7', 'code': ErrorCodes.INVALID_VALUE}), 400
if not isinstance(anchor_date, str):
return jsonify({'error': 'anchor_date must be a string', 'code': ErrorCodes.INVALID_VALUE}), 400
if not isinstance(interval_has_deadline, bool):
return jsonify({'error': 'interval_has_deadline must be a boolean', 'code': ErrorCodes.INVALID_VALUE}), 400
schedule = ChoreSchedule(
child_id=child_id,
task_id=task_id,
mode='interval',
interval_days=interval_days,
anchor_date=anchor_date,
interval_has_deadline=interval_has_deadline,
interval_hour=interval_hour,
interval_minute=interval_minute,
enabled=enabled,
)
delete_extension_for_child_task(child_id, task_id)
upsert_schedule(schedule)
# Reset pending chore confirmations when schedule changes (completed chores stay)
PendingQuery = Query()
pending_chores = pending_confirmations_db.search(
(PendingQuery.child_id == child_id) & (PendingQuery.entity_id == task_id) &
(PendingQuery.entity_type == 'chore') & (PendingQuery.status == 'pending')
)
for pc in pending_chores:
pending_confirmations_db.remove(
(PendingQuery.child_id == child_id) & (PendingQuery.entity_id == task_id) &
(PendingQuery.entity_type == 'chore') & (PendingQuery.status == 'pending')
)
send_event_for_current_user(Event(EventType.CHILD_CHORE_CONFIRMATION.value,
ChildChoreConfirmation(child_id, task_id, ChildChoreConfirmation.OPERATION_RESET)))
send_event_for_current_user(Event(
EventType.CHORE_SCHEDULE_MODIFIED.value,
ChoreScheduleModified(child_id, task_id, ChoreScheduleModified.OPERATION_SET)
))
return jsonify(schedule.to_dict()), 200
@chore_schedule_api.route('/child/<child_id>/task/<task_id>/schedule', methods=['DELETE'])
def delete_chore_schedule(child_id, task_id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
if not _validate_child(child_id, user_id):
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
removed = delete_schedule(child_id, task_id)
if not removed:
return jsonify({'error': 'Schedule not found'}), 404
send_event_for_current_user(Event(
EventType.CHORE_SCHEDULE_MODIFIED.value,
ChoreScheduleModified(child_id, task_id, ChoreScheduleModified.OPERATION_DELETED)
))
return jsonify({'message': 'Schedule deleted'}), 200
@chore_schedule_api.route('/child/<child_id>/task/<task_id>/extend', methods=['POST'])
def extend_chore_time(child_id, task_id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
if not _validate_child(child_id, user_id):
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
data = request.get_json() or {}
date = data.get('date')
if not date or not isinstance(date, str):
return jsonify({'error': 'date is required (ISO date string)', 'code': ErrorCodes.MISSING_FIELD}), 400
# 409 if already extended for this exact date
existing = get_extension(child_id, task_id, date)
if existing:
return jsonify({'error': 'Chore already extended for this date', 'code': 'ALREADY_EXTENDED'}), 409
# Clear any prior extension for this child+task before inserting the new one
# so stale records from previous dates do not accumulate.
delete_extension_for_child_task(child_id, task_id)
extension = TaskExtension(child_id=child_id, task_id=task_id, date=date)
add_extension(extension)
send_event_for_current_user(Event(
EventType.CHORE_TIME_EXTENDED.value,
ChoreTimeExtended(child_id, task_id)
))
return jsonify(extension.to_dict()), 200
+113
View File
@@ -0,0 +1,113 @@
import logging
from flask import Blueprint, redirect, make_response, jsonify, request
from tinydb import Query
from utils.digest_token import validate_and_consume_token, validate_unsubscribe_token, peek_token
from db.db import users_db
from api.child_action_helpers import approve_chore, reject_chore, approve_reward_request, deny_reward, approve_routine, reject_routine
from api.utils import get_validated_user_id
digest_action_api = Blueprint('digest_action_api', __name__)
logger = logging.getLogger(__name__)
_ERROR_HTML = """<!DOCTYPE html>
<html><head><title>Link Error</title></head>
<body style="font-family:sans-serif;text-align:center;margin-top:60px;">
<h2>This link is invalid or has expired.</h2>
<p>Action links expire after 24 hours and can only be used once.</p>
</body></html>"""
_UNSUB_HTML = """<!DOCTYPE html>
<html><head><title>Unsubscribed</title></head>
<body style="font-family:sans-serif;text-align:center;margin-top:60px;">
<h2>You have been unsubscribed from daily digest emails.</h2>
<p>To re-enable, visit your profile in the app.</p>
</body></html>"""
_UNSUB_ERROR_HTML = """<!DOCTYPE html>
<html><head><title>Link Error</title></head>
<body style="font-family:sans-serif;text-align:center;margin-top:60px;">
<h2>This unsubscribe link is invalid or has expired.</h2>
</body></html>"""
@digest_action_api.route('/digest-action/<token_id>', methods=['GET'])
def handle_digest_action(token_id: str):
"""
Validate a digest action token (without consuming it) and redirect to the
frontend ParentView with the token embedded so the action executes only
after the user authenticates as a parent.
"""
from flask import current_app
frontend_url = current_app.config.get('FRONTEND_URL', 'https://localhost:5173')
token = peek_token(token_id)
if not token:
return make_response(_ERROR_HTML, 400)
deep_link = (
f"{frontend_url}/parent/{token.child_id}"
f"?digestToken={token_id}&scrollTo={token.entity_id}&entityType={token.entity_type}"
)
return redirect(deep_link, 302)
@digest_action_api.route('/digest-action/<token_id>', methods=['POST'])
def execute_digest_action(token_id: str):
"""
Execute a digest action. Requires the user to be authenticated (JWT cookie).
Validates and consumes the token, then performs the approve/deny action.
"""
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
token = validate_and_consume_token(token_id)
if not token:
return jsonify({'error': 'This link is invalid or has expired.', 'code': 'INVALID_TOKEN'}), 400
if token.user_id != user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 403
try:
if token.entity_type == 'chore' and token.action == 'approve':
approve_chore(user_id, token.child_id, token.entity_id)
elif token.entity_type == 'chore' and token.action == 'deny':
reject_chore(user_id, token.child_id, token.entity_id)
elif token.entity_type == 'reward' and token.action == 'approve':
approve_reward_request(user_id, token.child_id, token.entity_id)
elif token.entity_type == 'reward' and token.action == 'deny':
deny_reward(user_id, token.child_id, token.entity_id)
elif token.entity_type == 'routine' and token.action == 'approve':
approve_routine(user_id, token.child_id, token.entity_id)
elif token.entity_type == 'routine' and token.action == 'deny':
reject_routine(user_id, token.child_id, token.entity_id)
else:
return jsonify({'error': 'Unknown action', 'code': 'INVALID_ACTION'}), 400
except Exception as e:
logger.error(f'Error executing digest action {token.action}/{token.entity_type}: {e}')
return jsonify({'error': 'Failed to execute action', 'code': 'ACTION_FAILED'}), 400
return jsonify({
'success': True,
'child_id': token.child_id,
'entity_id': token.entity_id,
'entity_type': token.entity_type,
'action': token.action,
}), 200
@digest_action_api.route('/digest-unsubscribe/<token>', methods=['GET'])
def handle_digest_unsubscribe(token: str):
user_id = validate_unsubscribe_token(token)
if not user_id:
return make_response(_UNSUB_ERROR_HTML, 400)
UserQ = Query()
users_db.update({'email_digest_enabled': False}, UserQ.id == user_id)
logger.info(f'User {user_id} unsubscribed from digest via email link')
return make_response(_UNSUB_HTML, 200)
+10
View File
@@ -12,6 +12,9 @@ INVALID_CREDENTIALS = "INVALID_CREDENTIALS"
NOT_VERIFIED = "NOT_VERIFIED" NOT_VERIFIED = "NOT_VERIFIED"
ACCOUNT_MARKED_FOR_DELETION = "ACCOUNT_MARKED_FOR_DELETION" ACCOUNT_MARKED_FOR_DELETION = "ACCOUNT_MARKED_FOR_DELETION"
ALREADY_MARKED = "ALREADY_MARKED" ALREADY_MARKED = "ALREADY_MARKED"
REFRESH_TOKEN_REUSE = "REFRESH_TOKEN_REUSE"
REFRESH_TOKEN_EXPIRED = "REFRESH_TOKEN_EXPIRED"
MISSING_REFRESH_TOKEN = "MISSING_REFRESH_TOKEN"
class ErrorCodes: class ErrorCodes:
@@ -26,3 +29,10 @@ class ErrorCodes:
INVALID_VALUE = "INVALID_VALUE" INVALID_VALUE = "INVALID_VALUE"
VALIDATION_ERROR = "VALIDATION_ERROR" VALIDATION_ERROR = "VALIDATION_ERROR"
INTERNAL_ERROR = "INTERNAL_ERROR" INTERNAL_ERROR = "INTERNAL_ERROR"
CHORE_EXPIRED = "CHORE_EXPIRED"
CHORE_ALREADY_PENDING = "CHORE_ALREADY_PENDING"
CHORE_ALREADY_COMPLETED = "CHORE_ALREADY_COMPLETED"
PENDING_NOT_FOUND = "PENDING_NOT_FOUND"
INSUFFICIENT_POINTS = "INSUFFICIENT_POINTS"
INVALID_TASK_TYPE = "INVALID_TASK_TYPE"
DUPLICATE_REWARD_REQUEST = "DUPLICATE_REWARD_REQUEST"
+165
View File
@@ -0,0 +1,165 @@
from flask import Blueprint, request, jsonify
from tinydb import Query
from api.utils import send_event_for_current_user, get_validated_user_id
from events.types.child_tasks_set import ChildTasksSet
from db.db import task_db, child_db
from db.child_overrides import delete_overrides_for_entity
from events.types.event import Event
from events.types.event_types import EventType
from events.types.task_modified import TaskModified
from models.task import Task
kindness_api = Blueprint('kindness_api', __name__)
TASK_TYPE = 'kindness'
@kindness_api.route('/kindness/add', methods=['PUT'])
def add_kindness():
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
data = request.get_json()
name = data.get('name')
points = data.get('points')
image = data.get('image_id', '')
if not name or points is None:
return jsonify({'error': 'Name and points are required'}), 400
task = Task(name=name, points=points, type=TASK_TYPE, image_id=image, user_id=user_id)
task_db.insert(task.to_dict())
send_event_for_current_user(Event(EventType.TASK_MODIFIED.value,
TaskModified(task.id, TaskModified.OPERATION_ADD)))
return jsonify({'message': f'Kindness {name} added.'}), 201
@kindness_api.route('/kindness/<id>', methods=['GET'])
def get_kindness(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
TaskQuery = Query()
result = task_db.search(
(TaskQuery.id == id) &
((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)) &
(TaskQuery.type == TASK_TYPE)
)
if not result:
return jsonify({'error': 'Kindness act not found'}), 404
return jsonify(result[0]), 200
@kindness_api.route('/kindness/list', methods=['GET'])
def list_kindness():
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
TaskQuery = Query()
tasks = task_db.search(
((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)) &
(TaskQuery.type == TASK_TYPE)
)
user_tasks = {t['name'].strip().lower(): t for t in tasks if t.get('user_id') == user_id}
filtered_tasks = []
for t in tasks:
if t.get('user_id') is None and t['name'].strip().lower() in user_tasks:
continue
filtered_tasks.append(t)
def sort_user_then_default(tasks_group):
user_created = sorted(
[t for t in tasks_group if t.get('user_id') == user_id],
key=lambda x: x['name'].lower(),
)
default_items = sorted(
[t for t in tasks_group if t.get('user_id') is None],
key=lambda x: x['name'].lower(),
)
return user_created + default_items
sorted_tasks = sort_user_then_default(filtered_tasks)
return jsonify({'tasks': sorted_tasks}), 200
@kindness_api.route('/kindness/<id>', methods=['DELETE'])
def delete_kindness(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
TaskQuery = Query()
task = task_db.get((TaskQuery.id == id) & (TaskQuery.type == TASK_TYPE))
if not task:
return jsonify({'error': 'Kindness act not found'}), 404
if task.get('user_id') is None:
import logging
logging.warning(f"Forbidden delete attempt on system kindness: id={id}, by user_id={user_id}")
return jsonify({'error': 'System kindness acts cannot be deleted.'}), 403
removed = task_db.remove((TaskQuery.id == id) & (TaskQuery.user_id == user_id))
if removed:
deleted_count = delete_overrides_for_entity(id)
if deleted_count > 0:
import logging
logging.info(f"Cascade deleted {deleted_count} overrides for kindness {id}")
ChildQuery = Query()
for child in child_db.all():
child_tasks = child.get('tasks', [])
if id in child_tasks:
child_tasks.remove(id)
child_db.update({'tasks': child_tasks}, ChildQuery.id == child.get('id'))
send_event_for_current_user(Event(EventType.CHILD_TASKS_SET.value, ChildTasksSet(id, child_tasks)))
send_event_for_current_user(Event(EventType.TASK_MODIFIED.value, TaskModified(id, TaskModified.OPERATION_DELETE)))
return jsonify({'message': f'Kindness {id} deleted.'}), 200
return jsonify({'error': 'Kindness act not found'}), 404
@kindness_api.route('/kindness/<id>/edit', methods=['PUT'])
def edit_kindness(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
TaskQuery = Query()
existing = task_db.get(
(TaskQuery.id == id) &
((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)) &
(TaskQuery.type == TASK_TYPE)
)
if not existing:
return jsonify({'error': 'Kindness act not found'}), 404
task = Task.from_dict(existing)
is_dirty = False
data = request.get_json(force=True) or {}
if 'name' in data:
name = data.get('name', '').strip()
if not name:
return jsonify({'error': 'Name cannot be empty'}), 400
task.name = name
is_dirty = True
if 'points' in data:
points = data.get('points')
if not isinstance(points, int) or points <= 0:
return jsonify({'error': 'Points must be a positive integer'}), 400
task.points = points
is_dirty = True
if 'image_id' in data:
task.image_id = data.get('image_id', '')
is_dirty = True
if not is_dirty:
return jsonify({'error': 'No valid fields to update'}), 400
if task.user_id is None:
new_task = Task(name=task.name, points=task.points, type=TASK_TYPE, image_id=task.image_id, user_id=user_id)
task_db.insert(new_task.to_dict())
send_event_for_current_user(Event(EventType.TASK_MODIFIED.value,
TaskModified(new_task.id, TaskModified.OPERATION_ADD)))
return jsonify(new_task.to_dict()), 200
task_db.update(task.to_dict(), (TaskQuery.id == id) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)))
send_event_for_current_user(Event(EventType.TASK_MODIFIED.value,
TaskModified(id, TaskModified.OPERATION_EDIT)))
return jsonify(task.to_dict()), 200
+165
View File
@@ -0,0 +1,165 @@
from flask import Blueprint, request, jsonify
from tinydb import Query
from api.utils import send_event_for_current_user, get_validated_user_id
from events.types.child_tasks_set import ChildTasksSet
from db.db import task_db, child_db
from db.child_overrides import delete_overrides_for_entity
from events.types.event import Event
from events.types.event_types import EventType
from events.types.task_modified import TaskModified
from models.task import Task
penalty_api = Blueprint('penalty_api', __name__)
TASK_TYPE = 'penalty'
@penalty_api.route('/penalty/add', methods=['PUT'])
def add_penalty():
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
data = request.get_json()
name = data.get('name')
points = data.get('points')
image = data.get('image_id', '')
if not name or points is None:
return jsonify({'error': 'Name and points are required'}), 400
task = Task(name=name, points=points, type=TASK_TYPE, image_id=image, user_id=user_id)
task_db.insert(task.to_dict())
send_event_for_current_user(Event(EventType.TASK_MODIFIED.value,
TaskModified(task.id, TaskModified.OPERATION_ADD)))
return jsonify({'message': f'Penalty {name} added.'}), 201
@penalty_api.route('/penalty/<id>', methods=['GET'])
def get_penalty(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
TaskQuery = Query()
result = task_db.search(
(TaskQuery.id == id) &
((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)) &
(TaskQuery.type == TASK_TYPE)
)
if not result:
return jsonify({'error': 'Penalty not found'}), 404
return jsonify(result[0]), 200
@penalty_api.route('/penalty/list', methods=['GET'])
def list_penalties():
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
TaskQuery = Query()
tasks = task_db.search(
((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)) &
(TaskQuery.type == TASK_TYPE)
)
user_tasks = {t['name'].strip().lower(): t for t in tasks if t.get('user_id') == user_id}
filtered_tasks = []
for t in tasks:
if t.get('user_id') is None and t['name'].strip().lower() in user_tasks:
continue
filtered_tasks.append(t)
def sort_user_then_default(tasks_group):
user_created = sorted(
[t for t in tasks_group if t.get('user_id') == user_id],
key=lambda x: x['name'].lower(),
)
default_items = sorted(
[t for t in tasks_group if t.get('user_id') is None],
key=lambda x: x['name'].lower(),
)
return user_created + default_items
sorted_tasks = sort_user_then_default(filtered_tasks)
return jsonify({'tasks': sorted_tasks}), 200
@penalty_api.route('/penalty/<id>', methods=['DELETE'])
def delete_penalty(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
TaskQuery = Query()
task = task_db.get((TaskQuery.id == id) & (TaskQuery.type == TASK_TYPE))
if not task:
return jsonify({'error': 'Penalty not found'}), 404
if task.get('user_id') is None:
import logging
logging.warning(f"Forbidden delete attempt on system penalty: id={id}, by user_id={user_id}")
return jsonify({'error': 'System penalties cannot be deleted.'}), 403
removed = task_db.remove((TaskQuery.id == id) & (TaskQuery.user_id == user_id))
if removed:
deleted_count = delete_overrides_for_entity(id)
if deleted_count > 0:
import logging
logging.info(f"Cascade deleted {deleted_count} overrides for penalty {id}")
ChildQuery = Query()
for child in child_db.all():
child_tasks = child.get('tasks', [])
if id in child_tasks:
child_tasks.remove(id)
child_db.update({'tasks': child_tasks}, ChildQuery.id == child.get('id'))
send_event_for_current_user(Event(EventType.CHILD_TASKS_SET.value, ChildTasksSet(id, child_tasks)))
send_event_for_current_user(Event(EventType.TASK_MODIFIED.value, TaskModified(id, TaskModified.OPERATION_DELETE)))
return jsonify({'message': f'Penalty {id} deleted.'}), 200
return jsonify({'error': 'Penalty not found'}), 404
@penalty_api.route('/penalty/<id>/edit', methods=['PUT'])
def edit_penalty(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
TaskQuery = Query()
existing = task_db.get(
(TaskQuery.id == id) &
((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)) &
(TaskQuery.type == TASK_TYPE)
)
if not existing:
return jsonify({'error': 'Penalty not found'}), 404
task = Task.from_dict(existing)
is_dirty = False
data = request.get_json(force=True) or {}
if 'name' in data:
name = data.get('name', '').strip()
if not name:
return jsonify({'error': 'Name cannot be empty'}), 400
task.name = name
is_dirty = True
if 'points' in data:
points = data.get('points')
if not isinstance(points, int) or points <= 0:
return jsonify({'error': 'Points must be a positive integer'}), 400
task.points = points
is_dirty = True
if 'image_id' in data:
task.image_id = data.get('image_id', '')
is_dirty = True
if not is_dirty:
return jsonify({'error': 'No valid fields to update'}), 400
if task.user_id is None:
new_task = Task(name=task.name, points=task.points, type=TASK_TYPE, image_id=task.image_id, user_id=user_id)
task_db.insert(new_task.to_dict())
send_event_for_current_user(Event(EventType.TASK_MODIFIED.value,
TaskModified(new_task.id, TaskModified.OPERATION_ADD)))
return jsonify(new_task.to_dict()), 200
task_db.update(task.to_dict(), (TaskQuery.id == id) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)))
send_event_for_current_user(Event(EventType.TASK_MODIFIED.value,
TaskModified(id, TaskModified.OPERATION_EDIT)))
return jsonify(task.to_dict()), 200
+29
View File
@@ -0,0 +1,29 @@
class PendingConfirmationResponse:
"""Response DTO for hydrated pending confirmation data."""
def __init__(self, _id, child_id, child_name, child_image_id,
entity_id, entity_type, entity_name, entity_image_id,
status='pending', approved_at=None):
self.id = _id
self.child_id = child_id
self.child_name = child_name
self.child_image_id = child_image_id
self.entity_id = entity_id
self.entity_type = entity_type
self.entity_name = entity_name
self.entity_image_id = entity_image_id
self.status = status
self.approved_at = approved_at
def to_dict(self):
return {
'id': self.id,
'child_id': self.child_id,
'child_name': self.child_name,
'child_image_id': self.child_image_id,
'entity_id': self.entity_id,
'entity_type': self.entity_type,
'entity_name': self.entity_name,
'entity_image_id': self.entity_image_id,
'status': self.status,
'approved_at': self.approved_at
}
+59
View File
@@ -0,0 +1,59 @@
from flask import Blueprint, request, jsonify, current_app
from tinydb import Query
from api.utils import get_validated_user_id
from db.push_subscriptions import upsert_subscription, delete_by_endpoint
from db.db import users_db
push_subscription_api = Blueprint('push_subscription_api', __name__)
@push_subscription_api.route('/push-vapid-key', methods=['GET'])
def get_vapid_public_key():
"""Return the VAPID public key for the frontend to use when subscribing."""
public_key = current_app.config.get('VAPID_PUBLIC_KEY', '')
return jsonify({'public_key': public_key}), 200
@push_subscription_api.route('/push-subscription', methods=['POST'])
def subscribe():
"""Upsert a push subscription for the current user."""
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
data = request.get_json() or {}
endpoint = data.get('endpoint')
keys = data.get('keys')
timezone_str = data.get('timezone')
if not endpoint or not isinstance(keys, dict):
return jsonify({'error': 'endpoint and keys are required', 'code': 'MISSING_FIELDS'}), 400
if 'p256dh' not in keys or 'auth' not in keys:
return jsonify({'error': 'keys must contain p256dh and auth', 'code': 'MISSING_FIELDS'}), 400
sub = upsert_subscription(user_id=user_id, endpoint=endpoint, keys=keys)
# Update user timezone if provided
if timezone_str:
UserQ = Query()
users_db.update({'timezone': timezone_str}, UserQ.id == user_id)
return jsonify({'message': 'Subscription saved', 'id': sub.id}), 200
@push_subscription_api.route('/push-subscription', methods=['DELETE'])
def unsubscribe():
"""Remove a push subscription for the current user by endpoint."""
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
data = request.get_json() or {}
endpoint = data.get('endpoint')
if not endpoint:
return jsonify({'error': 'endpoint is required', 'code': 'MISSING_FIELDS'}), 400
removed = delete_by_endpoint(user_id=user_id, endpoint=endpoint)
return jsonify({'message': 'Subscription removed', 'removed': removed}), 200
+26 -5
View File
@@ -3,11 +3,12 @@ from tinydb import Query
from api.utils import send_event_for_current_user, get_validated_user_id from api.utils import send_event_for_current_user, get_validated_user_id
from events.types.child_rewards_set import ChildRewardsSet from events.types.child_rewards_set import ChildRewardsSet
from db.db import reward_db, child_db from db.db import reward_db, child_db, pending_confirmations_db
from db.child_overrides import delete_overrides_for_entity from db.child_overrides import delete_overrides_for_entity
from events.types.event import Event from events.types.event import Event
from events.types.event_types import EventType from events.types.event_types import EventType
from events.types.reward_modified import RewardModified from events.types.reward_modified import RewardModified
from events.types.child_reward_request import ChildRewardRequest
from models.reward import Reward from models.reward import Reward
reward_api = Blueprint('reward_api', __name__) reward_api = Blueprint('reward_api', __name__)
@@ -128,14 +129,19 @@ def edit_reward(id):
is_dirty = True is_dirty = True
if 'description' in data: if 'description' in data:
desc = (data.get('description') or '').strip() # allow empty description (same behavior as add_reward)
if not desc: # note: front-end often submits an empty string, so don't block edits
return jsonify({'error': 'Description cannot be empty'}), 400 reward.description = data.get('description') or ''
reward.description = desc
is_dirty = True is_dirty = True
if 'cost' in data: if 'cost' in data:
cost = data.get('cost') cost = data.get('cost')
# allow numeric strings as well
if isinstance(cost, str):
try:
cost = int(cost)
except ValueError:
return jsonify({'error': 'Cost must be an integer'}), 400
if not isinstance(cost, int): if not isinstance(cost, int):
return jsonify({'error': 'Cost must be an integer'}), 400 return jsonify({'error': 'Cost must be an integer'}), 400
if cost <= 0: if cost <= 0:
@@ -150,6 +156,21 @@ def edit_reward(id):
if not is_dirty: if not is_dirty:
return jsonify({'error': 'No valid fields to update'}), 400 return jsonify({'error': 'No valid fields to update'}), 400
# Reset pending reward requests when cost changes
if 'cost' in data:
PendingQuery = Query()
pending_rewards = pending_confirmations_db.search(
(PendingQuery.entity_id == id) & (PendingQuery.entity_type == 'reward') &
(PendingQuery.status == 'pending')
)
for pr in pending_rewards:
pending_confirmations_db.remove(
(PendingQuery.child_id == pr['child_id']) & (PendingQuery.entity_id == id) &
(PendingQuery.entity_type == 'reward') & (PendingQuery.status == 'pending')
)
send_event_for_current_user(Event(EventType.CHILD_REWARD_REQUEST.value,
ChildRewardRequest(pr['child_id'], id, ChildRewardRequest.REQUEST_CANCELLED)))
if reward.user_id is None: # public reward 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) 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()) reward_db.insert(new_reward.to_dict())
+185
View File
@@ -0,0 +1,185 @@
from flask import Blueprint, request, jsonify
from tinydb import Query
from api.utils import send_event_for_current_user, get_validated_user_id
from db.db import routine_db, child_db, pending_confirmations_db
from db.child_overrides import delete_overrides_for_entity
from db.routine_items import delete_for_routine
from db.routine_schedules import delete_schedules_for_routine
from db.routine_extensions import delete_extensions_for_routine
from events.types.event import Event
from events.types.event_types import EventType
from events.types.routine_modified import RoutineModified
from events.types.child_routines_set import ChildRoutinesSet
from models.routine import Routine
routine_api = Blueprint('routine_api', __name__)
@routine_api.route('/routine/add', methods=['PUT'])
def add_routine():
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
data = request.get_json() or {}
name = data.get('name')
points = data.get('points')
image = data.get('image_id', '')
if not name or points is None:
return jsonify({'error': 'Name and points are required'}), 400
routine = Routine(name=name, points=points, image_id=image, user_id=user_id)
routine_db.insert(routine.to_dict())
send_event_for_current_user(
Event(EventType.ROUTINE_MODIFIED.value, RoutineModified(routine.id, RoutineModified.OPERATION_ADD))
)
return jsonify({'message': f'Routine {name} added.', 'routine': routine.to_dict()}), 201
@routine_api.route('/routine/<id>', methods=['GET'])
def get_routine(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
q = Query()
result = routine_db.search((q.id == id) & ((q.user_id == user_id) | (q.user_id == None)))
if not result:
return jsonify({'error': 'Routine not found'}), 404
return jsonify(result[0]), 200
@routine_api.route('/routine/list', methods=['GET'])
def list_routines():
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
ids_param = request.args.get('ids')
q = Query()
routines = routine_db.search((q.user_id == user_id) | (q.user_id == None))
if ids_param is not None:
if ids_param.strip() == '':
routines = []
else:
ids = set(ids_param.split(','))
routines = [routine for routine in routines if routine.get('id') in ids]
user_routines = {r['name'].strip().lower(): r for r in routines if r.get('user_id') == user_id}
filtered_routines = []
for routine in routines:
if routine.get('user_id') is None and routine['name'].strip().lower() in user_routines:
continue
filtered_routines.append(routine)
user_created = sorted(
[r for r in filtered_routines if r.get('user_id') == user_id],
key=lambda x: x['name'].lower(),
)
default_items = sorted(
[r for r in filtered_routines if r.get('user_id') is None],
key=lambda x: x['name'].lower(),
)
return jsonify({'routines': user_created + default_items}), 200
@routine_api.route('/routine/<id>/edit', methods=['PUT'])
def edit_routine(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
q = Query()
existing = routine_db.get((q.id == id) & ((q.user_id == user_id) | (q.user_id == None)))
if not existing:
return jsonify({'error': 'Routine not found'}), 404
routine = Routine.from_dict(existing)
data = request.get_json(force=True) or {}
is_dirty = False
if 'name' in data:
name = data.get('name', '').strip()
if not name:
return jsonify({'error': 'Name cannot be empty'}), 400
routine.name = name
is_dirty = True
if 'points' in data:
points = data.get('points')
if not isinstance(points, int) or points <= 0:
return jsonify({'error': 'Points must be a positive integer'}), 400
routine.points = points
is_dirty = True
if 'image_id' in data:
routine.image_id = data.get('image_id', '')
is_dirty = True
if not is_dirty:
return jsonify({'error': 'No valid fields to update'}), 400
if routine.user_id is None:
new_routine = Routine(name=routine.name, points=routine.points, image_id=routine.image_id, user_id=user_id)
routine_db.insert(new_routine.to_dict())
send_event_for_current_user(
Event(EventType.ROUTINE_MODIFIED.value, RoutineModified(new_routine.id, RoutineModified.OPERATION_ADD))
)
return jsonify(new_routine.to_dict()), 200
routine_db.update(routine.to_dict(), q.id == id)
send_event_for_current_user(
Event(EventType.ROUTINE_MODIFIED.value, RoutineModified(id, RoutineModified.OPERATION_EDIT))
)
return jsonify(routine.to_dict()), 200
@routine_api.route('/routine/<id>', methods=['DELETE'])
def delete_routine(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
q = Query()
routine = routine_db.get(q.id == id)
if not routine:
return jsonify({'error': 'Routine not found'}), 404
if routine.get('user_id') is None:
return jsonify({'error': 'System routines cannot be deleted.'}), 403
removed = routine_db.remove((q.id == id) & (q.user_id == user_id))
if not removed:
return jsonify({'error': 'Routine not found'}), 404
delete_overrides_for_entity(id)
delete_for_routine(id)
delete_schedules_for_routine(id)
delete_extensions_for_routine(id)
pending_q = Query()
pending_confirmations_db.remove(
(pending_q.entity_id == id) & (pending_q.entity_type == 'routine') & (pending_q.user_id == user_id)
)
child_q = Query()
children = child_db.search(child_q.user_id == user_id)
for child in children:
routine_ids = child.get('routines', [])
if id in routine_ids:
routine_ids = [rid for rid in routine_ids if rid != id]
child_db.update({'routines': routine_ids}, child_q.id == child.get('id'))
send_event_for_current_user(
Event(EventType.CHILD_ROUTINES_SET.value, ChildRoutinesSet(child.get('id'), routine_ids))
)
send_event_for_current_user(
Event(EventType.ROUTINE_MODIFIED.value, RoutineModified(id, RoutineModified.OPERATION_DELETE))
)
return jsonify({'message': f'Routine {id} deleted.'}), 200
+126
View File
@@ -0,0 +1,126 @@
from flask import Blueprint, request, jsonify
from tinydb import Query
from api.utils import get_validated_user_id, send_event_for_current_user
from db.db import routine_db
from db.routine_items import add_item, delete_item, get_item, get_items_for_routine, update_item
from events.types.event import Event
from events.types.event_types import EventType
from events.types.routine_modified import RoutineModified
from models.routine_item import RoutineItem
routine_item_api = Blueprint('routine_item_api', __name__)
def _validate_routine_owned_by_user(routine_id: str, user_id: str):
q = Query()
return routine_db.get((q.id == routine_id) & ((q.user_id == user_id) | (q.user_id == None)))
@routine_item_api.route('/routine/<routine_id>/item/add', methods=['PUT'])
def add_routine_item(routine_id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
routine = _validate_routine_owned_by_user(routine_id, user_id)
if not routine:
return jsonify({'error': 'Routine not found'}), 404
data = request.get_json() or {}
name = data.get('name', '').strip()
image_id = data.get('image_id')
if not name:
return jsonify({'error': 'name is required'}), 400
existing_items = get_items_for_routine(routine_id)
order = data.get('order', len(existing_items))
item = RoutineItem(routine_id=routine_id, name=name, image_id=image_id, order=order)
add_item(item)
send_event_for_current_user(
Event(EventType.ROUTINE_MODIFIED.value, RoutineModified(routine_id, RoutineModified.OPERATION_EDIT))
)
return jsonify(item.to_dict()), 201
@routine_item_api.route('/routine/<routine_id>/item/<item_id>/edit', methods=['PUT'])
def edit_routine_item(routine_id, item_id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
routine = _validate_routine_owned_by_user(routine_id, user_id)
if not routine:
return jsonify({'error': 'Routine not found'}), 404
existing = get_item(item_id)
if not existing or existing.routine_id != routine_id:
return jsonify({'error': 'Item not found'}), 404
data = request.get_json(force=True) or {}
is_dirty = False
if 'name' in data:
name = data.get('name', '').strip()
if not name:
return jsonify({'error': 'name cannot be empty'}), 400
existing.name = name
is_dirty = True
if 'image_id' in data:
existing.image_id = data.get('image_id')
is_dirty = True
if 'order' in data:
order = data.get('order')
if not isinstance(order, int) or order < 0:
return jsonify({'error': 'order must be a non-negative integer'}), 400
existing.order = order
is_dirty = True
if not is_dirty:
return jsonify({'error': 'No valid fields to update'}), 400
update_item(existing)
send_event_for_current_user(
Event(EventType.ROUTINE_MODIFIED.value, RoutineModified(routine_id, RoutineModified.OPERATION_EDIT))
)
return jsonify(existing.to_dict()), 200
@routine_item_api.route('/routine/<routine_id>/item/<item_id>', methods=['DELETE'])
def delete_routine_item(routine_id, item_id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
routine = _validate_routine_owned_by_user(routine_id, user_id)
if not routine:
return jsonify({'error': 'Routine not found'}), 404
item = get_item(item_id)
if not item or item.routine_id != routine_id:
return jsonify({'error': 'Item not found'}), 404
delete_item(item_id)
send_event_for_current_user(
Event(EventType.ROUTINE_MODIFIED.value, RoutineModified(routine_id, RoutineModified.OPERATION_EDIT))
)
return jsonify({'message': 'Item deleted'}), 200
@routine_item_api.route('/routine/<routine_id>/items', methods=['GET'])
def list_routine_items(routine_id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
routine = _validate_routine_owned_by_user(routine_id, user_id)
if not routine:
return jsonify({'error': 'Routine not found'}), 404
items = [item.to_dict() for item in get_items_for_routine(routine_id)]
return jsonify({'items': items, 'count': len(items)}), 200
+178
View File
@@ -0,0 +1,178 @@
from flask import Blueprint, request, jsonify
from tinydb import Query
from api.error_codes import ErrorCodes
from api.utils import get_validated_user_id, send_event_for_current_user
from db.db import child_db, pending_confirmations_db
from db.routine_extensions import add_extension, delete_extension_for_child_routine, get_extension
from db.routine_schedules import delete_schedule, get_schedule, upsert_schedule
from events.types.child_routine_confirmation import ChildRoutineConfirmation
from events.types.event import Event
from events.types.event_types import EventType
from events.types.routine_schedule_modified import RoutineScheduleModified
from events.types.routine_time_extended import RoutineTimeExtended
from models.routine_extension import RoutineExtension
from models.routine_schedule import RoutineSchedule
routine_schedule_api = Blueprint('routine_schedule_api', __name__)
def _validate_child(child_id: str, user_id: str):
q = Query()
result = child_db.search((q.id == child_id) & (q.user_id == user_id))
return result[0] if result else None
@routine_schedule_api.route('/child/<child_id>/routine/<routine_id>/schedule', methods=['GET'])
def get_routine_schedule(child_id, routine_id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
if not _validate_child(child_id, user_id):
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
schedule = get_schedule(child_id, routine_id)
if not schedule:
return jsonify({'error': 'Schedule not found'}), 404
return jsonify(schedule.to_dict()), 200
@routine_schedule_api.route('/child/<child_id>/routine/<routine_id>/schedule', methods=['PUT'])
def set_routine_schedule(child_id, routine_id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
if not _validate_child(child_id, user_id):
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
data = request.get_json() or {}
mode = data.get('mode')
if mode not in ('days', 'interval'):
return jsonify({'error': 'mode must be "days" or "interval"', 'code': ErrorCodes.INVALID_VALUE}), 400
enabled = data.get('enabled', True)
if not isinstance(enabled, bool):
return jsonify({'error': 'enabled must be a boolean', 'code': ErrorCodes.INVALID_VALUE}), 400
if mode == 'days':
day_configs = data.get('day_configs', [])
if not isinstance(day_configs, list):
return jsonify({'error': 'day_configs must be a list', 'code': ErrorCodes.INVALID_VALUE}), 400
schedule = RoutineSchedule(
child_id=child_id,
routine_id=routine_id,
mode='days',
day_configs=day_configs,
default_hour=data.get('default_hour', 8),
default_minute=data.get('default_minute', 0),
default_has_deadline=data.get('default_has_deadline', True),
enabled=enabled,
)
else:
interval_days = data.get('interval_days', 2)
anchor_date = data.get('anchor_date', '')
interval_has_deadline = data.get('interval_has_deadline', True)
interval_hour = data.get('interval_hour', 0)
interval_minute = data.get('interval_minute', 0)
if not isinstance(interval_days, int) or not (1 <= interval_days <= 7):
return jsonify({'error': 'interval_days must be an integer between 1 and 7', 'code': ErrorCodes.INVALID_VALUE}), 400
schedule = RoutineSchedule(
child_id=child_id,
routine_id=routine_id,
mode='interval',
interval_days=interval_days,
anchor_date=anchor_date,
interval_has_deadline=interval_has_deadline,
interval_hour=interval_hour,
interval_minute=interval_minute,
enabled=enabled,
)
delete_extension_for_child_routine(child_id, routine_id)
upsert_schedule(schedule)
pending_q = Query()
pending_routines = pending_confirmations_db.search(
(pending_q.child_id == child_id) & (pending_q.entity_id == routine_id) &
(pending_q.entity_type == 'routine') & (pending_q.status == 'pending')
)
for _ in pending_routines:
pending_confirmations_db.remove(
(pending_q.child_id == child_id) & (pending_q.entity_id == routine_id) &
(pending_q.entity_type == 'routine') & (pending_q.status == 'pending')
)
send_event_for_current_user(
Event(
EventType.CHILD_ROUTINE_CONFIRMATION.value,
ChildRoutineConfirmation(child_id, routine_id, ChildRoutineConfirmation.OPERATION_RESET)
)
)
send_event_for_current_user(
Event(
EventType.ROUTINE_SCHEDULE_MODIFIED.value,
RoutineScheduleModified(child_id, routine_id, RoutineScheduleModified.OPERATION_SET)
)
)
return jsonify(schedule.to_dict()), 200
@routine_schedule_api.route('/child/<child_id>/routine/<routine_id>/schedule', methods=['DELETE'])
def delete_routine_schedule(child_id, routine_id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
if not _validate_child(child_id, user_id):
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
removed = delete_schedule(child_id, routine_id)
if not removed:
return jsonify({'error': 'Schedule not found'}), 404
send_event_for_current_user(
Event(
EventType.ROUTINE_SCHEDULE_MODIFIED.value,
RoutineScheduleModified(child_id, routine_id, RoutineScheduleModified.OPERATION_DELETED)
)
)
return jsonify({'message': 'Schedule deleted'}), 200
@routine_schedule_api.route('/child/<child_id>/routine/<routine_id>/extend', methods=['POST'])
def extend_routine_time(child_id, routine_id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
if not _validate_child(child_id, user_id):
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
data = request.get_json() or {}
date = data.get('date')
if not date or not isinstance(date, str):
return jsonify({'error': 'date is required (ISO date string)', 'code': ErrorCodes.MISSING_FIELD}), 400
existing = get_extension(child_id, routine_id, date)
if existing:
return jsonify({'error': 'Routine already extended for this date', 'code': 'ALREADY_EXTENDED'}), 409
delete_extension_for_child_routine(child_id, routine_id)
extension = RoutineExtension(child_id=child_id, routine_id=routine_id, date=date)
add_extension(extension)
send_event_for_current_user(
Event(
EventType.ROUTINE_TIME_EXTENDED.value,
RoutineTimeExtended(child_id, routine_id)
)
)
return jsonify(extension.to_dict()), 200
+40 -10
View File
@@ -3,11 +3,12 @@ from tinydb import Query
from api.utils import send_event_for_current_user, get_validated_user_id from api.utils import send_event_for_current_user, get_validated_user_id
from events.types.child_tasks_set import ChildTasksSet from events.types.child_tasks_set import ChildTasksSet
from db.db import task_db, child_db from db.db import task_db, child_db, pending_confirmations_db
from db.child_overrides import delete_overrides_for_entity from db.child_overrides import delete_overrides_for_entity
from events.types.event import Event from events.types.event import Event
from events.types.event_types import EventType from events.types.event_types import EventType
from events.types.task_modified import TaskModified from events.types.task_modified import TaskModified
from events.types.child_chore_confirmation import ChildChoreConfirmation
from models.task import Task from models.task import Task
task_api = Blueprint('task_api', __name__) task_api = Blueprint('task_api', __name__)
@@ -21,11 +22,16 @@ def add_task():
data = request.get_json() data = request.get_json()
name = data.get('name') name = data.get('name')
points = data.get('points') points = data.get('points')
is_good = data.get('is_good') task_type = data.get('type')
# Support legacy is_good field
if task_type is None and 'is_good' in data:
task_type = 'chore' if data['is_good'] else 'penalty'
image = data.get('image_id', '') image = data.get('image_id', '')
if not name or points is None or is_good is None: if not name or points is None or task_type is None:
return jsonify({'error': 'Name, points, and is_good are required'}), 400 return jsonify({'error': 'Name, points, and type are required'}), 400
task = Task(name=name, points=points, is_good=is_good, image_id=image, user_id=user_id) if task_type not in ['chore', 'kindness', 'penalty']:
return jsonify({'error': 'type must be chore, kindness, or penalty'}), 400
task = Task(name=name, points=points, type=task_type, image_id=image, user_id=user_id)
task_db.insert(task.to_dict()) task_db.insert(task.to_dict())
send_event_for_current_user(Event(EventType.TASK_MODIFIED.value, send_event_for_current_user(Event(EventType.TASK_MODIFIED.value,
TaskModified(task.id, TaskModified.OPERATION_ADD))) TaskModified(task.id, TaskModified.OPERATION_ADD)))
@@ -65,10 +71,10 @@ def list_tasks():
filtered_tasks.append(t) filtered_tasks.append(t)
# Sort order: # Sort order:
# 1) good tasks first, then not-good tasks # 1) chore/kindness first, then penalties
# 2) within each group: user-created items first (by name), then default items (by name) # 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] good_tasks = [t for t in filtered_tasks if Task.from_dict(t).type != 'penalty']
not_good_tasks = [t for t in filtered_tasks if t.get('is_good') is not True] not_good_tasks = [t for t in filtered_tasks if Task.from_dict(t).type == 'penalty']
def sort_user_then_default(tasks_group): def sort_user_then_default(tasks_group):
user_created = sorted( user_created = sorted(
@@ -154,7 +160,15 @@ def edit_task(id):
is_good = data.get('is_good') is_good = data.get('is_good')
if not isinstance(is_good, bool): if not isinstance(is_good, bool):
return jsonify({'error': 'is_good must be a boolean'}), 400 return jsonify({'error': 'is_good must be a boolean'}), 400
task.is_good = is_good # Convert to type
task.type = 'chore' if is_good else 'penalty'
is_dirty = True
if 'type' in data:
task_type = data.get('type')
if task_type not in ['chore', 'kindness', 'penalty']:
return jsonify({'error': 'type must be chore, kindness, or penalty'}), 400
task.type = task_type
is_dirty = True is_dirty = True
if 'image_id' in data: if 'image_id' in data:
@@ -164,8 +178,24 @@ def edit_task(id):
if not is_dirty: if not is_dirty:
return jsonify({'error': 'No valid fields to update'}), 400 return jsonify({'error': 'No valid fields to update'}), 400
# Reset pending chore confirmations when task is modified (points, type, etc.)
# Completed (approved) chores stay in completed state
if 'points' in data or 'type' in data:
PendingQuery = Query()
pending_chores = pending_confirmations_db.search(
(PendingQuery.entity_id == id) & (PendingQuery.entity_type == 'chore') &
(PendingQuery.status == 'pending')
)
for pc in pending_chores:
pending_confirmations_db.remove(
(PendingQuery.child_id == pc['child_id']) & (PendingQuery.entity_id == id) &
(PendingQuery.entity_type == 'chore') & (PendingQuery.status == 'pending')
)
send_event_for_current_user(Event(EventType.CHILD_CHORE_CONFIRMATION.value,
ChildChoreConfirmation(pc['child_id'], id, ChildChoreConfirmation.OPERATION_RESET)))
if task.user_id is None: # public task if task.user_id is None: # public task
new_task = Task(name=task.name, points=task.points, is_good=task.is_good, image_id=task.image_id, user_id=user_id) new_task = Task(name=task.name, points=task.points, type=task.type, image_id=task.image_id, user_id=user_id)
task_db.insert(new_task.to_dict()) task_db.insert(new_task.to_dict())
send_event_for_current_user(Event(EventType.TASK_MODIFIED.value, send_event_for_current_user(Event(EventType.TASK_MODIFIED.value,
TaskModified(new_task.id, TaskModified.OPERATION_ADD))) TaskModified(new_task.id, TaskModified.OPERATION_ADD)))
+1 -50
View File
@@ -1,61 +1,12 @@
from flask import Blueprint, request, jsonify from flask import Blueprint, request, jsonify
from api.utils import get_validated_user_id from api.utils import get_validated_user_id, admin_required
from db.tracking import get_tracking_events_by_child, get_tracking_events_by_user from db.tracking import get_tracking_events_by_child, get_tracking_events_by_user
from models.tracking_event import TrackingEvent 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__) 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']) @tracking_api.route('/admin/tracking', methods=['GET'])
@admin_required @admin_required
def get_tracking(): def get_tracking():
+68 -3
View File
@@ -9,6 +9,8 @@ import string
import utils.email_sender as email_sender import utils.email_sender as email_sender
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from api.utils import get_validated_user_id, normalize_email, send_event_for_current_user from api.utils import get_validated_user_id, normalize_email, send_event_for_current_user
from events.sse import send_event_to_user
from events.types.payload import Payload
from api.error_codes import ACCOUNT_MARKED_FOR_DELETION, ALREADY_MARKED from api.error_codes import ACCOUNT_MARKED_FOR_DELETION, ALREADY_MARKED
from events.types.event_types import EventType from events.types.event_types import EventType
from events.types.event import Event from events.types.event import Event
@@ -21,7 +23,7 @@ user_api = Blueprint('user_api', __name__)
UserQuery = Query() UserQuery = Query()
def get_current_user(): def get_current_user():
token = request.cookies.get('token') token = request.cookies.get('access_token')
if not token: if not token:
return None return None
try: try:
@@ -44,7 +46,11 @@ def get_profile():
'first_name': user.first_name, 'first_name': user.first_name,
'last_name': user.last_name, 'last_name': user.last_name,
'email': user.email, 'email': user.email,
'image_id': user.image_id 'image_id': user.image_id,
'email_digest_enabled': user.email_digest_enabled,
'push_notifications_enabled': user.push_notifications_enabled,
'tutorial_enabled': user.tutorial_enabled,
'tutorial_progress': user.tutorial_progress or {},
}), 200 }), 200
@user_api.route('/user/profile', methods=['PUT']) @user_api.route('/user/profile', methods=['PUT'])
@@ -56,16 +62,22 @@ def update_profile():
if not user: if not user:
return jsonify({'error': 'Unauthorized'}), 401 return jsonify({'error': 'Unauthorized'}), 401
data = request.get_json() data = request.get_json()
# Only allow first_name, last_name, image_id to be updated # Only allow first_name, last_name, image_id, email_digest_enabled, push_notifications_enabled to be updated
first_name = data.get('first_name') first_name = data.get('first_name')
last_name = data.get('last_name') last_name = data.get('last_name')
image_id = data.get('image_id') image_id = data.get('image_id')
email_digest_enabled = data.get('email_digest_enabled')
push_notifications_enabled = data.get('push_notifications_enabled')
if first_name is not None: if first_name is not None:
user.first_name = first_name user.first_name = first_name
if last_name is not None: if last_name is not None:
user.last_name = last_name user.last_name = last_name
if image_id is not None: if image_id is not None:
user.image_id = image_id user.image_id = image_id
if email_digest_enabled is not None:
user.email_digest_enabled = bool(email_digest_enabled)
if push_notifications_enabled is not None:
user.push_notifications_enabled = bool(push_notifications_enabled)
users_db.update(user.to_dict(), UserQuery.email == user.email) users_db.update(user.to_dict(), UserQuery.email == user.email)
# Create tracking event # Create tracking event
@@ -76,6 +88,10 @@ def update_profile():
metadata['last_name_updated'] = True metadata['last_name_updated'] = True
if image_id is not None: if image_id is not None:
metadata['image_updated'] = True metadata['image_updated'] = True
if email_digest_enabled is not None:
metadata['email_digest_enabled_updated'] = True
if push_notifications_enabled is not None:
metadata['push_notifications_enabled_updated'] = True
tracking_event = TrackingEvent.create_event( tracking_event = TrackingEvent.create_event(
user_id=user_id, user_id=user_id,
@@ -95,6 +111,37 @@ def update_profile():
return jsonify({'message': 'Profile updated'}), 200 return jsonify({'message': 'Profile updated'}), 200
@user_api.route('/user/tutorial-progress', methods=['PATCH'])
def update_tutorial_progress():
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() or {}
if data.get('reset') is True:
user.tutorial_progress = {}
elif 'enabled' in data:
user.tutorial_enabled = bool(data.get('enabled'))
elif 'step_id' in data:
step_id = str(data.get('step_id') or '').strip()
if not step_id:
return jsonify({'error': 'Missing step_id'}), 400
progress = dict(user.tutorial_progress or {})
progress[step_id] = bool(data.get('seen', True))
user.tutorial_progress = progress
else:
return jsonify({'error': 'No-op'}), 400
users_db.update(user.to_dict(), UserQuery.email == user.email)
send_event_for_current_user(Event(EventType.PROFILE_UPDATED.value, ProfileUpdated(user.id)))
return jsonify({
'tutorial_enabled': user.tutorial_enabled,
'tutorial_progress': user.tutorial_progress,
}), 200
@user_api.route('/user/image', methods=['PUT']) @user_api.route('/user/image', methods=['PUT'])
def update_image(): def update_image():
user_id = get_validated_user_id() user_id = get_validated_user_id()
@@ -243,4 +290,22 @@ def mark_for_deletion():
# Trigger SSE event # Trigger SSE event
send_event_for_current_user(Event(EventType.USER_MARKED_FOR_DELETION.value, UserModified(user.id, UserModified.OPERATION_DELETE))) send_event_for_current_user(Event(EventType.USER_MARKED_FOR_DELETION.value, UserModified(user.id, UserModified.OPERATION_DELETE)))
# Notify all other active sessions to sign out and go to landing page
send_event_to_user(user.id, Event(EventType.FORCE_LOGOUT.value, Payload({'reason': 'account_deleted'})))
return jsonify({'success': True}), 200 return jsonify({'success': True}), 200
@user_api.route('/user/e2e-get-pin-code', methods=['GET'])
def e2e_get_pin_code():
"""Return the current user's pin_setup_code for e2e testing. Never available in production."""
import os
if os.environ.get('DB_ENV', 'prod') == 'prod':
return jsonify({'error': 'Not available in production'}), 403
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized'}), 401
user = get_current_user()
if not user:
return jsonify({'error': 'Unauthorized'}), 401
return jsonify({'code': user.pin_setup_code or ''}), 200
+45 -1
View File
@@ -1,10 +1,12 @@
import jwt import jwt
import re import re
from functools import wraps
from db.db import users_db from db.db import users_db
from tinydb import Query from tinydb import Query
from flask import request, current_app, jsonify from flask import request, current_app, jsonify
from events.sse import send_event_to_user from events.sse import send_event_to_user
from models.user import User
def normalize_email(email: str) -> str: def normalize_email(email: str) -> str:
@@ -21,7 +23,7 @@ def sanitize_email(email):
return email.replace('@', '_at_').replace('.', '_dot_') return email.replace('@', '_at_').replace('.', '_dot_')
def get_current_user_id(): def get_current_user_id():
token = request.cookies.get('token') token = request.cookies.get('access_token')
if not token: if not token:
return None return None
try: try:
@@ -51,3 +53,45 @@ def send_event_for_current_user(event):
return jsonify({'error': 'Unauthorized'}), 401 return jsonify({'error': 'Unauthorized'}), 401
send_event_to_user(user_id, event) send_event_to_user(user_id, event)
return None return None
def admin_required(f):
"""
Decorator to require admin role for endpoints.
Validates JWT from access_token cookie and checks admin role.
"""
@wraps(f)
def decorated_function(*args, **kwargs):
token = request.cookies.get('access_token')
if not token:
return jsonify({'error': 'Authentication required', 'code': 'AUTH_REQUIRED'}), 401
try:
payload = jwt.decode(token, current_app.config['SECRET_KEY'], algorithms=['HS256'])
user_id = payload.get('user_id')
if not user_id:
return jsonify({'error': 'Invalid token', 'code': 'INVALID_TOKEN'}), 401
user_dict = users_db.get(Query().id == user_id)
if not user_dict:
return jsonify({'error': 'User not found', 'code': 'USER_NOT_FOUND'}), 404
user = User.from_dict(user_dict)
if user.role != 'admin':
return jsonify({'error': 'Admin access required', 'code': 'ADMIN_REQUIRED'}), 403
# Store user info in request context for the endpoint
request.current_user = user
request.admin_user_id = user_id
except jwt.ExpiredSignatureError:
return jsonify({'error': 'Token expired', 'code': 'TOKEN_EXPIRED'}), 401
except jwt.InvalidTokenError:
return jsonify({'error': 'Invalid token', 'code': 'INVALID_TOKEN'}), 401
return f(*args, **kwargs)
return decorated_function
+6
View File
@@ -26,6 +26,12 @@ def get_database_dir(db_env: str | None = None) -> str:
env = (db_env or os.environ.get('DB_ENV', 'prod')).lower() env = (db_env or os.environ.get('DB_ENV', 'prod')).lower()
return os.path.join(PROJECT_ROOT, get_base_data_dir(env), 'db') return os.path.join(PROJECT_ROOT, get_base_data_dir(env), 'db')
def get_images_dir() -> str:
"""
Return the absolute directory path for storing images.
"""
return os.path.join(PROJECT_ROOT, get_base_data_dir(), 'images')
def get_user_image_dir(username: str | None) -> str: def get_user_image_dir(username: str | None) -> str:
""" """
Return the absolute directory path for storing images for a specific user. Return the absolute directory path for storing images for a specific user.
+1 -1
View File
@@ -2,7 +2,7 @@
# file: config/version.py # file: config/version.py
import os import os
BASE_VERSION = "1.0.5" # update manually when releasing features BASE_VERSION = "1.0.16" # update manually when releasing features
def get_full_version() -> str: def get_full_version() -> str:
""" """
+39
View File
@@ -0,0 +1,39 @@
from db.db import chore_schedules_db
from models.chore_schedule import ChoreSchedule
from tinydb import Query
def get_schedule(child_id: str, task_id: str) -> ChoreSchedule | None:
q = Query()
result = chore_schedules_db.search((q.child_id == child_id) & (q.task_id == task_id))
if not result:
return None
return ChoreSchedule.from_dict(result[0])
def upsert_schedule(schedule: ChoreSchedule) -> None:
q = Query()
existing = chore_schedules_db.get((q.child_id == schedule.child_id) & (q.task_id == schedule.task_id))
if existing:
chore_schedules_db.update(schedule.to_dict(), (q.child_id == schedule.child_id) & (q.task_id == schedule.task_id))
else:
chore_schedules_db.insert(schedule.to_dict())
def delete_schedule(child_id: str, task_id: str) -> bool:
q = Query()
existing = chore_schedules_db.get((q.child_id == child_id) & (q.task_id == task_id))
if not existing:
return False
chore_schedules_db.remove((q.child_id == child_id) & (q.task_id == task_id))
return True
def delete_schedules_for_child(child_id: str) -> None:
q = Query()
chore_schedules_db.remove(q.child_id == child_id)
def delete_schedules_for_task(task_id: str) -> None:
q = Query()
chore_schedules_db.remove(q.task_id == task_id)
+513 -20
View File
@@ -1,13 +1,42 @@
# python # python
import os import os
from config.paths import get_database_dir
import threading import threading
from config.paths import get_database_dir
from tinydb import TinyDB from tinydb import TinyDB
from tinydb.queries import QueryInstance
from db.mongo_client import get_mongo_client, get_mongo_db_name
try:
from tinydb.table import Document
except ImportError: # pragma: no cover - tinydb version compatibility
from tinydb.database import Document
def _stable_clause_key(clause: dict) -> str:
"""Return a stable string key for sorting MongoDB filter clauses."""
import json
return json.dumps(clause, sort_keys=True, default=str)
try:
from pymongo import ASCENDING
except ImportError: # pragma: no cover - pymongo is a required dependency
ASCENDING = 1
USE_MONGODB = os.environ.get('USE_MONGODB', 'true').lower() == 'true'
# Resolve the MongoDB database name once at module load so runtime changes to
# DB_ENV/DATA_ENV in tests do not switch databases mid-process.
_mongo_db_name = get_mongo_db_name() if USE_MONGODB else None
base_dir = get_database_dir() base_dir = get_database_dir()
os.makedirs(base_dir, exist_ok=True) os.makedirs(base_dir, exist_ok=True)
# ---------------------------------------------------------------------------
# TinyDB-backed table wrapper
# ---------------------------------------------------------------------------
class LockedTable: class LockedTable:
""" """
Thread-safe wrapper around a TinyDB table. All callable attribute access Thread-safe wrapper around a TinyDB table. All callable attribute access
@@ -65,44 +94,508 @@ class LockedTable:
with self._lock: with self._lock:
return self._table.truncate() return self._table.truncate()
def close(self):
with self._lock:
return self._table.close()
# ---------------------------------------------------------------------------
# TinyDB Query -> MongoDB filter translator
# ---------------------------------------------------------------------------
_MONGO_OP_MAP = {
'==': '$eq',
'!=': '$ne',
'<': '$lt',
'<=': '$lte',
'>': '$gt',
'>=': '$gte',
}
_NEGATED_OPS = {
'$eq': '$ne',
'$ne': '$eq',
'$lt': '$gte',
'$lte': '$gt',
'$gt': '$lte',
'$gte': '$lt',
}
def _field_to_mongo(field_path: tuple) -> str:
"""Map a TinyDB field path to a MongoDB field name.
The model ``id`` field is stored as the MongoDB ``_id`` field, so queries
on ``id`` are translated to queries on ``_id``.
"""
if len(field_path) == 1:
return '_id' if field_path[0] == 'id' else field_path[0]
return '.'.join('_id' if p == 'id' else p for p in field_path)
def _negate_condition(cond: dict) -> dict | None:
"""Return a MongoDB condition that negates a single-field condition."""
if len(cond) != 1:
return None
field, inner = next(iter(cond.items()))
if not isinstance(inner, dict) or len(inner) != 1:
return None
op, value = next(iter(inner.items()))
if op in _NEGATED_OPS:
return {field: {_NEGATED_OPS[op]: value}}
return None
def _hash_to_mongo_filter(query_hash) -> dict | None:
"""Translate a TinyDB query hash tuple to a MongoDB filter document.
Returns ``None`` when the query construct cannot be expressed as a native
MongoDB filter, signalling that the caller should fall back to in-memory
TinyDB evaluation.
"""
if not isinstance(query_hash, tuple) or len(query_hash) == 0:
return None
op = query_hash[0]
if op in _MONGO_OP_MAP:
field = _field_to_mongo(query_hash[1])
value = query_hash[2]
return {field: {_MONGO_OP_MAP[op]: value}}
if op == 'exists':
field = _field_to_mongo(query_hash[1])
return {field: {'$exists': True}}
if op == 'one_of':
field = _field_to_mongo(query_hash[1])
return {field: {'$in': list(query_hash[2])}}
if op == 'any':
field = _field_to_mongo(query_hash[1])
return {field: {'$in': list(query_hash[2])}}
if op == 'all':
field = _field_to_mongo(query_hash[1])
return {field: {'$all': list(query_hash[2])}}
if op == 'matches':
field = _field_to_mongo(query_hash[1])
# TinyDB matches() anchors the regex at the start of the string.
return {field: {'$regex': f'^{query_hash[2]}'}}
if op == 'search':
field = _field_to_mongo(query_hash[1])
return {field: {'$regex': query_hash[2]}}
if op == 'and':
merged: dict = {}
for sub_hash in query_hash[1]:
sub = _hash_to_mongo_filter(sub_hash)
if sub is None:
return None
for field, inner in sub.items():
if field in merged:
if isinstance(merged[field], dict) and isinstance(inner, dict):
merged[field].update(inner)
elif isinstance(merged[field], list) and isinstance(inner, list):
merged[field].extend(inner)
else:
return None
else:
merged[field] = (
dict(inner) if isinstance(inner, dict)
else list(inner) if isinstance(inner, list)
else inner
)
return merged
if op == 'or':
clauses = [_hash_to_mongo_filter(sub_hash) for sub_hash in query_hash[1]]
if any(c is None for c in clauses):
return None
return {'$or': sorted(clauses, key=_stable_clause_key)}
if op == 'not':
inner = _hash_to_mongo_filter(query_hash[1])
if inner is None:
return None
negated = _negate_condition(inner)
if negated is not None:
return negated
return None
# Unsupported operation (test, fragment, noop, etc.) -> fall back.
return None
def _query_to_mongo_filter(query) -> dict | None:
"""Translate a TinyDB QueryInstance to a MongoDB filter, if possible."""
if isinstance(query, dict):
return query
if isinstance(query, QueryInstance):
return _hash_to_mongo_filter(query._hash)
return None
def _evaluate_in_memory(docs, query) -> list:
"""Evaluate a TinyDB query against an in-memory list of documents."""
if callable(query):
return [doc for doc in docs if query(doc)]
return docs
# ---------------------------------------------------------------------------
# MongoDB-backed table wrapper
# ---------------------------------------------------------------------------
class MongoLockedTable:
"""Drop-in replacement for ``LockedTable`` that delegates to MongoDB.
The adapter preserves the existing table API while mapping the model
``id`` field to MongoDB's ``_id`` field on reads and writes.
"""
def __init__(self, collection_name: str):
self.collection_name = collection_name
def _collection(self):
client = get_mongo_client()
return client[_mongo_db_name][self.collection_name]
@staticmethod
def _doc_to_mongo(doc: dict) -> dict:
"""Store a copy of ``doc`` with ``id`` promoted to MongoDB ``_id``.
This avoids storing both ``_id`` and ``id`` with identical values.
The original ``id`` field is removed from the stored document.
"""
if doc is None:
return None
d = dict(doc)
if 'id' in d:
d['_id'] = d.pop('id')
return d
@staticmethod
def _doc_from_mongo(doc: dict):
"""Return a TinyDB-compatible Document with ``doc_id`` set to ``_id``.
Restores the model ``id`` field from MongoDB's ``_id`` and exposes
TinyDB's ``doc_id`` attribute so callers that rely on it continue to
work.
"""
if doc is None:
return None
d = dict(doc)
doc_id = d.pop('_id', None)
if doc_id is not None:
d['id'] = doc_id
return Document(d, doc_id=doc_id)
def _mongo_filter(self, cond):
"""Translate a TinyDB query or dict to a MongoDB filter."""
return _query_to_mongo_filter(cond)
def all(self):
return [self._doc_from_mongo(doc) for doc in self._collection().find({})]
def search(self, cond):
mongo_filter = self._mongo_filter(cond)
if mongo_filter is not None:
cursor = self._collection().find(mongo_filter)
return [self._doc_from_mongo(doc) for doc in cursor]
# Fallback: fetch all and evaluate the TinyDB query in Python.
docs = list(self._collection().find({}))
matched = _evaluate_in_memory(
[self._doc_from_mongo(doc) for doc in docs], cond
)
return matched
def get(self, cond):
mongo_filter = self._mongo_filter(cond)
if mongo_filter is not None:
doc = self._collection().find_one(mongo_filter)
return self._doc_from_mongo(doc)
docs = list(self._collection().find({}))
for doc in docs:
d = self._doc_from_mongo(doc)
if callable(cond) and cond(d):
return d
return None
def insert(self, document: dict):
doc = self._doc_to_mongo(document)
result = self._collection().insert_one(doc)
return str(result.inserted_id)
def insert_multiple(self, documents: list):
if not documents:
return []
docs = [self._doc_to_mongo(d) for d in documents]
result = self._collection().insert_many(docs)
return [str(iid) for iid in result.inserted_ids]
def update(self, fields, cond=None, doc_ids=None):
is_callable = callable(fields)
if doc_ids is not None:
mongo_filter = {'_id': {'$in': list(doc_ids)}}
target_ids = [str(did) for did in doc_ids]
if not target_ids:
return []
if is_callable:
# Fetch, apply callable in-memory, and replace each document.
updated_ids = []
for doc in self._collection().find(mongo_filter):
d = self._doc_from_mongo(doc)
fields(d)
new_doc = self._doc_to_mongo(d)
new_doc.pop('_id', None)
self._collection().update_one(
{'_id': doc['_id']}, {'$set': new_doc}
)
updated_ids.append(str(doc['_id']))
return updated_ids
update_doc = self._doc_to_mongo(fields) or {}
update_doc.pop('_id', None)
update_doc.pop('id', None)
if update_doc:
self._collection().update_many(mongo_filter, {'$set': update_doc})
return target_ids
mongo_filter = self._mongo_filter(cond)
if mongo_filter is not None and not is_callable:
update_doc = self._doc_to_mongo(fields) or {}
update_doc.pop('_id', None)
update_doc.pop('id', None)
target_ids = [
str(doc['_id'])
for doc in self._collection().find(mongo_filter, {'_id': 1})
]
if target_ids and update_doc:
self._collection().update_many(
mongo_filter, {'$set': update_doc}
)
return target_ids
# Fallback: evaluate the query in-memory and update one at a time.
docs = list(self._collection().find({}))
updated_ids = []
for doc in docs:
d = self._doc_from_mongo(doc)
match = cond(d) if callable(cond) else (mongo_filter is not None)
if not match:
continue
if is_callable:
fields(d)
new_doc = self._doc_to_mongo(d)
new_doc.pop('_id', None)
self._collection().update_one(
{'_id': doc['_id']}, {'$set': new_doc}
)
else:
update_doc = self._doc_to_mongo(fields) or {}
update_doc.pop('_id', None)
update_doc.pop('id', None)
if update_doc:
self._collection().update_one(
{'_id': doc['_id']}, {'$set': update_doc}
)
updated_ids.append(str(doc['_id']))
return updated_ids
def remove(self, cond):
mongo_filter = self._mongo_filter(cond)
if mongo_filter is not None:
target_ids = [
str(doc['_id'])
for doc in self._collection().find(mongo_filter, {'_id': 1})
]
if target_ids:
self._collection().delete_many(mongo_filter)
return target_ids
# Fallback: evaluate the query in-memory and delete one at a time.
docs = list(self._collection().find({}))
removed_ids = []
for doc in docs:
d = self._doc_from_mongo(doc)
if callable(cond) and cond(d):
self._collection().delete_one({'_id': doc['_id']})
removed_ids.append(str(doc['_id']))
return removed_ids
def truncate(self):
self._collection().delete_many({})
def close(self):
# MongoDB clients are shared and long-lived; nothing to close here.
pass
# ---------------------------------------------------------------------------
# Collection factory
# ---------------------------------------------------------------------------
def _make_table(json_path: str, collection_name: str):
if USE_MONGODB:
return MongoLockedTable(collection_name)
db = TinyDB(json_path, indent=2)
return LockedTable(db)
# Setup DB files next to this module # Setup DB files next to this module
child_path = os.path.join(base_dir, 'children.json') child_path = os.path.join(base_dir, 'children.json')
task_path = os.path.join(base_dir, 'tasks.json') task_path = os.path.join(base_dir, 'tasks.json')
routine_path = os.path.join(base_dir, 'routines.json')
routine_items_path = os.path.join(base_dir, 'routine_items.json')
routine_schedules_path = os.path.join(base_dir, 'routine_schedules.json')
routine_extensions_path = os.path.join(base_dir, 'routine_extensions.json')
reward_path = os.path.join(base_dir, 'rewards.json') reward_path = os.path.join(base_dir, 'rewards.json')
image_path = os.path.join(base_dir, 'images.json') image_path = os.path.join(base_dir, 'images.json')
pending_reward_path = os.path.join(base_dir, 'pending_rewards.json') pending_reward_path = os.path.join(base_dir, 'pending_rewards.json')
pending_confirmations_path = os.path.join(base_dir, 'pending_confirmations.json')
users_path = os.path.join(base_dir, 'users.json') users_path = os.path.join(base_dir, 'users.json')
tracking_events_path = os.path.join(base_dir, 'tracking_events.json') tracking_events_path = os.path.join(base_dir, 'tracking_events.json')
child_overrides_path = os.path.join(base_dir, 'child_overrides.json') child_overrides_path = os.path.join(base_dir, 'child_overrides.json')
chore_schedules_path = os.path.join(base_dir, 'chore_schedules.json')
task_extensions_path = os.path.join(base_dir, 'task_extensions.json')
refresh_tokens_path = os.path.join(base_dir, 'refresh_tokens.json')
push_subscriptions_path = os.path.join(base_dir, 'push_subscriptions.json')
digest_action_tokens_path = os.path.join(base_dir, 'digest_action_tokens.json')
# Use separate TinyDB instances/files for each collection # Expose table objects backed by TinyDB or MongoDB based on USE_MONGODB
_child_db = TinyDB(child_path, indent=2) child_db = _make_table(child_path, 'children')
_task_db = TinyDB(task_path, indent=2) task_db = _make_table(task_path, 'tasks')
_reward_db = TinyDB(reward_path, indent=2) routine_db = _make_table(routine_path, 'routines')
_image_db = TinyDB(image_path, indent=2) routine_items_db = _make_table(routine_items_path, 'routine_items')
_pending_rewards_db = TinyDB(pending_reward_path, indent=2) routine_schedules_db = _make_table(routine_schedules_path, 'routine_schedules')
_users_db = TinyDB(users_path, indent=2) routine_extensions_db = _make_table(routine_extensions_path, 'routine_extensions')
_tracking_events_db = TinyDB(tracking_events_path, indent=2) reward_db = _make_table(reward_path, 'rewards')
_child_overrides_db = TinyDB(child_overrides_path, indent=2) image_db = _make_table(image_path, 'images')
pending_reward_db = _make_table(pending_reward_path, 'pending_rewards')
pending_confirmations_db = _make_table(pending_confirmations_path, 'pending_confirmations')
users_db = _make_table(users_path, 'users')
tracking_events_db = _make_table(tracking_events_path, 'tracking_events')
child_overrides_db = _make_table(child_overrides_path, 'child_overrides')
chore_schedules_db = _make_table(chore_schedules_path, 'chore_schedules')
task_extensions_db = _make_table(task_extensions_path, 'task_extensions')
refresh_tokens_db = _make_table(refresh_tokens_path, 'refresh_tokens')
push_subscriptions_db = _make_table(push_subscriptions_path, 'push_subscriptions')
digest_action_tokens_db = _make_table(digest_action_tokens_path, 'digest_action_tokens')
# Expose table objects wrapped with locking
child_db = LockedTable(_child_db)
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)
# ---------------------------------------------------------------------------
# Index management
# ---------------------------------------------------------------------------
COLLECTION_INDEXES = {
# NOTE: The model ``id`` field is stored as MongoDB's primary key ``_id``,
# so no separate unique index on ``id`` is needed. Only secondary indexes
# for frequently queried fields are defined here.
'children': [
{'keys': [('user_id', ASCENDING)]},
],
'tasks': [
{'keys': [('user_id', ASCENDING)]},
],
'routines': [
{'keys': [('user_id', ASCENDING)]},
],
'routine_items': [
{'keys': [('user_id', ASCENDING)]},
],
'routine_schedules': [],
'routine_extensions': [],
'rewards': [
{'keys': [('user_id', ASCENDING)]},
],
'images': [
{'keys': [('user_id', ASCENDING)]},
],
'pending_rewards': [
{'keys': [('child_id', ASCENDING)]},
],
'pending_confirmations': [
{'keys': [('user_id', ASCENDING)]},
{'keys': [('child_id', ASCENDING)]},
{'keys': [('entity_id', ASCENDING), ('entity_type', ASCENDING)]},
],
'users': [],
'tracking_events': [
{'keys': [('user_id', ASCENDING)]},
{'keys': [('child_id', ASCENDING)]},
{'keys': [('entity_id', ASCENDING), ('entity_type', ASCENDING)]},
],
'child_overrides': [
{'keys': [('child_id', ASCENDING)]},
{'keys': [('entity_id', ASCENDING), ('entity_type', ASCENDING)]},
],
'chore_schedules': [
{'keys': [('user_id', ASCENDING)]},
{'keys': [('child_id', ASCENDING)]},
],
'task_extensions': [
{'keys': [('user_id', ASCENDING)]},
{'keys': [('child_id', ASCENDING)]},
],
'refresh_tokens': [
{'keys': [('user_id', ASCENDING)]},
{'keys': [('token', ASCENDING)], 'unique': True, 'sparse': True},
],
'push_subscriptions': [
{'keys': [('user_id', ASCENDING)]},
],
'digest_action_tokens': [
{'keys': [('user_id', ASCENDING)]},
{'keys': [('token', ASCENDING)], 'unique': True, 'sparse': True},
],
}
def ensure_mongodb_indexes(client=None, db_name=None):
"""Create required indexes on all MongoDB collections.
Safe to call repeatedly: MongoDB treats index creation as idempotent.
"""
if not USE_MONGODB:
return
client = client or get_mongo_client()
db_name = db_name or _mongo_db_name
db = client[db_name]
for collection_name, indexes in COLLECTION_INDEXES.items():
coll = db[collection_name]
for spec in indexes:
keys = spec['keys']
kwargs = {k: v for k, v in spec.items() if k != 'keys'}
coll.create_index(keys, **kwargs)
# Clear test collections at import time so tests start with a clean slate.
if os.environ.get('DB_ENV', 'prod') == 'test': if os.environ.get('DB_ENV', 'prod') == 'test':
child_db.truncate() child_db.truncate()
task_db.truncate() task_db.truncate()
routine_db.truncate()
routine_items_db.truncate()
routine_schedules_db.truncate()
routine_extensions_db.truncate()
reward_db.truncate() reward_db.truncate()
image_db.truncate() image_db.truncate()
pending_reward_db.truncate() pending_reward_db.truncate()
pending_confirmations_db.truncate()
users_db.truncate() users_db.truncate()
tracking_events_db.truncate() tracking_events_db.truncate()
child_overrides_db.truncate() child_overrides_db.truncate()
chore_schedules_db.truncate()
task_extensions_db.truncate()
refresh_tokens_db.truncate()
push_subscriptions_db.truncate()
digest_action_tokens_db.truncate()
+25 -20
View File
@@ -6,25 +6,29 @@ import os
import shutil import shutil
from api.image_api import IMAGE_TYPE_ICON, IMAGE_TYPE_PROFILE from api.image_api import IMAGE_TYPE_ICON, IMAGE_TYPE_PROFILE
from config.paths import get_images_dir
from db.db import task_db, reward_db, image_db from db.db import task_db, reward_db, image_db
from models.image import Image from models.image import Image
from models.reward import Reward from models.reward import Reward
from models.task import Task from models.task import Task
import logging
logger = logging.getLogger(__name__)
def populate_default_data(): def populate_default_data():
# Create tasks # Create tasks
task_defs = [ task_defs = [
('default_001', "Be Respectful", 2, True, ''), ('default_001', "Be Respectful", 2, 'chore', ''),
('default_002', "Brush Teeth", 2, True, ''), ('default_002', "Brush Teeth", 2, 'chore', ''),
('default_003', "Go To Bed", 2, True, ''), ('default_003', "Go To Bed", 2, 'chore', ''),
('default_004', "Do What You Are Told", 2, True, ''), ('default_004', "Do What You Are Told", 2, 'chore', ''),
('default_005', "Make Your Bed", 2, True, ''), ('default_005', "Make Your Bed", 2, 'chore', ''),
('default_006', "Do Homework", 2, True, ''), ('default_006', "Do Homework", 2, 'chore', ''),
] ]
tasks = [] tasks = []
for _id, name, points, is_good, image in task_defs: for _id, name, points, task_type, image in task_defs:
t = Task(name=name, points=points, is_good=is_good, image_id=image, id=_id) t = Task(name=name, points=points, type=task_type, image_id=image, id=_id)
tq = Query() tq = Query()
_result = task_db.search(tq.id == _id) _result = task_db.search(tq.id == _id)
if not _result: if not _result:
@@ -88,18 +92,18 @@ def createDefaultTasks():
"""Create default tasks if none exist.""" """Create default tasks if none exist."""
if len(task_db.all()) == 0: if len(task_db.all()) == 0:
default_tasks = [ default_tasks = [
Task(name="Take out trash", points=20, is_good=True, image_id="trash-can"), Task(name="Take out trash", points=20, type='chore', image_id="trash-can"),
Task(name="Make your bed", points=25, is_good=True, image_id="make-the-bed"), Task(name="Make your bed", points=25, type='chore', image_id="make-the-bed"),
Task(name="Sweep and clean kitchen", points=15, is_good=True, image_id="vacuum"), Task(name="Sweep and clean kitchen", points=15, type='chore', image_id="vacuum"),
Task(name="Do homework early", points=30, is_good=True, image_id="homework"), Task(name="Do homework early", points=30, type='chore', image_id="homework"),
Task(name="Be good for the day", points=15, is_good=True, image_id="good"), Task(name="Be good for the day", points=15, type='kindness', image_id="good"),
Task(name="Clean your mess", points=20, is_good=True, image_id="broom"), Task(name="Clean your mess", points=20, type='chore', image_id="broom"),
Task(name="Fighting", points=10, is_good=False, image_id="fighting"), Task(name="Fighting", points=10, type='penalty', image_id="fighting"),
Task(name="Yelling at parents", points=10, is_good=False, image_id="yelling"), Task(name="Yelling at parents", points=10, type='penalty', image_id="yelling"),
Task(name="Lying", points=10, is_good=False, image_id="lying"), Task(name="Lying", points=10, type='penalty', image_id="lying"),
Task(name="Not doing what told", points=5, is_good=False, image_id="ignore"), Task(name="Not doing what told", points=5, type='penalty', image_id="ignore"),
Task(name="Not flushing toilet", points=5, is_good=False, image_id="toilet"), Task(name="Not flushing toilet", points=5, type='penalty', image_id="toilet"),
] ]
for task in default_tasks: for task in default_tasks:
task_db.insert(task.to_dict()) task_db.insert(task.to_dict())
@@ -124,7 +128,8 @@ def initializeImages():
"""Initialize the image database with default images if empty, and copy images to data/images/default.""" """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 # Step 1: Create data/images/default directory if it doesn't exist
default_img_dir = os.path.join(os.path.dirname(__file__), '../data/images/default') default_img_dir = os.path.join(get_images_dir(), 'default')
logger.info(f"Initializing images. Ensuring directory exists: {default_img_dir}")
os.makedirs(default_img_dir, exist_ok=True) os.makedirs(default_img_dir, exist_ok=True)
# Step 2: Copy all image files from resources/images/ to data/images/default # Step 2: Copy all image files from resources/images/ to data/images/default
+18
View File
@@ -0,0 +1,18 @@
from tinydb import Query
from db.db import digest_action_tokens_db
from models.digest_action_token import DigestActionToken
def insert_token(token: DigestActionToken) -> None:
digest_action_tokens_db.insert(token.to_dict())
def get_token_by_id(token_id: str) -> DigestActionToken | None:
Q = Query()
result = digest_action_tokens_db.get(Q.id == token_id)
return DigestActionToken.from_dict(result) if result else None
def mark_token_used(token_id: str) -> None:
Q = Query()
digest_action_tokens_db.update({'used': True}, Q.id == token_id)
+113
View File
@@ -0,0 +1,113 @@
# python
"""Lazy MongoDB client factory and database-name helpers.
The client is intentionally **not** created at module import. Use
``get_mongo_client()`` to obtain a cached singleton. For Gunicorn multi-worker
deployments call ``init_mongo_client()`` from a ``post_fork`` hook so each
worker process owns its own connection pool rather than inheriting the parent
process's client.
"""
import os
import threading
from urllib.parse import urlparse
from pymongo import MongoClient
from pymongo.uri_parser import parse_uri
_mongo_client_lock = threading.Lock()
_mongo_client = None
def _create_mongo_client():
"""Build a fail-fast MongoClient from environment variables."""
uri = os.environ.get('MONGO_URI')
if not uri:
raise RuntimeError(
'MONGO_URI environment variable is required when USE_MONGODB=true.'
)
# mongomock is used for unit/integration tests without a real server.
if uri.lower().startswith('mongomock') or uri.lower() == 'mongomock':
try:
import mongomock
except ImportError as exc: # pragma: no cover - test dependency
raise RuntimeError(
'mongomock is required for test MongoDB mode. '
'Install it with: pip install mongomock'
) from exc
return mongomock.MongoClient()
return MongoClient(
uri,
serverSelectionTimeoutMS=5000,
connectTimeoutMS=5000,
maxPoolSize=20,
)
def init_mongo_client():
"""Create a fresh MongoClient and store it as the process singleton.
Call this from a Gunicorn ``post_fork`` hook so each worker process gets
its own client after forking. It can also be called in tests to reset the
shared client to a known state.
"""
global _mongo_client
with _mongo_client_lock:
_mongo_client = _create_mongo_client()
return _mongo_client
def get_mongo_client():
"""Return the cached process-level MongoClient, creating it lazily once."""
global _mongo_client
if _mongo_client is None:
with _mongo_client_lock:
if _mongo_client is None:
_mongo_client = _create_mongo_client()
return _mongo_client
def _db_name_from_uri(uri: str) -> str | None:
"""Extract the database name from a MongoDB connection URI, if present."""
if not uri or uri.lower().startswith('mongomock'):
return None
try:
parsed = parse_uri(uri)
return parsed.get('database') or None
except Exception:
# Fallback to a simple path-based parse for non-standard URIs.
try:
path = urlparse(uri).path
return path.lstrip('/') or None
except Exception:
return None
def get_mongo_db_name() -> str:
"""Resolve the MongoDB database name from env vars or the connection URI.
Precedence:
1. ``MONGO_DB_NAME`` environment variable.
2. Database name parsed from ``MONGO_URI``.
3. Default based on ``DATA_ENV`` / ``DB_ENV``:
* prod -> ``chore_db``
* e2e -> ``chore_db_e2e``
* test -> ``chore_db_test``
"""
env_name = os.environ.get('MONGO_DB_NAME')
if env_name:
return env_name
uri = os.environ.get('MONGO_URI', '')
db_name = _db_name_from_uri(uri)
if db_name:
return db_name
env = (os.environ.get('DATA_ENV') or os.environ.get('DB_ENV', 'prod')).lower()
if env == 'prod':
return 'chore_db'
if env == 'e2e':
return 'chore_db_e2e'
return 'chore_db_test'
+38
View File
@@ -0,0 +1,38 @@
from tinydb import Query
from db.db import push_subscriptions_db
from models.push_subscription import PushSubscription
def get_subscriptions_by_user(user_id: str) -> list[PushSubscription]:
"""Return all push subscriptions for a user."""
Q = Query()
results = push_subscriptions_db.search(Q.user_id == user_id)
return [PushSubscription.from_dict(r) for r in results]
def upsert_subscription(user_id: str, endpoint: str, keys: dict) -> PushSubscription:
"""Insert or update a subscription for the given user+endpoint pair."""
Q = Query()
existing = push_subscriptions_db.get((Q.user_id == user_id) & (Q.endpoint == endpoint))
if existing:
sub = PushSubscription.from_dict(existing)
sub.keys = keys
sub.touch()
push_subscriptions_db.update(sub.to_dict(), (Q.user_id == user_id) & (Q.endpoint == endpoint))
return sub
sub = PushSubscription(user_id=user_id, endpoint=endpoint, keys=keys)
push_subscriptions_db.insert(sub.to_dict())
return sub
def delete_by_endpoint(user_id: str, endpoint: str) -> int:
"""Remove the subscription with the given endpoint for this user. Returns count removed."""
Q = Query()
removed = push_subscriptions_db.remove((Q.user_id == user_id) & (Q.endpoint == endpoint))
return len(removed)
def delete_subscription_by_id(subscription_id: str) -> None:
"""Remove a subscription by its ID (used when push delivery fails)."""
Q = Query()
push_subscriptions_db.remove(Q.id == subscription_id)
+41
View File
@@ -0,0 +1,41 @@
from tinydb import Query
from db.db import routine_extensions_db
from models.routine_extension import RoutineExtension
def get_extension(child_id: str, routine_id: str, date: str) -> RoutineExtension | None:
q = Query()
result = routine_extensions_db.search(
(q.child_id == child_id) & (q.routine_id == routine_id) & (q.date == date)
)
if not result:
return None
return RoutineExtension.from_dict(result[0])
def add_extension(extension: RoutineExtension) -> None:
routine_extensions_db.insert(extension.to_dict())
def delete_extensions_for_child(child_id: str) -> None:
q = Query()
routine_extensions_db.remove(q.child_id == child_id)
def delete_extensions_for_routine(routine_id: str) -> None:
q = Query()
routine_extensions_db.remove(q.routine_id == routine_id)
def delete_extension_for_child_routine(child_id: str, routine_id: str) -> None:
q = Query()
routine_extensions_db.remove((q.child_id == child_id) & (q.routine_id == routine_id))
def get_extension_for_child_routine(child_id: str, routine_id: str) -> RoutineExtension | None:
q = Query()
results = routine_extensions_db.search((q.child_id == child_id) & (q.routine_id == routine_id))
if not results:
return None
latest = max(results, key=lambda r: r.get('date', ''))
return RoutineExtension.from_dict(latest)
+45
View File
@@ -0,0 +1,45 @@
from tinydb import Query
from db.db import routine_items_db
from models.routine_item import RoutineItem
def add_item(item: RoutineItem) -> None:
routine_items_db.insert(item.to_dict())
def get_item(item_id: str) -> RoutineItem | None:
q = Query()
result = routine_items_db.search(q.id == item_id)
if not result:
return None
return RoutineItem.from_dict(result[0])
def get_items_for_routine(routine_id: str) -> list[RoutineItem]:
q = Query()
results = routine_items_db.search(q.routine_id == routine_id)
items = [RoutineItem.from_dict(r) for r in results]
return sorted(items, key=lambda i: (i.order, i.created_at))
def update_item(item: RoutineItem) -> bool:
q = Query()
existing = routine_items_db.get(q.id == item.id)
if not existing:
return False
routine_items_db.update(item.to_dict(), q.id == item.id)
return True
def delete_item(item_id: str) -> bool:
q = Query()
existing = routine_items_db.get(q.id == item_id)
if not existing:
return False
routine_items_db.remove(q.id == item_id)
return True
def delete_for_routine(routine_id: str) -> None:
q = Query()
routine_items_db.remove(q.routine_id == routine_id)
+42
View File
@@ -0,0 +1,42 @@
from tinydb import Query
from db.db import routine_schedules_db
from models.routine_schedule import RoutineSchedule
def get_schedule(child_id: str, routine_id: str) -> RoutineSchedule | None:
q = Query()
result = routine_schedules_db.search((q.child_id == child_id) & (q.routine_id == routine_id))
if not result:
return None
return RoutineSchedule.from_dict(result[0])
def upsert_schedule(schedule: RoutineSchedule) -> None:
q = Query()
existing = routine_schedules_db.get((q.child_id == schedule.child_id) & (q.routine_id == schedule.routine_id))
if existing:
routine_schedules_db.update(
schedule.to_dict(),
(q.child_id == schedule.child_id) & (q.routine_id == schedule.routine_id)
)
else:
routine_schedules_db.insert(schedule.to_dict())
def delete_schedule(child_id: str, routine_id: str) -> bool:
q = Query()
existing = routine_schedules_db.get((q.child_id == child_id) & (q.routine_id == routine_id))
if not existing:
return False
routine_schedules_db.remove((q.child_id == child_id) & (q.routine_id == routine_id))
return True
def delete_schedules_for_child(child_id: str) -> None:
q = Query()
routine_schedules_db.remove(q.child_id == child_id)
def delete_schedules_for_routine(routine_id: str) -> None:
q = Query()
routine_schedules_db.remove(q.routine_id == routine_id)
+39
View File
@@ -0,0 +1,39 @@
from tinydb import Query
from db.db import routine_db
from models.routine import Routine
def add_routine(routine: Routine) -> None:
routine_db.insert(routine.to_dict())
def get_routine(routine_id: str) -> Routine | None:
q = Query()
result = routine_db.search(q.id == routine_id)
if not result:
return None
return Routine.from_dict(result[0])
def update_routine(routine: Routine) -> bool:
q = Query()
existing = routine_db.get(q.id == routine.id)
if not existing:
return False
routine_db.update(routine.to_dict(), q.id == routine.id)
return True
def delete_routine(routine_id: str) -> bool:
q = Query()
existing = routine_db.get(q.id == routine_id)
if not existing:
return False
routine_db.remove(q.id == routine_id)
return True
def list_routines_for_user(user_id: str) -> list[Routine]:
q = Query()
results = routine_db.search((q.user_id == user_id) | (q.user_id == None))
return [Routine.from_dict(r) for r in results]
+49
View File
@@ -0,0 +1,49 @@
from db.db import task_extensions_db
from models.task_extension import TaskExtension
from tinydb import Query
def get_extension(child_id: str, task_id: str, date: str) -> TaskExtension | None:
q = Query()
result = task_extensions_db.search(
(q.child_id == child_id) & (q.task_id == task_id) & (q.date == date)
)
if not result:
return None
return TaskExtension.from_dict(result[0])
def add_extension(extension: TaskExtension) -> None:
task_extensions_db.insert(extension.to_dict())
def delete_extensions_for_child(child_id: str) -> None:
q = Query()
task_extensions_db.remove(q.child_id == child_id)
def delete_extensions_for_task(task_id: str) -> None:
q = Query()
task_extensions_db.remove(q.task_id == task_id)
def delete_extension_for_child_task(child_id: str, task_id: str) -> None:
q = Query()
task_extensions_db.remove((q.child_id == child_id) & (q.task_id == task_id))
def get_extension_for_child_task(child_id: str, task_id: str) -> TaskExtension | None:
"""Return the extension for a child+task without filtering by date.
Avoids timezone mismatches between the server (UTC) and the client's local
date. The caller (or the frontend) is responsible for deciding whether the
returned extension_date is still applicable.
"""
q = Query()
results = task_extensions_db.search(
(q.child_id == child_id) & (q.task_id == task_id)
)
if not results:
return None
latest = max(results, key=lambda r: r.get('date', ''))
return TaskExtension.from_dict(latest)
+18 -4
View File
@@ -1,4 +1,5 @@
"""Helper functions for tracking events database operations.""" """Helper functions for tracking events database operations."""
import itertools
import logging import logging
from typing import Optional, List from typing import Optional, List
from tinydb import Query from tinydb import Query
@@ -8,6 +9,10 @@ from models.tracking_event import TrackingEvent, EntityType, ActionType
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Monotonic sequence used as a deterministic tiebreaker when tracking events
# share the same ``occurred_at``/``created_at`` timestamps (common in tests).
_tracking_event_seq = itertools.count()
def insert_tracking_event(event: TrackingEvent) -> str: def insert_tracking_event(event: TrackingEvent) -> str:
""" """
@@ -20,7 +25,9 @@ def insert_tracking_event(event: TrackingEvent) -> str:
The event ID The event ID
""" """
try: try:
tracking_events_db.insert(event.to_dict()) event_dict = event.to_dict()
event_dict['_seq'] = next(_tracking_event_seq)
tracking_events_db.insert(event_dict)
logger.info(f"Tracking event created: {event.action} {event.entity_type} {event.entity_id} for child {event.child_id}") logger.info(f"Tracking event created: {event.action} {event.entity_type} {event.entity_id} for child {event.child_id}")
return event.id return event.id
except Exception as e: except Exception as e:
@@ -61,8 +68,12 @@ def get_tracking_events_by_child(
all_results = tracking_events_db.search(query_condition) all_results = tracking_events_db.search(query_condition)
total = len(all_results) total = len(all_results)
# Sort by occurred_at desc, then created_at desc # Sort by occurred_at desc, then created_at desc, then _seq desc for
all_results.sort(key=lambda x: (x.get('occurred_at', ''), x.get('created_at', 0)), reverse=True) # deterministic ordering when timestamps collide (common in fast tests).
all_results.sort(
key=lambda x: (x.get('occurred_at', ''), x.get('created_at', 0), x.get('_seq', 0)),
reverse=True,
)
paginated = all_results[offset:offset + limit] paginated = all_results[offset:offset + limit]
events = [TrackingEvent.from_dict(r) for r in paginated] events = [TrackingEvent.from_dict(r) for r in paginated]
@@ -99,7 +110,10 @@ def get_tracking_events_by_user(
all_results = tracking_events_db.search(query_condition) all_results = tracking_events_db.search(query_condition)
total = len(all_results) total = len(all_results)
all_results.sort(key=lambda x: (x.get('occurred_at', ''), x.get('created_at', 0)), reverse=True) all_results.sort(
key=lambda x: (x.get('occurred_at', ''), x.get('created_at', 0), x.get('_seq', 0)),
reverse=True,
)
paginated = all_results[offset:offset + limit] paginated = all_results[offset:offset + limit]
events = [TrackingEvent.from_dict(r) for r in paginated] events = [TrackingEvent.from_dict(r) for r in paginated]
+8 -2
View File
@@ -59,9 +59,15 @@ def sse_response_for_user(user_id: str):
def generate(): def generate():
try: try:
while True: while True:
# Get message from queue (blocks until available) try:
message = user_queue.get() # Use a timeout so the thread yields periodically and keepalives are sent.
# This prevents Werkzeug's dev server from starving other connections.
message = user_queue.get(timeout=15)
yield message yield message
logger.debug(f"Sent message to {user_id} connection {connection_id}")
except queue.Empty:
# Send an SSE comment as a keepalive ping to maintain the connection.
yield b': ping\n\n'
except GeneratorExit: except GeneratorExit:
# Clean up when client disconnects # Clean up when client disconnects
if user_id in user_queues and connection_id in user_queues[user_id]: if user_id in user_queues and connection_id in user_queues[user_id]:
@@ -0,0 +1,28 @@
from events.types.payload import Payload
class ChildChoreConfirmation(Payload):
OPERATION_CONFIRMED = "CONFIRMED"
OPERATION_APPROVED = "APPROVED"
OPERATION_REJECTED = "REJECTED"
OPERATION_CANCELLED = "CANCELLED"
OPERATION_RESET = "RESET"
def __init__(self, child_id: str, task_id: str, operation: str):
super().__init__({
'child_id': child_id,
'task_id': task_id,
'operation': operation
})
@property
def child_id(self) -> str:
return self.get("child_id")
@property
def task_id(self) -> str:
return self.get("task_id")
@property
def operation(self) -> str:
return self.get("operation")
@@ -0,0 +1,15 @@
from events.types.payload import Payload
class ChildRoutineConfirmation(Payload):
OPERATION_PENDING = "PENDING"
OPERATION_APPROVED = "APPROVED"
OPERATION_REJECTED = "REJECTED"
OPERATION_RESET = "RESET"
def __init__(self, child_id: str, routine_id: str, operation: str):
super().__init__({
'child_id': child_id,
'routine_id': routine_id,
'operation': operation
})
@@ -0,0 +1,9 @@
from events.types.payload import Payload
class ChildRoutinesSet(Payload):
def __init__(self, child_id: str, routine_ids: list[str]):
super().__init__({
'child_id': child_id,
'routine_ids': routine_ids
})
@@ -0,0 +1,25 @@
from events.types.payload import Payload
class ChoreScheduleModified(Payload):
OPERATION_SET = 'SET'
OPERATION_DELETED = 'DELETED'
def __init__(self, child_id: str, task_id: str, operation: str):
super().__init__({
'child_id': child_id,
'task_id': task_id,
'operation': operation,
})
@property
def child_id(self) -> str:
return self.get('child_id')
@property
def task_id(self) -> str:
return self.get('task_id')
@property
def operation(self) -> str:
return self.get('operation')
@@ -0,0 +1,17 @@
from events.types.payload import Payload
class ChoreTimeExtended(Payload):
def __init__(self, child_id: str, task_id: str):
super().__init__({
'child_id': child_id,
'task_id': task_id,
})
@property
def child_id(self) -> str:
return self.get('child_id')
@property
def task_id(self) -> str:
return self.get('task_id')
+12
View File
@@ -23,3 +23,15 @@ class EventType(Enum):
CHILD_OVERRIDE_DELETED = "child_override_deleted" CHILD_OVERRIDE_DELETED = "child_override_deleted"
PROFILE_UPDATED = "profile_updated" PROFILE_UPDATED = "profile_updated"
CHORE_SCHEDULE_MODIFIED = "chore_schedule_modified"
CHORE_TIME_EXTENDED = "chore_time_extended"
CHILD_CHORE_CONFIRMATION = "child_chore_confirmation"
ROUTINE_MODIFIED = "routine_modified"
CHILD_ROUTINES_SET = "child_routines_set"
ROUTINE_SCHEDULE_MODIFIED = "routine_schedule_modified"
ROUTINE_TIME_EXTENDED = "routine_time_extended"
CHILD_ROUTINE_CONFIRMATION = "child_routine_confirmation"
FORCE_LOGOUT = "force_logout"
+13
View File
@@ -0,0 +1,13 @@
from events.types.payload import Payload
class RoutineModified(Payload):
OPERATION_ADD = "ADD"
OPERATION_EDIT = "EDIT"
OPERATION_DELETE = "DELETE"
def __init__(self, routine_id: str, operation: str):
super().__init__({
'routine_id': routine_id,
'operation': operation
})
@@ -0,0 +1,13 @@
from events.types.payload import Payload
class RoutineScheduleModified(Payload):
OPERATION_SET = 'SET'
OPERATION_DELETED = 'DELETED'
def __init__(self, child_id: str, routine_id: str, operation: str):
super().__init__({
'child_id': child_id,
'routine_id': routine_id,
'operation': operation,
})
@@ -0,0 +1,9 @@
from events.types.payload import Payload
class RoutineTimeExtended(Payload):
def __init__(self, child_id: str, routine_id: str):
super().__init__({
'child_id': child_id,
'routine_id': routine_id,
})
+17
View File
@@ -0,0 +1,17 @@
"""Gunicorn configuration for the chore/reward Flask backend.
This file is automatically loaded by Gunicorn when it is started from the
backend directory. It ensures each worker process creates its own MongoDB
client after forking, avoiding shared socket/file-descriptor issues.
"""
def post_fork(server, worker):
"""Reinitialize the MongoDB client in each worker process after forking."""
try:
from db.mongo_client import init_mongo_client
init_mongo_client()
except Exception:
# If MongoDB is not configured (USE_MONGODB=false), there is no client
# to initialize; ignore the error silently.
pass
+75 -8
View File
@@ -2,24 +2,46 @@ import logging
import sys import sys
import os import os
from dotenv import load_dotenv
from flask import Flask, request, jsonify from flask import Flask, request, jsonify
from flask_cors import CORS
from api.admin_api import admin_api from api.admin_api import admin_api
from api.auth_api import auth_api from api.auth_api import auth_api
from api.child_api import child_api from api.child_api import child_api
from api.child_override_api import child_override_api from api.child_override_api import child_override_api
from api.chore_api import chore_api
from api.chore_schedule_api import chore_schedule_api
from api.image_api import image_api from api.image_api import image_api
from api.kindness_api import kindness_api
from api.penalty_api import penalty_api
from api.reward_api import reward_api from api.reward_api import reward_api
from api.routine_api import routine_api
from api.routine_item_api import routine_item_api
from api.child_routine_api import child_routine_api
from api.routine_schedule_api import routine_schedule_api
from api.task_api import task_api from api.task_api import task_api
from api.tracking_api import tracking_api from api.tracking_api import tracking_api
from api.user_api import user_api from api.user_api import user_api
from api.push_subscription_api import push_subscription_api
from api.digest_action_api import digest_action_api
from config.version import get_full_version from config.version import get_full_version
from db.default import initializeImages, createDefaultTasks, createDefaultRewards from db.default import initializeImages, createDefaultTasks, createDefaultRewards
from db.db import ensure_mongodb_indexes
from events.broadcaster import Broadcaster from events.broadcaster import Broadcaster
from events.sse import sse_response_for_user, send_to_user from events.sse import sse_response_for_user, send_to_user
from api.utils import get_current_user_id
from utils.account_deletion_scheduler import start_deletion_scheduler from utils.account_deletion_scheduler import start_deletion_scheduler
from utils.chore_expiry_notification_scheduler import start_chore_expiry_notification_scheduler
from utils.digest_scheduler import start_digest_scheduler
from utils.state_expiry_scheduler import start_state_expiry_scheduler
# Load environment variables
load_dotenv()
# Ensure MongoDB indexes exist when running against MongoDB.
ensure_mongodb_indexes()
# Configure logging once at application startup # Configure logging once at application startup
logging.basicConfig( logging.basicConfig(
@@ -31,18 +53,31 @@ logging.basicConfig(
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logging.getLogger("werkzeug").setLevel(logging.WARNING)
logging.getLogger("events.sse").setLevel(logging.WARNING)
app = Flask(__name__) app = Flask(__name__)
#CORS(app, resources={r"/api/*": {"origins": ["http://localhost:3000", "http://localhost:5173"]}}) #CORS(app, resources={r"/api/*": {"origins": ["http://localhost:3000", "http://localhost:5173"]}})
#Todo - add prefix to all these routes instead of in each blueprint #Todo - add prefix to all these routes instead of in each blueprint
app.register_blueprint(admin_api) app.register_blueprint(admin_api)
app.register_blueprint(child_api) app.register_blueprint(child_api)
app.register_blueprint(child_override_api) app.register_blueprint(child_override_api)
app.register_blueprint(chore_api)
app.register_blueprint(chore_schedule_api)
app.register_blueprint(kindness_api)
app.register_blueprint(penalty_api)
app.register_blueprint(reward_api) app.register_blueprint(reward_api)
app.register_blueprint(routine_api)
app.register_blueprint(routine_item_api)
app.register_blueprint(child_routine_api)
app.register_blueprint(routine_schedule_api)
app.register_blueprint(task_api) app.register_blueprint(task_api)
app.register_blueprint(image_api) app.register_blueprint(image_api)
app.register_blueprint(auth_api, url_prefix='/auth') app.register_blueprint(auth_api, url_prefix='/auth')
app.register_blueprint(user_api) app.register_blueprint(user_api)
app.register_blueprint(tracking_api) app.register_blueprint(tracking_api)
app.register_blueprint(push_subscription_api)
app.register_blueprint(digest_action_api)
app.config.update( app.config.update(
MAIL_SERVER='smtp.gmail.com', MAIL_SERVER='smtp.gmail.com',
@@ -52,10 +87,42 @@ app.config.update(
MAIL_PASSWORD='ruyj hxjf nmrz buar', MAIL_PASSWORD='ruyj hxjf nmrz buar',
MAIL_DEFAULT_SENDER='ryan.kegel@gmail.com', MAIL_DEFAULT_SENDER='ryan.kegel@gmail.com',
FRONTEND_URL=os.environ.get('FRONTEND_URL', 'https://localhost:5173'), # Dynamic via env var, defaults to localhost 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 VAPID_CLAIMS_EMAIL=os.environ.get('VAPID_CLAIMS_EMAIL', 'admin@reward-app.local'),
) )
CORS(app) # Security: require SECRET_KEY and REFRESH_TOKEN_EXPIRY_DAYS from environment
_secret_key = os.environ.get('SECRET_KEY')
if not _secret_key:
raise RuntimeError(
'SECRET_KEY environment variable is required. '
'Set it to a random string (e.g. python -c "import secrets; print(secrets.token_urlsafe(64))")')
app.config['SECRET_KEY'] = _secret_key
_refresh_expiry = os.environ.get('REFRESH_TOKEN_EXPIRY_DAYS')
if not _refresh_expiry:
raise RuntimeError('REFRESH_TOKEN_EXPIRY_DAYS environment variable is required (e.g. 90).')
try:
app.config['REFRESH_TOKEN_EXPIRY_DAYS'] = int(_refresh_expiry)
except ValueError:
raise RuntimeError('REFRESH_TOKEN_EXPIRY_DAYS must be an integer.')
_digest_token_secret = os.environ.get('DIGEST_TOKEN_SECRET')
if not _digest_token_secret:
raise RuntimeError(
'DIGEST_TOKEN_SECRET environment variable is required. '
'Set it to a random string (e.g. python -c "import secrets; print(secrets.token_urlsafe(64))")')
app.config['DIGEST_TOKEN_SECRET'] = _digest_token_secret
_vapid_public_key = os.environ.get('VAPID_PUBLIC_KEY')
_vapid_private_key = os.environ.get('VAPID_PRIVATE_KEY')
if not _vapid_public_key or not _vapid_private_key:
raise RuntimeError(
'VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY environment variables are required. '
'Generate a key pair with: python -c "from py_vapid import Vapid; v=Vapid(); '
'v.generate_keys(); print(v.public_key.public_bytes_raw().hex(), v.private_key.private_bytes_raw().hex())"'
)
app.config['VAPID_PUBLIC_KEY'] = _vapid_public_key
app.config['VAPID_PRIVATE_KEY'] = _vapid_private_key
@app.route("/version") @app.route("/version")
def api_version(): def api_version():
@@ -63,11 +130,9 @@ def api_version():
@app.route("/events") @app.route("/events")
def events(): def events():
# Authenticate user or read a token user_id = get_current_user_id()
user_id = request.args.get("user_id")
if not user_id: if not user_id:
return {"error": "Missing user_id"}, 400 return {"error": "Authentication required"}, 401
return sse_response_for_user(user_id) return sse_response_for_user(user_id)
@@ -87,12 +152,14 @@ def start_background_threads():
broadcaster.daemon = True broadcaster.daemon = True
broadcaster.start() broadcaster.start()
# TODO: implement users
initializeImages() initializeImages()
createDefaultTasks() createDefaultTasks()
createDefaultRewards() createDefaultRewards()
start_background_threads() start_background_threads()
start_deletion_scheduler() start_deletion_scheduler()
start_digest_scheduler(app)
start_state_expiry_scheduler(app)
start_chore_expiry_notification_scheduler(app)
if __name__ == '__main__': if __name__ == '__main__':
app.run(debug=False, host='0.0.0.0', port=5000, threaded=True) app.run(debug=False, host='0.0.0.0', port=5000, threaded=True)
+3
View File
@@ -6,6 +6,7 @@ class Child(BaseModel):
name: str name: str
age: int | None = None age: int | None = None
tasks: list[str] = field(default_factory=list) tasks: list[str] = field(default_factory=list)
routines: list[str] = field(default_factory=list)
rewards: list[str] = field(default_factory=list) rewards: list[str] = field(default_factory=list)
points: int = 0 points: int = 0
image_id: str | None = None image_id: str | None = None
@@ -17,6 +18,7 @@ class Child(BaseModel):
name=d.get('name'), name=d.get('name'),
age=d.get('age'), age=d.get('age'),
tasks=d.get('tasks', []), tasks=d.get('tasks', []),
routines=d.get('routines', []),
rewards=d.get('rewards', []), rewards=d.get('rewards', []),
points=d.get('points', 0), points=d.get('points', 0),
image_id=d.get('image_id'), image_id=d.get('image_id'),
@@ -32,6 +34,7 @@ class Child(BaseModel):
'name': self.name, 'name': self.name,
'age': self.age, 'age': self.age,
'tasks': self.tasks, 'tasks': self.tasks,
'routines': self.routines,
'rewards': self.rewards, 'rewards': self.rewards,
'points': self.points, 'points': self.points,
'image_id': self.image_id, 'image_id': self.image_id,
+4 -4
View File
@@ -16,15 +16,15 @@ class ChildOverride(BaseModel):
""" """
child_id: str child_id: str
entity_id: str entity_id: str
entity_type: Literal['task', 'reward'] entity_type: Literal['task', 'reward', 'chore', 'kindness', 'penalty', 'routine']
custom_value: int custom_value: int
def __post_init__(self): def __post_init__(self):
"""Validate custom_value range and entity_type.""" """Validate custom_value range and entity_type."""
if self.custom_value < 0 or self.custom_value > 10000: if self.custom_value < 0 or self.custom_value > 10000:
raise ValueError("custom_value must be between 0 and 10000") raise ValueError("custom_value must be between 0 and 10000")
if self.entity_type not in ['task', 'reward']: if self.entity_type not in ['task', 'reward', 'chore', 'kindness', 'penalty', 'routine']:
raise ValueError("entity_type must be 'task' or 'reward'") raise ValueError("entity_type must be 'task', 'reward', 'chore', 'kindness', 'penalty', or 'routine'")
@classmethod @classmethod
def from_dict(cls, d: dict): def from_dict(cls, d: dict):
@@ -52,7 +52,7 @@ class ChildOverride(BaseModel):
def create_override( def create_override(
child_id: str, child_id: str,
entity_id: str, entity_id: str,
entity_type: Literal['task', 'reward'], entity_type: Literal['task', 'reward', 'chore', 'kindness', 'penalty', 'routine'],
custom_value: int custom_value: int
) -> 'ChildOverride': ) -> 'ChildOverride':
"""Factory method to create a new override.""" """Factory method to create a new override."""

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