93 Commits
Author SHA1 Message Date
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
463 changed files with 26224 additions and 4163 deletions
Vendored
BIN
View File
Binary file not shown.
+23 -4
View File
@@ -44,15 +44,15 @@ jobs:
with:
node-version: "20.19.0"
cache: "npm"
cache-dependency-path: frontend/vue-app/package-lock.json
cache-dependency-path: frontend/package-lock.json
- name: Install frontend dependencies
run: npm ci
working-directory: frontend/vue-app
working-directory: frontend
- name: Run frontend unit tests
run: npm run test:unit --if-present
working-directory: frontend/vue-app
working-directory: frontend
- name: Build Backend Docker Image
run: |
@@ -60,7 +60,7 @@ jobs:
- name: Build Frontend Docker Image
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
uses: docker/login-action@v2
@@ -139,6 +139,18 @@ jobs:
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)"
@@ -149,6 +161,13 @@ jobs:
docker-compose -f docker-compose.test.yml pull
docker-compose -f docker-compose.test.yml up -d
echo "Waiting for backend to be ready..."
sleep 10
echo "Seeding test user..."
docker-compose -f docker-compose.test.yml exec -T chores-test-app-backend python scripts/seed_test_user.py
echo "Creating admin user..."
docker-compose -f docker-compose.test.yml exec -T chores-test-app-backend python scripts/create_admin.py
- name: Send mail
if: always() # Runs on success or failure
uses: dawidd6/action-send-mail@v3
+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 }}
+1 -1
View File
@@ -17,7 +17,7 @@ You are a Senior UI/UX Architect specializing in clean, highly reactive web appl
This is the **Reward** app — a family chore/reward tracker. Vue 3 (Composition API / `<script setup lang="ts">`) frontend. Key files:
- Theme tokens: `frontend/vue-app/src/assets/colors.css`**always read this first** when reviewing a component.
- 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>`.
+9 -9
View File
@@ -5,7 +5,7 @@
- **Stack**: Flask (Python, backend) + Vue 3 (TypeScript, frontend) + TinyDB (JSON, thread-safe, see `db/`).
- **API**: RESTful endpoints in `api/`, grouped by entity (child, reward, task, user, image, etc). Each API file maps to a business domain.
- **Nginx Proxy**: Frontend nginx proxies `/api/*` to backend, stripping the `/api` prefix. Backend endpoints should NOT include `/api` in their route definitions. Example: Backend defines `@app.route('/user')`, frontend calls `/api/user`.
- **Models**: Maintain strict 1:1 mapping between Python `@dataclass`es (`backend/models/`) and TypeScript interfaces (`frontend/vue-app/src/common/models.ts`).
- **Models**: Maintain strict 1:1 mapping between Python `@dataclass`es (`backend/models/`) and TypeScript interfaces (`frontend/src/common/models.ts`).
- **Database**: Use TinyDB with `from_dict()`/`to_dict()` for serialization. All logic should operate on model instances, not raw dicts.
- **Events**: Real-time updates via Server-Sent Events (SSE). Every mutation (add/edit/delete/trigger) must call `send_event_for_current_user` (see `backend/events/`).
- **Changes**: Do not use comments to replace code. All changes must be reflected in both backend and frontend files as needed.
@@ -25,7 +25,7 @@
## 🚦 Frontend Logic & Event Bus
- **SSE Event Management**: Register listeners in `onMounted`, clean up in `onUnmounted`. Listen for events like `child_task_triggered`, `child_reward_request`, `task_modified`, etc. See `frontend/vue-app/src/common/backendEvents.ts` and `components/BackendEventsListener.vue`.
- **SSE Event Management**: Register listeners in `onMounted`, clean up in `onUnmounted`. Listen for events like `child_task_triggered`, `child_reward_request`, `task_modified`, etc. See `frontend/src/common/backendEvents.ts` and `components/BackendEventsListener.vue`.
- **Layout Hierarchy**: Use `ParentLayout` for admin/management, `ChildLayout` for dashboard/focus views.
## ⚖️ Business Logic & Safeguards
@@ -37,9 +37,9 @@
- **Backend**: Run Flask with `python -m flask run --host=0.0.0.0 --port=5000` from the `backend/` directory. Main entry: `backend/main.py`.
- **Virtual Env**: Python is running from a virtual environment located at `backend/.venv/`.
- **Frontend**: From `frontend/vue-app/`, run `npm install` then `npm run dev`.
- **Tests**: Run backend tests with `pytest` in `backend/tests/`. Frontend component tests: `npm run test` in `frontend/vue-app/components/__tests__/`. E2E tests: `npx playwright test` from `frontend/vue-app/` — requires both servers running (use the `flask-backend` and `vue-frontend` skills).
- **E2E Setup**: Playwright config is at `frontend/vue-app/playwright.config.ts`. Tests live in `frontend/vue-app/tests/`. The `globalSetup` in `playwright.config.ts` seeds the database and logs in once; all tests receive a pre-authenticated session via `storageState` — do NOT navigate to `/auth/login` in tests. Import `E2E_EMAIL` and `E2E_PASSWORD` from `tests/global-setup.ts` rather than hardcoding credentials. The backend must be started with `DB_ENV=e2e DATA_ENV=e2e` (the `flask-backend` skill does this) so test data goes to `backend/test_data/` and never touches production data.
- **Frontend**: From `frontend/`, run `npm install` then `npm run dev`.
- **Tests**: Run backend tests with `pytest` in `backend/tests/`. Frontend component tests: `npm run test` in `frontend/components/__tests__/`. E2E tests: `npx playwright test` from `frontend/` — requires both servers running (use the `flask-backend` and `vue-frontend` skills).
- **E2E Setup**: Playwright config is at `frontend/playwright.config.ts`. Tests live in `frontend/tests/`. The `globalSetup` in `playwright.config.ts` seeds the database and logs in once; all tests receive a pre-authenticated session via `storageState` — do NOT navigate to `/auth/login` in tests. Import `E2E_EMAIL` and `E2E_PASSWORD` from `tests/global-setup.ts` rather than hardcoding credentials. The backend must be started with `DB_ENV=e2e DATA_ENV=e2e` (the `flask-backend` skill does this) so test data goes to `backend/test_data/` and never touches production data.
- **Debugging**: Use VS Code launch configs or run Flask/Vue dev servers directly. For SSE, use browser dev tools to inspect event streams.
## 📁 Key Files & Directories
@@ -48,10 +48,10 @@
- `backend/models/` — Python dataclasses (business logic, serialization)
- `backend/db/` — TinyDB setup and helpers
- `backend/events/` — SSE event types, broadcaster, payloads
- `frontend/vue-app/` — Vue 3 frontend (see `src/common/`, `src/components/`, `src/layout/`) - Where tests are run from
- `frontend/vue-app/src/common/models.ts` — TypeScript interfaces (mirror Python models)
- `frontend/vue-app/src/common/api.ts` — API helpers, error parsing, validation
- `frontend/vue-app/src/common/backendEvents.ts` — SSE event types and handlers
- `frontend/` — Vue 3 frontend (see `src/common/`, `src/components/`, `src/layout/`) - Where tests are run from
- `frontend/src/common/models.ts` — TypeScript interfaces (mirror Python models)
- `frontend/src/common/api.ts` — API helpers, error parsing, validation
- `frontend/src/common/backendEvents.ts` — SSE event types and handlers
## 🧠 Integration & Cross-Component Patterns
@@ -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.
+2 -2
View File
@@ -8,10 +8,10 @@ disable-model-invocation: true
Use this skill when the user wants to "start the frontend," "run vue," or "launch the dev server."
1. **Verify Directory:** Navigate to `./frontend/vue-app`.
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/vue-app`.
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`
+9 -3
View File
@@ -1,7 +1,13 @@
.env
backend/test_data/
logs/
resources/
frontend/vue-app/playwright-report/
frontend/vue-app/test-results/
frontend/resources/
frontend/playwright-report/
frontend/test-results/
backend/test-results/
.vscode/keybindings.json
.DS_Store
**/.DS_Store
frontend/cert.pem
frontend/key.pem
tmp/
+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
+8 -4
View File
@@ -11,7 +11,11 @@
"FLASK_APP": "backend/main.py",
"FLASK_DEBUG": "1",
"SECRET_KEY": "dev-secret-key-change-in-production",
"REFRESH_TOKEN_EXPIRY_DAYS": "90"
"REFRESH_TOKEN_EXPIRY_DAYS": "90",
"DIGEST_TOKEN_SECRET": "dev-digest-token",
"VAPID_PUBLIC_KEY": "BNKkHdq45uLigohSG7c1TwlAo7ETncoRVLQK02LxHgu2P1DgSJD9njRMfbbzUsaTQGllvLBz7An1WiWsNYQhvKE",
"VAPID_PRIVATE_KEY": "jNiZJT0UO4H861KmnCt874Fg6p5jDAyYKS4V2MZf8bQ",
"FRONTEND_URL": "https://macbook:5173"
},
"args": [
"run",
@@ -32,7 +36,7 @@
"run",
"dev"
],
"cwd": "${workspaceFolder}/frontend/vue-app",
"cwd": "${workspaceFolder}/frontend",
"console": "integratedTerminal",
"serverReadyAction": {
"pattern": "Local:.*https://localhost:([0-9]+)",
@@ -45,7 +49,7 @@
"type": "pwa-chrome",
"request": "launch",
"url": "http://localhost:5173",
"webRoot": "${workspaceFolder}/frontend/vue-app"
"webRoot": "${workspaceFolder}/frontend"
},
{
"name": "Python: Backend Tests",
@@ -73,7 +77,7 @@
"run",
"test:unit"
],
"cwd": "${workspaceFolder}/frontend/vue-app",
"cwd": "${workspaceFolder}/frontend",
"console": "integratedTerminal",
"osx": {
"env": {
+3 -3
View File
@@ -28,7 +28,7 @@
"run",
"dev"
],
"cwd": "${workspaceFolder}/frontend/vue-app",
"cwd": "${workspaceFolder}/frontend",
"console": "integratedTerminal"
},
{
@@ -36,7 +36,7 @@
"type": "chrome",
"request": "launch",
"url": "https://localhost:5173", // or your Vite dev server port
"webRoot": "${workspaceFolder}/frontend/vue-app"
"webRoot": "${workspaceFolder}/frontend"
},
{
"name": "Python: Backend Tests",
@@ -60,7 +60,7 @@
"runtimeArgs": [
"vitest"
],
"cwd": "${workspaceFolder}/frontend/vue-app",
"cwd": "${workspaceFolder}/frontend",
"console": "integratedTerminal"
}
],
+16 -3
View File
@@ -8,8 +8,21 @@
"run-test-mcp-server",
"--config=playwright.config.ts"
],
"cwd": "frontend/vue-app"
"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"
}
},
"inputs": []
}
}
+5
View File
@@ -38,4 +38,9 @@
"editor.fontFamily": "JetBrains Mono",
"editor.fontSize": 13,
"editor.fontLigatures": true,
"python.testing.pytestArgs": [
"backend"
],
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true,
}
+11 -11
View File
@@ -44,56 +44,56 @@
{
"label": "PW: Task Modification Tests",
"type": "shell",
"command": "cd \"${workspaceFolder}/frontend/vue-app\" && npx playwright test e2e/mode_parent/task-modification --project=chromium-task-modification",
"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/vue-app'; npx playwright test e2e/mode_parent/task-modification --project=chromium-task-modification\"",
"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\\vue-app\\\" && npx playwright test e2e/mode_parent/task-modification --project=chromium-task-modification 2>&1\"",
"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/vue-app\" && npx playwright test e2e/mode_parent/user-profile --project=chromium-user-profile --project=chromium-user-profile-pin --project=chromium-user-profile-delete",
"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/vue-app\" && npx playwright test e2e/mode_parent/user-profile --project=chromium-user-profile --project=chromium-user-profile-pin --project=chromium-user-profile-delete",
"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/vue-app\" && npx playwright test e2e/mode_parent/user-profile --project=chromium-user-profile --project=chromium-user-profile-pin --project=chromium-user-profile-delete",
"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/vue-app\" && npx playwright test --reporter=line",
"command": "cd \"${workspaceFolder}/frontend\" && npx playwright test --reporter=line",
"isBackground": false,
"group": "test"
},
{
"label": "PW: Full Suite",
"type": "shell",
"command": "cd \"${workspaceFolder}/frontend/vue-app\" && npx playwright test --reporter=line",
"command": "cd \"${workspaceFolder}/frontend\" && npx playwright test --reporter=line",
"isBackground": false,
"group": "test"
},
@@ -104,7 +104,7 @@
"args": [
"-NoProfile",
"-Command",
"cd 'D:\\Python Utilities\\Reward\\frontend\\vue-app'; npx playwright test --reporter=line 2>&1"
"cd 'D:\\Python Utilities\\Reward\\frontend'; npx playwright test --reporter=line 2>&1"
],
"group": "test",
"presentation": {
@@ -119,7 +119,7 @@
"args": [
"-NoProfile",
"-Command",
"cd 'D:\\Python Utilities\\Reward\\frontend\\vue-app'; npx playwright test e2e/mode_parent/tasks/penalty-default.spec.ts --project=chromium-default-tasks --reporter=line 2>&1"
"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": {
@@ -134,7 +134,7 @@
"args": [
"-NoProfile",
"-Command",
"cd 'D:\\Python Utilities\\Reward\\frontend\\vue-app'; npx playwright test e2e/mode_parent/user-profile/profile-editing.spec.ts --project=chromium-user-profile --reporter=line 2>&1"
"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": {
+57
View File
@@ -0,0 +1,57 @@
# 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: `DB_ENV` / `DATA_ENV` (`prod` | `test` | `e2e`) — picks `data/` vs `test_data/` dir (see `config/paths.py`)
- Tests: `pytest tests/``conftest.py` forces `DB_ENV=test` 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`. Tests live in `e2e/`.
- 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 TinyDB via the thread-safe `LockedTable` wrapper (`backend/db/db.py`). Operate on model instances with `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 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.
+6 -7
View File
@@ -24,7 +24,7 @@ python -m flask run --host=0.0.0.0 --port=5000
### Frontend
```bash
cd frontend/vue-app
cd frontend
npm install
npm run dev
```
@@ -114,7 +114,7 @@ pytest tests/
### Frontend Tests
```bash
cd frontend/vue-app
cd frontend
npm run test
```
@@ -151,11 +151,10 @@ npm run test
│ ├── tests/ # Backend tests
│ └── utils/ # Utilities (scheduler, etc)
├── frontend/
│ └── vue-app/
── src/
├── common/ # Shared utilities
├── components/ # Vue components
│ └── layout/ # Layout components
│ └── src/
── common/ # Shared utilities
│ ├── components/ # Vue components
└── layout/ # Layout components
└── .github/
└── specs/ # Feature specifications
```
+133 -1
View File
@@ -1,10 +1,12 @@
import os
from flask import Blueprint, request, jsonify
from datetime import datetime, timedelta
from tinydb import Query
from db.db import users_db
from models.user import User
from api.utils import admin_required
from api.utils import admin_required, get_validated_user_id
from config.deletion_config import (
ACCOUNT_DELETION_THRESHOLD_HOURS,
MIN_THRESHOLD_HOURS,
@@ -153,3 +155,133 @@ def trigger_deletion_queue():
except Exception as e:
return jsonify({'error': str(e), 'code': 'SERVER_ERROR'}), 500
# ---------------------------------------------------------------------------
# Test-only endpoint — active ONLY when DB_ENV=e2e
# ---------------------------------------------------------------------------
@admin_api.route('/admin/test/digest-token', methods=['POST'])
def create_test_digest_token():
"""Create a valid DigestActionToken for E2E tests.
Only active when DB_ENV=e2e. Requires authentication.
"""
if os.environ.get('DB_ENV') != 'e2e':
return jsonify({'error': 'Not found', 'code': 'NOT_FOUND'}), 404
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
data = request.get_json() or {}
child_id = data.get('child_id')
entity_id = data.get('entity_id')
entity_type = data.get('entity_type')
action = data.get('action')
expires_in_hours = data.get('expires_in_hours', 24)
if not all([child_id, entity_id, entity_type, action]):
return jsonify({'error': 'child_id, entity_id, entity_type, and action are required',
'code': 'MISSING_FIELDS'}), 400
if entity_type not in ('chore', 'reward'):
return jsonify({'error': 'entity_type must be "chore" or "reward"',
'code': 'INVALID_ENTITY_TYPE'}), 400
if action not in ('approve', 'deny'):
return jsonify({'error': 'action must be "approve" or "deny"',
'code': 'INVALID_ACTION'}), 400
try:
from utils.digest_token import create_action_token
token = create_action_token(
user_id=user_id,
child_id=child_id,
entity_id=entity_id,
entity_type=entity_type,
action=action,
expiry_hours=int(expires_in_hours),
)
return jsonify({'token': token.id}), 200
except Exception as e:
return jsonify({'error': str(e), 'code': 'SERVER_ERROR'}), 500
@admin_api.route('/admin/test/send-digest', methods=['POST'])
def send_test_digest():
"""Trigger a digest email for a specific user by email address.
Only active when DB_ENV is not 'production'. Requires admin authentication.
Note: actual email delivery is skipped in e2e mode by email_sender.
"""
if os.environ.get('DB_ENV') == 'production':
return jsonify({'error': 'Not found', 'code': 'NOT_FOUND'}), 404
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
# Verify caller is admin
caller_dict = users_db.get(Query().id == user_id)
if not caller_dict or caller_dict.get('role') != 'admin':
return jsonify({'error': 'Admin access required', 'code': 'ADMIN_REQUIRED'}), 403
data = request.get_json() or {}
email = data.get('email', '').strip().lower()
if not email:
return jsonify({'error': 'email is required', 'code': 'MISSING_FIELDS'}), 400
target = users_db.get(Query().email == email)
if not target:
return jsonify({'error': 'User not found', 'code': 'USER_NOT_FOUND'}), 404
target_id = target.get('id')
target_email = target.get('email')
try:
from flask import current_app
from utils.digest_scheduler import send_digest_for_user
frontend_url = current_app.config.get('FRONTEND_URL', 'https://localhost:5173')
items_sent = send_digest_for_user(target_id, target_email, frontend_url)
return jsonify({'items_sent': items_sent}), 200
except Exception as e:
return jsonify({'error': str(e), 'code': 'SERVER_ERROR'}), 500
@admin_api.route('/admin/test/trigger-chore-expiry', methods=['POST'])
def trigger_test_chore_expiry():
"""Trigger the chore expiry notification check for a specific user by email address.
Only active when DB_ENV is not 'production'. Requires admin authentication.
Note: actual push delivery requires VAPID keys to be configured.
"""
if os.environ.get('DB_ENV') == 'production':
return jsonify({'error': 'Not found', 'code': 'NOT_FOUND'}), 404
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
caller_dict = users_db.get(Query().id == user_id)
if not caller_dict or caller_dict.get('role') != 'admin':
return jsonify({'error': 'Admin access required', 'code': 'ADMIN_REQUIRED'}), 403
data = request.get_json() or {}
email = data.get('email', '').strip().lower()
if not email:
return jsonify({'error': 'email is required', 'code': 'MISSING_FIELDS'}), 400
target = users_db.get(Query().email == email)
if not target:
return jsonify({'error': 'User not found', 'code': 'USER_NOT_FOUND'}), 404
target_id = target.get('id')
tz_str = target.get('timezone')
try:
from utils.chore_expiry_notification_scheduler import send_chore_expiry_notifications_for_user
chores_notified = send_chore_expiry_notifications_for_user(target_id, tz_str)
return jsonify({'chores_notified': chores_notified}), 200
except Exception as e:
return jsonify({'error': str(e), 'code': 'SERVER_ERROR'}), 500
+103 -23
View File
@@ -39,7 +39,16 @@ UserQuery = Query()
TokenQuery = Query()
TOKEN_EXPIRY_MINUTES = 60 * 4
RESET_PASSWORD_TOKEN_EXPIRY_MINUTES = 10
ACCESS_TOKEN_EXPIRY_MINUTES = 15
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'
@@ -49,6 +58,9 @@ 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):
@@ -406,18 +418,21 @@ def refresh():
token_record = RefreshToken.from_dict(token_dict)
# THEFT DETECTION: token was already used (rotated out) but replayed
if token_record.is_used:
logger.warning(
'Refresh token reuse detected! user_id=%s, family=%s, ip=%s — killing all sessions',
token_record.user_id, token_record.token_family, request.remote_addr,
)
# Nuke ALL refresh tokens for this user
refresh_tokens_db.remove(TokenQuery.user_id == token_record.user_id)
resp = jsonify({'error': 'Token reuse detected, all sessions invalidated', 'code': REFRESH_TOKEN_REUSE})
# 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)
@@ -434,23 +449,59 @@ def refresh():
_clear_auth_cookies(resp)
return resp, 401
# Look up the user
user_dict = users_db.get(UserQuery.id == token_record.user_id)
user = User.from_dict(user_dict) if user_dict else None
if not user:
refresh_tokens_db.remove(TokenQuery.id == token_record.id)
resp = jsonify({'error': 'User not found', 'code': USER_NOT_FOUND})
# 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
if user.marked_for_deletion:
refresh_tokens_db.remove(TokenQuery.user_id == user.id)
resp = jsonify({'error': 'Account marked for deletion', 'code': ACCOUNT_MARKED_FOR_DELETION})
_clear_auth_cookies(resp)
return resp, 403
# ROTATION: mark old token as used, create new one in same family
refresh_tokens_db.update({'is_used': True}, TokenQuery.id == token_record.id)
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
@@ -496,6 +547,7 @@ def e2e_create_delete_user():
verified=True,
role='user',
pin=E2E_DELETE_PIN,
tutorial_enabled=False,
)
users_db.insert(user.to_dict())
return jsonify({'email': norm_email}), 201
@@ -521,6 +573,33 @@ def e2e_create_cc_user():
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
@@ -559,6 +638,7 @@ def e2e_seed():
verified=True,
role='user',
pin=E2E_TEST_PIN,
tutorial_enabled=False,
)
users_db.insert(user.to_dict())
+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)))
+230 -106
View File
@@ -1,5 +1,6 @@
from time import sleep
from datetime import date as date_type, datetime, timezone
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
from flask import Blueprint, request, jsonify
from tinydb import Query
@@ -9,7 +10,8 @@ from api.child_tasks import ChildTask
from api.pending_confirmation import PendingConfirmationResponse
from api.reward_status import RewardStatus
from api.utils import send_event_for_current_user, get_validated_user_id
from db.db import child_db, task_db, reward_db, pending_reward_db, pending_confirmations_db
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.child_overrides import get_override, delete_override, delete_overrides_for_child
from events.types.child_chore_confirmation import ChildChoreConfirmation
@@ -29,14 +31,65 @@ from models.reward import Reward
from models.task import Task
from models.tracking_event import TrackingEvent
from utils.tracking_logger import log_tracking_event
from utils.push_sender import send_push_to_user
from utils.digest_token import create_action_token
from collections import defaultdict
from db.chore_schedules import get_schedule
from db.task_extensions import get_extension
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
child_api = Blueprint('child_api', __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/<id>', methods=['GET'])
def get_child(id):
@@ -147,6 +200,14 @@ def delete_child(id):
deleted_count = delete_overrides_for_child(id)
if deleted_count > 0:
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)):
resp = send_event_for_current_user(Event(EventType.CHILD_MODIFIED.value, ChildModified(id, ChildModified.OPERATION_DELETE)))
@@ -208,7 +269,7 @@ def set_child_tasks(id):
# Convert back to list if needed
new_tasks = list(new_task_ids)
# Identify unassigned tasks and delete their overrides
# Identify unassigned tasks and delete their overrides and pending confirmations
old_task_ids = set(child.tasks)
unassigned_task_ids = old_task_ids - new_task_ids
for task_id in unassigned_task_ids:
@@ -217,6 +278,12 @@ def set_child_tasks(id):
if override and override.entity_type == 'task':
delete_override(id, task_id)
logger.info(f"Deleted override for unassigned task: child={id}, task={task_id}")
# Clear any pending chore confirmation
PendingQ = Query()
pending_confirmations_db.remove(
(PendingQ.child_id == id) & (PendingQ.entity_id == task_id) &
(PendingQ.entity_type == 'chore') & (PendingQ.user_id == user_id)
)
# Replace tasks with validated IDs
child_db.update({'tasks': new_tasks}, ChildQuery.id == id)
@@ -250,6 +317,12 @@ def remove_task_from_child(id):
if task_id in child.get('tasks', []):
child['tasks'].remove(task_id)
child_db.update({'tasks': child['tasks']}, ChildQuery.id == id)
# Clear any pending chore confirmation for this task
PendingQ = Query()
pending_confirmations_db.remove(
(PendingQ.child_id == id) & (PendingQ.entity_id == task_id) &
(PendingQ.entity_type == 'chore') & (PendingQ.user_id == user_id)
)
return jsonify({'message': f'Task {task_id} removed from {child["name"]}.'}), 200
return jsonify({'error': 'Task not assigned to child'}), 400
@@ -267,6 +340,7 @@ def list_child_tasks(id):
task_ids = child.get('tasks', [])
TaskQuery = Query()
today_local, tz_str = _get_user_today_local(user_id)
child_tasks = []
for tid in task_ids:
task = task_db.get((TaskQuery.id == tid) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)))
@@ -288,8 +362,7 @@ def list_child_tasks(id):
if task_obj.type == 'chore':
schedule = get_schedule(id, tid)
ct_dict['schedule'] = schedule.to_dict() if schedule else None
today_str = date_type.today().isoformat()
ext = get_extension(id, tid, today_str)
ext = get_extension_for_child_task(id, tid)
ct_dict['extension_date'] = ext.date if ext else None
# Attach pending confirmation status for chores
@@ -299,8 +372,21 @@ def list_child_tasks(id):
(PendingQuery.entity_type == 'chore') & (PendingQuery.user_id == user_id)
)
if pending:
ct_dict['pending_status'] = pending.get('status')
ct_dict['approved_at'] = pending.get('approved_at')
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
@@ -331,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]
# Group by name
from collections import defaultdict
name_to_tasks = defaultdict(list)
for t in all_tasks:
name_to_tasks[t.get('name')].append(t)
@@ -540,7 +625,6 @@ def list_all_rewards(id):
ChildRewardQuery = Query()
all_rewards = reward_db.search((ChildRewardQuery.user_id == user_id) | (ChildRewardQuery.user_id == None))
from collections import defaultdict
name_to_rewards = defaultdict(list)
for r in all_rewards:
name_to_rewards[r.get('name')].append(r)
@@ -602,7 +686,7 @@ def set_child_rewards(id):
if reward_db.get((RewardQuery.id == rid) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None))):
valid_reward_ids.append(rid)
# Identify unassigned rewards and delete their overrides
# Identify unassigned rewards and delete their overrides and pending confirmations
new_reward_ids_set = set(valid_reward_ids)
unassigned_reward_ids = old_reward_ids - new_reward_ids_set
for reward_id in unassigned_reward_ids:
@@ -610,6 +694,12 @@ def set_child_rewards(id):
if override and override.entity_type == 'reward':
delete_override(id, reward_id)
logger.info(f"Deleted override for unassigned reward: child={id}, reward={reward_id}")
# Clear any pending reward confirmation
PendingQ = Query()
pending_confirmations_db.remove(
(PendingQ.child_id == id) & (PendingQ.entity_id == reward_id) &
(PendingQ.entity_type == 'reward') & (PendingQ.user_id == user_id)
)
# Replace rewards with validated IDs
child_db.update({'rewards': valid_reward_ids}, ChildQuery.id == id)
@@ -640,6 +730,12 @@ def remove_reward_from_child(id):
if reward_id in child.get('rewards', []):
child['rewards'].remove(reward_id)
child_db.update({'rewards': child['rewards']}, ChildQuery.id == id)
# Clear any pending reward confirmation for this reward
PendingQ = Query()
pending_confirmations_db.remove(
(PendingQ.child_id == id) & (PendingQ.entity_id == reward_id) &
(PendingQ.entity_type == 'reward') & (PendingQ.user_id == user_id)
)
return jsonify({'message': f'Reward {reward_id} removed from {child["name"]}.'}), 200
return jsonify({'error': 'Reward not assigned to child'}), 400
@@ -692,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]
# Group by name
from collections import defaultdict
name_to_rewards = defaultdict(list)
for r in all_rewards:
name_to_rewards[r.get('name')].append(r)
@@ -831,6 +926,7 @@ def reward_status(id):
reward_ids = child.rewards
RewardQuery = Query()
today_local, tz_str = _get_user_today_local(user_id)
statuses = []
for reward_id in reward_ids:
reward_dict = reward_db.get((RewardQuery.id == reward_id) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None)))
@@ -849,7 +945,16 @@ def reward_status(id):
(pending_query.child_id == child.id) & (pending_query.entity_id == reward.id) &
(pending_query.entity_type == 'reward') & (pending_query.user_id == user_id)
)
status = RewardStatus(reward.id, reward.name, points_needed, cost_value, pending is not None, reward.image_id)
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()
if override:
status_dict['custom_value'] = override.custom_value
@@ -896,6 +1001,21 @@ def request_reward(id):
'reward_cost': reward.cost
}), 400
# Check for duplicate pending request
DupQuery = Query()
duplicate = pending_confirmations_db.get(
(DupQuery.child_id == child.id) & (DupQuery.entity_id == reward.id) &
(DupQuery.entity_type == 'reward') & (DupQuery.status == 'pending') &
(DupQuery.user_id == user_id)
)
if duplicate:
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}')
@@ -918,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.CHILD_REWARD_REQUEST.value, ChildRewardRequest(child.id, reward.id, ChildRewardRequest.REQUEST_CREATED)))
# Fire web push notification to all parent subscriptions
_push_user = users_db.get(Query().id == user_id)
if _push_user and _push_user.get('push_notifications_enabled', True):
try:
approve_token = create_action_token(user_id, child.id, reward.id, 'reward', 'approve')
deny_token = create_action_token(user_id, child.id, reward.id, 'reward', 'deny')
push_payload = {
'type': 'reward_requested',
'title': f'{child.name} wants a reward',
'body': f'{reward.name} costs {reward.cost} points.',
'user_id': user_id,
'child_id': child.id,
'child_name': child.name,
'entity_id': reward.id,
'entity_type': 'reward',
'entity_name': reward.name,
'approve_token': approve_token.id,
'deny_token': deny_token.id,
}
send_push_to_user(user_id, push_payload)
except Exception as _push_err:
logger.warning(f'Push notification failed for reward request: {_push_err}')
return jsonify({
'message': f'Reward request for {reward.name} submitted for {child.name}.',
'reward_id': reward.id,
@@ -996,6 +1140,7 @@ def list_pending_confirmations():
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
PendingQuery = Query()
today_local, tz_str = _get_user_today_local(user_id)
pending_items = pending_confirmations_db.search(
(PendingQuery.user_id == user_id) & (PendingQuery.status == 'pending')
)
@@ -1003,11 +1148,16 @@ def list_pending_confirmations():
RewardQuery = Query()
TaskQuery = Query()
RoutineQuery = Query()
ChildQuery = Query()
for pr in pending_items:
pending = PendingConfirmation.from_dict(pr)
if not _is_epoch_timestamp_on_local_day(pending.created_at, today_local, tz_str):
pending_confirmations_db.remove(PendingQuery.id == pending.id)
continue
# Look up child details
child_result = child_db.get(ChildQuery.id == pending.child_id)
if not child_result:
@@ -1017,8 +1167,12 @@ def list_pending_confirmations():
# Look up entity details based on type
if pending.entity_type == 'reward':
entity_result = reward_db.get((RewardQuery.id == pending.entity_id) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None)))
else:
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
@@ -1080,13 +1234,20 @@ def confirm_chore(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':
return jsonify({'error': 'Chore already pending confirmation', 'code': 'CHORE_ALREADY_PENDING'}), 400
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', '')
today_utc = datetime.now(timezone.utc).strftime('%Y-%m-%d')
if approved_at and approved_at[:10] == today_utc:
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
@@ -1106,6 +1267,30 @@ def confirm_chore(id):
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
@@ -1172,77 +1357,18 @@ def approve_chore(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])
try:
result = chore_actions.approve_chore(user_id, id, task_id)
except ValueError:
return jsonify({'error': 'Child or task 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:
if result is None:
return jsonify({'error': 'No pending confirmation found', 'code': 'PENDING_NOT_FOUND'}), 400
TaskQuery = Query()
task_result = task_db.get((TaskQuery.id == task_id) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)))
if not task_result:
return jsonify({'error': 'Task not found'}), 404
task = Task.from_dict(task_result)
# Award points
override = get_override(id, task_id)
points_value = override.custom_value if override else task.points
points_before = child.points
child.points += points_value
child_db.update({'points': child.points}, ChildQuery.id == id)
# Update confirmation to approved
# For general (non-scheduled) chores, remove the confirmation so chore resets to normal
schedule = get_schedule(id, task_id)
if schedule:
now_str = datetime.now(timezone.utc).isoformat()
pending_confirmations_db.update(
{'status': 'approved', 'approved_at': now_str},
(PendingQuery.child_id == id) & (PendingQuery.entity_id == task_id) &
(PendingQuery.entity_type == 'chore') & (PendingQuery.user_id == user_id)
)
else:
pending_confirmations_db.remove(
(PendingQuery.child_id == id) & (PendingQuery.entity_id == task_id) &
(PendingQuery.entity_type == 'chore') & (PendingQuery.user_id == user_id)
)
tracking_metadata = {
'task_name': task.name,
'task_type': task.type,
'default_points': task.points
}
if override:
tracking_metadata['custom_points'] = override.custom_value
tracking_metadata['has_override'] = True
tracking_event = TrackingEvent.create_event(
user_id=user_id, child_id=id, entity_type='chore', entity_id=task_id,
action='approved', points_before=points_before, points_after=child.points,
metadata=tracking_metadata
)
insert_tracking_event(tracking_event)
log_tracking_event(tracking_event)
send_event_for_current_user(Event(EventType.TRACKING_EVENT_CREATED.value,
TrackingEventCreated(tracking_event.id, id, 'chore', 'approved')))
send_event_for_current_user(Event(EventType.CHILD_CHORE_CONFIRMATION.value,
ChildChoreConfirmation(id, task_id, ChildChoreConfirmation.OPERATION_APPROVED)))
send_event_for_current_user(Event(EventType.CHILD_TASK_TRIGGERED.value,
ChildTaskTriggered(task_id, id, child.points)))
return jsonify({
'message': f'Chore {task.name} approved for {child.name}.',
'points': child.points,
'id': child.id
'message': f'Chore {result["task_name"]} approved for {result["child_name"]}.',
'points': result['points'],
'id': result['child_id']
}), 200
@@ -1261,7 +1387,6 @@ def reject_chore(id):
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(
@@ -1272,27 +1397,7 @@ def reject_chore(id):
if not existing:
return jsonify({'error': 'No pending confirmation found', 'code': 'PENDING_NOT_FOUND'}), 400
pending_confirmations_db.remove(
(PendingQuery.child_id == id) & (PendingQuery.entity_id == task_id) &
(PendingQuery.entity_type == 'chore') & (PendingQuery.user_id == user_id)
)
TaskQuery = Query()
task_result = task_db.get((TaskQuery.id == task_id) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)))
task_name = task_result.get('name') if task_result else 'Unknown'
tracking_event = TrackingEvent.create_event(
user_id=user_id, child_id=id, entity_type='chore', entity_id=task_id,
action='rejected', points_before=child.points, points_after=child.points,
metadata={'task_name': task_name}
)
insert_tracking_event(tracking_event)
log_tracking_event(tracking_event)
send_event_for_current_user(Event(EventType.TRACKING_EVENT_CREATED.value,
TrackingEventCreated(tracking_event.id, id, 'chore', 'rejected')))
send_event_for_current_user(Event(EventType.CHILD_CHORE_CONFIRMATION.value,
ChildChoreConfirmation(id, task_id, ChildChoreConfirmation.OPERATION_REJECTED)))
chore_actions.reject_chore(user_id, id, task_id)
return jsonify({'message': 'Chore confirmation rejected.'}), 200
@@ -1345,3 +1450,22 @@ def reset_chore(id):
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
+17 -4
View File
@@ -2,7 +2,7 @@ from flask import Blueprint, request, jsonify
from tinydb import Query
from api.utils import get_validated_user_id, send_event_for_current_user
from api.error_codes import ErrorCodes
from db.db import child_db, task_db, reward_db
from db.db import child_db, task_db, reward_db, routine_db
from db.child_overrides import (
insert_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
# Validate entity_type
if entity_type not in ['task', 'reward']:
return jsonify({'error': 'entity_type must be "task" or "reward"', 'code': ErrorCodes.INVALID_VALUE, 'field': 'entity_type'}), 400
if entity_type not in ['task', 'reward', 'routine']:
return jsonify({'error': 'entity_type must be "task", "reward", or "routine"', 'code': ErrorCodes.INVALID_VALUE, 'field': 'entity_type'}), 400
# Validate custom_value range
if not isinstance(custom_value, int) or custom_value < 0 or custom_value > 10000:
@@ -74,7 +74,7 @@ def set_child_override(child_id):
if entity_id not in assigned_tasks:
return jsonify({'error': 'Task not assigned to child', 'code': ErrorCodes.ENTITY_NOT_ASSIGNED}), 404
else: # reward
elif entity_type == 'reward':
EntityQuery = Query()
entity_result = reward_db.search(
(EntityQuery.id == entity_id) &
@@ -87,6 +87,19 @@ def set_child_override(child_id):
assigned_rewards = child_dict.get('rewards', [])
if entity_id not in assigned_rewards:
return jsonify({'error': 'Reward not assigned to child', 'code': ErrorCodes.ENTITY_NOT_ASSIGNED}), 404
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
try:
+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
+4 -1
View File
@@ -160,11 +160,14 @@ def extend_chore_time(child_id, task_id):
if not date or not isinstance(date, str):
return jsonify({'error': 'date is required (ISO date string)', 'code': ErrorCodes.MISSING_FIELD}), 400
# 409 if already extended for this date
# 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)
+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)
+1
View File
@@ -35,3 +35,4 @@ class ErrorCodes:
PENDING_NOT_FOUND = "PENDING_NOT_FOUND"
INSUFFICIENT_POINTS = "INSUFFICIENT_POINTS"
INVALID_TASK_TYPE = "INVALID_TASK_TYPE"
DUPLICATE_REWARD_REQUEST = "DUPLICATE_REWARD_REQUEST"
+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
+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
+47 -2
View File
@@ -46,7 +46,11 @@ def get_profile():
'first_name': user.first_name,
'last_name': user.last_name,
'email': user.email,
'image_id': user.image_id
'image_id': user.image_id,
'email_digest_enabled': user.email_digest_enabled,
'push_notifications_enabled': user.push_notifications_enabled,
'tutorial_enabled': user.tutorial_enabled,
'tutorial_progress': user.tutorial_progress or {},
}), 200
@user_api.route('/user/profile', methods=['PUT'])
@@ -58,16 +62,22 @@ def update_profile():
if not user:
return jsonify({'error': 'Unauthorized'}), 401
data = request.get_json()
# Only allow first_name, last_name, image_id to be updated
# Only allow first_name, last_name, image_id, email_digest_enabled, push_notifications_enabled to be updated
first_name = data.get('first_name')
last_name = data.get('last_name')
image_id = data.get('image_id')
email_digest_enabled = data.get('email_digest_enabled')
push_notifications_enabled = data.get('push_notifications_enabled')
if first_name is not None:
user.first_name = first_name
if last_name is not None:
user.last_name = last_name
if image_id is not None:
user.image_id = image_id
if email_digest_enabled is not None:
user.email_digest_enabled = bool(email_digest_enabled)
if push_notifications_enabled is not None:
user.push_notifications_enabled = bool(push_notifications_enabled)
users_db.update(user.to_dict(), UserQuery.email == user.email)
# Create tracking event
@@ -78,6 +88,10 @@ def update_profile():
metadata['last_name_updated'] = True
if image_id is not None:
metadata['image_updated'] = True
if email_digest_enabled is not None:
metadata['email_digest_enabled_updated'] = True
if push_notifications_enabled is not None:
metadata['push_notifications_enabled_updated'] = True
tracking_event = TrackingEvent.create_event(
user_id=user_id,
@@ -97,6 +111,37 @@ def update_profile():
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'])
def update_image():
user_id = get_validated_user_id()
+1 -1
View File
@@ -2,7 +2,7 @@
# file: config/version.py
import os
BASE_VERSION = "1.0.7" # update manually when releasing features
BASE_VERSION = "1.0.16" # update manually when releasing features
def get_full_version() -> str:
"""
@@ -1,144 +0,0 @@
{
"_default": {
"1": {
"id": "57c21328-637e-4df3-be5b-7f619cbf4076",
"created_at": 1771343995.1881204,
"updated_at": 1771343995.188121,
"name": "Take out trash",
"points": 20,
"is_good": true,
"image_id": "trash-can",
"user_id": null
},
"2": {
"id": "70316500-e4ce-4399-8e4b-86a4046fafcb",
"created_at": 1771343995.1881304,
"updated_at": 1771343995.1881304,
"name": "Make your bed",
"points": 25,
"is_good": true,
"image_id": "make-the-bed",
"user_id": null
},
"3": {
"id": "71afb2e5-18de-4f99-9e1e-2f4e391e6c2c",
"created_at": 1771343995.1881359,
"updated_at": 1771343995.1881359,
"name": "Sweep and clean kitchen",
"points": 15,
"is_good": true,
"image_id": "vacuum",
"user_id": null
},
"4": {
"id": "e0aae53d-d4b6-4203-b910-004917db6003",
"created_at": 1771343995.1881409,
"updated_at": 1771343995.188141,
"name": "Do homework early",
"points": 30,
"is_good": true,
"image_id": "homework",
"user_id": null
},
"5": {
"id": "0ba544f6-2d61-4009-af8f-bcb4e94b7a11",
"created_at": 1771343995.188146,
"updated_at": 1771343995.188146,
"name": "Be good for the day",
"points": 15,
"is_good": true,
"image_id": "good",
"user_id": null
},
"6": {
"id": "8b5750d4-5a58-40cb-a31b-667569069d34",
"created_at": 1771343995.1881511,
"updated_at": 1771343995.1881511,
"name": "Clean your mess",
"points": 20,
"is_good": true,
"image_id": "broom",
"user_id": null
},
"7": {
"id": "aec5fb49-06d0-43c4-aa09-9583064b7275",
"created_at": 1771343995.1881557,
"updated_at": 1771343995.1881557,
"name": "Fighting",
"points": 10,
"is_good": false,
"image_id": "fighting",
"user_id": null
},
"8": {
"id": "0221ab72-c6c0-429f-a5f1-bc3d843fce9e",
"created_at": 1771343995.1881602,
"updated_at": 1771343995.1881602,
"name": "Yelling at parents",
"points": 10,
"is_good": false,
"image_id": "yelling",
"user_id": null
},
"9": {
"id": "672bfc74-4b85-4e8e-a2d0-74f14ab966cc",
"created_at": 1771343995.1881647,
"updated_at": 1771343995.1881647,
"name": "Lying",
"points": 10,
"is_good": false,
"image_id": "lying",
"user_id": null
},
"10": {
"id": "d8cc254f-922b-4dc2-ac4c-32fc3bbda584",
"created_at": 1771343995.1881692,
"updated_at": 1771343995.1881695,
"name": "Not doing what told",
"points": 5,
"is_good": false,
"image_id": "ignore",
"user_id": null
},
"11": {
"id": "8be18d9a-48e6-402b-a0ba-630a2d50e325",
"created_at": 1771343995.188174,
"updated_at": 1771343995.188174,
"name": "Not flushing toilet",
"points": 5,
"is_good": false,
"image_id": "toilet",
"user_id": null
},
"12": {
"id": "b3b44115-529b-4eb3-9f8b-686dd24547a1",
"created_at": 1771345063.4665146,
"updated_at": 1771345063.4665148,
"name": "Take out trash",
"points": 21,
"is_good": true,
"image_id": "trash-can",
"user_id": "a5f05d38-7f7c-4663-b00f-3d6138e0e246"
},
"13": {
"id": "c74fc8c7-5af1-4d40-afbb-6da2647ca18b",
"created_at": 1771345069.1633172,
"updated_at": 1771345069.1633174,
"name": "aaa",
"points": 1,
"is_good": true,
"image_id": "computer-game",
"user_id": "a5f05d38-7f7c-4663-b00f-3d6138e0e246"
},
"14": {
"id": "65e79bbd-6cdf-4636-9e9d-f608206dbd80",
"created_at": 1772251855.4823341,
"updated_at": 1772251855.4823341,
"name": "Be Cool \ud83d\ude0e",
"points": 5,
"type": "kindness",
"image_id": "58d4adb9-3cee-4d7c-8e90-d81173716ce5",
"user_id": "6da06108-0db8-46be-b4cb-60ce7b54564d"
}
}
}
File diff suppressed because it is too large Load Diff
+24
View File
@@ -69,6 +69,10 @@ class LockedTable:
child_path = os.path.join(base_dir, 'children.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')
image_path = os.path.join(base_dir, 'images.json')
pending_reward_path = os.path.join(base_dir, 'pending_rewards.json')
@@ -79,10 +83,16 @@ child_overrides_path = os.path.join(base_dir, 'child_overrides.json')
chore_schedules_path = os.path.join(base_dir, 'chore_schedules.json')
task_extensions_path = os.path.join(base_dir, 'task_extensions.json')
refresh_tokens_path = os.path.join(base_dir, 'refresh_tokens.json')
push_subscriptions_path = os.path.join(base_dir, 'push_subscriptions.json')
digest_action_tokens_path = os.path.join(base_dir, 'digest_action_tokens.json')
# Use separate TinyDB instances/files for each collection
_child_db = TinyDB(child_path, indent=2)
_task_db = TinyDB(task_path, indent=2)
_routine_db = TinyDB(routine_path, indent=2)
_routine_items_db = TinyDB(routine_items_path, indent=2)
_routine_schedules_db = TinyDB(routine_schedules_path, indent=2)
_routine_extensions_db = TinyDB(routine_extensions_path, indent=2)
_reward_db = TinyDB(reward_path, indent=2)
_image_db = TinyDB(image_path, indent=2)
_pending_rewards_db = TinyDB(pending_reward_path, indent=2)
@@ -93,10 +103,16 @@ _child_overrides_db = TinyDB(child_overrides_path, indent=2)
_chore_schedules_db = TinyDB(chore_schedules_path, indent=2)
_task_extensions_db = TinyDB(task_extensions_path, indent=2)
_refresh_tokens_db = TinyDB(refresh_tokens_path, indent=2)
_push_subscriptions_db = TinyDB(push_subscriptions_path, indent=2)
_digest_action_tokens_db = TinyDB(digest_action_tokens_path, indent=2)
# Expose table objects wrapped with locking
child_db = LockedTable(_child_db)
task_db = LockedTable(_task_db)
routine_db = LockedTable(_routine_db)
routine_items_db = LockedTable(_routine_items_db)
routine_schedules_db = LockedTable(_routine_schedules_db)
routine_extensions_db = LockedTable(_routine_extensions_db)
reward_db = LockedTable(_reward_db)
image_db = LockedTable(_image_db)
pending_reward_db = LockedTable(_pending_rewards_db)
@@ -107,10 +123,16 @@ child_overrides_db = LockedTable(_child_overrides_db)
chore_schedules_db = LockedTable(_chore_schedules_db)
task_extensions_db = LockedTable(_task_extensions_db)
refresh_tokens_db = LockedTable(_refresh_tokens_db)
push_subscriptions_db = LockedTable(_push_subscriptions_db)
digest_action_tokens_db = LockedTable(_digest_action_tokens_db)
if os.environ.get('DB_ENV', 'prod') == 'test':
child_db.truncate()
task_db.truncate()
routine_db.truncate()
routine_items_db.truncate()
routine_schedules_db.truncate()
routine_extensions_db.truncate()
reward_db.truncate()
image_db.truncate()
pending_reward_db.truncate()
@@ -121,4 +143,6 @@ if os.environ.get('DB_ENV', 'prod') == 'test':
chore_schedules_db.truncate()
task_extensions_db.truncate()
refresh_tokens_db.truncate()
push_subscriptions_db.truncate()
digest_action_tokens_db.truncate()
+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)
+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]
+17
View File
@@ -30,3 +30,20 @@ def delete_extensions_for_task(task_id: str) -> None:
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)
@@ -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
})
+6
View File
@@ -28,4 +28,10 @@ class EventType(Enum):
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,
})
+37
View File
@@ -14,9 +14,15 @@ 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.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.tracking_api import tracking_api
from api.user_api import user_api
from api.push_subscription_api import push_subscription_api
from api.digest_action_api import digest_action_api
from config.version import get_full_version
from db.default import initializeImages, createDefaultTasks, createDefaultRewards
@@ -24,6 +30,9 @@ from events.broadcaster import Broadcaster
from events.sse import sse_response_for_user, send_to_user
from api.utils import get_current_user_id
from utils.account_deletion_scheduler import start_deletion_scheduler
from utils.chore_expiry_notification_scheduler import start_chore_expiry_notification_scheduler
from utils.digest_scheduler import start_digest_scheduler
from utils.state_expiry_scheduler import start_state_expiry_scheduler
# Configure logging once at application startup
logging.basicConfig(
@@ -49,11 +58,17 @@ app.register_blueprint(chore_schedule_api)
app.register_blueprint(kindness_api)
app.register_blueprint(penalty_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(image_api)
app.register_blueprint(auth_api, url_prefix='/auth')
app.register_blueprint(user_api)
app.register_blueprint(tracking_api)
app.register_blueprint(push_subscription_api)
app.register_blueprint(digest_action_api)
app.config.update(
MAIL_SERVER='smtp.gmail.com',
@@ -63,6 +78,7 @@ app.config.update(
MAIL_PASSWORD='ruyj hxjf nmrz buar',
MAIL_DEFAULT_SENDER='ryan.kegel@gmail.com',
FRONTEND_URL=os.environ.get('FRONTEND_URL', 'https://localhost:5173'), # Dynamic via env var, defaults to localhost
VAPID_CLAIMS_EMAIL=os.environ.get('VAPID_CLAIMS_EMAIL', 'admin@reward-app.local'),
)
# Security: require SECRET_KEY and REFRESH_TOKEN_EXPIRY_DAYS from environment
@@ -81,6 +97,24 @@ try:
except ValueError:
raise RuntimeError('REFRESH_TOKEN_EXPIRY_DAYS must be an integer.')
_digest_token_secret = os.environ.get('DIGEST_TOKEN_SECRET')
if not _digest_token_secret:
raise RuntimeError(
'DIGEST_TOKEN_SECRET environment variable is required. '
'Set it to a random string (e.g. python -c "import secrets; print(secrets.token_urlsafe(64))")')
app.config['DIGEST_TOKEN_SECRET'] = _digest_token_secret
_vapid_public_key = os.environ.get('VAPID_PUBLIC_KEY')
_vapid_private_key = os.environ.get('VAPID_PRIVATE_KEY')
if not _vapid_public_key or not _vapid_private_key:
raise RuntimeError(
'VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY environment variables are required. '
'Generate a key pair with: python -c "from py_vapid import Vapid; v=Vapid(); '
'v.generate_keys(); print(v.public_key.public_bytes_raw().hex(), v.private_key.private_bytes_raw().hex())"'
)
app.config['VAPID_PUBLIC_KEY'] = _vapid_public_key
app.config['VAPID_PRIVATE_KEY'] = _vapid_private_key
@app.route("/version")
def api_version():
return jsonify({"version": get_full_version()})
@@ -115,6 +149,9 @@ createDefaultTasks()
createDefaultRewards()
start_background_threads()
start_deletion_scheduler()
start_digest_scheduler(app)
start_state_expiry_scheduler(app)
start_chore_expiry_notification_scheduler(app)
if __name__ == '__main__':
app.run(debug=False, host='0.0.0.0', port=5000, threaded=True)
+3
View File
@@ -6,6 +6,7 @@ class Child(BaseModel):
name: str
age: int | None = None
tasks: list[str] = field(default_factory=list)
routines: list[str] = field(default_factory=list)
rewards: list[str] = field(default_factory=list)
points: int = 0
image_id: str | None = None
@@ -17,6 +18,7 @@ class Child(BaseModel):
name=d.get('name'),
age=d.get('age'),
tasks=d.get('tasks', []),
routines=d.get('routines', []),
rewards=d.get('rewards', []),
points=d.get('points', 0),
image_id=d.get('image_id'),
@@ -32,6 +34,7 @@ class Child(BaseModel):
'name': self.name,
'age': self.age,
'tasks': self.tasks,
'routines': self.routines,
'rewards': self.rewards,
'points': self.points,
'image_id': self.image_id,
+4 -4
View File
@@ -16,15 +16,15 @@ class ChildOverride(BaseModel):
"""
child_id: str
entity_id: str
entity_type: Literal['task', 'reward', 'chore', 'kindness', 'penalty']
entity_type: Literal['task', 'reward', 'chore', 'kindness', 'penalty', 'routine']
custom_value: int
def __post_init__(self):
"""Validate custom_value range and entity_type."""
if self.custom_value < 0 or self.custom_value > 10000:
raise ValueError("custom_value must be between 0 and 10000")
if self.entity_type not in ['task', 'reward', 'chore', 'kindness', 'penalty']:
raise ValueError("entity_type must be 'task', 'reward', 'chore', 'kindness', or 'penalty'")
if self.entity_type not in ['task', 'reward', 'chore', 'kindness', 'penalty', 'routine']:
raise ValueError("entity_type must be 'task', 'reward', 'chore', 'kindness', 'penalty', or 'routine'")
@classmethod
def from_dict(cls, d: dict):
@@ -52,7 +52,7 @@ class ChildOverride(BaseModel):
def create_override(
child_id: str,
entity_id: str,
entity_type: Literal['task', 'reward', 'chore', 'kindness', 'penalty'],
entity_type: Literal['task', 'reward', 'chore', 'kindness', 'penalty', 'routine'],
custom_value: int
) -> 'ChildOverride':
"""Factory method to create a new override."""
+44
View File
@@ -0,0 +1,44 @@
from dataclasses import dataclass
from models.base import BaseModel
@dataclass
class DigestActionToken(BaseModel):
user_id: str
child_id: str
entity_id: str
entity_type: str # 'chore' or 'reward'
action: str # 'approve' or 'deny'
expires_at: str # ISO timestamp
used: bool = False
signature: str = ''
@classmethod
def from_dict(cls, d: dict) -> 'DigestActionToken':
return cls(
user_id=d.get('user_id'),
child_id=d.get('child_id'),
entity_id=d.get('entity_id'),
entity_type=d.get('entity_type'),
action=d.get('action'),
expires_at=d.get('expires_at'),
used=d.get('used', False),
signature=d.get('signature', ''),
id=d.get('id'),
created_at=d.get('created_at'),
updated_at=d.get('updated_at'),
)
def to_dict(self) -> dict:
base = super().to_dict()
base.update({
'user_id': self.user_id,
'child_id': self.child_id,
'entity_id': self.entity_id,
'entity_type': self.entity_type,
'action': self.action,
'expires_at': self.expires_at,
'used': self.used,
'signature': self.signature,
})
return base
+1 -1
View File
@@ -3,7 +3,7 @@ from typing import Literal, Optional
from models.base import BaseModel
PendingEntityType = Literal['chore', 'reward']
PendingEntityType = Literal['chore', 'reward', 'routine']
PendingStatus = Literal['pending', 'approved', 'rejected']
+29
View File
@@ -0,0 +1,29 @@
from dataclasses import dataclass
from models.base import BaseModel
@dataclass
class PushSubscription(BaseModel):
user_id: str
endpoint: str
keys: dict # {'p256dh': str, 'auth': str}
@classmethod
def from_dict(cls, d: dict) -> 'PushSubscription':
return cls(
user_id=d.get('user_id'),
endpoint=d.get('endpoint'),
keys=d.get('keys', {}),
id=d.get('id'),
created_at=d.get('created_at'),
updated_at=d.get('updated_at'),
)
def to_dict(self) -> dict:
base = super().to_dict()
base.update({
'user_id': self.user_id,
'endpoint': self.endpoint,
'keys': self.keys,
})
return base
+3
View File
@@ -9,6 +9,7 @@ class RefreshToken(BaseModel):
token_family: str = ''
expires_at: str = ''
is_used: bool = False
rotated_at: str | None = None
def to_dict(self):
return {
@@ -18,6 +19,7 @@ class RefreshToken(BaseModel):
'token_family': self.token_family,
'expires_at': self.expires_at,
'is_used': self.is_used,
'rotated_at': self.rotated_at,
}
@staticmethod
@@ -31,4 +33,5 @@ class RefreshToken(BaseModel):
token_family=data.get('token_family', ''),
expires_at=data.get('expires_at', ''),
is_used=data.get('is_used', False),
rotated_at=data.get('rotated_at'),
)
+32
View File
@@ -0,0 +1,32 @@
from dataclasses import dataclass
from models.base import BaseModel
@dataclass
class Routine(BaseModel):
name: str
points: int
image_id: str | None = None
user_id: str | None = None
@classmethod
def from_dict(cls, d: dict):
return cls(
name=d.get('name'),
points=d.get('points', 0),
image_id=d.get('image_id'),
user_id=d.get('user_id'),
id=d.get('id'),
created_at=d.get('created_at'),
updated_at=d.get('updated_at')
)
def to_dict(self):
base = super().to_dict()
base.update({
'name': self.name,
'points': self.points,
'image_id': self.image_id,
'user_id': self.user_id
})
return base
+29
View File
@@ -0,0 +1,29 @@
from dataclasses import dataclass
from models.base import BaseModel
@dataclass
class RoutineExtension(BaseModel):
child_id: str
routine_id: str
date: str
@classmethod
def from_dict(cls, d: dict) -> 'RoutineExtension':
return cls(
child_id=d.get('child_id'),
routine_id=d.get('routine_id'),
date=d.get('date'),
id=d.get('id'),
created_at=d.get('created_at'),
updated_at=d.get('updated_at'),
)
def to_dict(self) -> dict:
base = super().to_dict()
base.update({
'child_id': self.child_id,
'routine_id': self.routine_id,
'date': self.date,
})
return base
+32
View File
@@ -0,0 +1,32 @@
from dataclasses import dataclass
from models.base import BaseModel
@dataclass
class RoutineItem(BaseModel):
routine_id: str
name: str
image_id: str | None = None
order: int = 0
@classmethod
def from_dict(cls, d: dict):
return cls(
routine_id=d.get('routine_id'),
name=d.get('name'),
image_id=d.get('image_id'),
order=d.get('order', 0),
id=d.get('id'),
created_at=d.get('created_at'),
updated_at=d.get('updated_at')
)
def to_dict(self):
base = super().to_dict()
base.update({
'routine_id': self.routine_id,
'name': self.name,
'image_id': self.image_id,
'order': self.order
})
return base
+63
View File
@@ -0,0 +1,63 @@
from dataclasses import dataclass, field
from typing import Literal
from models.base import BaseModel
@dataclass
class RoutineSchedule(BaseModel):
child_id: str
routine_id: str
mode: Literal['days', 'interval']
day_configs: list = field(default_factory=list)
default_hour: int = 8
default_minute: int = 0
default_has_deadline: bool = True
interval_days: int = 2
anchor_date: str = ""
interval_has_deadline: bool = True
interval_hour: int = 0
interval_minute: int = 0
enabled: bool = True
@classmethod
def from_dict(cls, d: dict) -> 'RoutineSchedule':
return cls(
child_id=d.get('child_id'),
routine_id=d.get('routine_id'),
mode=d.get('mode', 'days'),
day_configs=d.get('day_configs', []),
default_hour=d.get('default_hour', 8),
default_minute=d.get('default_minute', 0),
default_has_deadline=d.get('default_has_deadline', True),
interval_days=d.get('interval_days', 2),
anchor_date=d.get('anchor_date', ''),
interval_has_deadline=d.get('interval_has_deadline', True),
interval_hour=d.get('interval_hour', 0),
interval_minute=d.get('interval_minute', 0),
enabled=d.get('enabled', True),
id=d.get('id'),
created_at=d.get('created_at'),
updated_at=d.get('updated_at'),
)
def to_dict(self) -> dict:
base = super().to_dict()
base.update({
'child_id': self.child_id,
'routine_id': self.routine_id,
'mode': self.mode,
'day_configs': self.day_configs,
'default_hour': self.default_hour,
'default_minute': self.default_minute,
'default_has_deadline': self.default_has_deadline,
'interval_days': self.interval_days,
'anchor_date': self.anchor_date,
'interval_has_deadline': self.interval_has_deadline,
'interval_hour': self.interval_hour,
'interval_minute': self.interval_minute,
'enabled': self.enabled,
})
return base
+15
View File
@@ -22,6 +22,11 @@ class User(BaseModel):
deletion_attempted_at: str | None = None
role: str = 'user'
token_version: int = 0
timezone: str | None = None
email_digest_enabled: bool = True
push_notifications_enabled: bool = True
tutorial_enabled: bool = True
tutorial_progress: dict = field(default_factory=dict)
@classmethod
def from_dict(cls, d: dict):
@@ -45,6 +50,11 @@ class User(BaseModel):
deletion_attempted_at=d.get('deletion_attempted_at'),
role=d.get('role', 'user'),
token_version=d.get('token_version', 0),
timezone=d.get('timezone'),
email_digest_enabled=d.get('email_digest_enabled', True),
push_notifications_enabled=d.get('push_notifications_enabled', True),
tutorial_enabled=d.get('tutorial_enabled', True),
tutorial_progress=d.get('tutorial_progress', {}) or {},
id=d.get('id'),
created_at=d.get('created_at'),
updated_at=d.get('updated_at')
@@ -73,5 +83,10 @@ class User(BaseModel):
'deletion_attempted_at': self.deletion_attempted_at,
'role': self.role,
'token_version': self.token_version,
'timezone': self.timezone,
'email_digest_enabled': self.email_digest_enabled,
'push_notifications_enabled': self.push_notifications_enabled,
'tutorial_enabled': self.tutorial_enabled,
'tutorial_progress': self.tutorial_progress,
})
return base
Binary file not shown.
+29 -17
View File
@@ -42,23 +42,35 @@ def create_admin_user(email: str, password: str, first_name: str, last_name: str
if __name__ == '__main__':
print("=== Create Admin User ===\n")
email = input("Email: ").strip()
password = input("Password: ").strip()
first_name = input("First name: ").strip()
last_name = input("Last name: ").strip()
if not all([email, password, first_name, last_name]):
print("Error: All fields are required")
sys.exit(1)
env_email = os.environ.get('ADMIN_EMAIL')
env_password = os.environ.get('ADMIN_PASSWORD')
env_first_name = os.environ.get('ADMIN_FIRST_NAME')
env_last_name = os.environ.get('ADMIN_LAST_NAME')
if env_email and env_password:
email = env_email
password = env_password
first_name = env_first_name or 'First Name'
last_name = env_last_name or 'Last Name'
print(f"Using environment variables for admin user '{email}'")
else:
email = input("Email: ").strip()
password = input("Password: ").strip()
first_name = input("First name: ").strip()
last_name = input("Last name: ").strip()
if not all([email, password, first_name, last_name]):
print("Error: All fields are required")
sys.exit(1)
confirm = input(f"\nCreate admin user '{email}'? (yes/no): ").strip().lower()
if confirm != 'yes':
print("Cancelled")
sys.exit(0)
if len(password) < 8:
print("Error: Password must be at least 8 characters")
sys.exit(1)
confirm = input(f"\nCreate admin user '{email}'? (yes/no): ").strip().lower()
if confirm == 'yes':
create_admin_user(email, password, first_name, last_name)
else:
print("Cancelled")
create_admin_user(email, password, first_name, last_name)
+85
View File
@@ -0,0 +1,85 @@
"""
Script to seed a test user account from environment variables.
Intended for use in the test/staging deployment pipeline.
Required environment variables:
SEED_EMAIL - User email address
SEED_PASSWORD - Plain-text password (will be hashed)
SEED_PIN - 4-6 digit parent PIN (stored as plain text)
SEED_FIRST_NAME - User first name
SEED_LAST_NAME - User last name
Usage:
python scripts/seed_test_user.py
"""
import sys
import os
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from db.db import users_db
from models.user import User
from werkzeug.security import generate_password_hash
from tinydb import Query
import uuid
def seed_test_user() -> bool:
email = os.environ.get('SEED_EMAIL', '').strip().lower()
password = os.environ.get('SEED_PASSWORD', '').strip()
pin = os.environ.get('SEED_PIN', '').strip()
first_name = os.environ.get('SEED_FIRST_NAME', '').strip()
last_name = os.environ.get('SEED_LAST_NAME', '').strip()
missing = [name for name, val in [
('SEED_EMAIL', email),
('SEED_PASSWORD', password),
('SEED_PIN', pin),
('SEED_FIRST_NAME', first_name),
('SEED_LAST_NAME', last_name),
] if not val]
if missing:
print(f"Error: Missing required environment variables: {', '.join(missing)}")
return False
if not (4 <= len(pin) <= 6 and pin.isdigit()):
print("Error: SEED_PIN must be 4-6 digits")
return False
hashed_password = generate_password_hash(password)
Query_ = Query()
existing = users_db.get(Query_.email == email)
if existing:
users_db.update(
{'password': hashed_password, 'pin': pin},
Query_.email == email
)
print(f"✓ Test user updated successfully")
print(f" Email: {email}")
print(f" Name: {first_name} {last_name}")
print(f" PIN: {'*' * len(pin)}")
else:
user = User(
id=str(uuid.uuid4()),
email=email,
first_name=first_name,
last_name=last_name,
password=hashed_password,
pin=pin,
verified=True,
role='user',
)
users_db.insert(user.to_dict())
print(f"✓ Test user created successfully")
print(f" Email: {email}")
print(f" Name: {first_name} {last_name}")
print(f" PIN: {'*' * len(pin)}")
return True
if __name__ == '__main__':
print("=== Seed Test User ===\n")
success = seed_test_user()
sys.exit(0 if success else 1)
+117
View File
@@ -0,0 +1,117 @@
"""
Script to trigger a digest email for a specific user via the admin API.
Usage:
python scripts/send_digest.py <email>
The script logs in as an admin using the ADMIN_EMAIL and ADMIN_PASSWORD
environment variables (defaults to localhost:5000).
Environment variables:
ADMIN_EMAIL - Admin account email (required)
ADMIN_PASSWORD - Admin account password (required)
API_BASE_URL - Base URL of the backend API (default: http://localhost:5000)
"""
import sys
import os
import json
import urllib.request
import urllib.error
# NOTE: When targeting a proxied frontend URL (e.g. nginx), append /api to the URL.
# e.g. API_BASE_URL=https://dev.chores.ryankegel.com/api
# Without /api, nginx will not forward requests to the backend and will return 405.
API_BASE_URL = os.environ.get('API_BASE_URL', 'http://localhost:5000').rstrip('/')
def _post(path: str, body: dict, cookie: str | None = None) -> tuple[int, dict]:
url = f"{API_BASE_URL}{path}"
data = json.dumps(body).encode()
headers = {'Content-Type': 'application/json'}
if cookie:
headers['Cookie'] = cookie
req = urllib.request.Request(url, data=data, headers=headers, method='POST')
try:
with urllib.request.urlopen(req) as resp:
return resp.status, json.loads(resp.read())
except urllib.error.HTTPError as e:
try:
return e.code, json.loads(e.read())
except Exception:
return e.code, {}
def main() -> None:
if len(sys.argv) != 2:
print('Usage: python scripts/send_digest.py <email>')
sys.exit(1)
target_email = sys.argv[1].strip()
admin_email = os.environ.get('ADMIN_EMAIL', '').strip()
admin_password = os.environ.get('ADMIN_PASSWORD', '').strip()
if not admin_email or not admin_password:
print('Error: ADMIN_EMAIL and ADMIN_PASSWORD environment variables are required')
sys.exit(1)
# Log in to get a session cookie
status, body = _post('/auth/login', {'email': admin_email, 'password': admin_password})
if status != 200:
print(f'Login failed ({status}): {body.get("error", body)}')
sys.exit(1)
# urllib doesn't automatically store cookies; we need to capture Set-Cookie manually.
# Re-do the login request to grab the cookie header.
login_url = f"{API_BASE_URL}/auth/login"
login_data = json.dumps({'email': admin_email, 'password': admin_password}).encode()
login_req = urllib.request.Request(
login_url,
data=login_data,
headers={'Content-Type': 'application/json'},
method='POST',
)
try:
with urllib.request.urlopen(login_req) as resp:
raw_cookies = resp.headers.get_all('Set-Cookie') or []
except urllib.error.HTTPError:
print('Login failed (could not retrieve cookie)')
sys.exit(1)
# Extract access_token cookie value
cookie_header = '; '.join(
part.split(';')[0] for part in raw_cookies
)
if not cookie_header:
print('Login succeeded but no cookies were returned')
sys.exit(1)
# Call the send-digest endpoint
status, body = _post(
'/admin/test/send-digest',
{'email': target_email},
cookie=cookie_header,
)
if status == 200:
items_sent = body.get('items_sent', 0)
if items_sent == 0:
print(f'No pending items found for {target_email} — no email sent.')
else:
print(f'Digest sent to {target_email} with {items_sent} item(s).')
elif status == 404 and body.get('code') == 'NOT_FOUND':
print('Error: This endpoint is disabled in the current environment (DB_ENV=production).')
sys.exit(1)
elif status == 404 and body.get('code') == 'USER_NOT_FOUND':
print(f'Error: No user found with email {target_email}')
sys.exit(1)
elif status == 403:
print(f'Error: {admin_email} is not an admin account.')
sys.exit(1)
else:
print(f'Error ({status}): {body.get("error", body)}')
sys.exit(1)
if __name__ == '__main__':
main()
+112
View File
@@ -0,0 +1,112 @@
"""
Script to trigger the chore expiry notification check for a specific user via the admin API.
Usage:
python scripts/trigger_chore_expiry.py <email>
The script logs in as an admin using the ADMIN_EMAIL and ADMIN_PASSWORD
environment variables (defaults to localhost:5000).
Environment variables:
ADMIN_EMAIL - Admin account email (required)
ADMIN_PASSWORD - Admin account password (required)
API_BASE_URL - Base URL of the backend API (default: http://localhost:5000)
NOTE: When targeting a proxied frontend URL (e.g. nginx), append /api to the URL.
e.g. API_BASE_URL=https://dev.chores.ryankegel.com/api
"""
import sys
import os
import json
import urllib.request
import urllib.error
API_BASE_URL = os.environ.get('API_BASE_URL', 'http://localhost:5000').rstrip('/')
def _post(path: str, body: dict, cookie: str | None = None) -> tuple[int, dict]:
url = f"{API_BASE_URL}{path}"
data = json.dumps(body).encode()
headers = {'Content-Type': 'application/json'}
if cookie:
headers['Cookie'] = cookie
req = urllib.request.Request(url, data=data, headers=headers, method='POST')
try:
with urllib.request.urlopen(req) as resp:
return resp.status, json.loads(resp.read())
except urllib.error.HTTPError as e:
try:
return e.code, json.loads(e.read())
except Exception:
return e.code, {}
def _login() -> str:
"""Log in as admin and return the cookie header string."""
admin_email = os.environ.get('ADMIN_EMAIL', '').strip()
admin_password = os.environ.get('ADMIN_PASSWORD', '').strip()
if not admin_email or not admin_password:
print('Error: ADMIN_EMAIL and ADMIN_PASSWORD environment variables are required')
sys.exit(1)
login_url = f"{API_BASE_URL}/auth/login"
login_data = json.dumps({'email': admin_email, 'password': admin_password}).encode()
login_req = urllib.request.Request(
login_url,
data=login_data,
headers={'Content-Type': 'application/json'},
method='POST',
)
try:
with urllib.request.urlopen(login_req) as resp:
raw_cookies = resp.headers.get_all('Set-Cookie') or []
except urllib.error.HTTPError as e:
print(f'Login failed ({e.code})')
sys.exit(1)
cookie_header = '; '.join(part.split(';')[0] for part in raw_cookies)
if not cookie_header:
print('Login succeeded but no cookies were returned')
sys.exit(1)
return cookie_header
def main() -> None:
if len(sys.argv) != 2:
print('Usage: python scripts/trigger_chore_expiry.py <email>')
sys.exit(1)
target_email = sys.argv[1].strip()
cookie = _login()
status, body = _post(
'/admin/test/trigger-chore-expiry',
{'email': target_email},
cookie=cookie,
)
if status == 200:
count = body.get('chores_notified', 0)
if count == 0:
print(f'No expiring chores found for {target_email} in the next 75 minutes — no push sent.')
else:
print(f'Push notification sent for {count} expiring chore(s) for {target_email}.')
elif status == 404 and body.get('code') == 'NOT_FOUND':
print('Error: This endpoint is disabled in the current environment (DB_ENV=production).')
sys.exit(1)
elif status == 404 and body.get('code') == 'USER_NOT_FOUND':
print(f'Error: No user found with email {target_email}')
sys.exit(1)
elif status == 403:
admin_email = os.environ.get('ADMIN_EMAIL', '')
print(f'Error: {admin_email} is not an admin account.')
sys.exit(1)
else:
print(f'Error ({status}): {body.get("error", body)}')
sys.exit(1)
if __name__ == '__main__':
main()
+3
View File
@@ -2,6 +2,9 @@ import os
os.environ['DB_ENV'] = 'test'
os.environ.setdefault('SECRET_KEY', 'test-secret-key')
os.environ.setdefault('REFRESH_TOKEN_EXPIRY_DAYS', '90')
os.environ.setdefault('DIGEST_TOKEN_SECRET', 'test-digest-secret')
os.environ.setdefault('VAPID_PUBLIC_KEY', 'test-vapid-public-key')
os.environ.setdefault('VAPID_PRIVATE_KEY', 'test-vapid-private-key')
import sys
import pytest
+244 -3
View File
@@ -1,11 +1,11 @@
import pytest
from werkzeug.security import generate_password_hash, check_password_hash
from flask import Flask
from api.auth_api import auth_api
from api.auth_api import auth_api, _hash_token
from db.db import users_db, refresh_tokens_db
from tinydb import Query
from models.user import User
from datetime import datetime
from datetime import datetime, timedelta, timezone
from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS
@pytest.fixture
@@ -188,4 +188,245 @@ def test_migration_script_hashes_plain_text_passwords():
# Check user2 password unchanged
user2_dict = users_db.get(Query().email == 'test2@example.com')
assert user2_dict['password'] == already_hashed
assert user2_dict['password'] == already_hashed
def _extract_cookie_value(response, cookie_name):
"""Extract a raw cookie value from a response's Set-Cookie headers."""
for cookie in response.headers.getlist('Set-Cookie'):
if cookie.startswith(f'{cookie_name}='):
return cookie.split(f'{cookie_name}=', 1)[1].split(';', 1)[0]
return None
def _create_verified_user(email, password):
"""Insert a verified user with the given credentials."""
users_db.remove(Query().email == email)
user = User(
first_name='Test',
last_name='User',
email=email,
password=generate_password_hash(password),
verified=True,
)
users_db.insert(user.to_dict())
return user
def _set_refresh_cookie(client, raw_token):
"""
Set the refresh token cookie on a test client so it is sent to /auth/refresh.
Production uses path='/api/auth' because the frontend calls /api/auth/refresh,
but the test fixture exposes the blueprint at /auth/refresh directly.
"""
client.set_cookie('refresh_token', raw_token, path='/')
def test_refresh_rotates_token(client):
"""A successful refresh marks the old token used and issues a new one in the same family."""
email = 'refresh-rotate@test.com'
password = 'password123'
_create_verified_user(email, password)
login_response = client.post('/auth/login', json={'email': email, 'password': password})
assert login_response.status_code == 200
old_refresh = _extract_cookie_value(login_response, 'refresh_token')
assert old_refresh
_set_refresh_cookie(client, old_refresh)
refresh_response = client.post('/auth/refresh')
assert refresh_response.status_code == 200
new_refresh = _extract_cookie_value(refresh_response, 'refresh_token')
assert new_refresh
assert new_refresh != old_refresh
user_dict = users_db.get(Query().email == email)
old_hash = _hash_token(old_refresh)
new_hash = _hash_token(new_refresh)
old_record = refresh_tokens_db.get(Query().token_hash == old_hash)
assert old_record is not None
assert old_record['is_used'] is True
assert old_record['rotated_at'] is not None
new_record = refresh_tokens_db.get(Query().token_hash == new_hash)
assert new_record is not None
assert new_record['is_used'] is False
assert new_record['token_family'] == old_record['token_family']
def test_refresh_reuse_only_invalidates_family(client):
"""Replay of a used refresh token only kills its own family, not other devices."""
email = 'refresh-family@test.com'
password = 'password123'
user = _create_verified_user(email, password)
# Device A logs in
client_a = client
login_a = client_a.post('/auth/login', json={'email': email, 'password': password})
assert login_a.status_code == 200
refresh_a = _extract_cookie_value(login_a, 'refresh_token')
# Device B logs in (separate client = separate cookie jar)
app = client_a.application
client_b = app.test_client()
login_b = client_b.post('/auth/login', json={'email': email, 'password': password})
assert login_b.status_code == 200
refresh_b = _extract_cookie_value(login_b, 'refresh_token')
assert refresh_a != refresh_b
# Device A refreshes normally
_set_refresh_cookie(client_a, refresh_a)
refresh_a_response = client_a.post('/auth/refresh')
assert refresh_a_response.status_code == 200
# Capture families before any purge so we can assert afterwards.
family_a = refresh_tokens_db.get(Query().token_hash == _hash_token(refresh_a))['token_family']
family_b = refresh_tokens_db.get(Query().token_hash == _hash_token(refresh_b))['token_family']
assert family_a != family_b
# Backdate rotation so the replay is past the grace period and treated as theft.
old_hash_a = _hash_token(refresh_a)
backdated = (datetime.now(timezone.utc) - timedelta(seconds=60)).isoformat()
refresh_tokens_db.update({'rotated_at': backdated}, Query().token_hash == old_hash_a)
# Attacker replays device A's old token
_set_refresh_cookie(client_a, refresh_a)
reuse_response = client_a.post('/auth/refresh')
assert reuse_response.status_code == 401
assert reuse_response.json['code'] == 'REFRESH_TOKEN_REUSE'
# Device B's refresh token should still be valid
_set_refresh_cookie(client_b, refresh_b)
refresh_b_response = client_b.post('/auth/refresh')
assert refresh_b_response.status_code == 200
# Only family A should be purged; family B should remain
remaining = refresh_tokens_db.search(Query().user_id == user.id)
remaining_families = {t['token_family'] for t in remaining}
assert family_a not in remaining_families
assert family_b in remaining_families
def test_refresh_reuse_within_grace_period_is_tolerated(client):
"""A replay within the grace period is treated as a race condition, not theft."""
email = 'refresh-race@test.com'
password = 'password123'
_create_verified_user(email, password)
login_response = client.post('/auth/login', json={'email': email, 'password': password})
assert login_response.status_code == 200
refresh_token = _extract_cookie_value(login_response, 'refresh_token')
# First refresh marks the token as used
_set_refresh_cookie(client, refresh_token)
first_refresh = client.post('/auth/refresh')
assert first_refresh.status_code == 200
# Immediate replay (same legitimate client racing) should succeed
_set_refresh_cookie(client, refresh_token)
race_response = client.post('/auth/refresh')
assert race_response.status_code == 200
# The family should still be valid
user_dict = users_db.get(Query().email == email)
family = refresh_tokens_db.get(Query().token_hash == _hash_token(refresh_token))['token_family']
family_tokens = refresh_tokens_db.search(
(Query().user_id == user_dict['id']) & (Query().token_family == family)
)
assert len(family_tokens) >= 1
assert any(t['is_used'] is False for t in family_tokens)
def test_refresh_reuse_after_grace_period_invalidates_family(client):
"""A replay after the grace period is treated as theft and kills only that family."""
email = 'refresh-theft@test.com'
password = 'password123'
user = _create_verified_user(email, password)
login_response = client.post('/auth/login', json={'email': email, 'password': password})
assert login_response.status_code == 200
refresh_token = _extract_cookie_value(login_response, 'refresh_token')
# Refresh once, then backdate the rotation timestamp past the grace period
_set_refresh_cookie(client, refresh_token)
client.post('/auth/refresh')
old_hash = _hash_token(refresh_token)
old_record = refresh_tokens_db.get(Query().token_hash == old_hash)
old_family = old_record['token_family']
backdated = (datetime.now(timezone.utc) - timedelta(seconds=60)).isoformat()
refresh_tokens_db.update({'rotated_at': backdated}, Query().token_hash == old_hash)
# Replay now should be detected as theft
_set_refresh_cookie(client, refresh_token)
reuse_response = client.post('/auth/refresh')
assert reuse_response.status_code == 401
assert reuse_response.json['code'] == 'REFRESH_TOKEN_REUSE'
remaining = refresh_tokens_db.search(Query().user_id == user.id)
remaining_families = {t['token_family'] for t in remaining}
assert old_family not in remaining_families
def test_refresh_reuse_without_rotated_at_invalidates_family(client):
"""Legacy used tokens without rotated_at are treated as theft, not race conditions."""
email = 'refresh-legacy@test.com'
password = 'password123'
user = _create_verified_user(email, password)
login_response = client.post('/auth/login', json={'email': email, 'password': password})
assert login_response.status_code == 200
refresh_token = _extract_cookie_value(login_response, 'refresh_token')
# Rotate the token, then strip rotated_at to simulate pre-migration data
_set_refresh_cookie(client, refresh_token)
client.post('/auth/refresh')
old_hash = _hash_token(refresh_token)
refresh_tokens_db.update({'rotated_at': None}, Query().token_hash == old_hash)
old_record = refresh_tokens_db.get(Query().token_hash == old_hash)
old_family = old_record['token_family']
# Replay should be treated as theft because rotated_at is missing
_set_refresh_cookie(client, refresh_token)
reuse_response = client.post('/auth/refresh')
assert reuse_response.status_code == 401
assert reuse_response.json['code'] == 'REFRESH_TOKEN_REUSE'
remaining = refresh_tokens_db.search(Query().user_id == user.id)
remaining_families = {t['token_family'] for t in remaining}
assert old_family not in remaining_families
def test_refresh_reuse_with_zero_grace_period(client):
"""A grace period of 0 means any replay of a used token is treated as theft."""
email = 'refresh-zero-grace@test.com'
password = 'password123'
user = _create_verified_user(email, password)
# Configure the app with a 0-second grace period
client.application.config['REFRESH_TOKEN_REUSE_GRACE_PERIOD_SECONDS'] = 0
login_response = client.post('/auth/login', json={'email': email, 'password': password})
assert login_response.status_code == 200
refresh_token = _extract_cookie_value(login_response, 'refresh_token')
# Rotate the token; rotated_at is within the normal default grace period
_set_refresh_cookie(client, refresh_token)
client.post('/auth/refresh')
old_hash = _hash_token(refresh_token)
old_record = refresh_tokens_db.get(Query().token_hash == old_hash)
old_family = old_record['token_family']
# Immediate replay should still be theft with a 0-second grace period
_set_refresh_cookie(client, refresh_token)
reuse_response = client.post('/auth/refresh')
assert reuse_response.status_code == 401
assert reuse_response.json['code'] == 'REFRESH_TOKEN_REUSE'
remaining = refresh_tokens_db.search(Query().user_id == user.id)
remaining_families = {t['token_family'] for t in remaining}
assert old_family not in remaining_families
+340 -3
View File
@@ -4,13 +4,14 @@ import os
from flask import Flask
from api.child_api import child_api
import api.child_api as child_api_module
from api.auth_api import auth_api
from db.db import child_db, reward_db, task_db, users_db, chore_schedules_db, task_extensions_db
from db.db import child_db, reward_db, task_db, users_db, chore_schedules_db, task_extensions_db, pending_confirmations_db
from tinydb import Query
from models.child import Child
import jwt
from werkzeug.security import generate_password_hash
from datetime import date as date_type
from datetime import date as date_type, datetime, timedelta, timezone
# Test user credentials
@@ -382,6 +383,7 @@ def _setup_sched_child_and_tasks(task_db, child_db):
})
chore_schedules_db.remove(Query().child_id == CHILD_SCHED_ID)
task_extensions_db.remove(Query().child_id == CHILD_SCHED_ID)
pending_confirmations_db.remove(Query().child_id == CHILD_SCHED_ID)
def test_list_child_tasks_always_has_schedule_and_extension_date_keys(client):
@@ -439,6 +441,29 @@ def test_list_child_tasks_returns_extension_date_when_set(client):
assert tasks[TASK_GOOD_ID]['extension_date'] == today
def test_list_child_tasks_returns_extension_date_for_yesterday(client):
"""Regression: extension inserted for yesterday's date must still be returned.
Previously, list-tasks used date_type.today() on the server to look up
extensions, causing a timezone mismatch when the client's local date
differed from the server's UTC date. The fix removes the date filter so
any stored extension is returned regardless of which date it was created
for.
"""
_setup_sched_child_and_tasks(task_db, child_db)
from datetime import date as date_type_local, timedelta
yesterday = (date_type_local.today() - timedelta(days=1)).isoformat()
task_extensions_db.insert({
'id': 'ext-yesterday',
'child_id': CHILD_SCHED_ID,
'task_id': TASK_GOOD_ID,
'date': yesterday,
})
resp = client.get(f'/child/{CHILD_SCHED_ID}/list-tasks')
tasks = {t['id']: t for t in resp.get_json()['tasks']}
assert tasks[TASK_GOOD_ID]['extension_date'] == yesterday
def test_list_child_tasks_extension_date_null_when_not_set(client):
"""Good chore with no extension returns extension_date=null."""
_setup_sched_child_and_tasks(task_db, child_db)
@@ -491,4 +516,316 @@ def test_list_child_tasks_no_server_side_filtering(client):
returned_ids = {t['id'] for t in resp.get_json()['tasks']}
# Both good tasks must be present; server never filters based on schedule/time
assert TASK_GOOD_ID in returned_ids
assert extra_id in returned_ids
assert extra_id in returned_ids
def test_list_child_tasks_shows_pending_for_today(client):
"""A chore confirmed today should return pending_status='pending'."""
_setup_sched_child_and_tasks(task_db, child_db)
now_ts = datetime.now(timezone.utc).timestamp()
pending_confirmations_db.insert({
'id': 'pend_today_chore',
'child_id': CHILD_SCHED_ID,
'entity_id': TASK_GOOD_ID,
'entity_type': 'chore',
'user_id': 'testuserid',
'status': 'pending',
'approved_at': None,
'created_at': now_ts,
'updated_at': now_ts,
})
resp = client.get(f'/child/{CHILD_SCHED_ID}/list-tasks')
assert resp.status_code == 200
tasks = {t['id']: t for t in resp.get_json()['tasks']}
assert tasks[TASK_GOOD_ID]['pending_status'] == 'pending'
assert tasks[TASK_GOOD_ID]['approved_at'] is None
def test_list_child_tasks_clears_stale_approved_and_pending(client):
"""Yesterday's chore pending/approved records should be reset and ignored."""
_setup_sched_child_and_tasks(task_db, child_db)
old_ts = (datetime.now(timezone.utc) - timedelta(days=2)).timestamp()
old_approved = (datetime.now(timezone.utc) - timedelta(days=2)).isoformat()
pending_confirmations_db.insert({
'id': 'pend_old_chore_pending',
'child_id': CHILD_SCHED_ID,
'entity_id': TASK_GOOD_ID,
'entity_type': 'chore',
'user_id': 'testuserid',
'status': 'pending',
'approved_at': None,
'created_at': old_ts,
'updated_at': old_ts,
})
resp = client.get(f'/child/{CHILD_SCHED_ID}/list-tasks')
assert resp.status_code == 200
tasks = {t['id']: t for t in resp.get_json()['tasks']}
assert tasks[TASK_GOOD_ID]['pending_status'] is None
assert tasks[TASK_GOOD_ID]['approved_at'] is None
# Reinsert as stale approved and ensure it is also cleared.
pending_confirmations_db.insert({
'id': 'pend_old_chore_approved',
'child_id': CHILD_SCHED_ID,
'entity_id': TASK_GOOD_ID,
'entity_type': 'chore',
'user_id': 'testuserid',
'status': 'approved',
'approved_at': old_approved,
'created_at': old_ts,
'updated_at': old_ts,
})
resp2 = client.get(f'/child/{CHILD_SCHED_ID}/list-tasks')
assert resp2.status_code == 200
tasks2 = {t['id']: t for t in resp2.get_json()['tasks']}
assert tasks2[TASK_GOOD_ID]['pending_status'] is None
assert tasks2[TASK_GOOD_ID]['approved_at'] is None
def test_list_child_tasks_local_day_keeps_approved_across_utc_rollover(client, monkeypatch):
"""Approved chore should remain completed when UTC date differs but user-local day matches.
Example: 2026-05-11T22:30:00Z is 2026-05-12 local day in Pacific/Kiritimati (UTC+14).
"""
_setup_sched_child_and_tasks(task_db, child_db)
# Force deterministic local-day basis for this endpoint call.
monkeypatch.setattr(
child_api_module,
'_get_user_today_local',
lambda user_id: ('2026-05-12', 'Pacific/Kiritimati'),
)
pending_confirmations_db.insert({
'id': 'pend_local_day_approved',
'child_id': CHILD_SCHED_ID,
'entity_id': TASK_GOOD_ID,
'entity_type': 'chore',
'user_id': 'testuserid',
'status': 'approved',
'approved_at': '2026-05-11T22:30:00+00:00',
'created_at': 1778538600,
'updated_at': 1778538600,
})
resp = client.get(f'/child/{CHILD_SCHED_ID}/list-tasks')
assert resp.status_code == 200
tasks = {t['id']: t for t in resp.get_json()['tasks']}
assert tasks[TASK_GOOD_ID]['pending_status'] == 'approved'
assert tasks[TASK_GOOD_ID]['approved_at'] == '2026-05-11T22:30:00+00:00'
def test_confirm_chore_allows_when_previous_pending_is_stale(client):
"""A stale pending chore record from a prior day must not block confirm-chore."""
old_ts = (datetime.now(timezone.utc) - timedelta(days=2)).timestamp()
task_db.insert({'id': 't_stale_chore', 'name': 'Stale Chore', 'points': 2, 'type': 'chore', 'user_id': 'testuserid'})
child_db.insert({
'id': 'child_stale_chore',
'name': 'Stale Chore Kid',
'age': 8,
'points': 0,
'tasks': ['t_stale_chore'],
'rewards': [],
'user_id': 'testuserid',
})
pending_confirmations_db.insert({
'id': 'pend_stale_chore',
'child_id': 'child_stale_chore',
'entity_id': 't_stale_chore',
'entity_type': 'chore',
'user_id': 'testuserid',
'status': 'pending',
'approved_at': None,
'created_at': old_ts,
'updated_at': old_ts,
})
resp = client.post('/child/child_stale_chore/confirm-chore', json={'task_id': 't_stale_chore'})
assert resp.status_code == 200
active_pending = pending_confirmations_db.search(
(Query().child_id == 'child_stale_chore') & (Query().entity_id == 't_stale_chore') &
(Query().entity_type == 'chore') & (Query().status == 'pending')
)
assert len(active_pending) == 1
assert active_pending[0].get('id') != 'pend_stale_chore'
pending_confirmations_db.remove(Query().child_id == 'child_stale_chore')
child_db.remove(Query().id == 'child_stale_chore')
task_db.remove(Query().id == 't_stale_chore')
# ---------------------------------------------------------------------------
# request-reward: duplicate guard
# ---------------------------------------------------------------------------
def test_request_reward_duplicate_returns_409(client):
"""Requesting the same reward twice without resolution returns 409 Conflict."""
reward_db.insert({'id': 'r_dup', 'name': 'Duplicate Reward', 'cost': 5, 'user_id': 'testuserid'})
child_db.insert({
'id': 'child_dup',
'name': 'Dupe Kid',
'age': 8,
'points': 20,
'tasks': [],
'rewards': ['r_dup'],
'user_id': 'testuserid',
})
first = client.post('/child/child_dup/request-reward', json={'reward_id': 'r_dup'})
assert first.status_code == 200
second = client.post('/child/child_dup/request-reward', json={'reward_id': 'r_dup'})
assert second.status_code == 409
assert second.get_json()['code'] == 'DUPLICATE_REWARD_REQUEST'
# Cleanup
pending_confirmations_db.remove(Query().child_id == 'child_dup')
child_db.remove(Query().id == 'child_dup')
reward_db.remove(Query().id == 'r_dup')
def test_request_reward_allows_new_when_stale_pending_exists(client):
"""A stale pending reward from a prior day must not block a new request."""
old_ts = (datetime.now(timezone.utc) - timedelta(days=2)).timestamp()
reward_db.insert({'id': 'r_stale', 'name': 'Stale Reward', 'cost': 5, 'user_id': 'testuserid'})
child_db.insert({
'id': 'child_stale',
'name': 'Stale Kid',
'age': 8,
'points': 20,
'tasks': [],
'rewards': ['r_stale'],
'user_id': 'testuserid',
})
pending_confirmations_db.insert({
'id': 'pend_stale_reward',
'child_id': 'child_stale',
'entity_id': 'r_stale',
'entity_type': 'reward',
'user_id': 'testuserid',
'status': 'pending',
'approved_at': None,
'created_at': old_ts,
'updated_at': old_ts,
})
resp = client.post('/child/child_stale/request-reward', json={'reward_id': 'r_stale'})
assert resp.status_code == 200
active_pending = pending_confirmations_db.search(
(Query().child_id == 'child_stale') & (Query().entity_id == 'r_stale') &
(Query().entity_type == 'reward') & (Query().status == 'pending')
)
assert len(active_pending) == 1
assert active_pending[0].get('id') != 'pend_stale_reward'
pending_confirmations_db.remove(Query().child_id == 'child_stale')
child_db.remove(Query().id == 'child_stale')
reward_db.remove(Query().id == 'r_stale')
def test_reward_status_ignores_stale_pending_reward(client):
"""reward-status should not mark a reward as redeeming if pending is stale."""
old_ts = (datetime.now(timezone.utc) - timedelta(days=2)).timestamp()
reward_db.insert({'id': 'r_status_stale', 'name': 'Status Reward', 'cost': 4, 'user_id': 'testuserid'})
child_db.insert({
'id': 'child_status_stale',
'name': 'Status Kid',
'age': 9,
'points': 10,
'tasks': [],
'rewards': ['r_status_stale'],
'user_id': 'testuserid',
})
pending_confirmations_db.insert({
'id': 'pend_status_stale',
'child_id': 'child_status_stale',
'entity_id': 'r_status_stale',
'entity_type': 'reward',
'user_id': 'testuserid',
'status': 'pending',
'approved_at': None,
'created_at': old_ts,
'updated_at': old_ts,
})
resp = client.get('/child/child_status_stale/reward-status')
assert resp.status_code == 200
statuses = {s['id']: s for s in resp.get_json()['reward_status']}
assert statuses['r_status_stale']['redeeming'] is False
assert pending_confirmations_db.get(Query().id == 'pend_status_stale') is None
pending_confirmations_db.remove(Query().child_id == 'child_status_stale')
child_db.remove(Query().id == 'child_status_stale')
reward_db.remove(Query().id == 'r_status_stale')
# ---------------------------------------------------------------------------
# deny-reward-request endpoint
# ---------------------------------------------------------------------------
def test_deny_reward_request_removes_pending(client):
"""Denying a reward request removes the pending confirmation and fires REQUEST_CANCELLED."""
reward_db.insert({'id': 'r_deny1', 'name': 'Deny Reward', 'cost': 5, 'user_id': 'testuserid'})
child_db.insert({
'id': 'child_deny1',
'name': 'Deny Kid',
'age': 9,
'points': 10,
'tasks': [],
'rewards': ['r_deny1'],
'user_id': 'testuserid',
})
pending_confirmations_db.insert({
'id': 'pend_deny1',
'child_id': 'child_deny1',
'entity_id': 'r_deny1',
'entity_type': 'reward',
'user_id': 'testuserid',
'status': 'pending',
'approved_at': None,
})
resp = client.post('/child/child_deny1/deny-reward-request', json={'reward_id': 'r_deny1'})
assert resp.status_code == 200
remaining = pending_confirmations_db.get(
(Query().child_id == 'child_deny1') & (Query().entity_id == 'r_deny1')
)
assert remaining is None
# Cleanup
child_db.remove(Query().id == 'child_deny1')
reward_db.remove(Query().id == 'r_deny1')
def test_deny_reward_request_already_resolved_returns_200(client):
"""If no pending request exists, denying returns 200 with an informational message."""
resp = client.post('/child/child_gone/deny-reward-request', json={'reward_id': 'r_gone'})
assert resp.status_code == 200
data = resp.get_json()
assert 'already been resolved' in data['message']
def test_deny_reward_request_requires_auth(client):
"""Deny-reward-request endpoint rejects unauthenticated requests."""
# Create a fresh unauthenticated client
from flask import Flask
from api.child_api import child_api as child_api_bp
from api.auth_api import auth_api as auth_api_bp
from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS
app2 = Flask(__name__)
app2.register_blueprint(child_api_bp)
app2.register_blueprint(auth_api_bp, url_prefix='/auth')
app2.config['TESTING'] = True
app2.config['SECRET_KEY'] = TEST_SECRET_KEY
app2.config['REFRESH_TOKEN_EXPIRY_DAYS'] = TEST_REFRESH_TOKEN_EXPIRY_DAYS
with app2.test_client() as anon:
resp = anon.post('/child/someid/deny-reward-request', json={'reward_id': 'r1'})
assert resp.status_code == 401
+19
View File
@@ -3,6 +3,7 @@ from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS
import os
from werkzeug.security import generate_password_hash
from datetime import date as date_type
import api.child_api as child_api_module
from flask import Flask
from api.child_api import child_api
@@ -71,6 +72,24 @@ def setup_child_and_chore(child_name='TestChild', age=8, chore_points=10):
return child['id'], 'chore1'
def test_local_day_iso_check_handles_utc_rollover():
# 22:30 UTC on 2026-05-11 is 12:30 local on 2026-05-12 in Pacific/Kiritimati (UTC+14).
assert child_api_module._is_iso_timestamp_on_local_day(
'2026-05-11T22:30:00+00:00',
'2026-05-12',
'Pacific/Kiritimati',
)
def test_local_day_epoch_check_handles_utc_rollover():
# Same instant as above represented as epoch seconds.
assert child_api_module._is_epoch_timestamp_on_local_day(
1778538600,
'2026-05-12',
'Pacific/Kiritimati',
)
# ---------------------------------------------------------------------------
# Child Confirm Flow
# ---------------------------------------------------------------------------
@@ -0,0 +1,544 @@
"""Tests for the chore expiry notification scheduler."""
import os
import pytest
from datetime import datetime, timedelta, timezone
from unittest.mock import patch, MagicMock
from flask import Flask
from tinydb import Query
from utils.chore_expiry_notification_scheduler import (
get_expiring_chores_for_user,
_build_push_payload,
send_chore_expiry_notifications_for_user,
run_chore_expiry_check,
)
from utils.schedule_utils import interval_hits_today, is_scheduled_today, get_due_time_today
from db.db import users_db, child_db, task_db, chore_schedules_db, pending_confirmations_db
from tests.conftest import TEST_SECRET_KEY
USER_ID = "expiry_notif_user"
CHILD_ID = "expiry_notif_child"
CHILD_ID_2 = "expiry_notif_child_2"
TASK_ID = "expiry_notif_task"
TASK_ID_2 = "expiry_notif_task_2"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _seed_user(push_enabled: bool = True, verified: bool = True):
users_db.remove(Query().id == USER_ID)
users_db.insert({
"id": USER_ID,
"first_name": "Notify",
"last_name": "Tester",
"email": f"{USER_ID}@example.com",
"password": "hashed",
"verified": verified,
"role": "user",
"image_id": None,
"marked_for_deletion": False,
"timezone": "UTC",
"email_digest_enabled": False,
"push_notifications_enabled": push_enabled,
})
def _seed_child(child_id: str = CHILD_ID, task_ids: list = None):
if task_ids is None:
task_ids = [TASK_ID]
child_db.remove(Query().id == child_id)
child_db.insert({
"id": child_id,
"user_id": USER_ID,
"name": "Alex" if child_id == CHILD_ID else "Sam",
"age": 8,
"points": 0,
"tasks": task_ids,
"rewards": [],
"image_id": None,
})
def _seed_task(task_id: str = TASK_ID, name: str = "Clean Room"):
task_db.remove(Query().id == task_id)
task_db.insert({
"id": task_id,
"user_id": USER_ID,
"name": name,
"points": 10,
"type": "chore",
"image_id": None,
})
def _seed_schedule(
child_id: str = CHILD_ID,
task_id: str = TASK_ID,
mode: str = "days",
day_configs: list = None,
default_has_deadline: bool = True,
default_hour: int = 21,
default_minute: int = 0,
enabled: bool = True,
interval_days: int = 1,
anchor_date: str = "",
interval_has_deadline: bool = True,
interval_hour: int = 21,
interval_minute: int = 0,
):
"""Seed a chore schedule. For 'days' mode, day_configs defaults to all 7 days."""
if day_configs is None:
day_configs = [
{"day": d, "hour": default_hour, "minute": default_minute}
for d in range(7)
]
chore_schedules_db.remove(
(Query().child_id == child_id) & (Query().task_id == task_id)
)
chore_schedules_db.insert({
"id": f"sched_{child_id}_{task_id}",
"child_id": child_id,
"task_id": task_id,
"mode": mode,
"day_configs": day_configs,
"default_hour": default_hour,
"default_minute": default_minute,
"default_has_deadline": default_has_deadline,
"interval_days": interval_days,
"anchor_date": anchor_date,
"interval_has_deadline": interval_has_deadline,
"interval_hour": interval_hour,
"interval_minute": interval_minute,
"enabled": enabled,
})
def _seed_confirmation(child_id: str = CHILD_ID, task_id: str = TASK_ID, status: str = "pending"):
pending_confirmations_db.remove(
(Query().child_id == child_id) & (Query().entity_id == task_id)
)
pending_confirmations_db.insert({
"id": f"conf_{child_id}_{task_id}",
"user_id": USER_ID,
"child_id": child_id,
"entity_id": task_id,
"entity_type": "chore",
"status": status,
"approved_at": None,
"created_at": 0,
"updated_at": 0,
})
def _cleanup():
for cid in (CHILD_ID, CHILD_ID_2):
child_db.remove(Query().id == cid)
chore_schedules_db.remove(Query().child_id == cid)
pending_confirmations_db.remove(Query().child_id == cid)
for tid in (TASK_ID, TASK_ID_2):
task_db.remove(Query().id == tid)
users_db.remove(Query().id == USER_ID)
def _now_with_deadline_in_window(minutes_ahead: int = 30) -> tuple[datetime, int, int]:
"""Return (now_dt, hour, minute) so that deadline = now + minutes_ahead."""
now = datetime.now(timezone.utc)
deadline = now + timedelta(minutes=minutes_ahead)
return now, deadline.hour, deadline.minute
@pytest.fixture
def app():
flask_app = Flask(__name__)
flask_app.config['TESTING'] = True
flask_app.config['SECRET_KEY'] = TEST_SECRET_KEY
return flask_app
# ---------------------------------------------------------------------------
# schedule_utils unit tests
# ---------------------------------------------------------------------------
class TestIntervalHitsToday:
def test_same_day_as_anchor_hits(self):
from datetime import date
d = date(2026, 4, 22)
assert interval_hits_today("2026-04-22", 3, d) is True
def test_interval_day_hits(self):
from datetime import date
assert interval_hits_today("2026-04-22", 3, date(2026, 4, 25)) is True
def test_non_interval_day_misses(self):
from datetime import date
assert interval_hits_today("2026-04-22", 3, date(2026, 4, 24)) is False
def test_before_anchor_misses(self):
from datetime import date
assert interval_hits_today("2026-04-22", 1, date(2026, 4, 21)) is False
def test_empty_anchor_hits_today(self):
from datetime import date
d = date(2026, 4, 22)
assert interval_hits_today("", 1, d) is True
class TestIsScheduledToday:
def test_days_mode_matching_weekday(self):
from datetime import date
# 2026-04-22 is Wednesday → JS weekday 3 (Sun=0..Sat=6)
schedule = {"mode": "days", "enabled": True, "day_configs": [{"day": 3}]}
assert is_scheduled_today(schedule, date(2026, 4, 22)) is True
def test_days_mode_non_matching_weekday(self):
from datetime import date
schedule = {"mode": "days", "enabled": True, "day_configs": [{"day": 1}]}
assert is_scheduled_today(schedule, date(2026, 4, 22)) is False
def test_paused_schedule_always_true(self):
from datetime import date
schedule = {"mode": "days", "enabled": False, "day_configs": []}
assert is_scheduled_today(schedule, date(2026, 4, 22)) is True
def test_interval_mode_hit(self):
from datetime import date
schedule = {
"mode": "interval", "enabled": True,
"interval_days": 1, "anchor_date": "2026-04-22",
}
assert is_scheduled_today(schedule, date(2026, 4, 22)) is True
def test_interval_mode_miss(self):
from datetime import date
schedule = {
"mode": "interval", "enabled": True,
"interval_days": 3, "anchor_date": "2026-04-22",
}
assert is_scheduled_today(schedule, date(2026, 4, 23)) is False
class TestGetDueTimeToday:
def test_days_mode_returns_due_time(self):
from datetime import date
# Wednesday → JS 3 (Sun=0..Sat=6)
schedule = {
"mode": "days", "enabled": True,
"default_has_deadline": True,
"day_configs": [{"day": 3, "hour": 21, "minute": 0}],
}
assert get_due_time_today(schedule, date(2026, 4, 22)) == (21, 0)
def test_days_mode_anytime_returns_none(self):
from datetime import date
schedule = {
"mode": "days", "enabled": True,
"default_has_deadline": False,
"day_configs": [{"day": 4, "hour": 21, "minute": 0}],
}
assert get_due_time_today(schedule, date(2026, 4, 22)) is None
def test_days_mode_wrong_day_returns_none(self):
from datetime import date
schedule = {
"mode": "days", "enabled": True,
"default_has_deadline": True,
"day_configs": [{"day": 1, "hour": 21, "minute": 0}],
}
assert get_due_time_today(schedule, date(2026, 4, 22)) is None
def test_paused_returns_none(self):
from datetime import date
schedule = {
"mode": "days", "enabled": False,
"default_has_deadline": True,
"day_configs": [{"day": 4, "hour": 21, "minute": 0}],
}
assert get_due_time_today(schedule, date(2026, 4, 22)) is None
def test_interval_mode_returns_due_time(self):
from datetime import date
schedule = {
"mode": "interval", "enabled": True,
"interval_days": 1, "anchor_date": "2026-04-22",
"interval_has_deadline": True,
"interval_hour": 20, "interval_minute": 30,
}
assert get_due_time_today(schedule, date(2026, 4, 22)) == (20, 30)
def test_interval_mode_anytime_returns_none(self):
from datetime import date
schedule = {
"mode": "interval", "enabled": True,
"interval_days": 1, "anchor_date": "2026-04-22",
"interval_has_deadline": False,
"interval_hour": 20, "interval_minute": 30,
}
assert get_due_time_today(schedule, date(2026, 4, 22)) is None
# ---------------------------------------------------------------------------
# get_expiring_chores_for_user tests
# ---------------------------------------------------------------------------
class TestGetExpiringChoresForUser:
def setup_method(self):
_cleanup()
_seed_user()
_seed_child()
_seed_task()
def teardown_method(self):
_cleanup()
def test_chore_in_window_included(self, app):
now, h, m = _now_with_deadline_in_window(minutes_ahead=30)
_seed_schedule(default_hour=h, default_minute=m)
with app.app_context():
result = get_expiring_chores_for_user(USER_ID, "UTC", now)
assert len(result) == 1
assert result[0]['task_id'] == TASK_ID
assert result[0]['child_id'] == CHILD_ID
def test_chore_deadline_past_excluded(self, app):
# Deadline 10 minutes in the past
now = datetime.now(timezone.utc)
past = now - timedelta(minutes=10)
_seed_schedule(default_hour=past.hour, default_minute=past.minute)
with app.app_context():
result = get_expiring_chores_for_user(USER_ID, "UTC", now)
assert result == []
def test_chore_deadline_beyond_window_excluded(self, app):
# Deadline 80 minutes ahead (beyond 75-min window)
now, h, m = _now_with_deadline_in_window(minutes_ahead=80)
_seed_schedule(default_hour=h, default_minute=m)
with app.app_context():
result = get_expiring_chores_for_user(USER_ID, "UTC", now)
assert result == []
def test_anytime_chore_excluded(self, app):
now, h, m = _now_with_deadline_in_window(minutes_ahead=30)
_seed_schedule(default_has_deadline=False, default_hour=h, default_minute=m)
with app.app_context():
result = get_expiring_chores_for_user(USER_ID, "UTC", now)
assert result == []
def test_wrong_day_excluded(self, app):
from datetime import date
now = datetime.now(timezone.utc)
# Schedule only on a day that is NOT today
today_js = (now.weekday() + 1) % 7
wrong_day = (today_js + 1) % 7
h = (now + timedelta(minutes=30)).hour
m = (now + timedelta(minutes=30)).minute
_seed_schedule(
day_configs=[{"day": wrong_day, "hour": h, "minute": m}],
default_hour=h,
default_minute=m,
)
with app.app_context():
result = get_expiring_chores_for_user(USER_ID, "UTC", now)
assert result == []
def test_pending_confirmation_excludes_chore(self, app):
now, h, m = _now_with_deadline_in_window(minutes_ahead=30)
_seed_schedule(default_hour=h, default_minute=m)
_seed_confirmation(status="pending")
with app.app_context():
result = get_expiring_chores_for_user(USER_ID, "UTC", now)
assert result == []
def test_approved_confirmation_excludes_chore(self, app):
now, h, m = _now_with_deadline_in_window(minutes_ahead=30)
_seed_schedule(default_hour=h, default_minute=m)
_seed_confirmation(status="approved")
with app.app_context():
result = get_expiring_chores_for_user(USER_ID, "UTC", now)
assert result == []
def test_no_schedule_excluded(self, app):
now = datetime.now(timezone.utc)
# No schedule seeded
with app.app_context():
result = get_expiring_chores_for_user(USER_ID, "UTC", now)
assert result == []
def test_paused_schedule_excluded(self, app):
now, h, m = _now_with_deadline_in_window(minutes_ahead=30)
_seed_schedule(enabled=False, default_hour=h, default_minute=m)
with app.app_context():
result = get_expiring_chores_for_user(USER_ID, "UTC", now)
assert result == []
def test_interval_mode_hit_included(self, app):
now, h, m = _now_with_deadline_in_window(minutes_ahead=30)
today_iso = now.strftime("%Y-%m-%d")
_seed_schedule(
mode="interval",
interval_days=1,
anchor_date=today_iso,
interval_has_deadline=True,
interval_hour=h,
interval_minute=m,
)
with app.app_context():
result = get_expiring_chores_for_user(USER_ID, "UTC", now)
assert len(result) == 1
def test_interval_mode_miss_excluded(self, app):
now = datetime.now(timezone.utc)
h = (now + timedelta(minutes=30)).hour
m = (now + timedelta(minutes=30)).minute
# Anchor yesterday with interval_days=2 → not today
yesterday = (now - timedelta(days=1)).strftime("%Y-%m-%d")
_seed_schedule(
mode="interval",
interval_days=2,
anchor_date=yesterday,
interval_has_deadline=True,
interval_hour=h,
interval_minute=m,
)
with app.app_context():
result = get_expiring_chores_for_user(USER_ID, "UTC", now)
assert result == []
def test_multiple_children_grouped(self, app):
_seed_child(CHILD_ID_2, task_ids=[TASK_ID_2])
_seed_task(TASK_ID_2, name="Take Out Trash")
now, h, m = _now_with_deadline_in_window(minutes_ahead=30)
_seed_schedule(child_id=CHILD_ID, task_id=TASK_ID, default_hour=h, default_minute=m)
_seed_schedule(child_id=CHILD_ID_2, task_id=TASK_ID_2, default_hour=h, default_minute=m)
with app.app_context():
result = get_expiring_chores_for_user(USER_ID, "UTC", now)
assert len(result) == 2
child_ids = {r['child_id'] for r in result}
assert CHILD_ID in child_ids
assert CHILD_ID_2 in child_ids
# ---------------------------------------------------------------------------
# _build_push_payload tests
# ---------------------------------------------------------------------------
class TestBuildPushPayload:
def test_single_chore_sets_title_and_deep_link(self):
expiring = [{"child_id": "c1", "child_name": "Alex", "task_id": "t1", "task_name": "Clean Room"}]
payload = _build_push_payload(expiring)
assert payload['title'] == "Chore ending soon: Clean Room"
assert payload['child_id'] == "c1"
assert payload['entity_id'] == "t1"
assert payload['type'] == 'chore_expiring_soon'
def test_multiple_chores_uses_generic_title(self):
expiring = [
{"child_id": "c1", "child_name": "Alex", "task_id": "t1", "task_name": "Clean Room"},
{"child_id": "c1", "child_name": "Alex", "task_id": "t2", "task_name": "Make Bed"},
]
payload = _build_push_payload(expiring)
assert payload['title'] == "Chores ending soon"
assert payload['child_id'] is None
assert payload['entity_id'] is None
def test_body_groups_by_child(self):
expiring = [
{"child_id": "c1", "child_name": "Alex", "task_id": "t1", "task_name": "Clean Room"},
{"child_id": "c1", "child_name": "Alex", "task_id": "t2", "task_name": "Make Bed"},
{"child_id": "c2", "child_name": "Sam", "task_id": "t3", "task_name": "Trash"},
]
payload = _build_push_payload(expiring)
assert "Alex: Clean Room, Make Bed" in payload['body']
assert "Sam: Trash" in payload['body']
def test_no_approve_deny_tokens(self):
expiring = [{"child_id": "c1", "child_name": "Alex", "task_id": "t1", "task_name": "Clean Room"}]
payload = _build_push_payload(expiring)
assert 'approve_token' not in payload
assert 'deny_token' not in payload
# ---------------------------------------------------------------------------
# send_chore_expiry_notifications_for_user tests
# ---------------------------------------------------------------------------
class TestSendChoreExpiryNotificationsForUser:
def setup_method(self):
_cleanup()
_seed_user()
_seed_child()
_seed_task()
def teardown_method(self):
_cleanup()
def test_sends_push_when_chore_expiring(self, app):
now, h, m = _now_with_deadline_in_window(minutes_ahead=30)
_seed_schedule(default_hour=h, default_minute=m)
with app.app_context():
with patch('utils.chore_expiry_notification_scheduler.send_push_to_user') as mock_push:
count = send_chore_expiry_notifications_for_user(USER_ID, "UTC")
assert count == 1
mock_push.assert_called_once()
payload = mock_push.call_args[0][1]
assert payload['type'] == 'chore_expiring_soon'
def test_no_push_when_no_expiring_chores(self, app):
# No schedule seeded → nothing expiring
with app.app_context():
with patch('utils.chore_expiry_notification_scheduler.send_push_to_user') as mock_push:
count = send_chore_expiry_notifications_for_user(USER_ID, "UTC")
assert count == 0
mock_push.assert_not_called()
# ---------------------------------------------------------------------------
# run_chore_expiry_check tests
# ---------------------------------------------------------------------------
class TestRunChoreExpiryCheck:
def setup_method(self):
_cleanup()
def teardown_method(self):
_cleanup()
def test_skips_when_db_env_is_e2e(self, app):
with patch.dict(os.environ, {'DB_ENV': 'e2e'}):
with patch('utils.chore_expiry_notification_scheduler.send_push_to_user') as mock_push:
run_chore_expiry_check(app)
mock_push.assert_not_called()
def test_skips_user_with_push_disabled(self, app):
_seed_user(push_enabled=False)
_seed_child()
_seed_task()
now, h, m = _now_with_deadline_in_window(minutes_ahead=30)
_seed_schedule(default_hour=h, default_minute=m)
with app.app_context():
with patch('utils.chore_expiry_notification_scheduler.send_push_to_user') as mock_push:
run_chore_expiry_check(app)
mock_push.assert_not_called()
def test_skips_unverified_user(self, app):
_seed_user(verified=False)
_seed_child()
_seed_task()
now, h, m = _now_with_deadline_in_window(minutes_ahead=30)
_seed_schedule(default_hour=h, default_minute=m)
with app.app_context():
with patch('utils.chore_expiry_notification_scheduler.send_push_to_user') as mock_push:
run_chore_expiry_check(app)
mock_push.assert_not_called()
def test_sends_push_for_eligible_user(self, app):
_seed_user(push_enabled=True, verified=True)
_seed_child()
_seed_task()
now, h, m = _now_with_deadline_in_window(minutes_ahead=30)
_seed_schedule(default_hour=h, default_minute=m)
with app.app_context():
with patch('utils.chore_expiry_notification_scheduler.send_push_to_user') as mock_push:
run_chore_expiry_check(app)
mock_push.assert_called_once()
+292
View File
@@ -0,0 +1,292 @@
import os
import jwt
import pytest
from datetime import datetime, timedelta, timezone
from flask import Flask
from werkzeug.security import generate_password_hash
from tinydb import Query
from api.digest_action_api import digest_action_api
from db.db import (
users_db, child_db, task_db, reward_db,
pending_confirmations_db, digest_action_tokens_db,
tracking_events_db,
)
from utils.digest_token import (
create_action_token, create_unsubscribe_token, validate_unsubscribe_token
)
from tests.conftest import TEST_SECRET_KEY
TEST_USER_ID = "daa_user_id"
OTHER_USER_ID = "daa_other_user_id"
TEST_CHILD_ID = "daa_child_id"
TEST_TASK_ID = "daa_task_id"
TEST_REWARD_ID = "daa_reward_id"
FRONTEND_URL = "http://localhost:5173"
def make_auth_token(user_id: str) -> str:
"""Create a JWT access token for the given user_id, for use in tests."""
payload = {
'user_id': user_id,
'token_version': 0,
'exp': datetime.now(timezone.utc) + timedelta(minutes=15),
}
return jwt.encode(payload, TEST_SECRET_KEY, algorithm='HS256')
def seed_data():
users_db.remove(Query().id == TEST_USER_ID)
users_db.remove(Query().id == OTHER_USER_ID)
child_db.remove(Query().id == TEST_CHILD_ID)
task_db.remove(Query().id == TEST_TASK_ID)
reward_db.remove(Query().id == TEST_REWARD_ID)
pending_confirmations_db.remove(Query().user_id == TEST_USER_ID)
digest_action_tokens_db.truncate()
tracking_events_db.remove(Query().user_id == TEST_USER_ID)
users_db.insert({
"id": TEST_USER_ID,
"first_name": "Digest",
"last_name": "Tester",
"email": "digest@example.com",
"password": generate_password_hash("password"),
"verified": True,
"role": "user",
"image_id": None,
"marked_for_deletion": False,
"marked_for_deletion_at": None,
"email_digest_enabled": True,
"timezone": "America/New_York",
"token_version": 0,
})
users_db.insert({
"id": OTHER_USER_ID,
"first_name": "Other",
"last_name": "User",
"email": "other@example.com",
"password": generate_password_hash("password"),
"verified": True,
"role": "user",
"image_id": None,
"marked_for_deletion": False,
"marked_for_deletion_at": None,
"email_digest_enabled": True,
"timezone": "America/New_York",
"token_version": 0,
})
child_db.insert({
"id": TEST_CHILD_ID,
"user_id": TEST_USER_ID,
"name": "Test Child",
"age": 8,
"tasks": [TEST_TASK_ID],
"rewards": [TEST_REWARD_ID],
"points": 100,
"image_id": None,
})
task_db.insert({
"id": TEST_TASK_ID,
"user_id": TEST_USER_ID,
"name": "Clean Room",
"points": 10,
"type": "chore",
"description": "",
"image_id": None,
})
reward_db.insert({
"id": TEST_REWARD_ID,
"user_id": TEST_USER_ID,
"name": "Extra Screen Time",
"cost": 20,
"description": "",
"image_id": None,
})
def add_pending_chore():
pending_confirmations_db.insert({
"id": "pending_chore_id",
"user_id": TEST_USER_ID,
"child_id": TEST_CHILD_ID,
"entity_id": TEST_TASK_ID,
"entity_type": "chore",
"status": "pending",
"created_at": "2024-01-01T10:00:00+00:00",
})
def add_pending_reward():
pending_confirmations_db.insert({
"id": "pending_reward_id",
"user_id": TEST_USER_ID,
"child_id": TEST_CHILD_ID,
"entity_id": TEST_REWARD_ID,
"entity_type": "reward",
"status": "pending",
"created_at": "2024-01-01T10:00:00+00:00",
})
@pytest.fixture
def client():
app = Flask(__name__)
app.register_blueprint(digest_action_api)
app.config['TESTING'] = True
app.config['SECRET_KEY'] = TEST_SECRET_KEY
app.config['FRONTEND_URL'] = FRONTEND_URL
seed_data()
with app.test_client() as c:
yield c
seed_data()
@pytest.fixture
def auth_client(client):
"""Client pre-authenticated as TEST_USER_ID via JWT cookie."""
client.set_cookie('access_token', make_auth_token(TEST_USER_ID))
return client
class TestHandleDigestActionGet:
"""GET /digest-action/<token_id>: validates token, redirects with digestToken param, executes nothing."""
def test_redirects_to_parent_view_with_digest_token(self, client):
add_pending_chore()
token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_TASK_ID, 'chore', 'approve')
res = client.get(f'/digest-action/{token.id}')
assert res.status_code == 302
location = res.headers['Location']
assert f'/parent/{TEST_CHILD_ID}' in location
assert f'digestToken={token.id}' in location
assert 'scrollTo=' in location
def test_does_not_execute_action(self, client):
"""GET must not change any data; action is deferred to the authenticated POST."""
add_pending_chore()
token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_TASK_ID, 'chore', 'approve')
client.get(f'/digest-action/{token.id}')
child = child_db.get(Query().id == TEST_CHILD_ID)
assert child['points'] == 100 # unchanged
def test_does_not_consume_token(self, client):
"""GET can be called multiple times without consuming the token."""
add_pending_chore()
token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_TASK_ID, 'chore', 'approve')
res1 = client.get(f'/digest-action/{token.id}')
res2 = client.get(f'/digest-action/{token.id}')
assert res1.status_code == 302
assert res2.status_code == 302
def test_invalid_token_returns_400(self, client):
res = client.get('/digest-action/nonexistent-token-id')
assert res.status_code == 400
def test_already_resolved_chore_still_redirects(self, client):
"""GET still redirects even if the pending chore no longer exists; token is valid."""
token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_TASK_ID, 'chore', 'approve')
# No pending chore added — already resolved
res = client.get(f'/digest-action/{token.id}')
assert res.status_code == 302
class TestExecuteDigestActionPost:
"""POST /digest-action/<token_id>: requires auth, consumes token, executes action."""
def test_approve_chore_awards_points(self, auth_client):
add_pending_chore()
token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_TASK_ID, 'chore', 'approve')
res = auth_client.post(f'/digest-action/{token.id}')
assert res.status_code == 200
child = child_db.get(Query().id == TEST_CHILD_ID)
assert child['points'] == 110 # 100 + 10
def test_deny_chore_removes_pending(self, auth_client):
add_pending_chore()
token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_TASK_ID, 'chore', 'deny')
res = auth_client.post(f'/digest-action/{token.id}')
assert res.status_code == 200
pending = pending_confirmations_db.get(
(Query().child_id == TEST_CHILD_ID) & (Query().entity_id == TEST_TASK_ID)
)
assert pending is None
def test_approve_reward_deducts_points(self, auth_client):
add_pending_reward()
token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_REWARD_ID, 'reward', 'approve')
res = auth_client.post(f'/digest-action/{token.id}')
assert res.status_code == 200
child = child_db.get(Query().id == TEST_CHILD_ID)
assert child['points'] == 80 # 100 - 20
def test_deny_reward_removes_pending(self, auth_client):
add_pending_reward()
token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_REWARD_ID, 'reward', 'deny')
res = auth_client.post(f'/digest-action/{token.id}')
assert res.status_code == 200
pending = pending_confirmations_db.get(
(Query().child_id == TEST_CHILD_ID) & (Query().entity_id == TEST_REWARD_ID)
)
assert pending is None
def test_response_contains_success_payload(self, auth_client):
add_pending_chore()
token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_TASK_ID, 'chore', 'approve')
res = auth_client.post(f'/digest-action/{token.id}')
assert res.status_code == 200
data = res.get_json()
assert data['success'] is True
assert data['child_id'] == TEST_CHILD_ID
assert data['entity_id'] == TEST_TASK_ID
assert data['action'] == 'approve'
def test_unauthenticated_returns_401(self, client):
add_pending_chore()
token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_TASK_ID, 'chore', 'approve')
res = client.post(f'/digest-action/{token.id}')
assert res.status_code == 401
def test_wrong_user_returns_403(self, client):
"""A token created for TEST_USER_ID cannot be used by OTHER_USER_ID."""
add_pending_chore()
token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_TASK_ID, 'chore', 'approve')
client.set_cookie('access_token', make_auth_token(OTHER_USER_ID))
res = client.post(f'/digest-action/{token.id}')
assert res.status_code == 403
def test_invalid_token_returns_400(self, auth_client):
res = auth_client.post('/digest-action/nonexistent-token-id')
assert res.status_code == 400
def test_used_token_returns_400(self, auth_client):
add_pending_chore()
token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_TASK_ID, 'chore', 'approve')
auth_client.post(f'/digest-action/{token.id}') # first use — consumes token
res = auth_client.post(f'/digest-action/{token.id}') # second use
assert res.status_code == 400
def test_get_after_post_returns_400(self, auth_client):
"""Once the token is consumed via POST, the GET redirect should also fail."""
add_pending_chore()
token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_TASK_ID, 'chore', 'approve')
auth_client.post(f'/digest-action/{token.id}') # consumes token
res = auth_client.get(f'/digest-action/{token.id}')
assert res.status_code == 400
class TestHandleDigestUnsubscribe:
def test_valid_token_unsubscribes_user(self, client):
token = create_unsubscribe_token(TEST_USER_ID)
res = client.get(f'/digest-unsubscribe/{token}')
assert res.status_code == 200
user = users_db.get(Query().id == TEST_USER_ID)
assert user['email_digest_enabled'] is False
def test_invalid_token_returns_400(self, client):
res = client.get('/digest-unsubscribe/garbage-token')
assert res.status_code == 400
def test_response_contains_unsubscribed_message(self, client):
token = create_unsubscribe_token(TEST_USER_ID)
res = client.get(f'/digest-unsubscribe/{token}')
assert b'unsubscribed' in res.data.lower()
+262
View File
@@ -0,0 +1,262 @@
"""Tests for the digest scheduler and email HTML generation."""
import os
import pytest
from unittest.mock import patch, MagicMock
from flask import Flask
from werkzeug.security import generate_password_hash
from tinydb import Query
from utils.digest_scheduler import send_digests
from utils.email_sender import send_digest_email
from db.db import users_db, pending_confirmations_db, child_db, task_db, reward_db
from tests.conftest import TEST_SECRET_KEY
SCHED_USER_ID = "sched_test_user"
SCHED_EMAIL = "schedtest@example.com"
SCHED_CHILD_ID = "sched_child_id"
SCHED_TASK_ID = "sched_task_id"
SCHED_REWARD_ID = "sched_reward_id"
def seed_scheduler_data(
verified=True,
email_digest_enabled=True,
timezone="UTC",
has_pending=True,
):
users_db.remove(Query().id == SCHED_USER_ID)
child_db.remove(Query().id == SCHED_CHILD_ID)
task_db.remove(Query().id == SCHED_TASK_ID)
reward_db.remove(Query().id == SCHED_REWARD_ID)
pending_confirmations_db.remove(Query().user_id == SCHED_USER_ID)
users_db.insert({
"id": SCHED_USER_ID,
"first_name": "Sched",
"last_name": "Tester",
"email": SCHED_EMAIL,
"password": generate_password_hash("schedpass"),
"verified": verified,
"role": "user",
"image_id": None,
"marked_for_deletion": False,
"marked_for_deletion_at": None,
"timezone": timezone,
"email_digest_enabled": email_digest_enabled,
})
child_db.insert({
"id": SCHED_CHILD_ID,
"user_id": SCHED_USER_ID,
"name": "Sched Child",
"age": 8,
"points": 50,
"tasks": [SCHED_TASK_ID],
"rewards": [SCHED_REWARD_ID],
"image_id": None,
})
task_db.insert({
"id": SCHED_TASK_ID,
"user_id": SCHED_USER_ID,
"name": "Clean Room",
"points": 10,
"type": "chore",
"image_id": None,
})
reward_db.insert({
"id": SCHED_REWARD_ID,
"user_id": SCHED_USER_ID,
"name": "Movie Night",
"cost": 20,
"image_id": None,
})
if has_pending:
pending_confirmations_db.insert({
"id": "sched_pending_id",
"user_id": SCHED_USER_ID,
"child_id": SCHED_CHILD_ID,
"entity_id": SCHED_TASK_ID,
"entity_type": "chore",
"status": "pending",
"approved_at": None,
})
def cleanup_scheduler_data():
users_db.remove(Query().id == SCHED_USER_ID)
child_db.remove(Query().id == SCHED_CHILD_ID)
task_db.remove(Query().id == SCHED_TASK_ID)
reward_db.remove(Query().id == SCHED_REWARD_ID)
pending_confirmations_db.remove(Query().user_id == SCHED_USER_ID)
@pytest.fixture
def app():
flask_app = Flask(__name__)
flask_app.config['TESTING'] = True
flask_app.config['SECRET_KEY'] = TEST_SECRET_KEY
flask_app.config['FRONTEND_URL'] = 'http://localhost:5173'
flask_app.config['MAIL_DEFAULT_SENDER'] = 'no-reply@reward-app.local'
return flask_app
class TestSendDigests:
def setup_method(self):
cleanup_scheduler_data()
def teardown_method(self):
cleanup_scheduler_data()
def test_sends_digest_to_eligible_user_at_9pm(self, app):
"""Identifies verified, digest-enabled users whose local time is 9 pm and sends digest."""
seed_scheduler_data()
with patch('utils.digest_scheduler._get_local_hour', return_value=21), \
patch('utils.email_sender.Mail') as mock_mail:
send_digests(app)
assert mock_mail.return_value.send.called
def test_skips_user_with_no_pending_items(self, app):
"""Digest is not sent if there are no pending items."""
seed_scheduler_data(has_pending=False)
with patch('utils.digest_scheduler._get_local_hour', return_value=21), \
patch('utils.email_sender.Mail') as mock_mail:
send_digests(app)
assert not mock_mail.return_value.send.called
def test_skips_unverified_user(self, app):
"""Digest is not sent to unverified users."""
seed_scheduler_data(verified=False)
with patch('utils.digest_scheduler._get_local_hour', return_value=21), \
patch('utils.email_sender.Mail') as mock_mail:
send_digests(app)
assert not mock_mail.return_value.send.called
def test_skips_user_with_digest_disabled(self, app):
"""Digest is not sent to users who have email_digest_enabled == False."""
seed_scheduler_data(email_digest_enabled=False)
with patch('utils.digest_scheduler._get_local_hour', return_value=21), \
patch('utils.email_sender.Mail') as mock_mail:
send_digests(app)
assert not mock_mail.return_value.send.called
def test_skips_user_not_at_9pm(self, app):
"""Digest is not sent when the user's local time is not 21."""
seed_scheduler_data()
with patch('utils.digest_scheduler._get_local_hour', return_value=10), \
patch('utils.email_sender.Mail') as mock_mail:
send_digests(app)
assert not mock_mail.return_value.send.called
def test_reads_timezone_and_falls_back_to_utc(self, app):
"""_get_local_hour uses User.timezone; None falls back to UTC."""
from utils.digest_scheduler import _get_local_hour
from datetime import datetime, timezone as tz
utc_hour = datetime.now(tz.utc).hour
assert _get_local_hour(None) == utc_hour
# A real timezone that differs from UTC (New York is UTC-4 or UTC-5)
result = _get_local_hour("America/New_York")
assert 0 <= result <= 23
def test_skips_when_db_env_is_e2e(self, app):
"""Digest scheduler does nothing in the e2e test environment."""
seed_scheduler_data()
original = os.environ.get('DB_ENV')
try:
os.environ['DB_ENV'] = 'e2e'
with patch('utils.email_sender.Mail') as mock_mail:
send_digests(app)
assert not mock_mail.return_value.send.called
finally:
if original is not None:
os.environ['DB_ENV'] = original
else:
os.environ.pop('DB_ENV', None)
class TestDigestEmailHtml:
"""Tests for the HTML content of send_digest_email."""
@pytest.fixture
def app_ctx(self, app):
with app.app_context():
yield
def _build_items(self):
return [
{
'child_name': 'Alice',
'entity_name': 'Clean Room',
'entity_type': 'chore',
'view_url': 'http://localhost:5173/parent/child1?scrollTo=task1&entityType=chore',
'approve_url': 'http://localhost:5173/api/digest-action/approve_tok',
'deny_url': 'http://localhost:5173/api/digest-action/deny_tok',
'child_id': 'child1',
'entity_id': 'task1',
},
{
'child_name': 'Alice',
'entity_name': 'Movie Night',
'entity_type': 'reward',
'view_url': 'http://localhost:5173/parent/child1?scrollTo=reward1&entityType=reward',
'approve_url': 'http://localhost:5173/api/digest-action/approve_tok2',
'deny_url': 'http://localhost:5173/api/digest-action/deny_tok2',
'child_id': 'child1',
'entity_id': 'reward1',
},
]
def test_email_contains_child_section(self, app_ctx):
"""Email HTML contains a section for each child."""
with patch('utils.email_sender.Mail') as mock_mail:
send_digest_email(SCHED_EMAIL, self._build_items(), 'unsub_tok')
msg = mock_mail.return_value.send.call_args[0][0]
assert 'Alice' in msg.html
def test_email_contains_item_names(self, app_ctx):
"""Email HTML lists each pending item's name."""
with patch('utils.email_sender.Mail') as mock_mail:
send_digest_email(SCHED_EMAIL, self._build_items(), 'unsub_tok')
msg = mock_mail.return_value.send.call_args[0][0]
assert 'Clean Room' in msg.html
assert 'Movie Night' in msg.html
def test_email_contains_approve_and_deny_links(self, app_ctx):
"""Email HTML contains Approve and Deny links for each item."""
with patch('utils.email_sender.Mail') as mock_mail:
send_digest_email(SCHED_EMAIL, self._build_items(), 'unsub_tok')
msg = mock_mail.return_value.send.call_args[0][0]
assert 'approve_tok' in msg.html
assert 'deny_tok' in msg.html
assert 'Approve' in msg.html
assert 'Deny' in msg.html
def test_email_contains_view_links(self, app_ctx):
"""Email HTML contains a View link for each item."""
with patch('utils.email_sender.Mail') as mock_mail:
send_digest_email(SCHED_EMAIL, self._build_items(), 'unsub_tok')
msg = mock_mail.return_value.send.call_args[0][0]
assert 'View' in msg.html
assert 'scrollTo=task1' in msg.html
def test_email_contains_unsubscribe_link(self, app_ctx):
"""Email HTML footer contains the unsubscribe link."""
with patch('utils.email_sender.Mail') as mock_mail:
send_digest_email(SCHED_EMAIL, self._build_items(), 'unsub_tok')
msg = mock_mail.return_value.send.call_args[0][0]
assert 'unsub_tok' in msg.html
assert 'Unsubscribe' in msg.html
def test_approve_link_styled_green(self, app_ctx):
"""Approve links use green color styling."""
with patch('utils.email_sender.Mail') as mock_mail:
send_digest_email(SCHED_EMAIL, self._build_items(), 'unsub_tok')
msg = mock_mail.return_value.send.call_args[0][0]
# Find the Approve link and verify green color is adjacent
assert '#22c55e' in msg.html # green used for Approve
def test_deny_link_styled_red(self, app_ctx):
"""Deny links use red color styling."""
with patch('utils.email_sender.Mail') as mock_mail:
send_digest_email(SCHED_EMAIL, self._build_items(), 'unsub_tok')
msg = mock_mail.return_value.send.call_args[0][0]
assert '#ef4444' in msg.html # red used for Deny
+133
View File
@@ -0,0 +1,133 @@
import os
import time
import pytest
from tinydb import Query
from db.db import digest_action_tokens_db
from utils.digest_token import (
create_action_token,
validate_and_consume_token,
create_unsubscribe_token,
validate_unsubscribe_token,
)
def cleanup():
digest_action_tokens_db.truncate()
@pytest.fixture(autouse=True)
def clear_tokens():
cleanup()
yield
cleanup()
class TestCreateActionToken:
def test_returns_token_with_correct_fields(self):
token = create_action_token('u1', 'c1', 'e1', 'chore', 'approve')
assert token.user_id == 'u1'
assert token.child_id == 'c1'
assert token.entity_id == 'e1'
assert token.entity_type == 'chore'
assert token.action == 'approve'
assert token.used is False
assert token.signature
def test_persists_to_db(self):
token = create_action_token('u1', 'c1', 'e1', 'chore', 'approve')
stored = digest_action_tokens_db.get(Query().id == token.id)
assert stored is not None
assert stored['entity_type'] == 'chore'
def test_different_tokens_have_unique_ids(self):
t1 = create_action_token('u1', 'c1', 'e1', 'chore', 'approve')
t2 = create_action_token('u1', 'c1', 'e1', 'reward', 'deny')
assert t1.id != t2.id
class TestValidateAndConsumeToken:
def test_valid_token_is_returned_and_marked_used(self):
token = create_action_token('u1', 'c1', 'e1', 'chore', 'approve')
result = validate_and_consume_token(token.id)
assert result is not None
assert result.id == token.id
stored = digest_action_tokens_db.get(Query().id == token.id)
assert stored['used'] is True
def test_nonexistent_token_returns_none(self):
assert validate_and_consume_token('nonexistent-id') is None
def test_already_used_token_returns_none(self):
token = create_action_token('u1', 'c1', 'e1', 'chore', 'approve')
validate_and_consume_token(token.id) # first use
result = validate_and_consume_token(token.id) # second use
assert result is None
def test_expired_token_returns_none(self):
# Create a token that is already expired
from datetime import datetime, timedelta, timezone
from db.digest_action_tokens import insert_token
from models.digest_action_token import DigestActionToken
import uuid, json, hmac, hashlib
token_id = str(uuid.uuid4())
expires_at = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat()
payload = {
'id': token_id,
'user_id': 'u1',
'child_id': 'c1',
'entity_id': 'e1',
'entity_type': 'chore',
'action': 'approve',
'expires_at': expires_at,
}
secret = os.environ.get('DIGEST_TOKEN_SECRET')
canonical = json.dumps(payload, sort_keys=True, separators=(',', ':'))
sig = hmac.new(secret.encode(), canonical.encode(), hashlib.sha256).hexdigest()
token = DigestActionToken(
id=token_id, user_id='u1', child_id='c1', entity_id='e1',
entity_type='chore', action='approve', expires_at=expires_at,
used=False, signature=sig,
)
insert_token(token)
assert validate_and_consume_token(token_id) is None
def test_tampered_signature_returns_none(self):
token = create_action_token('u1', 'c1', 'e1', 'chore', 'approve')
# Tamper the signature in DB
digest_action_tokens_db.update(
{'signature': 'deadbeef' * 8},
Query().id == token.id
)
assert validate_and_consume_token(token.id) is None
class TestUnsubscribeToken:
def test_create_and_validate(self):
token = create_unsubscribe_token('user123')
assert token
result = validate_unsubscribe_token(token)
assert result == 'user123'
def test_invalid_token_returns_none(self):
assert validate_unsubscribe_token('garbage-token') is None
def test_tampered_token_returns_none(self):
token = create_unsubscribe_token('user123')
# Change one character
tampered = token[:-1] + ('A' if token[-1] != 'A' else 'B')
assert validate_unsubscribe_token(tampered) is None
def test_expired_token_returns_none(self):
"""Build a token with a past expiry timestamp by mocking time."""
import base64, hmac, hashlib
user_id = 'user_expired'
expiry_ts = int(time.time()) - 1 # already expired
payload_str = f"{user_id}:{expiry_ts}"
secret = os.environ.get('DIGEST_TOKEN_SECRET')
sig = hmac.new(secret.encode(), payload_str.encode(), hashlib.sha256).hexdigest()
raw = f"{payload_str}:{sig}"
token = base64.urlsafe_b64encode(raw.encode()).decode()
assert validate_unsubscribe_token(token) is None
+170
View File
@@ -0,0 +1,170 @@
import os
import pytest
from flask import Flask
from werkzeug.security import generate_password_hash
from tinydb import Query
from api.push_subscription_api import push_subscription_api
from api.auth_api import auth_api
from db.db import users_db, push_subscriptions_db
from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS
TEST_EMAIL = "pushtest@example.com"
TEST_PASSWORD = "pushpassword123"
TEST_USER_ID = "push_test_user_id"
TEST_ENDPOINT = "https://fcm.googleapis.com/fcm/send/test-endpoint-abc"
TEST_KEYS = {"p256dh": "BNgz3XcMv1", "auth": "abc123"}
def seed_user():
users_db.remove(Query().email == TEST_EMAIL)
users_db.insert({
"id": TEST_USER_ID,
"first_name": "Push",
"last_name": "Tester",
"email": TEST_EMAIL,
"password": generate_password_hash(TEST_PASSWORD),
"verified": True,
"image_id": None,
"marked_for_deletion": False,
"marked_for_deletion_at": None,
"role": "user",
"timezone": None,
"email_digest_enabled": True,
})
def cleanup():
users_db.remove(Query().email == TEST_EMAIL)
push_subscriptions_db.remove(Query().user_id == TEST_USER_ID)
@pytest.fixture
def client():
app = Flask(__name__)
app.register_blueprint(push_subscription_api)
app.register_blueprint(auth_api, url_prefix='/auth')
app.config['TESTING'] = True
app.config['SECRET_KEY'] = TEST_SECRET_KEY
app.config['REFRESH_TOKEN_EXPIRY_DAYS'] = TEST_REFRESH_TOKEN_EXPIRY_DAYS
app.config['VAPID_PUBLIC_KEY'] = 'test-vapid-public-key'
app.config['FRONTEND_URL'] = 'http://localhost:5173'
cleanup()
seed_user()
with app.test_client() as c:
yield c
cleanup()
@pytest.fixture
def auth_client(client):
client.post('/auth/login', json={"email": TEST_EMAIL, "password": TEST_PASSWORD})
return client
class TestVapidKey:
def test_returns_public_key(self, client):
res = client.get('/push-vapid-key')
assert res.status_code == 200
assert res.get_json()['public_key'] == 'test-vapid-public-key'
def test_unauthenticated_ok(self, client):
# VAPID key endpoint is public
res = client.get('/push-vapid-key')
assert res.status_code == 200
class TestSubscribe:
def test_subscribe_stores_subscription(self, auth_client):
res = auth_client.post('/push-subscription', json={
"endpoint": TEST_ENDPOINT,
"keys": TEST_KEYS,
})
assert res.status_code == 200
data = res.get_json()
assert 'id' in data
saved = push_subscriptions_db.get(
(Query().user_id == TEST_USER_ID) & (Query().endpoint == TEST_ENDPOINT)
)
assert saved is not None
def test_subscribe_updates_timezone(self, auth_client):
auth_client.post('/push-subscription', json={
"endpoint": TEST_ENDPOINT,
"keys": TEST_KEYS,
"timezone": "America/New_York",
})
user = users_db.get(Query().id == TEST_USER_ID)
assert user['timezone'] == 'America/New_York'
def test_subscribe_upserts_same_endpoint(self, auth_client):
auth_client.post('/push-subscription', json={"endpoint": TEST_ENDPOINT, "keys": TEST_KEYS})
auth_client.post('/push-subscription', json={"endpoint": TEST_ENDPOINT, "keys": TEST_KEYS})
count = len(push_subscriptions_db.search(
(Query().user_id == TEST_USER_ID) & (Query().endpoint == TEST_ENDPOINT)
))
assert count == 1
def test_subscribe_requires_endpoint(self, auth_client):
res = auth_client.post('/push-subscription', json={"keys": TEST_KEYS})
assert res.status_code == 400
def test_subscribe_requires_keys(self, auth_client):
res = auth_client.post('/push-subscription', json={"endpoint": TEST_ENDPOINT})
assert res.status_code == 400
def test_subscribe_requires_p256dh_and_auth(self, auth_client):
res = auth_client.post('/push-subscription', json={
"endpoint": TEST_ENDPOINT,
"keys": {"p256dh": "only-one-key"},
})
assert res.status_code == 400
def test_subscribe_requires_auth(self, client):
res = client.post('/push-subscription', json={
"endpoint": TEST_ENDPOINT,
"keys": TEST_KEYS,
})
assert res.status_code == 401
def test_subscribe_multiple_endpoints_per_user(self, auth_client):
"""Multiple subscriptions can coexist for the same user (one per device)."""
endpoint2 = "https://fcm.googleapis.com/fcm/send/second-device-endpoint"
keys2 = {"p256dh": "BNgz3XcMv2", "auth": "def456"}
auth_client.post('/push-subscription', json={"endpoint": TEST_ENDPOINT, "keys": TEST_KEYS})
auth_client.post('/push-subscription', json={"endpoint": endpoint2, "keys": keys2})
subs = push_subscriptions_db.search(Query().user_id == TEST_USER_ID)
assert len(subs) == 2
endpoints = {s['endpoint'] for s in subs}
assert TEST_ENDPOINT in endpoints
assert endpoint2 in endpoints
class TestUnsubscribe:
def test_unsubscribe_removes_subscription(self, auth_client):
auth_client.post('/push-subscription', json={"endpoint": TEST_ENDPOINT, "keys": TEST_KEYS})
res = auth_client.delete('/push-subscription', json={"endpoint": TEST_ENDPOINT})
assert res.status_code == 200
data = res.get_json()
assert data['removed'] >= 1
saved = push_subscriptions_db.get(
(Query().user_id == TEST_USER_ID) & (Query().endpoint == TEST_ENDPOINT)
)
assert saved is None
def test_unsubscribe_nonexistent_endpoint_ok(self, auth_client):
res = auth_client.delete('/push-subscription', json={"endpoint": "https://nonexistent"})
assert res.status_code == 200
assert res.get_json()['removed'] == 0
def test_unsubscribe_requires_endpoint(self, auth_client):
res = auth_client.delete('/push-subscription', json={})
assert res.status_code == 400
def test_unsubscribe_requires_auth(self, client):
res = client.delete('/push-subscription', json={"endpoint": TEST_ENDPOINT})
assert res.status_code == 401
+296
View File
@@ -0,0 +1,296 @@
"""Unit tests for routine API endpoints."""
import pytest
from datetime import datetime, timezone
from tinydb import Query
from models.routine import Routine
from models.routine_item import RoutineItem
from models.routine_schedule import RoutineSchedule
from models.routine_extension import RoutineExtension
from models.pending_confirmation import PendingConfirmation
from api.error_codes import ErrorCodes
from db.db import routine_db, routine_items_db, routine_schedules_db, routine_extensions_db, pending_confirmations_db
class TestRoutineModel:
"""Test Routine model serialization and validation."""
def test_routine_creation(self):
"""Test creating a routine instance."""
routine = Routine(name="Morning Routine", points=50, image_id="img123", user_id="user1")
assert routine.name == "Morning Routine"
assert routine.points == 50
assert routine.image_id == "img123"
assert routine.user_id == "user1"
assert routine.id is not None
def test_routine_to_dict(self):
"""Test routine serialization."""
routine = Routine(name="Test", points=10, image_id=None, user_id="user1")
data = routine.to_dict()
assert data["name"] == "Test"
assert data["points"] == 10
assert data["id"] == routine.id
def test_routine_from_dict(self):
"""Test routine deserialization."""
routine_dict = {"id": "r1", "name": "Test", "points": 20, "image_id": "img1", "user_id": "u1"}
routine = Routine.from_dict(routine_dict)
assert routine.id == "r1"
assert routine.name == "Test"
assert routine.points == 20
class TestRoutineItemModel:
"""Test RoutineItem model."""
def test_routine_item_creation(self):
"""Test creating a routine item."""
item = RoutineItem(routine_id="r1", name="Make Bed", image_id=None, order=0)
assert item.routine_id == "r1"
assert item.name == "Make Bed"
assert item.order == 0
def test_routine_item_to_dict(self):
"""Test item serialization."""
item = RoutineItem(routine_id="r1", name="Get Dressed", image_id="img1", order=1)
data = item.to_dict()
assert data["routine_id"] == "r1"
assert data["name"] == "Get Dressed"
assert data["order"] == 1
class TestRoutineScheduleModel:
"""Test RoutineSchedule model."""
def test_days_mode_schedule_creation(self):
"""Test creating days mode schedule."""
day_configs = [
{"day": 0, "hour": 8, "minute": 0},
{"day": 1, "hour": 9, "minute": 30},
]
schedule = RoutineSchedule(
child_id="c1",
routine_id="r1",
mode="days",
enabled=True,
day_configs=day_configs,
default_hour=8,
default_minute=0,
default_has_deadline=True,
)
assert schedule.mode == "days"
assert len(schedule.day_configs) == 2
assert schedule.enabled is True
def test_interval_mode_schedule_creation(self):
"""Test creating interval mode schedule."""
schedule = RoutineSchedule(
child_id="c1",
routine_id="r1",
mode="interval",
enabled=True,
interval_days=3,
anchor_date="2026-05-01",
interval_has_deadline=True,
interval_hour=10,
interval_minute=30,
)
assert schedule.mode == "interval"
assert schedule.interval_days == 3
assert schedule.interval_hour == 10
class TestRoutineExtensionModel:
"""Test RoutineExtension model."""
def test_extension_creation(self):
"""Test creating a routine extension."""
extension = RoutineExtension(child_id="c1", routine_id="r1", date="2026-05-10")
assert extension.child_id == "c1"
assert extension.routine_id == "r1"
assert extension.date == "2026-05-10"
class TestRoutineDB:
"""Test routine database operations."""
def setup_method(self):
"""Clear routine db before each test."""
routine_db.truncate()
def test_add_routine(self):
"""Test adding a routine to database."""
routine = Routine(name="Test Routine", points=50, image_id=None, user_id="user1")
routine_db.insert(routine.to_dict())
result = routine_db.search(Query().id == routine.id)
assert len(result) == 1
assert result[0]["name"] == "Test Routine"
def test_get_routine_by_id(self):
"""Test fetching routine by ID."""
routine = Routine(name="Fetch Test", points=30, image_id=None, user_id="user1")
routine_db.insert(routine.to_dict())
result = routine_db.get(Query().id == routine.id)
assert result is not None
assert result["name"] == "Fetch Test"
def test_list_user_routines(self):
"""Test listing routines for a user."""
r1 = Routine(name="R1", points=10, image_id=None, user_id="user1")
r2 = Routine(name="R2", points=20, image_id=None, user_id="user1")
r3 = Routine(name="R3", points=15, image_id=None, user_id="user2")
routine_db.insert(r1.to_dict())
routine_db.insert(r2.to_dict())
routine_db.insert(r3.to_dict())
results = routine_db.search(Query().user_id == "user1")
assert len(results) == 2
def test_update_routine(self):
"""Test updating a routine."""
routine = Routine(name="Original", points=50, image_id=None, user_id="user1")
routine_db.insert(routine.to_dict())
routine_db.update({"name": "Updated", "points": 100}, Query().id == routine.id)
result = routine_db.get(Query().id == routine.id)
assert result["name"] == "Updated"
assert result["points"] == 100
class TestRoutineItemDB:
"""Test routine item database operations."""
def setup_method(self):
"""Clear item db before each test."""
routine_items_db.truncate()
def test_add_routine_item(self):
"""Test adding a routine item."""
item = RoutineItem(routine_id="r1", name="Make Bed", image_id=None, order=0)
routine_items_db.insert(item.to_dict())
result = routine_items_db.search(Query().routine_id == "r1")
assert len(result) == 1
assert result[0]["name"] == "Make Bed"
def test_get_items_for_routine(self):
"""Test fetching all items for a routine."""
i1 = RoutineItem(routine_id="r1", name="Item1", image_id=None, order=0)
i2 = RoutineItem(routine_id="r1", name="Item2", image_id=None, order=1)
i3 = RoutineItem(routine_id="r2", name="Item3", image_id=None, order=0)
routine_items_db.insert(i1.to_dict())
routine_items_db.insert(i2.to_dict())
routine_items_db.insert(i3.to_dict())
results = routine_items_db.search(Query().routine_id == "r1")
assert len(results) == 2
def test_delete_item(self):
"""Test deleting a routine item."""
item = RoutineItem(routine_id="r1", name="Test", image_id=None, order=0)
routine_items_db.insert(item.to_dict())
routine_items_db.remove(Query().id == item.id)
result = routine_items_db.search(Query().id == item.id)
assert len(result) == 0
class TestRoutineScheduleDB:
"""Test routine schedule database operations."""
def setup_method(self):
"""Clear schedule db before each test."""
routine_schedules_db.truncate()
def test_upsert_schedule(self):
"""Test upserting a routine schedule."""
day_configs = [{"day": 0, "hour": 8, "minute": 0}]
schedule = RoutineSchedule(
child_id="c1",
routine_id="r1",
mode="days",
enabled=True,
day_configs=day_configs,
default_hour=8,
default_minute=0,
default_has_deadline=True,
)
routine_schedules_db.insert(schedule.to_dict())
result = routine_schedules_db.get(
(Query().child_id == "c1") & (Query().routine_id == "r1")
)
assert result is not None
assert result["mode"] == "days"
def test_delete_schedule(self):
"""Test deleting a schedule."""
schedule = RoutineSchedule(
child_id="c1",
routine_id="r1",
mode="interval",
enabled=True,
interval_days=2,
anchor_date="2026-05-01",
interval_has_deadline=True,
interval_hour=10,
interval_minute=0,
)
routine_schedules_db.insert(schedule.to_dict())
routine_schedules_db.remove(
(Query().child_id == "c1") & (Query().routine_id == "r1")
)
result = routine_schedules_db.search(Query().child_id == "c1")
assert len(result) == 0
class TestPendingRoutineConfirmation:
"""Test pending routine confirmation workflow."""
def setup_method(self):
"""Clear db before each test."""
pending_confirmations_db.truncate()
def test_create_routine_confirmation(self):
"""Test creating a pending routine confirmation."""
confirmation = PendingConfirmation(
child_id="c1",
entity_id="r1",
entity_type="routine",
user_id="u1",
status="pending",
)
pending_confirmations_db.insert(confirmation.to_dict())
result = pending_confirmations_db.get(
(Query().child_id == "c1") & (Query().entity_id == "r1") & (Query().entity_type == "routine")
)
assert result is not None
assert result["status"] == "pending"
def test_approve_routine_confirmation(self):
"""Test approving a routine confirmation."""
confirmation = PendingConfirmation(
child_id="c1",
entity_id="r1",
entity_type="routine",
user_id="u1",
status="pending",
)
pending_confirmations_db.insert(confirmation.to_dict())
today_utc = datetime.now(timezone.utc).isoformat()
pending_confirmations_db.update(
{"status": "approved", "approved_at": today_utc},
Query().id == confirmation.id,
)
result = pending_confirmations_db.get(Query().id == confirmation.id)
assert result["status"] == "approved"
assert result["approved_at"] is not None
+187
View File
@@ -0,0 +1,187 @@
from flask import Flask
from tinydb import Query
from werkzeug.security import generate_password_hash
from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS
from api.auth_api import auth_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 db.db import (
users_db,
child_db,
routine_db,
routine_items_db,
routine_schedules_db,
routine_extensions_db,
pending_confirmations_db,
)
TEST_USER_ID = 'routine-user-1'
TEST_EMAIL = 'routine-user@example.com'
TEST_PASSWORD = 'testpass'
def add_test_user():
users_db.remove(Query().email == TEST_EMAIL)
users_db.insert({
'id': TEST_USER_ID,
'first_name': 'Routine',
'last_name': 'Tester',
'email': TEST_EMAIL,
'password': generate_password_hash(TEST_PASSWORD),
'verified': True,
'image_id': 'boy01',
})
def login_and_set_cookie(client):
resp = client.post('/auth/login', json={'email': TEST_EMAIL, 'password': TEST_PASSWORD})
assert resp.status_code == 200
def seed_child(child_id: str):
child_db.remove(Query().id == child_id)
child_db.insert({
'id': child_id,
'name': 'Routine Kid',
'age': 9,
'tasks': [],
'routines': [],
'rewards': [],
'points': 0,
'image_id': 'boy01',
'user_id': TEST_USER_ID,
})
def _first_routine_id():
routines = routine_db.all()
assert routines
return routines[0]['id']
def _first_confirmation_id():
confirmations = pending_confirmations_db.all()
assert confirmations
return confirmations[0]['id']
def _make_client():
app = Flask(__name__)
app.register_blueprint(auth_api, url_prefix='/auth')
app.register_blueprint(routine_api)
app.register_blueprint(routine_item_api)
app.register_blueprint(child_routine_api)
app.register_blueprint(routine_schedule_api)
app.config['TESTING'] = True
app.config['SECRET_KEY'] = TEST_SECRET_KEY
app.config['REFRESH_TOKEN_EXPIRY_DAYS'] = TEST_REFRESH_TOKEN_EXPIRY_DAYS
return app
def setup_function(_):
routine_db.truncate()
routine_items_db.truncate()
routine_schedules_db.truncate()
routine_extensions_db.truncate()
pending_confirmations_db.truncate()
child_db.truncate()
def test_routine_crud_flow():
app = _make_client()
with app.test_client() as client:
add_test_user()
login_and_set_cookie(client)
add_resp = client.put('/routine/add', json={'name': 'Morning Routine', 'points': 8, 'image_id': 'sun'})
assert add_resp.status_code == 201
rid = _first_routine_id()
list_resp = client.get('/routine/list')
assert list_resp.status_code == 200
routines = list_resp.get_json()['routines']
assert len(routines) == 1
assert routines[0]['name'] == 'Morning Routine'
edit_resp = client.put(f'/routine/{rid}/edit', json={'points': 10})
assert edit_resp.status_code == 200
assert edit_resp.get_json()['points'] == 10
delete_resp = client.delete(f'/routine/{rid}')
assert delete_resp.status_code == 200
assert routine_db.all() == []
def test_child_routine_assignment_confirmation_and_approval():
app = _make_client()
with app.test_client() as client:
add_test_user()
login_and_set_cookie(client)
seed_child('routine-child-1')
client.put('/routine/add', json={'name': 'Evening Routine', 'points': 6, 'image_id': 'moon'})
rid = _first_routine_id()
add_item_resp = client.put(f'/routine/{rid}/item/add', json={'name': 'Brush Teeth', 'order': 0})
assert add_item_resp.status_code == 201
assign_resp = client.post('/child/routine-child-1/assign-routine', json={'routine_id': rid})
assert assign_resp.status_code == 200
list_resp = client.get('/child/routine-child-1/list-routines')
assert list_resp.status_code == 200
routines = list_resp.get_json()['routines']
assert len(routines) == 1
assert routines[0]['id'] == rid
assert routines[0]['items'][0]['name'] == 'Brush Teeth'
confirm_resp = client.post('/child/routine-child-1/confirm-routine', json={'routine_id': rid})
assert confirm_resp.status_code == 200
confirmation_id = _first_confirmation_id()
approve_resp = client.post(f'/child/routine-child-1/approve-routine/{confirmation_id}')
assert approve_resp.status_code == 200
child = child_db.get(Query().id == 'routine-child-1')
assert child['points'] == 6
def test_routine_schedule_and_extend_flow():
app = _make_client()
with app.test_client() as client:
add_test_user()
login_and_set_cookie(client)
seed_child('routine-child-2')
client.put('/routine/add', json={'name': 'School Routine', 'points': 5, 'image_id': 'book'})
rid = _first_routine_id()
client.post('/child/routine-child-2/assign-routine', json={'routine_id': rid})
set_resp = client.put(
f'/child/routine-child-2/routine/{rid}/schedule',
json={
'mode': 'days',
'day_configs': [{'day': 1, 'hour': 8, 'minute': 0}],
'default_hour': 8,
'default_minute': 0,
'default_has_deadline': True,
},
)
assert set_resp.status_code == 200
get_resp = client.get(f'/child/routine-child-2/routine/{rid}/schedule')
assert get_resp.status_code == 200
assert get_resp.get_json()['mode'] == 'days'
extend_resp = client.post(
f'/child/routine-child-2/routine/{rid}/extend',
json={'date': '2026-05-04'},
)
assert extend_resp.status_code == 200
assert extend_resp.get_json()['routine_id'] == rid
@@ -0,0 +1,250 @@
"""Tests for the state expiry scheduler."""
import os
import time
import pytest
from unittest.mock import patch, MagicMock
from flask import Flask
from tinydb import Query
from utils.state_expiry_scheduler import (
_get_today_start_timestamp,
expire_stale_pending_for_user,
run_state_expiry_check,
)
from db.db import users_db, pending_confirmations_db
from tests.conftest import TEST_SECRET_KEY
EXPIRY_USER_ID = "expiry_test_user"
EXPIRY_USER_ID_2 = "expiry_test_user_2"
EXPIRY_CHILD_ID = "expiry_child_id"
EXPIRY_CHILD_ID_2 = "expiry_child_id_2"
EXPIRY_TASK_ID = "expiry_task_id"
EXPIRY_REWARD_ID = "expiry_reward_id"
def _yesterday_timestamp() -> float:
"""Return a Unix timestamp from 25 hours ago (safely before today's midnight)."""
return time.time() - (25 * 3600)
def _seed_user(user_id: str = EXPIRY_USER_ID, timezone: str = "UTC", verified: bool = True):
users_db.remove(Query().id == user_id)
users_db.insert({
"id": user_id,
"first_name": "Expiry",
"last_name": "Tester",
"email": f"{user_id}@example.com",
"password": "hashed",
"verified": verified,
"role": "user",
"image_id": None,
"marked_for_deletion": False,
"timezone": timezone,
"email_digest_enabled": False,
})
def _seed_pending(
pending_id: str,
user_id: str = EXPIRY_USER_ID,
child_id: str = EXPIRY_CHILD_ID,
entity_id: str = EXPIRY_TASK_ID,
entity_type: str = "chore",
status: str = "pending",
created_at: float = None,
):
pending_confirmations_db.remove(Query().id == pending_id)
pending_confirmations_db.insert({
"id": pending_id,
"user_id": user_id,
"child_id": child_id,
"entity_id": entity_id,
"entity_type": entity_type,
"status": status,
"approved_at": None,
"created_at": created_at if created_at is not None else _yesterday_timestamp(),
"updated_at": time.time(),
})
def _cleanup():
for uid in (EXPIRY_USER_ID, EXPIRY_USER_ID_2):
users_db.remove(Query().id == uid)
pending_confirmations_db.remove(Query().user_id == uid)
@pytest.fixture
def app():
flask_app = Flask(__name__)
flask_app.config['TESTING'] = True
flask_app.config['SECRET_KEY'] = TEST_SECRET_KEY
return flask_app
class TestGetTodayStartTimestamp:
def test_returns_float(self):
ts = _get_today_start_timestamp("UTC")
assert isinstance(ts, float)
def test_today_start_is_before_now(self):
ts = _get_today_start_timestamp("UTC")
assert ts <= time.time()
def test_falls_back_to_utc_on_none(self):
ts_utc = _get_today_start_timestamp("UTC")
ts_none = _get_today_start_timestamp(None)
assert abs(ts_utc - ts_none) < 1 # within 1 second
def test_falls_back_to_utc_on_invalid_timezone(self):
ts_utc = _get_today_start_timestamp("UTC")
ts_bad = _get_today_start_timestamp("Invalid/Timezone")
assert abs(ts_utc - ts_bad) < 1
def test_timezone_shifts_midnight(self):
"""UTC-12 midnight is 12 hours later than UTC midnight in absolute terms."""
ts_utc = _get_today_start_timestamp("UTC")
ts_west = _get_today_start_timestamp("Etc/GMT+12")
# The western timezone's "today start" is at most 12 hours away from UTC's
assert abs(ts_utc - ts_west) <= 12 * 3600 + 1
class TestExpireStaleForUser:
def setup_method(self):
_cleanup()
def teardown_method(self):
_cleanup()
def test_pending_chore_from_yesterday_is_deleted(self, app):
_seed_pending("exp_chore_1", entity_type="chore", created_at=_yesterday_timestamp())
with app.app_context():
with patch('events.sse.send_event_to_user') as mock_send:
count = expire_stale_pending_for_user(EXPIRY_USER_ID, "UTC")
assert count == 1
assert pending_confirmations_db.get(Query().id == "exp_chore_1") is None
def test_pending_chore_fires_reset_sse(self, app):
_seed_pending("exp_chore_2", entity_type="chore", created_at=_yesterday_timestamp())
with app.app_context():
with patch('utils.state_expiry_scheduler.send_event_to_user') as mock_send:
expire_stale_pending_for_user(EXPIRY_USER_ID, "UTC")
mock_send.assert_called_once()
event = mock_send.call_args[0][1]
assert event.type == "child_chore_confirmation"
assert event.payload.data['operation'] == "RESET"
assert event.payload.data['task_id'] == EXPIRY_TASK_ID
def test_pending_reward_from_yesterday_is_deleted(self, app):
_seed_pending(
"exp_reward_1",
entity_id=EXPIRY_REWARD_ID,
entity_type="reward",
created_at=_yesterday_timestamp(),
)
with app.app_context():
with patch('utils.state_expiry_scheduler.send_event_to_user'):
count = expire_stale_pending_for_user(EXPIRY_USER_ID, "UTC")
assert count == 1
assert pending_confirmations_db.get(Query().id == "exp_reward_1") is None
def test_pending_reward_fires_cancelled_sse(self, app):
_seed_pending(
"exp_reward_2",
entity_id=EXPIRY_REWARD_ID,
entity_type="reward",
created_at=_yesterday_timestamp(),
)
with app.app_context():
with patch('utils.state_expiry_scheduler.send_event_to_user') as mock_send:
expire_stale_pending_for_user(EXPIRY_USER_ID, "UTC")
mock_send.assert_called_once()
event = mock_send.call_args[0][1]
assert event.type == "child_reward_request"
assert event.payload.data['operation'] == "CANCELLED"
assert event.payload.data['reward_id'] == EXPIRY_REWARD_ID
def test_pending_record_from_today_is_not_deleted(self, app):
_seed_pending("exp_today_1", created_at=time.time())
with app.app_context():
with patch('utils.state_expiry_scheduler.send_event_to_user') as mock_send:
count = expire_stale_pending_for_user(EXPIRY_USER_ID, "UTC")
assert count == 0
assert pending_confirmations_db.get(Query().id == "exp_today_1") is not None
mock_send.assert_not_called()
def test_approved_record_from_yesterday_is_not_deleted(self, app):
_seed_pending("exp_approved_1", status="approved", created_at=_yesterday_timestamp())
with app.app_context():
with patch('utils.state_expiry_scheduler.send_event_to_user') as mock_send:
count = expire_stale_pending_for_user(EXPIRY_USER_ID, "UTC")
assert count == 0
assert pending_confirmations_db.get(Query().id == "exp_approved_1") is not None
mock_send.assert_not_called()
def test_no_pending_records_returns_zero(self, app):
with app.app_context():
with patch('utils.state_expiry_scheduler.send_event_to_user') as mock_send:
count = expire_stale_pending_for_user(EXPIRY_USER_ID, "UTC")
assert count == 0
mock_send.assert_not_called()
def test_multiple_stale_records_all_expired(self, app):
_seed_pending("exp_multi_1", entity_id=EXPIRY_TASK_ID, entity_type="chore",
created_at=_yesterday_timestamp())
_seed_pending("exp_multi_2", entity_id=EXPIRY_REWARD_ID, entity_type="reward",
created_at=_yesterday_timestamp())
with app.app_context():
with patch('utils.state_expiry_scheduler.send_event_to_user') as mock_send:
count = expire_stale_pending_for_user(EXPIRY_USER_ID, "UTC")
assert count == 2
assert mock_send.call_count == 2
class TestRunStateExpiryCheck:
def setup_method(self):
_cleanup()
def teardown_method(self):
_cleanup()
def test_expires_stale_records_for_all_verified_users(self, app):
_seed_user(EXPIRY_USER_ID)
_seed_user(EXPIRY_USER_ID_2)
_seed_pending("exp_u1", user_id=EXPIRY_USER_ID, created_at=_yesterday_timestamp())
_seed_pending("exp_u2", user_id=EXPIRY_USER_ID_2, child_id=EXPIRY_CHILD_ID_2,
created_at=_yesterday_timestamp())
with patch('utils.state_expiry_scheduler.send_event_to_user'):
run_state_expiry_check(app)
assert pending_confirmations_db.get(Query().id == "exp_u1") is None
assert pending_confirmations_db.get(Query().id == "exp_u2") is None
def test_only_expires_stale_records_not_todays(self, app):
_seed_user(EXPIRY_USER_ID)
_seed_pending("exp_stale", created_at=_yesterday_timestamp())
_seed_pending("exp_fresh", created_at=time.time())
with patch('utils.state_expiry_scheduler.send_event_to_user'):
run_state_expiry_check(app)
assert pending_confirmations_db.get(Query().id == "exp_stale") is None
assert pending_confirmations_db.get(Query().id == "exp_fresh") is not None
def test_skips_when_db_env_is_e2e(self, app):
_seed_user(EXPIRY_USER_ID)
_seed_pending("exp_e2e", created_at=_yesterday_timestamp())
original = os.environ.get('DB_ENV')
try:
os.environ['DB_ENV'] = 'e2e'
with patch('utils.state_expiry_scheduler.send_event_to_user') as mock_send:
run_state_expiry_check(app)
mock_send.assert_not_called()
assert pending_confirmations_db.get(Query().id == "exp_e2e") is not None
finally:
if original is not None:
os.environ['DB_ENV'] = original
else:
os.environ.pop('DB_ENV', None)
def test_no_error_when_user_has_no_pending_records(self, app):
_seed_user(EXPIRY_USER_ID)
with patch('utils.state_expiry_scheduler.send_event_to_user') as mock_send:
run_state_expiry_check(app)
mock_send.assert_not_called()
+33
View File
@@ -227,3 +227,36 @@ def test_update_profile_success(authenticated_client):
assert user['first_name'] == 'Updated'
assert user['last_name'] == 'Name'
assert user['image_id'] == 'new_image'
def test_get_profile_includes_email_digest_enabled(authenticated_client):
"""GET /user/profile response includes email_digest_enabled field."""
response = authenticated_client.get('/user/profile')
assert response.status_code == 200
data = response.get_json()
assert 'email_digest_enabled' in data
assert isinstance(data['email_digest_enabled'], bool)
def test_update_profile_disables_digest(authenticated_client):
"""PUT /user/profile with email_digest_enabled: false disables the digest."""
# Ensure it starts enabled
users_db.update({'email_digest_enabled': True}, Query().email == TEST_EMAIL)
response = authenticated_client.put('/user/profile', json={'email_digest_enabled': False})
assert response.status_code == 200
user = users_db.search(Query().email == TEST_EMAIL)[0]
assert user['email_digest_enabled'] is False
def test_update_profile_enables_digest(authenticated_client):
"""PUT /user/profile with email_digest_enabled: true re-enables the digest."""
# Start disabled
users_db.update({'email_digest_enabled': False}, Query().email == TEST_EMAIL)
response = authenticated_client.put('/user/profile', json={'email_digest_enabled': True})
assert response.status_code == 200
user = users_db.search(Query().email == TEST_EMAIL)[0]
assert user['email_digest_enabled'] is True
+201
View File
@@ -0,0 +1,201 @@
"""Tests for web push notifications triggered by child actions.
Patches `utils.push_sender.webpush` so no real HTTP requests are made.
A push subscription is seeded in push_subscriptions_db so send_push_to_user
actually calls webpush (rather than short-circuiting at "no subscriptions").
"""
import json
import pytest
from unittest.mock import patch, MagicMock
from flask import Flask
from werkzeug.security import generate_password_hash
from tinydb import Query
from api.child_api import child_api
from api.auth_api import auth_api
from db.db import (
child_db, task_db, reward_db, users_db,
pending_confirmations_db, push_subscriptions_db,
)
from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS
TEST_USER_ID = "wp_test_user_id"
TEST_EMAIL = "wptest@example.com"
TEST_PASSWORD = "wptestpass"
TEST_CHILD_ID = "wp_child_id"
TEST_TASK_ID = "wp_task_id"
TEST_REWARD_ID_AFFORD = "wp_reward_afford"
TEST_REWARD_ID_CANT = "wp_reward_cant"
TEST_ENDPOINT = "https://push.example.com/wp_endpoint"
def seed_data():
users_db.remove(Query().id == TEST_USER_ID)
child_db.remove(Query().id == TEST_CHILD_ID)
task_db.remove(Query().id == TEST_TASK_ID)
reward_db.remove((Query().id == TEST_REWARD_ID_AFFORD) | (Query().id == TEST_REWARD_ID_CANT))
pending_confirmations_db.remove(Query().user_id == TEST_USER_ID)
push_subscriptions_db.remove(Query().user_id == TEST_USER_ID)
users_db.insert({
"id": TEST_USER_ID,
"first_name": "Push",
"last_name": "Tester",
"email": TEST_EMAIL,
"password": generate_password_hash(TEST_PASSWORD),
"verified": True,
"role": "user",
"image_id": None,
"marked_for_deletion": False,
"marked_for_deletion_at": None,
"timezone": "America/New_York",
"email_digest_enabled": True,
})
child_db.insert({
"id": TEST_CHILD_ID,
"user_id": TEST_USER_ID,
"name": "Push Child",
"age": 8,
"points": 50,
"tasks": [TEST_TASK_ID],
"rewards": [TEST_REWARD_ID_AFFORD, TEST_REWARD_ID_CANT],
"image_id": None,
})
task_db.insert({
"id": TEST_TASK_ID,
"user_id": TEST_USER_ID,
"name": "Clean Room",
"points": 10,
"type": "chore",
"image_id": None,
})
# Affordable reward (cost <= child.points = 50)
reward_db.insert({
"id": TEST_REWARD_ID_AFFORD,
"user_id": TEST_USER_ID,
"name": "Movie Night",
"cost": 20,
"image_id": None,
})
# Unaffordable reward (cost > child.points = 50)
reward_db.insert({
"id": TEST_REWARD_ID_CANT,
"user_id": TEST_USER_ID,
"name": "New Bike",
"cost": 200,
"image_id": None,
})
def seed_subscription():
push_subscriptions_db.insert({
"id": "wp_sub_id",
"user_id": TEST_USER_ID,
"endpoint": TEST_ENDPOINT,
"keys": {"p256dh": "BNgz3test", "auth": "authtest"},
"created_at": "2024-01-01T00:00:00+00:00",
})
def cleanup():
users_db.remove(Query().id == TEST_USER_ID)
child_db.remove(Query().id == TEST_CHILD_ID)
task_db.remove(Query().id == TEST_TASK_ID)
reward_db.remove((Query().id == TEST_REWARD_ID_AFFORD) | (Query().id == TEST_REWARD_ID_CANT))
pending_confirmations_db.remove(Query().user_id == TEST_USER_ID)
push_subscriptions_db.remove(Query().user_id == TEST_USER_ID)
@pytest.fixture
def client():
app = Flask(__name__)
app.register_blueprint(child_api)
app.register_blueprint(auth_api, url_prefix='/auth')
app.config['TESTING'] = True
app.config['SECRET_KEY'] = TEST_SECRET_KEY
app.config['REFRESH_TOKEN_EXPIRY_DAYS'] = TEST_REFRESH_TOKEN_EXPIRY_DAYS
app.config['FRONTEND_URL'] = 'http://localhost:5173'
cleanup()
seed_data()
with app.test_client() as c:
c.post('/auth/login', json={"email": TEST_EMAIL, "password": TEST_PASSWORD})
yield c
cleanup()
class TestWebPushOnChoreConfirm:
def test_push_fired_to_all_subscriptions_on_chore_confirm(self, client):
"""Web push is sent to all stored subscriptions when a chore is confirmed."""
seed_subscription()
with patch('utils.push_sender.webpush') as mock_webpush:
resp = client.post(
f'/child/{TEST_CHILD_ID}/confirm-chore',
json={'task_id': TEST_TASK_ID},
)
assert resp.status_code == 200
assert mock_webpush.called
def test_push_payload_includes_required_fields(self, client):
"""Push payload includes user_id, approve_token, and deny_token."""
seed_subscription()
with patch('utils.push_sender.webpush') as mock_webpush:
client.post(
f'/child/{TEST_CHILD_ID}/confirm-chore',
json={'task_id': TEST_TASK_ID},
)
call_kwargs = mock_webpush.call_args[1]
payload = json.loads(call_kwargs['data'])
assert payload['user_id'] == TEST_USER_ID
assert 'approve_token' in payload
assert 'deny_token' in payload
def test_push_not_fired_when_no_subscriptions(self, client):
"""No error is raised when the parent has no push subscriptions."""
push_subscriptions_db.remove(Query().user_id == TEST_USER_ID)
with patch('utils.push_sender.webpush') as mock_webpush:
resp = client.post(
f'/child/{TEST_CHILD_ID}/confirm-chore',
json={'task_id': TEST_TASK_ID},
)
assert resp.status_code == 200
assert not mock_webpush.called
class TestWebPushOnRewardRequest:
def test_push_fired_for_affordable_reward_request(self, client):
"""Web push is fired when a child requests a reward they can afford."""
seed_subscription()
with patch('utils.push_sender.webpush') as mock_webpush:
resp = client.post(
f'/child/{TEST_CHILD_ID}/request-reward',
json={'reward_id': TEST_REWARD_ID_AFFORD},
)
assert resp.status_code == 200
assert mock_webpush.called
# Cleanup pending
pending_confirmations_db.remove(Query().child_id == TEST_CHILD_ID)
def test_push_not_fired_for_unaffordable_reward(self, client):
"""Web push is NOT fired when a child requests a reward they cannot afford."""
seed_subscription()
with patch('utils.push_sender.webpush') as mock_webpush:
resp = client.post(
f'/child/{TEST_CHILD_ID}/request-reward',
json={'reward_id': TEST_REWARD_ID_CANT},
)
# request-reward rejects unaffordable requests with 400
assert resp.status_code == 400
assert not mock_webpush.called
def test_push_not_fired_for_reward_when_no_subscriptions(self, client):
"""No error is raised for reward requests when the parent has no subscriptions."""
push_subscriptions_db.remove(Query().user_id == TEST_USER_ID)
with patch('utils.push_sender.webpush') as mock_webpush:
resp = client.post(
f'/child/{TEST_CHILD_ID}/request-reward',
json={'reward_id': TEST_REWARD_ID_AFFORD},
)
assert resp.status_code == 200
assert not mock_webpush.called
# Cleanup pending
pending_confirmations_db.remove(Query().child_id == TEST_CHILD_ID)
@@ -0,0 +1,242 @@
"""
Hourly scheduler that sends push notifications to parents when their children's
scheduled chores are going to expire within the next 75 minutes and haven't been
marked completed or submitted for confirmation yet.
Window rationale (75 min):
The spec requires that chores expiring between 9:00 pm and 9:15 pm both
generate a notification at 8:00 pm. A 75-minute look-ahead window
(now < deadline <= now + 75 min) satisfies this for the hourly cron run at
the top of each hour.
Double-notification note:
A chore deadline that falls within the overlap of two consecutive 75-minute
windows (e.g. 9:10 pm appears in both the 8:00 pm and 9:00 pm windows) may
produce two push notifications. No deduplication is applied; this is
intentional per the product decision made during spec review.
"""
import logging
import os
from datetime import datetime, timedelta, timezone
from apscheduler.schedulers.background import BackgroundScheduler
from tinydb import Query
from utils.schedule_utils import get_due_time_today, is_scheduled_today
from utils.push_sender import send_push_to_user
logger = logging.getLogger(__name__)
_WINDOW_MINUTES = 75
def get_expiring_chores_for_user(
user_id: str,
tz_str: str | None,
now_dt: datetime,
) -> list[dict]:
"""Return chores that will expire within the next _WINDOW_MINUTES minutes.
Each entry is::
{
'child_id': str,
'child_name': str,
'task_id': str,
'task_name': str,
}
A chore is included only when ALL of the following are true:
- It has a ChoreSchedule with enabled=True
- It is scheduled today (days or interval mode)
- It has a deadline (not Anytime)
- The deadline falls in (now_dt, now_dt + _WINDOW_MINUTES]
- There is no existing pending_confirmation record for this child + task
(status='pending' means awaiting approval; status='approved' means done)
Must be called within a Flask app context.
"""
from db.db import child_db, task_db, pending_confirmations_db
from db.chore_schedules import get_schedule
try:
from zoneinfo import ZoneInfo
local_now = now_dt.astimezone(ZoneInfo(tz_str)) if tz_str else now_dt
except Exception:
local_now = now_dt
local_date = local_now.date()
window_end = local_now + timedelta(minutes=_WINDOW_MINUTES)
ChildQ = Query()
children = child_db.search(ChildQ.user_id == user_id)
expiring: list[dict] = []
for child_dict in children:
child_id = child_dict.get('id')
child_name = child_dict.get('name', 'Unknown')
task_ids = child_dict.get('tasks', [])
TaskQ = Query()
for task_id in task_ids:
task = task_db.get(
(TaskQ.id == task_id) &
(TaskQ.type == 'chore') &
((TaskQ.user_id == user_id) | (TaskQ.user_id == None))
)
if not task:
continue
schedule = get_schedule(child_id, task_id)
if schedule is None:
continue
schedule_dict = schedule.to_dict()
if not schedule_dict.get('enabled', True):
continue # paused schedules have no deadline
if not is_scheduled_today(schedule_dict, local_date):
continue
due = get_due_time_today(schedule_dict, local_date)
if due is None:
continue # Anytime — no expiry
due_hour, due_minute = due
# Build a timezone-aware deadline datetime for the next occurrence of the due time.
# Schedules run hourly and may span midnight (e.g. a 23:00 run needs to catch
# a chore due at 00:15 the next day), so roll forward a day when the candidate
# deadline has already passed.
deadline_dt = local_now.replace(
hour=due_hour, minute=due_minute, second=0, microsecond=0
)
if deadline_dt <= local_now:
deadline_dt = deadline_dt + timedelta(days=1)
# Include only when deadline is strictly after now and within window
if not (local_now < deadline_dt <= window_end):
continue
# Skip if any confirmation record exists (pending OR approved = done today)
PendingQ = Query()
has_confirmation = pending_confirmations_db.get(
(PendingQ.child_id == child_id) &
(PendingQ.entity_id == task_id) &
(PendingQ.entity_type == 'chore') &
(PendingQ.user_id == user_id)
)
if has_confirmation:
continue
expiring.append({
'child_id': child_id,
'child_name': child_name,
'task_id': task_id,
'task_name': task.get('name', 'Unknown'),
})
return expiring
def _build_push_payload(expiring: list[dict]) -> dict:
"""Build the push notification payload from a list of expiring chore entries."""
if len(expiring) == 1:
entry = expiring[0]
title = f"Chore ending soon: {entry['task_name']}"
child_id = entry['child_id']
entity_id = entry['task_id']
else:
title = 'Chores ending soon'
child_id = None
entity_id = None
# Group by child and format body text
by_child: dict[str, list[str]] = {}
for entry in expiring:
by_child.setdefault(entry['child_name'], []).append(entry['task_name'])
body_lines = [
f"{name}: {', '.join(tasks)}" for name, tasks in by_child.items()
]
body = '\n'.join(body_lines)
return {
'type': 'chore_expiring_soon',
'title': title,
'body': body,
'child_id': child_id,
'entity_id': entity_id,
'entity_type': 'chore',
}
def send_chore_expiry_notifications_for_user(user_id: str, tz_str: str | None) -> int:
"""Check for expiring chores and send a push notification if any are found.
Returns the number of chores included in the notification (0 = nothing sent).
Must be called within a Flask app context.
"""
now_dt = datetime.now(timezone.utc)
expiring = get_expiring_chores_for_user(user_id, tz_str, now_dt)
if not expiring:
return 0
payload = _build_push_payload(expiring)
send_push_to_user(user_id, payload)
logger.info(
f'Chore expiry: sent notification for {len(expiring)} chore(s) to user {user_id}'
)
return len(expiring)
def run_chore_expiry_check(app) -> None:
"""Check all verified, push-enabled users for soon-to-expire chores."""
if os.environ.get('DB_ENV') == 'e2e':
return
with app.app_context():
try:
from db.db import users_db
UserQ = Query()
users = users_db.search(UserQ.verified == True)
for user_dict in users:
user_id = user_dict.get('id')
tz_str = user_dict.get('timezone')
if not user_dict.get('push_notifications_enabled', True):
continue
try:
count = send_chore_expiry_notifications_for_user(user_id, tz_str)
if count:
logger.info(
f'Chore expiry: notified {count} expiring chore(s) for user {user_id}'
)
except Exception as e:
logger.error(f'Chore expiry: failed for user {user_id}: {e}')
except Exception as e:
logger.error(f'Error in run_chore_expiry_check: {e}')
def start_chore_expiry_notification_scheduler(app) -> BackgroundScheduler:
"""Start the hourly chore-expiry notification scheduler.
Runs at the top of every hour (minute=0), matching the existing schedulers.
Returns the scheduler instance.
"""
scheduler = BackgroundScheduler()
scheduler.add_job(
run_chore_expiry_check,
'cron',
minute=0,
args=[app],
)
scheduler.start()
logger.info('Chore expiry notification scheduler started (runs every hour at :00)')
return scheduler
+162
View File
@@ -0,0 +1,162 @@
import logging
import os
from datetime import datetime, timezone
from apscheduler.schedulers.background import BackgroundScheduler
from tinydb import Query
logger = logging.getLogger(__name__)
def _get_local_hour(tz_str: str | None) -> int:
"""Return the current hour (0-23) in the given IANA timezone, falling back to UTC."""
try:
if tz_str:
from zoneinfo import ZoneInfo # Python 3.9+
local_now = datetime.now(ZoneInfo(tz_str))
return local_now.hour
except Exception:
pass
return datetime.now(timezone.utc).hour
def send_digest_for_user(user_id: str, user_email: str, frontend_url: str) -> int:
"""Gather pending items for a user and send them a digest email.
Returns the number of items included in the digest (0 if nothing was sent).
Must be called within a Flask app context with all DB imports already available.
"""
from db.db import pending_confirmations_db, child_db, task_db, reward_db
from utils.email_sender import send_digest_email
from utils.digest_token import create_action_token, create_unsubscribe_token
PendingQ = Query()
pending_items = pending_confirmations_db.search(
(PendingQ.user_id == user_id) & (PendingQ.status == 'pending')
)
if not pending_items:
return 0
ChildQ = Query()
TaskQ = Query()
RewardQ = Query()
items = []
for p in pending_items:
child_id = p.get('child_id')
entity_id = p.get('entity_id')
entity_type = p.get('entity_type')
child_result = child_db.get(ChildQ.id == child_id)
if not child_result:
logger.warning(f'Digest: child {child_id} not found for pending item {p.get("id")} (user {user_id})')
continue
child_name = child_result.get('name') or 'Unknown'
if entity_type == 'chore':
entity_result = task_db.get(
(TaskQ.id == entity_id) &
((TaskQ.user_id == user_id) | (TaskQ.user_id == None))
)
else:
entity_result = reward_db.get(
(RewardQ.id == entity_id) &
((RewardQ.user_id == user_id) | (RewardQ.user_id == None))
)
if not entity_result:
logger.warning(f'Digest: {entity_type} {entity_id} not found for pending item {p.get("id")} (user {user_id})')
continue
entity_name = entity_result.get('name') or 'Unknown'
approve_token = create_action_token(
user_id=user_id,
child_id=child_id,
entity_id=entity_id,
entity_type=entity_type,
action='approve',
)
deny_token = create_action_token(
user_id=user_id,
child_id=child_id,
entity_id=entity_id,
entity_type=entity_type,
action='deny',
)
view_url = (
f"{frontend_url}/parent/{child_id}"
f"?scrollTo={entity_id}&entityType={entity_type}"
)
approve_url = f"{frontend_url}/api/digest-action/{approve_token.id}"
deny_url = f"{frontend_url}/api/digest-action/{deny_token.id}"
items.append({
'child_name': child_name,
'entity_name': entity_name,
'entity_type': entity_type,
'view_url': view_url,
'approve_url': approve_url,
'deny_url': deny_url,
'child_id': child_id,
'entity_id': entity_id,
})
if not items:
return 0
unsubscribe_token = create_unsubscribe_token(user_id)
send_digest_email(user_email, items, unsubscribe_token)
return len(items)
def send_digests(app) -> None:
"""Hourly job: send the 9pm digest to every eligible user whose local time is 21."""
if os.environ.get('DB_ENV') == 'e2e':
return
with app.app_context():
try:
from db.db import users_db
from flask import current_app
frontend_url = current_app.config.get('FRONTEND_URL', 'https://localhost:5173')
UserQ = Query()
users = users_db.search(
(UserQ.verified == True) & (UserQ.email_digest_enabled == True)
)
for user_dict in users:
user_id = user_dict.get('id')
user_email = user_dict.get('email')
user_tz = user_dict.get('timezone')
local_hour = _get_local_hour(user_tz)
if local_hour != 21:
continue
try:
send_digest_for_user(user_id, user_email, frontend_url)
except Exception as e:
logger.error(f'Failed to send digest to {user_email}: {e}')
except Exception as e:
logger.error(f'Error in send_digests job: {e}')
def start_digest_scheduler(app) -> BackgroundScheduler:
"""Start the hourly digest scheduler. Returns the scheduler instance."""
scheduler = BackgroundScheduler()
scheduler.add_job(
send_digests,
'cron',
minute=0,
args=[app],
id='digest_scheduler',
replace_existing=True,
)
scheduler.start()
logger.info('Digest scheduler started (hourly)')
return scheduler

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