259 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
ryan 8080a59de1 fix: update BASE_VERSION to 1.0.7 for release
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 1m48s
2026-04-08 13:31:40 -04:00
ryan a4e23aad11 feat: implement cross-tab coordination for token refresh and enhance logout handling
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m57s
2026-03-27 23:02:29 -04:00
ryan 89097a390e fix: update BASE_VERSION to 1.0.6 for release
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m13s
2026-03-26 17:05:19 -04:00
ryan 469a2ffc24 refactor: simplify array initialization in child sort order tests
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m30s
2026-03-26 12:51:29 -04:00
ryan 028f99b5c3 Add end-to-end tests for task assignment, modification, sorting, and child view updates
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m57s
- Implement tests to verify that assigned titles display the correct child name in various chore and reward views.
- Create tests to ensure that child updates via SSE reflect correctly in the UI for task modifications, including point overrides and pending status resets.
- Add tests to validate the sorting order of tasks and rewards in both child and parent views, ensuring proper prioritization of pending, completed, and scheduled tasks.
- Introduce tests for scrolling behavior to ensure edited tasks are brought into view after modifications.
2026-03-26 12:30:48 -04:00
ryan 16701278ed fix: update BASE_VERSION to 1.0.6RC03 for release
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m12s
2026-03-25 15:30:18 -04:00
ryan 359c170b27 fix: implement fetch generation counter to handle concurrent refreshes in ScrollingList
Chore App Build, Test, and Push Docker Images / build-and-push (push) Has been cancelled
- Added a fetch generation counter to the fetchItems method to discard stale results from concurrent fetches.
- Updated the loading state management to ensure it only updates if the fetch is the latest.
- Enhanced the refresh method to prevent stale data from being rendered when multiple refreshes occur.
- Added a test case to verify that stale fetches are discarded correctly when concurrent refreshes happen.
- Fixed CSS for mobile banners to prevent overflow and adjusted font sizes for better visibility.
- Resolved issues with task icons disappearing when extending time on overdue chores by ensuring only one fetch is active at a time.
2026-03-25 15:30:00 -04:00
ryan f64311689b feat: add completed chores to the display order in ChildView and update sorting priority
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m50s
2026-03-24 17:10:51 -04:00
ryan ac29ee9bd0 fix: update ParentView tests to use mount options and improve mock implementations
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m18s
2026-03-24 16:38:06 -04:00
ryan e9f4343426 added fixes for bug plan 1.0.6RC01
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 5m43s
2026-03-24 15:31:57 -04:00
ryan 81169da05e fix: update version number to 1.0.6RC01 and clarify verification code description
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m30s
2026-03-20 16:48:43 -04:00
ryan ef9cb01d92 feat: add enable/disable toggle for chore scheduling in ScheduleModal
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m39s
- Introduced a toggle button to enable or disable chores in the scheduler.
- The toggle will be available in both types of schedulers.
- Added UI design proposal and considerations for mobile dimensions.
- Included E2E tests to ensure toggle state persistence and correct behavior in child view.
2026-03-20 16:42:13 -04:00
ryan db6e0a7ce8 feat: Implement user profile and parent profile button tests
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m38s
- Added tests for user profile editing, including name changes, image uploads, and password changes.
- Implemented tests for changing the parent PIN with verification code handling.
- Created tests for account deletion with confirmation dialog and email validation.
- Introduced parent profile button tests for both temporary and permanent modes, verifying badge visibility and menu options.
- Updated Playwright configuration to include new test buckets for user profile and parent profile button scenarios.
- Added e2e plans documentation for user profile and parent profile button tests.
2026-03-18 17:20:31 -04:00
ryan a9131242a7 Add end-to-end tests for task modification and assignment features
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m33s
- Implemented tests for editing penalty points and reward costs in `penalty-edit-points.spec.ts` and `reward-edit-cost.spec.ts`.
- Created detailed plans for task activation and assignment scenarios in `task-activated.plan.md` and `task-assignment.plan.md`.
- Added comprehensive test cases for modifying tasks, including editing points for chores, kindness acts, penalties, and rewards in `task-modified.plan.md`.
- Ensured all tests are isolated and run in serial mode to maintain state integrity.
2026-03-17 22:46:27 -04:00
ryan b2115ceb57 Add editor.gotoLocation settings for improved navigation
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 2m26s
2026-03-13 23:52:37 -04:00
ryan cd902da292 Refactor test expectations for child kebab menu options
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m18s
2026-03-13 23:29:01 -04:00
ryan c2b022eb0b Refactor Playwright tests and update configurations
Chore App Build, Test, and Push Docker Images / build-and-push (push) Has been cancelled
- Consolidated kindness and penalty tests into single files to ensure serial execution and prevent conflicts.
- Updated Playwright configuration to define separate test buckets for child options and create child tests, ensuring proper execution order.
- Added new tests for child kebab menu options including editing, deleting points, and confirming child deletion.
- Removed obsolete tests for kindness and penalty default management.
- Updated authentication tokens in user.json for improved security.
- Enhanced test reliability by implementing retry logic for UI interactions in the create-child happy path test.
2026-03-13 23:26:27 -04:00
ryan 8da04676ca Add end-to-end tests for parent rewards management
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 2m33s
- Implement tests for creating, editing, canceling, and deleting rewards in parent mode.
- Include scenarios for converting default rewards to user items and verifying restoration of default rewards after deletion.
- Create a comprehensive test plan outlining the steps and expectations for each scenario.
2026-03-12 23:53:36 -04:00
ryan f250c42e5e Add end-to-end tests for parent item management
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m31s
- Implement tests for creating, editing, and deleting chores, kindness acts, and penalties.
- Add tests to verify conversion of default items to user items and restoration of system defaults upon deletion.
- Ensure proper cancellation of creation and editing actions.
- Create a comprehensive plan document outlining the test scenarios and expected behaviors.
2026-03-12 12:22:37 -04:00
ryan accf596bd7 more tests
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m30s
2026-03-09 13:28:43 -04:00
ryan 2c65d3ecaf temp changes
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m59s
2026-03-09 10:16:39 -04:00
ryan a8d7427a95 feat: enhance Playwright testing setup with E2E tests, new skills, and improved documentation
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 1m44s
- Added E2E test setup in `auth_api.py` with `/e2e-seed` endpoint for database reset and test user creation.
- Integrated Playwright for end-to-end testing in the frontend with necessary dependencies in `package.json` and `package-lock.json`.
- Created Playwright configuration in `playwright.config.ts` to manage test execution and server setup.
- Developed new skills for Playwright best practices, visual regression, smoke test generation, and self-healing tests.
- Implemented new test cases for chore creation in `chores-create.smoke.spec.ts` and `chores-create.spec.ts`.
- Added page object models for `ChildEditPage` and `LandingPage` to streamline test interactions.
- Updated `.gitignore` to exclude Playwright reports and test results.
- Enhanced documentation in `copilot-instructions.md` for testing and E2E setup.
2026-03-07 10:13:21 -05:00
ryan b2618361e4 feat: implement force logout notifications for password reset and account deletion
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m29s
2026-03-05 16:52:11 -05:00
ryan a10836d412 feat: allow bypass of reset-password and verify routes for logged-in users
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m27s
2026-03-05 15:47:42 -05:00
ryan bb5330ac17 feat: allow bypass of reset-password and verify routes for logged-in users
Chore App Build, Test, and Push Docker Images / build-and-push (push) Has been cancelled
2026-03-05 15:46:59 -05:00
ryan 8cdc26cb88 feat: add email notification for build job status
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m11s
2026-03-05 14:41:26 -05:00
ryan de56eb064f feat: add email notification for build job status
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m13s
2026-03-05 14:03:45 -05:00
ryan 031d7c0eec feat: add email notification for build job status
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 2m39s
2026-03-05 13:51:35 -05:00
ryan f07af135b7 feat: add email notification for build job status
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 2m15s
2026-03-05 13:39:55 -05:00
ryan 60647bc742 feat: add email notification for build job status
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 2m39s
2026-03-05 12:53:53 -05:00
ryan 384be2a79e feat: update sign-out redirect to landing page
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 13m17s
2026-03-05 12:32:04 -05:00
ryan ccfc710753 feat: implement force logout event and update navigation redirects
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 1m37s
2026-03-05 09:52:19 -05:00
ryan 992dd8423f Refactor code structure for improved readability and maintainability
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m52s
2026-03-04 17:12:04 -05:00
ryan c922e1180d feat: add landing page components including hero, features, problem, and footer
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m23s
- Introduced LandingHero component with logo, tagline, and action buttons.
- Created LandingFeatures component to showcase chore system benefits.
- Developed LandingProblem component explaining the importance of a structured chore system.
- Implemented LandingFooter for navigation and copyright information.
- Added LandingPage to assemble all components and manage navigation.
- Included unit tests for LandingHero component to ensure functionality.
2026-03-04 16:21:26 -05:00
ryan 82ac820c67 Fixed issue with refresh token
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m9s
2026-03-02 16:01:54 -05:00
ryan 76fef8c688 feat: update test environment setup to include secret key and refresh token expiry
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m3s
2026-03-01 21:39:03 -05:00
ryan 16d3500368 feat: update test environment setup to include secret key and refresh token expiry
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m19s
2026-03-01 21:21:52 -05:00
ryan c3538cc3d4 feat: update test environment setup to include secret key and refresh token expiry
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m2s
2026-03-01 21:15:19 -05:00
ryan 6433236191 feat: update test environment setup to include secret key and refresh token expiry
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m14s
2026-03-01 20:59:26 -05:00
ryan ebaef16daf feat: implement long-term user login with refresh tokens
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m23s
- Introduced a dual-token system for user authentication: a short-lived access token and a long-lived rotating refresh token.
- Created a new RefreshToken model to manage refresh tokens securely.
- Updated auth_api.py to handle login, refresh, and logout processes with the new token system.
- Enhanced security measures including token rotation and theft detection.
- Updated frontend to handle token refresh on 401 errors and adjusted SSE authentication.
- Removed CORS middleware as it's unnecessary behind the nginx proxy.
- Added tests to ensure functionality and security of the new token system.
2026-03-01 19:27:25 -05:00
ryan d7316bb00a feat: add chore, kindness, and penalty management components
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m34s
- Implemented ChoreAssignView for assigning chores to children.
- Created ChoreConfirmDialog for confirming chore completion.
- Developed KindnessAssignView for assigning kindness acts.
- Added PenaltyAssignView for assigning penalties.
- Introduced ChoreEditView and ChoreView for editing and viewing chores.
- Created KindnessEditView and KindnessView for managing kindness acts.
- Developed PenaltyEditView and PenaltyView for managing penalties.
- Added TaskSubNav for navigation between chores, kindness acts, and penalties.
2026-02-28 11:25:56 -05:00
ryan 65e987ceb6 feat: add delay before showing dialogs and enhance item card styles for better user feedback
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 1m11s
2026-02-27 14:05:09 -05:00
ryan f12940dc11 fix: improve formatting and readability in ScheduleModal component
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m19s
2026-02-27 10:43:10 -05:00
ryan 1777700cc8 feat: add default_has_deadline to ChoreSchedule and update related components for deadline management
Chore App Build, Test, and Push Docker Images / build-and-push (push) Has been cancelled
2026-02-27 10:42:43 -05:00
ryan f5a752d873 fix: reset ready state on outside click and prevent task trigger on ignored clicks
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m30s
2026-02-26 16:44:22 -05:00
ryan a197f8e206 feat: Refactor ScheduleModal to support interval scheduling with date input and deadline toggle
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 2m30s
- Updated ChoreSchedule model to include anchor_date and interval_has_deadline.
- Refactored interval scheduling logic in scheduleUtils to use anchor_date.
- Introduced DateInputField component for selecting anchor dates in ScheduleModal.
- Enhanced ScheduleModal to include a stepper for interval days and a toggle for deadline.
- Updated tests for ScheduleModal and scheduleUtils to reflect new interval scheduling logic.
- Added DateInputField tests to ensure proper functionality and prop handling.
2026-02-26 15:16:46 -05:00
ryan 2403daa3f7 feat: add detailed specifications for Daily chore scheduler refactor phase 2
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 57s
2026-02-25 21:06:57 -05:00
ryan 91a52c1973 Refactor Time Selector and Scheduler UI; Implement TimePickerPopover Component
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 1m5s
- Updated TimeSelector.vue styles for smaller dimensions and font sizes.
- Added new API proxy for '/events' in vite.config.ts.
- Created bug specifications for various UI issues and fixes in bugs-1.0.5-001.md and bugs-1.0.5-002.md.
- Introduced TimePickerPopover.vue for a new time selection interface in the chore scheduler.
- Refactored ScheduleModal.vue to replace checkbox rows with a chip-based design for selecting specific days.
- Enhanced chore scheduling logic to ensure proper handling of time extensions and UI updates.
2026-02-25 19:45:31 -05:00
ryan a41a357f50 feat: update Vite configuration to load environment variables for backend host
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 1m1s
2026-02-23 16:52:22 -05:00
ryan 234adbe05f Add TimeSelector and ScheduleModal components with tests
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m45s
- Implemented TimeSelector component for selecting time with AM/PM toggle and minute/hour increment/decrement functionality.
- Created ScheduleModal component for scheduling chores with options for specific days or intervals.
- Added utility functions for scheduling logic in scheduleUtils.ts.
- Developed comprehensive tests for TimeSelector and scheduleUtils functions to ensure correct behavior.
2026-02-23 15:44:55 -05:00
ryan d8822b44be feat: add child actions menu specification for ParentView
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 1m37s
2026-02-22 16:24:26 -05:00
ryan d68272bb57 feat: add feature specification for scheduling chores on calendar days
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 4m30s
2026-02-22 10:10:56 -05:00
ryan 3673119ae2 fix: update BASE_VERSION to remove release candidate suffix
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 1m30s
2026-02-20 16:47:27 -05:00
ryan 55e7dc7568 feat: remove hashed passwords feature spec and migrate to archive
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m6s
fix: update login token expiration to 62 days

chore: bump version to 1.0.5RC1

test: add isParentPersistent to LoginButton.spec.ts

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

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

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

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

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

feat: implement parent mode expiry logic in auth.ts

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

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

test: add unit tests for ChildView and ParentView components

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

test: add ScrollingList component tests

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

feat: Introduce account deletion scheduler

- Implemented a scheduler to delete accounts marked for deletion after a configurable threshold.
- Added new fields to the User model to manage deletion status and attempts.
- Created admin API endpoints for managing deletion thresholds and viewing the deletion queue.
- Integrated error handling and logging for the deletion process.
- Developed unit tests for the deletion scheduler and related API endpoints.
- Documented the deletion process and acceptance criteria.
2026-02-09 15:39:43 -05:00
ryan 27f02224ab feat: Implement admin role validation and enhance user management scripts
Gitea Actions Demo / build-and-push (push) Successful in 17s
2026-02-08 23:19:30 -05:00
ryan 060b2953fa Add account deletion scheduler and comprehensive tests
Gitea Actions Demo / build-and-push (push) Successful in 49s
- Implemented account deletion scheduler in `account_deletion_scheduler.py` to manage user deletions based on a defined threshold.
- Added logging for deletion processes, including success and error messages.
- Created tests for deletion logic, including edge cases, retry logic, and integration tests to ensure complete deletion workflows.
- Ensured that deletion attempts are tracked and that users are marked for manual intervention after exceeding maximum attempts.
- Implemented functionality to check for interrupted deletions on application startup and retry them.
2026-02-08 22:42:36 -05:00
ryan 04f50c32ae feat: Refactor path handling for data directories and enhance test setup with user-specific image management
Gitea Actions Demo / build-and-push (push) Successful in 11s
2026-02-06 17:02:45 -05:00
ryan 0d651129cb feat: Implement account deletion (mark for removal) feature
Gitea Actions Demo / build-and-push (push) Successful in 23s
- Added `marked_for_deletion` and `marked_for_deletion_at` fields to User model (Python and TypeScript) with serialization updates
- Created POST /api/user/mark-for-deletion endpoint with JWT auth, error handling, and SSE event trigger
- Blocked login and password reset for marked users; added new error codes ACCOUNT_MARKED_FOR_DELETION and ALREADY_MARKED
- Updated UserProfile.vue with "Delete My Account" button, confirmation modal (email input), loading state, success/error modals, and sign-out/redirect logic
- Synced error codes and model fields between backend and frontend
- Added and updated backend and frontend tests to cover all flows and edge cases
- All Acceptance Criteria from the spec are complete and verified
2026-02-06 16:19:08 -05:00
ryan 47541afbbf Add unit tests for LoginButton component with comprehensive coverage
Gitea Actions Demo / build-and-push (push) Successful in 46s
2026-02-05 16:37:10 -05:00
ryan fd70eca0c9 feat: add restriction to prevent deletion of system tasks and rewards
Gitea Actions Demo / build-and-push (push) Successful in 23s
- Implemented logic to hide delete button for system tasks and rewards in ItemList.vue, TaskView.vue, and RewardView.vue.
- Added backend checks in task_api.py and reward_api.py to return 403 for delete requests on system items.
- Ensured that items without a user_id are treated as system items across frontend and backend.
- Updated acceptance criteria to include UI and backend tests for the new functionality.
2026-02-03 14:54:38 -05:00
ryan 99d3aeb068 refactor: Update layout and styling components; remove unused CSS files and enhance button styles
Gitea Actions Demo / build-and-push (push) Successful in 46s
2026-02-02 23:53:04 -05:00
ryan 5351932194 feat: Enhance task and reward assignment logic to prioritize user items over system items with the same name; add corresponding tests
Gitea Actions Demo / build-and-push (push) Successful in 15s
2026-02-01 23:39:55 -05:00
ryan e42c6c1ef2 feat: Implement logic to prevent deletion of system tasks and rewards; update APIs and tests accordingly
Gitea Actions Demo / build-and-push (push) Successful in 34s
2026-02-01 16:57:12 -05:00
ryan f14de28daa feat: Implement user validation and ownership checks for image, reward, and task APIs
Gitea Actions Demo / build-and-push (push) Successful in 36s
- Added `get_validated_user_id` utility function to validate user authentication across multiple APIs.
- Updated image upload, request, and listing endpoints to ensure user ownership and proper error handling.
- Enhanced reward management endpoints to include user validation and ownership checks.
- Modified task management endpoints to enforce user authentication and ownership verification.
- Updated models to include `user_id` for images, rewards, tasks, and children to track ownership.
- Implemented frontend changes to ensure UI reflects the ownership of tasks and rewards.
- Added a new feature specification to prevent deletion of system tasks and rewards.
2026-01-31 19:48:51 -05:00
ryan 6f5b61de7f feat: normalize email handling in signup, login, and verification processes; refactor event handling in task and reward components
Gitea Actions Demo / build-and-push (push) Successful in 50s
2026-01-28 16:42:06 -05:00
ryan 3066d7d356 feat: add parent PIN setup functionality and email notifications
Gitea Actions Demo / build-and-push (push) Successful in 23s
- Implemented User model updates to include PIN and related fields.
- Created email sender utility for sending verification and reset emails.
- Developed ParentPinSetup component for setting up a parent PIN with verification code.
- Enhanced UserProfile and EntityEditForm components to support new features.
- Updated routing to include PIN setup and authentication checks.
- Added styles for new components and improved existing styles for consistency.
- Introduced loading states and error handling in various components.
2026-01-27 14:47:49 -05:00
ryan cd9070ec99 Refactor build workflow to separate backend and frontend image builds; enhance clarity and structure
Gitea Actions Demo / build-and-push (push) Successful in 36s
2026-01-23 22:10:25 -05:00
ryan 74d6f5819c Refactor forms to use EntityEditForm component; enhance styles and improve structure
Gitea Actions Demo / build-and-push (push) Failing after 6s
2026-01-23 19:29:27 -05:00
ryan 63769fbe32 Refactor components to use ModalDialog and StatusMessage; update styles and remove unused files
Gitea Actions Demo / build-and-push (push) Failing after 9s
- Replaced inline modal dialogs in ParentView with a reusable ModalDialog component.
- Introduced StatusMessage component for loading and error states in ParentView.
- Updated styles to use new colors.css and styles.css for consistent theming.
- Removed ChildRewardList.vue and ChildTaskList.vue components as they were no longer needed.
- Adjusted RewardAssignView and TaskAssignView to use new styles and shared button styles.
- Cleaned up imports across components to reflect the new styles and removed unused CSS files.
2026-01-22 16:37:53 -05:00
ryan a0a059472b Moved things around
Gitea Actions Demo / build-and-push (push) Failing after 6s
2026-01-21 17:18:58 -05:00
ryan a47df7171c Add detailed Copilot instructions and enhance child API logging
Gitea Actions Demo / build-and-push (push) Successful in 14s
- Introduced a comprehensive instructions document for the Reward project, outlining architecture, data flow, key patterns, and developer workflows.
- Enhanced logging in the child API to track points and reward costs, improving error handling for insufficient points.
- Updated Vue components to reflect changes in reward handling and improve user experience with pending rewards.
2026-01-21 16:35:50 -05:00
ryan 59b480621e refactoring
Gitea Actions Demo / build-and-push (push) Successful in 12s
2026-01-18 21:56:19 -05:00
ryan 904185e5c8 refactoring
Gitea Actions Demo / build-and-push (push) Successful in 12s
2026-01-15 16:42:01 -05:00
ryan dcac2742e9 refactoring
Gitea Actions Demo / build-and-push (push) Successful in 13s
2026-01-14 14:42:54 -05:00
ryan c7c3cce76d added in gitea actions
Gitea Actions Demo / build-and-push (push) Successful in 10s
2026-01-13 16:09:38 -05:00
ryan 7de7047a4d added in gitea actions
Gitea Actions Demo / build-and-push (push) Successful in 9s
2026-01-13 16:05:34 -05:00
ryan 49c175c01d added in gitea actions
Gitea Actions Demo / build-and-push (push) Failing after 9s
2026-01-13 16:04:13 -05:00
ryan 35c4fcb9bb added in gitea actions
Gitea Actions Demo / build-and-push (push) Failing after 8s
2026-01-13 16:03:12 -05:00
ryan 3b1e1eae6d added in gitea actions
Gitea Actions Demo / build-and-push (push) Failing after 9s
2026-01-13 16:00:51 -05:00
ryan 6cec6bdb50 added in gitea actions
Gitea Actions Demo / build-and-push (push) Failing after 10s
2026-01-13 15:59:29 -05:00
ryan cc436798d1 added in gitea actions
Gitea Actions Demo / build-and-push (push) Has been cancelled
2026-01-13 15:54:57 -05:00
ryan cd34d27f76 added in gitea actions
Gitea Actions Demo / build-and-push (push) Has been cancelled
2026-01-13 15:54:16 -05:00
ryan 92020e68ce added in gitea actions
Gitea Actions Demo / build-and-push (push) Failing after 0s
2026-01-13 15:52:57 -05:00
ryan 7b91d2c8a4 added in gitea actions
Gitea Actions Demo / build-and-push (push) Has been cancelled
2026-01-13 15:38:57 -05:00
ryan 696683cf30 added in gitea actions
Gitea Actions Demo / build-and-push (push) Failing after 9s
2026-01-13 15:37:39 -05:00
ryan 96ccc1b04c added in gitea actions
Gitea Actions Demo / build-and-push (push) Failing after 7s
2026-01-13 15:36:42 -05:00
ryan 007187020b added in gitea actions
Gitea Actions Demo / build-and-push (push) Failing after 8s
2026-01-13 15:34:45 -05:00
ryan 39eea3ed07 added in gitea actions
Gitea Actions Demo / build-and-push (push) Failing after 27s
2026-01-13 15:33:36 -05:00
ryan eac6f4b848 added in gitea actions
Gitea Actions Demo / build-and-push (push) Has been cancelled
2026-01-13 14:54:51 -05:00
ryan caa28a3a2b added in gitea actions
Gitea Actions Demo / build-and-push (push) Failing after 7s
2026-01-13 14:51:44 -05:00
ryan fd5a828084 added in gitea actions
Gitea Actions Demo / build-and-push (push) Has been cancelled
2026-01-13 14:29:52 -05:00
ryan 76091ff06c added in gitea actions
Gitea Actions Demo / build-and-push (push) Failing after 15s
2026-01-13 14:11:32 -05:00
ryan 4b1b3cedd1 added in gitea actions
Gitea Actions Demo / build-and-push (push) Failing after 6s
2026-01-13 14:07:50 -05:00
ryan 40a835cfd2 added in gitea actions
Gitea Actions Demo / build-and-push (push) Failing after 7s
2026-01-13 14:06:55 -05:00
ryan a6936ce609 added in gitea actions
Gitea Actions Demo / build-and-push (push) Failing after 10s
2026-01-13 13:59:34 -05:00
ryan 0fd9c2618d added in gitea actions
Gitea Actions Demo / build-and-push (push) Failing after 16s
2026-01-13 10:35:35 -05:00
ryan 3091c5ca97 added in gitea actions
Gitea Actions Demo / build-and-push (push) Successful in 21s
2026-01-13 10:30:14 -05:00
ryan ee903f8bd6 added in gitea actions
Gitea Actions Demo / build-and-push (push) Failing after 11s
2026-01-13 10:04:44 -05:00
ryan c4713dd9ef added in gitea actions
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 11s
2026-01-12 21:22:35 -05:00
ryan a89d3d7313 starting refactor styling 2026-01-10 22:19:23 -05:00
ryan 9a6fbced15 added password reset 2026-01-07 15:28:07 -05:00
ryan 5b0fe2adc2 added password reset 2026-01-07 15:16:17 -05:00
ryan fd1057662f added seperate users for backend events 2026-01-06 16:25:09 -05:00
ryan d7fc3c0cab Added beginning of login functionality 2026-01-05 16:58:38 -05:00
ryan 1900667328 Added beginning of login functionality 2026-01-05 16:58:10 -05:00
ryan 03356d813f Added beginning of login functionality 2026-01-05 16:51:04 -05:00
ryan f65d97a50a Added beginning of login functionality 2026-01-05 16:18:59 -05:00
ryan 46af0fb959 Added beginning of login functionality 2026-01-05 15:08:29 -05:00
630 changed files with 73221 additions and 6204 deletions
+1
View File
@@ -0,0 +1 @@
FRONTEND_URL=https://yourdomain.com
+187
View File
@@ -0,0 +1,187 @@
name: Chore App Build, Test, and Push Docker Images
run-name: ${{ gitea.actor }} is building Chores [${{ gitea.ref_name }}@${{ gitea.sha }}] 🚀
on:
push:
branches:
- master
- next
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Check out repository code
uses: actions/checkout@v3
- name: Determine Image Tag
id: vars
run: |
version=$(python -c "import sys; sys.path.append('./backend'); from config.version import BASE_VERSION; print(BASE_VERSION)")
current_date=$(date +%Y%m%d)
if [ "${{ gitea.ref }}" == "refs/heads/master" ]; then
echo "tag=$version" >> $GITHUB_OUTPUT
else
echo "tag=next-$version-$current_date" >> $GITHUB_OUTPUT
fi
- name: Set up Python for backend tests
uses: actions/setup-python@v4
with:
python-version: "3.11"
- name: Install backend dependencies
run: |
python -m pip install --upgrade pip
pip install -r backend/requirements.txt
- name: Run backend unit tests
run: |
cd backend
pytest -q
- name: Set up Node.js for frontend tests
uses: actions/setup-node@v4
with:
node-version: "20.19.0"
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Install frontend dependencies
run: npm ci
working-directory: frontend
- name: Run frontend unit tests
run: npm run test:unit --if-present
working-directory: frontend
- name: Build Backend Docker Image
run: |
docker build -t git.ryankegel.com:3000/kegel/chores/backend:${{ steps.vars.outputs.tag }} ./backend
- name: Build Frontend Docker Image
run: |
docker build -t git.ryankegel.com:3000/kegel/chores/frontend:${{ steps.vars.outputs.tag }} ./frontend
- name: Log in to Registry
uses: docker/login-action@v2
with:
registry: git.ryankegel.com:3000
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Push Backend Image to Gitea Registry
run: |
for i in {1..3}; do
echo "Attempt $i to push backend image..."
if docker push git.ryankegel.com:3000/kegel/chores/backend:${{ steps.vars.outputs.tag }}; then
echo "Backend push succeeded on attempt $i"
break
else
echo "Backend push failed on attempt $i"
if [ $i -lt 3 ]; then
sleep 10
else
exit 1
fi
fi
done
if [ "${{ gitea.ref }}" == "refs/heads/master" ]; then
docker tag git.ryankegel.com:3000/kegel/chores/backend:${{ steps.vars.outputs.tag }} git.ryankegel.com:3000/kegel/chores/backend:latest
docker push git.ryankegel.com:3000/kegel/chores/backend:latest
elif [ "${{ gitea.ref }}" == "refs/heads/next" ]; then
docker tag git.ryankegel.com:3000/kegel/chores/backend:${{ steps.vars.outputs.tag }} git.ryankegel.com:3000/kegel/chores/backend:next
docker push git.ryankegel.com:3000/kegel/chores/backend:next
fi
- name: Push Frontend Image to Gitea Registry
run: |
for i in {1..3}; do
echo "Attempt $i to push frontend image..."
if docker push git.ryankegel.com:3000/kegel/chores/frontend:${{ steps.vars.outputs.tag }}; then
echo "Frontend push succeeded on attempt $i"
break
else
echo "Frontend push failed on attempt $i"
if [ $i -lt 3 ]; then
sleep 10
else
exit 1
fi
fi
done
if [ "${{ gitea.ref }}" == "refs/heads/master" ]; then
docker tag git.ryankegel.com:3000/kegel/chores/frontend:${{ steps.vars.outputs.tag }} git.ryankegel.com:3000/kegel/chores/frontend:latest
docker push git.ryankegel.com:3000/kegel/chores/frontend:latest
elif [ "${{ gitea.ref }}" == "refs/heads/next" ]; then
docker tag git.ryankegel.com:3000/kegel/chores/frontend:${{ steps.vars.outputs.tag }} git.ryankegel.com:3000/kegel/chores/frontend:next
docker push git.ryankegel.com:3000/kegel/chores/frontend:next
fi
- name: Deploy Test Environment
if: gitea.ref == 'refs/heads/next'
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.DEPLOY_TEST_HOST }}
username: ${{ secrets.DEPLOY_TEST_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
port: 22
script: |
cd /tmp
if [ -d "chore" ]; then
cd chore
git pull origin next || true
else
git clone --branch next https://git.ryankegel.com/ryan/chore.git
cd chore
fi
# Write .env file — docker-compose automatically reads this
cat > .env << EOF
SECRET_KEY=${{ secrets.SECRET_KEY }}
REFRESH_TOKEN_EXPIRY_DAYS=1
DIGEST_TOKEN_SECRET=${{ secrets.DIGEST_TOKEN_SECRET }}
VAPID_PUBLIC_KEY=${{ secrets.VAPID_PUBLIC_KEY }}
VAPID_PRIVATE_KEY=${{ secrets.VAPID_PRIVATE_KEY }}
SEED_EMAIL=${{ secrets.SEED_EMAIL }}
SEED_PASSWORD=${{ secrets.SEED_PASSWORD }}
SEED_PIN=${{ secrets.SEED_PIN }}
SEED_FIRST_NAME=${{ secrets.SEED_FIRST_NAME }}
SEED_LAST_NAME=${{ secrets.SEED_LAST_NAME }}
ADMIN_EMAIL=${{ secrets.ADMIN_EMAIL }}
ADMIN_PASSWORD=${{ secrets.ADMIN_PASSWORD }}
ADMIN_FIRST_NAME=${{ secrets.ADMIN_FIRST_NAME }}
ADMIN_LAST_NAME=${{ secrets.ADMIN_LAST_NAME }}
EOF
echo "SECRET_KEY is set: $(grep -q 'SECRET_KEY=' .env && echo YES || echo NO)"
echo "Bringing down previous test environment..."
docker-compose -f docker-compose.test.yml down --volumes --remove-orphans || true
echo "Starting new test environment..."
docker-compose -f docker-compose.test.yml pull
docker-compose -f docker-compose.test.yml up -d
echo "Waiting for backend to be ready..."
sleep 10
echo "Seeding test user..."
docker-compose -f docker-compose.test.yml exec -T chores-test-app-backend python scripts/seed_test_user.py
echo "Creating admin user..."
docker-compose -f docker-compose.test.yml exec -T chores-test-app-backend python scripts/create_admin.py
- name: Send mail
if: always() # Runs on success or failure
uses: dawidd6/action-send-mail@v3
with:
server_address: smtp.gmail.com
server_port: 465
username: ${{ secrets.MAIL_USER }}
password: ${{ secrets.MAIL_PASSWORD }}
secure: true
to: ${{ secrets.MAIL_TO }}
from: Gitea <git@git.ryankegel.com>
subject: ${{ github.repository }} - Job ${{ job.status }}
convert_markdown: true
html_body: |
### Job ${{ job.status }}
${{ github.repository }}: [${{ github.ref }}@${{ github.sha }}](${{ vars.GIT_SERVER }}/${{ github.repository }}/actions)
+503
View File
@@ -0,0 +1,503 @@
name: Promote Master to Production
run-name: ${{ gitea.actor }} promoting ${{ github.event.inputs.target_ref || 'master' }} [${{ gitea.sha }}]
on:
workflow_dispatch:
inputs:
target_ref:
description: "Git ref/branch to promote"
required: true
default: "master"
run_backend_tests:
description: "Run backend pytest gate"
required: true
default: "true"
run_frontend_tests:
description: "Run frontend unit test gate"
required: true
default: "true"
run_playwright_tests:
description: "Run Playwright E2E gate"
required: true
default: "true"
skip_tests:
description: "Skip the entire tests stage"
required: true
default: "false"
deploy:
description: "Run production deploy over SSH"
required: true
default: "true"
create_tag:
description: "Create and push git release tag after successful deploy"
required: true
default: "true"
require_manual_approval:
description: "Require approval token for deploy job"
required: true
default: "false"
approval_token:
description: "Approval token value when manual approval is required"
required: false
default: ""
rollback_on_failed_healthcheck:
description: "Auto-rollback if post-deploy health checks fail"
required: true
default: "false"
concurrency:
group: promotion-production
cancel-in-progress: false
jobs:
prepare:
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
target_ref: ${{ steps.resolve.outputs.target_ref }}
version: ${{ steps.version.outputs.version }}
release_tag: ${{ steps.version.outputs.release_tag }}
commit_sha: ${{ steps.commit.outputs.commit_sha }}
steps:
- name: Resolve target ref
id: resolve
run: |
target_ref="${{ github.event.inputs.target_ref }}"
if [ -z "$target_ref" ]; then
target_ref="master"
fi
echo "target_ref=$target_ref" >> "$GITHUB_OUTPUT"
- name: Check out repository code
uses: actions/checkout@v3
with:
ref: ${{ steps.resolve.outputs.target_ref }}
fetch-depth: 0
- name: Resolve version and release tag
id: version
run: |
version=$(python -c "import sys; sys.path.append('./backend'); from config.version import BASE_VERSION; print(BASE_VERSION)")
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "release_tag=v$version" >> "$GITHUB_OUTPUT"
- name: Resolve promoted commit SHA
id: commit
run: |
echo "commit_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Guard against duplicate release tags
if: ${{ github.event.inputs.create_tag != 'false' }}
run: |
release_tag="${{ steps.version.outputs.release_tag }}"
if git ls-remote --exit-code --tags origin "refs/tags/${release_tag}" >/dev/null 2>&1; then
echo "Release tag ${release_tag} already exists on origin; aborting promotion."
exit 1
fi
tests:
runs-on: ubuntu-latest
timeout-minutes: 120
needs: prepare
steps:
- name: Skip tests stage
if: ${{ github.event.inputs.skip_tests == 'true' }}
run: echo "Skipping tests stage because skip_tests=true"
- name: Check out promoted ref
if: ${{ github.event.inputs.skip_tests != 'true' }}
uses: actions/checkout@v3
with:
ref: ${{ needs.prepare.outputs.target_ref }}
- name: Set up Python for backend tests
if: ${{ github.event.inputs.skip_tests != 'true' && (github.event.inputs.run_backend_tests != 'false' || github.event.inputs.run_playwright_tests != 'false') }}
uses: actions/setup-python@v4
with:
python-version: "3.11"
- name: Install backend dependencies (runner python)
if: ${{ github.event.inputs.skip_tests != 'true' && github.event.inputs.run_backend_tests != 'false' }}
run: |
python -m pip install --upgrade pip
pip install -r backend/requirements.txt
- name: Run backend unit tests
if: ${{ github.event.inputs.skip_tests != 'true' && github.event.inputs.run_backend_tests != 'false' }}
run: |
cd backend
pytest -q
- name: Set up Node.js
if: ${{ github.event.inputs.skip_tests != 'true' && (github.event.inputs.run_frontend_tests != 'false' || github.event.inputs.run_playwright_tests != 'false') }}
uses: actions/setup-node@v4
with:
node-version: "20.19.0"
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Install frontend dependencies
if: ${{ github.event.inputs.skip_tests != 'true' && (github.event.inputs.run_frontend_tests != 'false' || github.event.inputs.run_playwright_tests != 'false') }}
run: npm ci
working-directory: frontend
- name: Run frontend unit tests
if: ${{ github.event.inputs.skip_tests != 'true' && github.event.inputs.run_frontend_tests != 'false' }}
run: npm run test:unit --if-present
working-directory: frontend
- name: Create backend venv for Playwright webServer
if: ${{ github.event.inputs.skip_tests != 'true' && github.event.inputs.run_playwright_tests != 'false' }}
run: |
python -m venv backend/.venv
backend/.venv/bin/python -m pip install --upgrade pip
backend/.venv/bin/python -m pip install -r backend/requirements.txt
- name: Install Playwright browsers
if: ${{ github.event.inputs.skip_tests != 'true' && github.event.inputs.run_playwright_tests != 'false' }}
run: npx playwright install --with-deps
working-directory: frontend
- name: Run Playwright tests
if: ${{ github.event.inputs.skip_tests != 'true' && github.event.inputs.run_playwright_tests != 'false' }}
run: npx playwright test --reporter=line
working-directory: frontend
env:
CI: "true"
PLAYWRIGHT_BASE_URL: "https://localhost:5173"
E2E_ACCESS_TOKEN_EXPIRY_MINUTES: "180"
deploy:
runs-on: ubuntu-latest
timeout-minutes: 45
needs:
- prepare
- tests
if: ${{ github.event.inputs.deploy != 'false' }}
steps:
- name: Validate manual approval token
if: ${{ github.event.inputs.require_manual_approval != 'false' }}
run: |
if [ -z "${{ secrets.PROD_APPROVAL_TOKEN }}" ]; then
echo "PROD_APPROVAL_TOKEN secret is required when manual approval is enabled."
exit 1
fi
if [ -z "${{ github.event.inputs.approval_token }}" ]; then
echo "approval_token input is required when manual approval is enabled."
exit 1
fi
if [ "${{ github.event.inputs.approval_token }}" != "${{ secrets.PROD_APPROVAL_TOKEN }}" ]; then
echo "Approval token does not match PROD_APPROVAL_TOKEN."
exit 1
fi
- name: Deploy to production homeserver
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.DEPLOY_PROD_HOST }}
username: ${{ secrets.DEPLOY_PROD_USER }}
key: ${{ secrets.PROD_SSH_PRIVATE_KEY }}
port: 22
script: |
set -euo pipefail
repo_dir="${{ secrets.DEPLOY_PROD_PATH }}"
if [ -z "$repo_dir" ]; then
repo_dir="$HOME/chore"
fi
repo_parent=$(dirname "$repo_dir")
if [ ! -d "$repo_parent" ]; then
mkdir -p "$repo_parent"
fi
if [ ! -w "$repo_parent" ]; then
echo "Deploy user $(whoami) cannot write to ${repo_parent}."
echo "Set DEPLOY_PROD_PATH to a writable location or grant write access before rerunning promotion."
exit 1
fi
if [ -d "$repo_dir/.git" ]; then
cd "$repo_dir"
git fetch --all --tags
else
git clone --branch "${{ needs.prepare.outputs.target_ref }}" https://git.ryankegel.com/ryan/chore.git "$repo_dir"
cd "$repo_dir"
fi
git checkout "${{ needs.prepare.outputs.target_ref }}"
git reset --hard "origin/${{ needs.prepare.outputs.target_ref }}"
timestamp=$(date +%Y%m%d-%H%M%S)
mkdir -p backups
# Capture currently running images for rollback.
backend_prev_id=""
frontend_prev_id=""
if docker ps --format '{{.Names}}' | grep -q '^chores-app-backend-prod$'; then
backend_prev_id=$(docker inspect -f '{{.Image}}' chores-app-backend-prod)
docker tag "$backend_prev_id" git.ryankegel.com:3000/kegel/chores/backend:predeploy-backup || true
fi
if docker ps --format '{{.Names}}' | grep -q '^chores-app-frontend-prod$'; then
frontend_prev_id=$(docker inspect -f '{{.Image}}' chores-app-frontend-prod)
docker tag "$frontend_prev_id" git.ryankegel.com:3000/kegel/chores/frontend:predeploy-backup || true
fi
cat > .rollback-meta << EOF
BACKEND_PREV_ID=${backend_prev_id}
FRONTEND_PREV_ID=${frontend_prev_id}
CREATED_AT=${timestamp}
PROMOTED_SHA=${{ needs.prepare.outputs.commit_sha }}
RELEASE_TAG=${{ needs.prepare.outputs.release_tag }}
EOF
# Backup TinyDB volume before replacement.
docker run --rm \
-v chores-app-backend-data:/data \
-v "$PWD/backups:/backup" \
alpine sh -c "tar czf /backup/chores-backend-data-${timestamp}.tar.gz -C /data ." || true
# Keep long-lived cryptographic values stable; fall back to previous .env if secrets are absent.
PREV_SECRET_KEY=""
PREV_DIGEST_TOKEN_SECRET=""
PREV_VAPID_PUBLIC_KEY=""
PREV_VAPID_PRIVATE_KEY=""
PREV_REFRESH_TOKEN_EXPIRY_DAYS=""
if [ -f .env ]; then
PREV_SECRET_KEY=$(grep '^SECRET_KEY=' .env | head -n1 | cut -d '=' -f2- || true)
PREV_DIGEST_TOKEN_SECRET=$(grep '^DIGEST_TOKEN_SECRET=' .env | head -n1 | cut -d '=' -f2- || true)
PREV_VAPID_PUBLIC_KEY=$(grep '^VAPID_PUBLIC_KEY=' .env | head -n1 | cut -d '=' -f2- || true)
PREV_VAPID_PRIVATE_KEY=$(grep '^VAPID_PRIVATE_KEY=' .env | head -n1 | cut -d '=' -f2- || true)
PREV_REFRESH_TOKEN_EXPIRY_DAYS=$(grep '^REFRESH_TOKEN_EXPIRY_DAYS=' .env | head -n1 | cut -d '=' -f2- || true)
fi
SECRET_KEY_VALUE="${{ secrets.PROD_SECRET_KEY }}"
DIGEST_TOKEN_SECRET_VALUE="${{ secrets.PROD_DIGEST_TOKEN_SECRET }}"
VAPID_PUBLIC_KEY_VALUE="${{ secrets.PROD_VAPID_PUBLIC_KEY }}"
VAPID_PRIVATE_KEY_VALUE="${{ secrets.PROD_VAPID_PRIVATE_KEY }}"
REFRESH_TOKEN_EXPIRY_DAYS_VALUE="${{ secrets.PROD_REFRESH_TOKEN_EXPIRY_DAYS }}"
if [ -z "$SECRET_KEY_VALUE" ]; then
if [ -n "$PREV_SECRET_KEY" ]; then
SECRET_KEY_VALUE="$PREV_SECRET_KEY"
else
SECRET_KEY_VALUE=$(openssl rand -hex 32)
fi
fi
if [ -z "$DIGEST_TOKEN_SECRET_VALUE" ]; then
if [ -n "$PREV_DIGEST_TOKEN_SECRET" ]; then
DIGEST_TOKEN_SECRET_VALUE="$PREV_DIGEST_TOKEN_SECRET"
else
DIGEST_TOKEN_SECRET_VALUE=$(openssl rand -hex 32)
fi
fi
if [ -z "$VAPID_PUBLIC_KEY_VALUE" ] && [ -n "$PREV_VAPID_PUBLIC_KEY" ]; then
VAPID_PUBLIC_KEY_VALUE="$PREV_VAPID_PUBLIC_KEY"
fi
if [ -z "$VAPID_PRIVATE_KEY_VALUE" ] && [ -n "$PREV_VAPID_PRIVATE_KEY" ]; then
VAPID_PRIVATE_KEY_VALUE="$PREV_VAPID_PRIVATE_KEY"
fi
if [ -z "$VAPID_PUBLIC_KEY_VALUE" ] || [ -z "$VAPID_PRIVATE_KEY_VALUE" ]; then
echo "VAPID keys are required (set PROD_VAPID_PUBLIC_KEY/PROD_VAPID_PRIVATE_KEY or keep previous .env values)."
exit 1
fi
if [ -z "$REFRESH_TOKEN_EXPIRY_DAYS_VALUE" ]; then
if [ -n "$PREV_REFRESH_TOKEN_EXPIRY_DAYS" ]; then
REFRESH_TOKEN_EXPIRY_DAYS_VALUE="$PREV_REFRESH_TOKEN_EXPIRY_DAYS"
else
REFRESH_TOKEN_EXPIRY_DAYS_VALUE="90"
fi
fi
if ! printf '%s' "$REFRESH_TOKEN_EXPIRY_DAYS_VALUE" | grep -Eq '^[0-9]+$'; then
echo "REFRESH_TOKEN_EXPIRY_DAYS must be a positive integer, got: $REFRESH_TOKEN_EXPIRY_DAYS_VALUE"
exit 1
fi
cat > .env << EOF
FRONTEND_URL=${{ secrets.PROD_FRONTEND_URL }}
BACKEND_HOST_PORT=${{ secrets.PROD_BACKEND_HOST_PORT }}
FRONTEND_HOST_PORT=${{ secrets.PROD_FRONTEND_HOST_PORT }}
SECRET_KEY=${SECRET_KEY_VALUE}
REFRESH_TOKEN_EXPIRY_DAYS=${REFRESH_TOKEN_EXPIRY_DAYS_VALUE}
DIGEST_TOKEN_SECRET=${DIGEST_TOKEN_SECRET_VALUE}
VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY_VALUE}
VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY_VALUE}
ADMIN_EMAIL=${{ secrets.PROD_ADMIN_EMAIL }}
ADMIN_PASSWORD=${{ secrets.PROD_ADMIN_PASSWORD }}
ADMIN_FIRST_NAME=${{ secrets.PROD_ADMIN_FIRST_NAME }}
ADMIN_LAST_NAME=${{ secrets.PROD_ADMIN_LAST_NAME }}
EOF
chmod 600 .env .rollback-meta
backend_host_port=$(grep '^BACKEND_HOST_PORT=' .env | head -n1 | cut -d '=' -f2- || true)
frontend_host_port=$(grep '^FRONTEND_HOST_PORT=' .env | head -n1 | cut -d '=' -f2- || true)
if [ -z "$backend_host_port" ]; then
backend_host_port="5001"
fi
if [ -z "$frontend_host_port" ]; then
frontend_host_port="4601"
fi
docker-compose down
wait_for_container_removal() {
container_name="$1"
attempts=0
while docker ps -a --format '{{.Names}}' | grep -qx "$container_name"; do
attempts=$((attempts + 1))
if [ "$attempts" -ge 12 ]; then
echo "Container ${container_name} still exists after waiting for teardown."
docker ps -a --filter "name=^${container_name}$"
return 1
fi
echo "Waiting for ${container_name} to be removed..."
sleep 5
done
}
wait_for_port_release() {
port="$1"
attempts=0
while docker ps -a --format '{{.ID}} {{.Names}} {{.Ports}} {{.Status}}' | grep -E "(^|[[:space:]])0\.0\.0\.0:${port}->|:::${port}->" >/dev/null 2>&1; do
attempts=$((attempts + 1))
if [ "$attempts" -ge 12 ]; then
echo "Port ${port} is still allocated after teardown. Blocking container(s):"
docker ps -a --format '{{.ID}} {{.Names}} {{.Ports}} {{.Status}}' | grep -E "(^|[[:space:]])0\.0\.0\.0:${port}->|:::${port}->" || true
return 1
fi
echo "Waiting for port ${port} to be released..."
sleep 5
done
}
wait_for_container_removal chores-app-frontend-prod
wait_for_container_removal chores-app-backend-prod
wait_for_port_release "$frontend_host_port"
wait_for_port_release "$backend_host_port"
docker-compose pull
docker-compose up -d
sleep 20
backend_running=$(docker inspect -f '{{.State.Running}}' chores-app-backend-prod 2>/dev/null || echo false)
frontend_running=$(docker inspect -f '{{.State.Running}}' chores-app-frontend-prod 2>/dev/null || echo false)
backend_ok=false
if curl -fsS "http://localhost:${backend_host_port}/version" >/dev/null 2>&1; then
backend_ok=true
fi
frontend_ok=false
if curl -fsS "http://localhost:${frontend_host_port}/" >/dev/null 2>&1; then
frontend_ok=true
fi
if [ "$backend_running" != "true" ] || [ "$frontend_running" != "true" ] || [ "$backend_ok" != "true" ] || [ "$frontend_ok" != "true" ]; then
echo "Post-deploy health check failed."
docker-compose ps
echo "--- Backend logs (last 200 lines) ---"
docker logs --tail 200 chores-app-backend-prod || true
echo "--- Frontend logs (last 120 lines) ---"
docker logs --tail 120 chores-app-frontend-prod || true
if [ "${{ github.event.inputs.rollback_on_failed_healthcheck }}" = "true" ]; then
echo "Attempting rollback using predeploy-backup image tags..."
docker tag git.ryankegel.com:3000/kegel/chores/backend:predeploy-backup git.ryankegel.com:3000/kegel/chores/backend:latest || true
docker tag git.ryankegel.com:3000/kegel/chores/frontend:predeploy-backup git.ryankegel.com:3000/kegel/chores/frontend:latest || true
docker-compose up -d
sleep 20
curl -fsS "http://localhost:${backend_host_port}/version" >/dev/null
fi
exit 1
fi
docker-compose ps
create-release-tag:
runs-on: ubuntu-latest
timeout-minutes: 10
needs:
- prepare
- tests
- deploy
if: ${{ github.event.inputs.create_tag != 'false' && github.event.inputs.deploy != 'false' }}
steps:
- name: Check out promoted ref
uses: actions/checkout@v3
with:
ref: ${{ needs.prepare.outputs.target_ref }}
fetch-depth: 0
- name: Create and push annotated release tag
run: |
tag_name="${{ needs.prepare.outputs.release_tag }}"
git config user.name "gitea-actions"
git config user.email "gitea-actions@local"
git tag -a "$tag_name" -m "Release $tag_name"
git push origin "$tag_name"
notify-on-failure:
runs-on: ubuntu-latest
needs:
- prepare
- tests
- deploy
- create-release-tag
if: ${{ always() }}
steps:
- name: Send email when promotion fails
if: ${{ needs.prepare.result == 'failure' || needs.tests.result == 'failure' || needs.deploy.result == 'failure' || needs.create-release-tag.result == 'failure' }}
uses: dawidd6/action-send-mail@v3
with:
server_address: smtp.gmail.com
server_port: 465
username: ${{ secrets.MAIL_USER }}
password: ${{ secrets.MAIL_PASSWORD }}
secure: true
to: ${{ secrets.MAIL_TO }}
from: Gitea <git@git.ryankegel.com>
subject: Promotion failed - ${{ gitea.repository }} [${{ needs.prepare.outputs.target_ref }}@${{ needs.prepare.outputs.commit_sha }}]
convert_markdown: true
html_body: |
### Production promotion failed
- Repository: ${{ gitea.repository }}
- Ref: ${{ needs.prepare.outputs.target_ref }}
- Commit: ${{ needs.prepare.outputs.commit_sha }}
- Intended tag: ${{ needs.prepare.outputs.release_tag }}
- prepare: ${{ needs.prepare.result }}
- tests: ${{ needs.tests.result }}
- deploy: ${{ needs.deploy.result }}
- tag: ${{ needs.create-release-tag.result }}
- name: Send email when promotion succeeds
if: ${{ needs.prepare.result == 'success' && needs.tests.result == 'success' && (needs.deploy.result == 'success' || needs.deploy.result == 'skipped') && (needs.create-release-tag.result == 'success' || needs.create-release-tag.result == 'skipped') }}
uses: dawidd6/action-send-mail@v3
with:
server_address: smtp.gmail.com
server_port: 465
username: ${{ secrets.MAIL_USER }}
password: ${{ secrets.MAIL_PASSWORD }}
secure: true
to: ${{ secrets.MAIL_TO }}
from: Gitea <git@git.ryankegel.com>
subject: Promotion succeeded - ${{ gitea.repository }} [${{ needs.prepare.outputs.target_ref }}@${{ needs.prepare.outputs.commit_sha }}]
convert_markdown: true
html_body: |
### Production promotion succeeded
- Repository: ${{ gitea.repository }}
- Ref: ${{ needs.prepare.outputs.target_ref }}
- Commit: ${{ needs.prepare.outputs.commit_sha }}
- Release tag: ${{ needs.prepare.outputs.release_tag }}
- prepare: ${{ needs.prepare.result }}
- tests: ${{ needs.tests.result }}
- deploy: ${{ needs.deploy.result }}
- tag: ${{ needs.create-release-tag.result }}
@@ -0,0 +1,29 @@
---
name: playwright-implementation
description: Converts plans into code and performs self-healing verification.
tools: [execute, read, edit, search, "playwright-test/*"]
---
# Role: Senior QA Automation Engineer
You are a Playwright expert. Your goal is to create robust, flake-free E2E tests.
# Test Implementation & Healing Workflow
When you receive a test plan:
1. **Implement**: Generate the `.spec.ts` files in `/tests` using standard Playwright patterns.
2. **Verify**: Once files are written, execute the following command in the terminal:
`npx playwright test --agent=healer`
3. **Analyze & Repair**:
- If the Healer Agent proposes a patch, review it.
- If the test still fails after healing, check the **Flask backend logs** to see if it's an API error rather than a UI error.
4. **Final Check**: Only mark the task as "Complete" once `npx playwright test` returns a clean pass.
## Rules of Engagement
1. **Locators:** Prioritize `getByRole`, `getByLabel`, and `getByText`. Avoid CSS selectors unless necessary.
2. **Page Objects:** Always use the Page Object Model (POM). Check `tests/pages/` for existing objects before creating new ones.
3. **Environment:** The app runs at `https://localhost:5173` (HTTPS — self-signed cert). The backend runs at `http://localhost:5000`.
4. **Authentication:** Auth is handled globally via `storageState`. Do NOT navigate to `/auth/login` in any test — you are already logged in. Never hardcode credentials; import `E2E_EMAIL` and `E2E_PASSWORD` from `tests/global-setup.ts` if needed.
@@ -0,0 +1,30 @@
---
name: playwright-research
description: Scans codebase and explores URLs to create Playwright test plans.
#argument-hint: The inputs this agent expects, e.g., "a task to implement" or "a question to answer".
tools: ["read", "search", "playwright/*", "web"]
handoffs:
- label: Start Implementation
agent: playwright-implementation
prompt: Implement the test plan
send: true
# tools: ['vscode', 'execute', 'read', 'agent', 'edit', 'search', 'web', 'todo'] # specify the tools this agent can use. If not set, all enabled tools are allowed.
---
# Test Architect Persona
You are a Senior QA Architect. Your goal is to analyze the user's codebase and
live application to identify high-value test cases.
### Your Workflow:
1. **Scan**: Use `read` and `search` to understand existing project structure and components.
2. **Explore**: Use `playwright/navigate` and `playwright/screenshot` to explore the live UI.
3. **Analyze**: Identify edge cases, happy paths, and critical user journeys.
4. **Present**: Output a structured Markdown Test Plan.
### Hard Constraints:
- **DO NOT** write any `.spec.ts` or `.js` files.
- **DO NOT** modify existing code.
- **ONLY** present the plan and wait for feedback.
@@ -0,0 +1,93 @@
---
name: playwright-test-generator
description: 'Use this agent when you need to create automated browser tests using Playwright Examples: <example>Context: User wants to generate a test for the test plan item. <test-suite><!-- Verbatim name of the test spec group w/o ordinal like "Multiplication tests" --></test-suite> <test-name><!-- Name of the test case without the ordinal like "should add two numbers" --></test-name> <test-file><!-- Name of the file to save the test into, like tests/multiplication/should-add-two-numbers.spec.ts --></test-file> <seed-file><!-- Seed file path from test plan --></seed-file> <body><!-- Test case content including steps and expectations --></body></example>'
tools:
- search
- playwright-test/browser_click
- playwright-test/browser_drag
- playwright-test/browser_evaluate
- playwright-test/browser_file_upload
- playwright-test/browser_handle_dialog
- playwright-test/browser_hover
- playwright-test/browser_navigate
- playwright-test/browser_press_key
- playwright-test/browser_select_option
- playwright-test/browser_snapshot
- playwright-test/browser_type
- playwright-test/browser_verify_element_visible
- playwright-test/browser_verify_list_visible
- playwright-test/browser_verify_text_visible
- playwright-test/browser_verify_value
- playwright-test/browser_wait_for
- playwright-test/generator_read_log
- playwright-test/generator_setup_page
- playwright-test/generator_write_test
model: Claude Sonnet 4.6
mcp-servers:
playwright-test:
type: stdio
command: npx
args:
- playwright
- run-test-mcp-server
tools:
- "*"
---
You are a Playwright Test Generator, an expert in browser automation and end-to-end testing.
Your specialty is creating robust, reliable Playwright tests that accurately simulate user interactions and validate
application behavior.
# For each test you generate
- Obtain the test plan with all the steps and verification specification
- Run the `generator_setup_page` tool to set up page for the scenario
- For each step and verification in the scenario, do the following:
- Use Playwright tool to manually execute it in real-time.
- Use the step description as the intent for each Playwright tool call.
- Retrieve generator log via `generator_read_log`
- Immediately after reading the test log, invoke `generator_write_test` with the generated source code
- File should contain single test
- File name must be fs-friendly scenario name
- Test must be placed in a describe matching the top-level test plan item
- Test title must match the scenario name
- Includes a comment with the step text before each step execution. Do not duplicate comments if step requires
multiple actions.
- Always use best practices from the log when generating tests.
<example-generation>
For following plan:
```markdown file=specs/plan.md
### 1. Adding New Todos
**Seed:** `tests/seed.spec.ts`
#### 1.1 Add Valid Todo
**Steps:**
1. Click in the "What needs to be done?" input field
#### 1.2 Add Multiple Todos
...
```
Following file is generated:
```ts file=add-valid-todo.spec.ts
// spec: specs/plan.md
// seed: tests/seed.spec.ts
test.describe('Adding New Todos', () => {
test('Add Valid Todo', async { page } => {
// 1. Click in the "What needs to be done?" input field
await page.click(...);
...
});
});
```
</example-generation>
@@ -0,0 +1,63 @@
---
name: playwright-test-healer
description: Use this agent when you need to debug and fix failing Playwright tests
tools:
- search
- edit
- playwright-test/browser_console_messages
- playwright-test/browser_evaluate
- playwright-test/browser_generate_locator
- playwright-test/browser_network_requests
- playwright-test/browser_snapshot
- playwright-test/test_debug
- playwright-test/test_list
- playwright-test/test_run
model: Claude Sonnet 4
mcp-servers:
playwright-test:
type: stdio
command: npx
args:
- playwright
- run-test-mcp-server
tools:
- "*"
---
You are the Playwright Test Healer, an expert test automation engineer specializing in debugging and
resolving Playwright test failures. Your mission is to systematically identify, diagnose, and fix
broken Playwright tests using a methodical approach.
Your workflow:
1. **Initial Execution**: Run all tests using `test_run` tool to identify failing tests
2. **Debug failed tests**: For each failing test run `test_debug`.
3. **Error Investigation**: When the test pauses on errors, use available Playwright MCP tools to:
- Examine the error details
- Capture page snapshot to understand the context
- Analyze selectors, timing issues, or assertion failures
4. **Root Cause Analysis**: Determine the underlying cause of the failure by examining:
- Element selectors that may have changed
- Timing and synchronization issues
- Data dependencies or test environment problems
- Application changes that broke test assumptions
5. **Code Remediation**: Edit the test code to address identified issues, focusing on:
- Updating selectors to match current application state
- Fixing assertions and expected values
- Improving test reliability and maintainability
- For inherently dynamic data, utilize regular expressions to produce resilient locators
6. **Verification**: Restart the test after each fix to validate the changes
7. **Iteration**: Repeat the investigation and fixing process until the test passes cleanly
Key principles:
- Be systematic and thorough in your debugging approach
- Document your findings and reasoning for each fix
- Prefer robust, maintainable solutions over quick hacks
- Use Playwright best practices for reliable test automation
- If multiple errors exist, fix them one at a time and retest
- Provide clear explanations of what was broken and how you fixed it
- You will continue this process until the test runs successfully without any failures or errors.
- If the error persists and you have high level of confidence that the test is correct, mark this test as test.fixme()
so that it is skipped during the execution. Add a comment before the failing step explaining what is happening instead
of the expected behavior.
- Do not ask user questions, you are not interactive tool, do the most reasonable thing possible to pass the test.
- Never wait for networkidle or use other discouraged or deprecated apis
@@ -0,0 +1,82 @@
---
name: playwright-test-planner
description: Use this agent when you need to create comprehensive test plan for a web application or website
tools:
- search
- playwright-test/browser_click
- playwright-test/browser_close
- playwright-test/browser_console_messages
- playwright-test/browser_drag
- playwright-test/browser_evaluate
- playwright-test/browser_file_upload
- playwright-test/browser_handle_dialog
- playwright-test/browser_hover
- playwright-test/browser_navigate
- playwright-test/browser_navigate_back
- playwright-test/browser_network_requests
- playwright-test/browser_press_key
- playwright-test/browser_run_code
- playwright-test/browser_select_option
- playwright-test/browser_snapshot
- playwright-test/browser_take_screenshot
- playwright-test/browser_type
- playwright-test/browser_wait_for
- playwright-test/planner_setup_page
- playwright-test/planner_save_plan
model: Claude Sonnet 4.6
mcp-servers:
playwright-test:
type: stdio
command: npx
args:
- playwright
- run-test-mcp-server
tools:
- "*"
---
You are an expert web test planner with extensive experience in quality assurance, user experience testing, and test
scenario design. Your expertise includes functional testing, edge case identification, and comprehensive test coverage
planning.
You will:
1. **Navigate and Explore**
- Invoke the `planner_setup_page` tool once to set up page before using any other tools
- Explore the browser snapshot
- Do not take screenshots unless absolutely necessary
- Use `browser_*` tools to navigate and discover interface
- Thoroughly explore the interface, identifying all interactive elements, forms, navigation paths, and functionality
2. **Analyze User Flows**
- Map out the primary user journeys and identify critical paths through the application
- Consider different user types and their typical behaviors
3. **Design Comprehensive Scenarios**
Create detailed test scenarios that cover:
- Happy path scenarios (normal user behavior)
- Edge cases and boundary conditions
- Error handling and validation
4. **Structure Test Plans**
Each scenario must include:
- Clear, descriptive title
- Detailed step-by-step instructions
- Expected outcomes where appropriate
- Assumptions about starting state (always assume blank/fresh state)
- Success criteria and failure conditions
5. **Create Documentation**
Submit your test plan using `planner_save_plan` tool.
**Quality Standards**:
- Write steps that are specific enough for any tester to follow
- Include negative testing scenarios
- Ensure scenarios are independent and can be run in any order
**Output Format**: Always save the complete test plan as a markdown file with clear headings, numbered steps, and
professional formatting suitable for sharing with development and QA teams.
+25
View File
@@ -0,0 +1,25 @@
---
name: playwright
description: Expert in end-to-end testing using Playwright and TypeScript.
tools: [runCommands, readFile, editFiles, fetchWebpage, codebase, findTestFiles]
---
# Role: Senior QA Automation Engineer
You are a Playwright expert. Your goal is to create robust, flake-free E2E tests.
## Rules of Engagement
1. **Locators:** Prioritize `getByRole`, `getByLabel`, and `getByText`. Avoid CSS selectors unless necessary.
2. **Page Objects:** Always use the Page Object Model (POM). Check `tests/pages/` for existing objects before creating new ones.
3. **Execution:** After writing a test, run it using `npx playwright test` from `frontend/vue-app/`. If it fails, read the trace and fix the test immediately.
4. **Authentication:** Auth is handled globally via `storageState`. Do NOT navigate to `/auth/login` in any test — you are already logged in. Never hardcode credentials; import `E2E_EMAIL` and `E2E_PASSWORD` from `tests/global-setup.ts` if needed.
5. **Test Naming:** Test files must match the pattern `*.smoke.spec.ts` to be picked up by the `smoke` project in `playwright.config.ts`.
## Workflow
- **Step 1:** Read the relevant source code or component file.
- **Step 2:** Generate a plan for the test steps.
- **Step 3:** Create/Update Page Objects in `tests/pages/`.
- **Step 4:** Write the test file in `tests/` using the `*.smoke.spec.ts` naming convention.
- **Step 5:** Run the test and verify success.
+25
View File
@@ -0,0 +1,25 @@
---
name: playwright
description: Expert in end-to-end testing using Playwright and TypeScript.
---
# Role: Senior QA Automation Engineer
You are a Playwright expert. Your goal is to create robust, flake-free E2E tests.
## Rules of Engagement
1. **Locators:** Prioritize `getByRole`, `getByLabel`, and `getByText`. Avoid CSS selectors unless necessary.
2. **Page Objects:** Always use the Page Object Model (POM). Check `tests/pages/` for existing objects before creating new ones.
3. **Execution:** After writing a test, run it using `npx playwright test` from `frontend/vue-app/`. If it fails, read the trace and fix the test immediately.
4. **Environment:** The app runs at `https://localhost:5173` (HTTPS — self-signed cert). The backend runs at `http://localhost:5000`. Both must be running before tests execute. Use the `flask-backend` skill (sets `DB_ENV=e2e DATA_ENV=e2e`) and `vue-frontend` skill to start them.
5. **Authentication:** Auth is handled globally via `storageState`. Do NOT navigate to `/auth/login` in any test — you are already logged in. Never hardcode credentials; import `E2E_EMAIL` and `E2E_PASSWORD` from `tests/global-setup.ts` if needed.
6. **Test Naming:** Test files must match the pattern `*.smoke.spec.ts` to be picked up by the `smoke` project in `playwright.config.ts`.
## Workflow
- **Step 1:** Read the relevant source code or component file.
- **Step 2:** Generate a plan for the test steps.
- **Step 3:** Create/Update Page Objects in `tests/pages/`.
- **Step 4:** Write the test file in `tests/` using the `*.smoke.spec.ts` naming convention.
- **Step 5:** Run the test and verify success.
+73
View File
@@ -0,0 +1,73 @@
---
name: ui-planner
description: Expert UI/UX design review and creative alternative layouts for Vue 3 components. Use when asking for design critiques, layout ideas, visual polish, animation suggestions, or CSS improvements on existing .vue files.
argument-hint: review this design
tools:
- read
- search
- edit
- vscode
- web
- todo
---
You are a Senior UI/UX Architect specializing in clean, highly reactive web applications. Your goal is to provide expert design critiques and creative layout alternatives that are technically compatible with Vue 3 and this project's conventions.
## 🏗 Project Context
This is the **Reward** app — a family chore/reward tracker. Vue 3 (Composition API / `<script setup lang="ts">`) frontend. Key files:
- Theme tokens: `frontend/src/assets/colors.css`**always read this first** when reviewing a component.
- Layout wrappers: `ParentLayout` (admin views) and `ChildLayout` (child dashboard views).
- All `.vue` files use `<style scoped>`. Child component styling uses `:deep()` selectors.
- File order within `.vue` files: `<template>`, then `<script>`, then `<style>`.
### Available Design Tokens (from `colors.css`)
**Brand:** `--primary` (#667eea), `--secondary` (#7257b3), `--accent` (#cbd5e1)
**Header:** `--header-bg` (gradient primary → secondary)
**Buttons:** `--btn-primary`, `--btn-primary-hover`, `--btn-secondary`, `--btn-secondary-hover`, `--btn-secondary-text`, `--btn-danger`, `--btn-danger-hover`, `--btn-green`, `--btn-green-hover`
**List items:** `--list-item-bg`, `--list-item-bg-good` (#8dabfd), `--list-item-bg-bad` (#f98a8a), `--list-item-bg-reward` (#4ed271), `--list-item-border-good`, `--list-item-border-bad`, `--list-item-border-reward`
**Forms:** `--form-bg`, `--form-shadow`, `--form-heading`, `--form-label`, `--form-input-bg`, `--form-input-border`
**Modals/Cards:** `--modal-bg`, `--modal-shadow`, `--card-bg`, `--card-shadow`, `--card-title`
**Feedback:** `--error`, `--error-bg`, `--loading-color`, `--list-loading-color`
**FAB:** `--fab-bg`, `--fab-hover-bg`, `--fab-active-bg`
## 🛠 Technical Constraints
- **No utility frameworks.** No Tailwind, no Bootstrap.
- **Layout:** Flexbox and CSS Grid only.
- **Animations:** Vue `<Transition>` / `<TransitionGroup>` for state changes; `@keyframes` for loaders.
- **Theming:** Only use `var(--token)` from `colors.css` for any color, shadow, or spacing token. Never hardcode hex values.
- **Component-first:** Consider how changes affect child component slots and reusability.
---
## Workflow
When a component is shared, always **read the file** plus `colors.css` before responding.
### Step 1 — Design Audit
Critique the current UI concisely:
1. **Visual Hierarchy:** Does the most important information catch the eye first?
2. **State Clarity:** Are loading / empty / pending / success / error states visually distinct?
3. **Consistency:** Does spacing, typography, and color usage feel cohesive with the rest of the app?
### Step 2 — Creative Exploration
Offer a range of design directions — not a fixed number, but varied **perspectives**:
- **Refinement:** Polish what exists for better clarity or polish.
- **Layout Shift:** Reorganize spatial relationships of elements.
- **Visual Metaphor:** New ways to represent status (icons vs. text vs. color-coding vs. progress indicators).
- **Interactivity:** Transitions, micro-animations, or hover states that make the UI feel alive.
### Step 3 — Implementation Spec
For any direction the user wants to pursue, produce a complete spec:
- **Template:** Semantic HTML using existing project components and slot patterns.
- **CSS:** Complete `<style scoped>` rules using only `var(--token)` design tokens.
- **Logic:** TypeScript / Vue reactivity changes needed (computed props, refs, transitions).
+19
View File
@@ -0,0 +1,19 @@
**Powershell
git config --global alias.save-wip "!f() { git add . ; if (git log -1 --format=%s -eq 'wip') { git commit --amend --no-edit } else { git commit -m 'wip' }; git push origin `$(git branch --show-current):wip-sync --force-with-lease; }; f"
git config --global alias.load-wip "!f() { if (git diff-index --quiet HEAD --) { git fetch origin wip-sync; git merge origin/wip-sync; if (git log -1 --format=%s -eq 'wip') { git reset --soft HEAD~1; echo 'WIP Loaded and unwrapped.' } else { echo 'No WIP found. Merge complete.' } } else { echo 'Error: Uncommitted changes detected.'; exit 1 }; }; f"
git config --global alias.abort-wip "git reset --hard HEAD"
**Git Bash
git config --global alias.save-wip '!f() { git add . ; if [ "$(git log -1 --format=%s)" = "wip" ]; then git commit --amend --no-edit; else git commit -m "wip"; fi; git push origin $(git branch --show-current):wip-sync --force-with-lease; }; f'
git config --global alias.load-wip '!f() { if ! git diff-index --quiet HEAD --; then echo "Error: Uncommitted changes detected."; exit 1; fi; git fetch origin wip-sync && git merge origin/wip-sync && if [ "$(git log -1 --format=%s)" = "wip" ]; then git reset --soft HEAD~1; echo "WIP Loaded and unwrapped."; else echo "No WIP found. Merge complete."; fi; }; f'
git config --global alias.abort-wip 'git reset --hard HEAD'
**Mac
git config --global alias.save-wip '!f() { git add . ; if [ "$(git log -1 --format=%s)" = "wip" ]; then git commit --amend --no-edit; else git commit -m "wip"; fi; git push origin $(git branch --show-current):wip-sync --force-with-lease; }; f'
git config --global alias.load-wip '!f() { if ! git diff-index --quiet HEAD --; then echo "Error: Uncommitted changes detected."; exit 1; fi; git fetch origin wip-sync && git merge origin/wip-sync && if [ "$(git log -1 --format=%s)" = "wip" ]; then git reset --soft HEAD~1; echo "WIP Loaded and unwrapped."; else echo "No WIP found. Merge complete."; fi; }; f'
git config --global alias.abort-wip 'git reset --hard HEAD'
***Reset wip-sync
git push origin --delete wip-sync
+64
View File
@@ -0,0 +1,64 @@
# Reward Project: AI Coding Agent Instructions
## 🏗️ Architecture & Data Flow
- **Stack**: Flask (Python, backend) + Vue 3 (TypeScript, frontend) + TinyDB (JSON, thread-safe, see `db/`).
- **API**: RESTful endpoints in `api/`, grouped by entity (child, reward, task, user, image, etc). Each API file maps to a business domain.
- **Nginx Proxy**: Frontend nginx proxies `/api/*` to backend, stripping the `/api` prefix. Backend endpoints should NOT include `/api` in their route definitions. Example: Backend defines `@app.route('/user')`, frontend calls `/api/user`.
- **Models**: Maintain strict 1:1 mapping between Python `@dataclass`es (`backend/models/`) and TypeScript interfaces (`frontend/src/common/models.ts`).
- **Database**: Use TinyDB with `from_dict()`/`to_dict()` for serialization. All logic should operate on model instances, not raw dicts.
- **Events**: Real-time updates via Server-Sent Events (SSE). Every mutation (add/edit/delete/trigger) must call `send_event_for_current_user` (see `backend/events/`).
- **Changes**: Do not use comments to replace code. All changes must be reflected in both backend and frontend files as needed.
- **Specs**: If specs have a checklist, all items must be completed and marked done.
## 🧩 Key Patterns & Conventions
- **Frontend Styling**: Use only `:root` CSS variables from `colors.css` for all colors, spacing, and tokens. Example: `--btn-primary`, `--list-item-bg-good`.
- **Scoped Styles**: All `.vue` files must use `<style scoped>`. Reference global variables for theme consistency.
- **API Error Handling**: Backend returns JSON with `error` and `code` (see `backend/api/error_codes.py`). Frontend extracts `{ msg, code }` using `parseErrorResponse(res)` from `api.ts`.
- **JWT Auth**: Tokens are stored in HttpOnly, Secure, SameSite=Strict cookies.
- **Code Style**:
1. Follow PEP 8 for Python, and standard TypeScript conventions.
2. Use type annotations everywhere in Python.
3. Place all imports at the top of the file.
4. Vue files should specifically place `<template>`, `<script>`, then `<style>` in that order. Make sure to put ts code in `<script>` only.
## 🚦 Frontend Logic & Event Bus
- **SSE Event Management**: Register listeners in `onMounted`, clean up in `onUnmounted`. Listen for events like `child_task_triggered`, `child_reward_request`, `task_modified`, etc. See `frontend/src/common/backendEvents.ts` and `components/BackendEventsListener.vue`.
- **Layout Hierarchy**: Use `ParentLayout` for admin/management, `ChildLayout` for dashboard/focus views.
## ⚖️ Business Logic & Safeguards
- **Token Expiry**: Verification tokens expire in 4 hours; password reset tokens in 10 minutes.
- **Image Assets**: Models use `image_id` for storage; frontend resolves to `image_url` for rendering.
## 🛠️ Developer Workflows
- **Backend**: Run Flask with `python -m flask run --host=0.0.0.0 --port=5000` from the `backend/` directory. Main entry: `backend/main.py`.
- **Virtual Env**: Python is running from a virtual environment located at `backend/.venv/`.
- **Frontend**: From `frontend/`, 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
- `backend/api/` — Flask API endpoints (one file per entity)
- `backend/models/` — Python dataclasses (business logic, serialization)
- `backend/db/` — TinyDB setup and helpers
- `backend/events/` — SSE event types, broadcaster, payloads
- `frontend/` — Vue 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
- **Every backend mutation must trigger an SSE event** for the current user.
- **Frontend state is event-driven**: always listen for and react to SSE events for real-time updates.
- **Model changes require updating both Python and TypeScript definitions** to maintain parity.
---
For any unclear or missing conventions, review the referenced files or ask for clarification. Keep this document concise and actionable for AI agents.
+24
View File
@@ -0,0 +1,24 @@
---
name: flask-backend
description: Starts the Flask backend using the local virtual environment.
disable-model-invocation: true
---
# Instructions
1. **Locate Environment:** Check for a virtual environment folder (usually `.venv` or `venv`) inside the `/backend` directory.
2. **Activation Logic:**
- If on **Windows**: Use `backend\.venv\Scripts\activate`
- If on **macOS/Linux**: Use `source backend/.venv/bin/activate`
3. **Set Environment Variables:**
- `FLASK_APP`: `main.py`
- `FLASK_DEBUG`: `1`
- `DB_ENV`: `e2e`
- `DATA_ENV`: `e2e`
- `SECRET_KEY`: `dev-secret-key-change-in-production`
- `REFRESH_TOKEN_EXPIRY_DAYS`: `90`
4. **Command:** Execute the following via the `terminal` tool:
`flask run --host=0.0.0.0 --port=5000 --no-debugger --no-reload`
5. **Execution:** Run `python -m flask run --host=0.0.0.0 --port=5000`
_Note: Using `python -m flask` ensures the version inside the venv is used._
6. **Verification:** After running, check the terminal output for "Running on http://0.0.0.0:5000". If it fails, check if port 5000 is already in use.
@@ -0,0 +1,12 @@
---
name: playwright-best-practices
description: Enforces stable, maintainable, and high-performance Playwright test code.
---
# Playwright Best Practices
When generating or refactoring tests, you must adhere to these standards:
1. **User-Visible Locators**: Prioritize `page.getByRole()`, `page.getByText()`, and `page.getByLabel()`. Never use fragile CSS selectors (e.g., `.btn-primary`) or XPath unless no other option exists.
2. **Web-First Assertions**: Use the `expect(locator).to...` pattern (e.g., `toBeVisible()`, `toHaveText()`) to leverage Playwright's built-in auto-waiting and retry logic.
3. **Test Isolation**: Every test must be independent. Use `test.beforeEach` for setup (like navigation or login) rather than relying on the state of a previous test.
4. **Avoid Logic in Tests**: Keep tests declarative. Use Page Object Models (POM) if the logic for finding an element requires more than one line of code.
5. **Network Reliability**: Mock third-party APIs using `page.route()` to prevent external flakiness.
@@ -0,0 +1,27 @@
---
name: playwright-default
description: Provides Playwright test generation and analysis for E2E testing.
---
# Role: Senior QA Automation Engineer
You are a Playwright expert. Your goal is to create robust, flake-free E2E tests.
# Test Implementation & Healing Workflow
When you receive a test plan:
1. **Implement**: Generate the `.spec.ts` files in `/tests` using standard Playwright patterns.
2. **Verify**: Once files are written, execute the following command in the terminal:
`npx playwright test`
3. **Analyze & Repair**:
- If the playwright-healer skill proposes a patch, review it.
- If the test still fails after healing, check the **Flask backend logs** to see if it's an API error rather than a UI error.
4. **Final Check**: Only mark the task as "Complete" once `npx playwright test` returns a clean pass.
## Rules of Engagement
1. **Locators:** Prioritize `getByRole`, `getByLabel`, and `getByText`. Avoid CSS selectors unless necessary.
2. **Page Objects:** Always use the Page Object Model (POM). Check `tests/pages/` for existing objects before creating new ones.
3. **Environment:** The app runs at `https://localhost:5173` (HTTPS — self-signed cert). The backend runs at `http://localhost:5000`.
4. **Authentication:** Auth is handled globally via `storageState`. Do NOT navigate to `/auth/login` in any test — you are already logged in. Never hardcode credentials; import `E2E_EMAIL` and `E2E_PASSWORD` from `tests/global-setup.ts` if needed.
+14
View File
@@ -0,0 +1,14 @@
---
name: playwright-healer
description: Analyzes failing Playwright tests and suggests automated fixes based on UI changes.
---
# Playwright Self-Healing Instructions
When a user asks to "fix" or "heal" a test:
1. **Analyze the Trace**: Use the Playwright MCP or CLI to open the latest trace file in `.playwright-cli/traces/`.
2. **Compare Snapshots**: If a locator failed, take a fresh `snapshot` of the page. Identify if the element moved, changed its ARIA role, or had its text updated.
3. **Propose the Patch**:
- If the UI changed, suggest the updated locator.
- If it's a timing issue, suggest adding an `expect(locator).toBeVisible()` wait.
- If it's a data issue, check the mock definitions.
4. **Verify**: Run the patched test once before presenting the final code to the user.
@@ -0,0 +1,11 @@
---
name: playwright-smoke-gen
description: Generates high-level smoke tests by exploring a running web application.
---
# Playwright Smoke Test Instructions
When this skill is active, follow these rules:
1. **Explore First**: Use the Playwright MCP `snapshot` tool to understand the page structure before writing code.
2. **Web-First Assertions**: Always use `expect(locator).toBeVisible()` or `toBeEnabled()`.
3. **Naming Convention**: Save tests in `tests/smoke/[feature].spec.ts`.
4. **Setup/Teardown**: Use `test.beforeEach` for repeated actions like navigating to the base URL.
5. **No Hardcoded Secrets**: If a login is required, use `process.env.TEST_USER` placeholders.
@@ -0,0 +1,12 @@
---
name: playwright-visual-reg
description: Generates and manages visual regression snapshots for UI consistency.
---
# Playwright Visual Regression Standards
When creating visual tests:
1. **Standard Assertion**: Use `await expect(page).toHaveScreenshot('name.png');`.
2. **Masking**: Automatically mask dynamic content (dates, usernames, or random IDs) using the `mask` option:
`await expect(page).toHaveScreenshot({ mask: [page.locator('.dynamic-id')] });`
3. **Consistency**: Set `animations: 'disabled'` and `timezoneId: 'UTC'` in the generated test metadata to prevent false positives.
4. **Update Strategy**: Instruct the user to run `npx playwright test --update-snapshots` if they intentionally changed the UI.
+21
View File
@@ -0,0 +1,21 @@
---
name: vue-frontend
description: Starts the Vue development server for the frontend application.
disable-model-invocation: true
---
# Instructions
Use this skill when the user wants to "start the frontend," "run vue," or "launch the dev server."
1. **Verify Directory:** Navigate to `./frontend`.
- _Self-Correction:_ If the directory doesn't exist, search the workspace for `package.json` files and ask for clarification.
2. **Check Dependencies:** - Before running, check if `node_modules` exists in `./frontend`.
- If missing, ask the user: "Should I run `npm install` first?"
3. **Execution:** - Run the command: `npm run dev`
- This script is configured in `package.json` to start the Vite/Vue dev server.
4. **Success Criteria:** - Monitor the terminal output for a local URL (typically `http://localhost:5173` or similar).
- Once the server is "Ready," notify the user.
+34
View File
@@ -0,0 +1,34 @@
name: "Copilot Setup Steps"
on:
workflow_dispatch:
push:
paths:
- .github/workflows/copilot-setup-steps.yml
pull_request:
paths:
- .github/workflows/copilot-setup-steps.yml
jobs:
copilot-setup-steps:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: lts/*
- name: Install dependencies
run: npm ci
- name: Install Playwright Browsers
run: npx playwright install --with-deps
# Customize this step as needed
- name: Build application
run: npx run build
+11 -76
View File
@@ -1,78 +1,13 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
PIPFILE.lock
# Virtual Environment
venv/
ENV/
env/
.venv
# PyCharm
.idea/
*.iml
*.iws
.idea_modules/
# VS Code
.vscode/
# Database files
*.db
*.sqlite
*.sqlite3
data/db/*.json
# Flask
instance/
.webassets-cache
# Environment variables
.env .env
.env.local backend/test_data/
.env.*.local
# Node.js / Vue (web directory)
web/node_modules/
web/npm-debug.log*
web/yarn-debug.log*
web/yarn-error.log*
web/dist/
web/.nuxt/
web/.cache/
# OS files
.DS_Store
Thumbs.db
*.swp
*.swo
*~
# Logs
*.log
logs/ logs/
frontend/resources/
# Testing frontend/playwright-report/
.pytest_cache/ frontend/test-results/
.coverage backend/test-results/
htmlcov/ .vscode/keybindings.json
.tox/ .DS_Store
**/.DS_Store
frontend/cert.pem
frontend/key.pem
tmp/
+5
View File
@@ -0,0 +1,5 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
+15
View File
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="jdk" jdkName="Python 3.12 (Reward)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
<component name="PyDocumentationSettings">
<option name="format" value="PLAIN" />
<option name="myDocStringFormat" value="Plain" />
</component>
<component name="TestRunnerService">
<option name="PROJECT_TEST_RUNNER" value="py.test" />
</component>
</module>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AgentMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Ask2AgentMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="EditMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>
+10
View File
@@ -0,0 +1,10 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="PyPackageRequirementsInspection" enabled="true" level="WARNING" enabled_by_default="true">
<option name="ignoredPackages">
<list />
</option>
</inspection_tool>
</profile>
</component>
+6
View File
@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.12 (Reward)" project-jdk-type="Python SDK" />
</project>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/Reward.iml" filepath="$PROJECT_DIR$/.idea/Reward.iml" />
</modules>
</component>
</project>
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
+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
+9
View File
@@ -0,0 +1,9 @@
{
"recommendations": [
"Vue.volar",
"vitest.explorer",
"dbaeumer.vscode-eslint",
"EditorConfig.EditorConfig",
"esbenp.prettier-vscode"
]
}
+99
View File
@@ -0,0 +1,99 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Python: Flask",
"type": "python",
"request": "launch",
"module": "flask",
"python": "${command:python.interpreterPath}",
"env": {
"FLASK_APP": "backend/main.py",
"FLASK_DEBUG": "1",
"SECRET_KEY": "dev-secret-key-change-in-production",
"REFRESH_TOKEN_EXPIRY_DAYS": "90",
"DIGEST_TOKEN_SECRET": "dev-digest-token",
"VAPID_PUBLIC_KEY": "BNKkHdq45uLigohSG7c1TwlAo7ETncoRVLQK02LxHgu2P1DgSJD9njRMfbbzUsaTQGllvLBz7An1WiWsNYQhvKE",
"VAPID_PRIVATE_KEY": "jNiZJT0UO4H861KmnCt874Fg6p5jDAyYKS4V2MZf8bQ",
"FRONTEND_URL": "https://macbook:5173"
},
"args": [
"run",
"--host=0.0.0.0",
"--port=5000",
"--no-debugger",
"--no-reload"
],
"cwd": "${workspaceFolder}/backend",
"console": "integratedTerminal"
},
{
"name": "Vue: Dev Server",
"type": "node",
"request": "launch",
"runtimeExecutable": "npm",
"runtimeArgs": [
"run",
"dev"
],
"cwd": "${workspaceFolder}/frontend",
"console": "integratedTerminal",
"serverReadyAction": {
"pattern": "Local:.*https://localhost:([0-9]+)",
"uriFormat": "https://localhost:%s",
"action": "debugWithChrome"
}
},
{
"name": "Chrome: Launch (Vue App)",
"type": "pwa-chrome",
"request": "launch",
"url": "http://localhost:5173",
"webRoot": "${workspaceFolder}/frontend"
},
{
"name": "Python: Backend Tests",
"type": "python",
"request": "launch",
"module": "pytest",
"args": [
"tests/"
],
"cwd": "${workspaceFolder}/backend",
"console": "integratedTerminal",
"env": {
"PYTHONPATH": "${workspaceFolder}/backend"
}
},
{
"name": "Vue: Frontend Tests",
"type": "node",
"request": "launch",
"runtimeExecutable": "npm",
"windows": {
"runtimeExecutable": "npm.cmd"
},
"runtimeArgs": [
"run",
"test:unit"
],
"cwd": "${workspaceFolder}/frontend",
"console": "integratedTerminal",
"osx": {
"env": {
"PATH": "/opt/homebrew/bin:${env:PATH}"
}
}
}
],
"compounds": [
{
"name": "Full Stack (Backend + Frontend)",
"stopAll": true,
"configurations": [
"Python: Flask",
"Vue: Dev Server"
]
}
]
}
+77
View File
@@ -0,0 +1,77 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Python: Flask",
"type": "debugpy",
"request": "launch",
"module": "flask",
"python": "${command:python.interpreterPath}",
"env": {
"FLASK_APP": "backend/main.py",
"FLASK_DEBUG": "1"
},
"args": [
"run",
"--host=0.0.0.0",
"--port=5000",
"--no-debugger",
"--no-reload"
]
},
{
"name": "Vue: Dev Server",
"type": "node",
"request": "launch",
"runtimeExecutable": "npm",
"runtimeArgs": [
"run",
"dev"
],
"cwd": "${workspaceFolder}/frontend",
"console": "integratedTerminal"
},
{
"name": "Chrome: Attach to Vue App",
"type": "chrome",
"request": "launch",
"url": "https://localhost:5173", // or your Vite dev server port
"webRoot": "${workspaceFolder}/frontend"
},
{
"name": "Python: Backend Tests",
"type": "python",
"request": "launch",
"program": "${workspaceFolder}/backend/.venv/Scripts/pytest.exe",
"args": [
"tests/"
],
"cwd": "${workspaceFolder}/backend",
"console": "integratedTerminal",
"env": {
"PYTHONPATH": "${workspaceFolder}/backend"
}
},
{
"name": "Vue: Frontend Tests",
"type": "node",
"request": "launch",
"runtimeExecutable": "npx",
"runtimeArgs": [
"vitest"
],
"cwd": "${workspaceFolder}/frontend",
"console": "integratedTerminal"
}
],
"compounds": [
{
"name": "Full Stack (Backend + Frontend)",
"configurations": [
"Python: Flask",
"Vue: Dev Server",
"Chrome: Attach to Vue App"
]
}
]
}
+28
View File
@@ -0,0 +1,28 @@
{
"servers": {
"playwright-test": {
"type": "stdio",
"command": "npx",
"args": [
"playwright",
"run-test-mcp-server",
"--config=playwright.config.ts"
],
"cwd": "frontend"
},
"gitea": {
"command": "docker",
"args": [
"run",
"-i",
"--rm",
"-e",
"GITEA_ACCESS_TOKEN=${env:GITEA_ACCESS_TOKEN}",
"-e",
"GITEA_HOST=https://git.ryankegel.com",
"docker.gitea.com/gitea-mcp-server"
],
"type": "stdio"
}
}
}
+46
View File
@@ -0,0 +1,46 @@
{
"python.venvPath": "${workspaceFolder}/backend/.venv",
"python.terminal.activateEnvironment": true,
"python.terminal.shellIntegration.enabled": true,
"explorer.fileNesting.enabled": true,
"explorer.fileNesting.patterns": {
"tsconfig.json": "tsconfig.*.json, env.d.ts",
"vite.config.*": "jsconfig*, vitest.config.*, cypress.config.*, playwright.config.*",
"package.json": "package-lock.json, pnpm*, .yarnrc*, yarn*, .eslint*, eslint*, .oxlint*, oxlint*, .prettier*, prettier*, .editorconfig"
},
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"[json]": {
"editor.tabSize": 2,
"editor.insertSpaces": true
},
"[jsonc]": {
"editor.defaultFormatter": "vscode.json-language-features"
},
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"chat.tools.terminal.autoApprove": {
"&": true
},
"terminal.integrated.automationProfile.windows": {
"path": "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
"args": []
},
"python-envs.defaultEnvManager": "ms-python.python:venv",
"python-envs.pythonProjects": [],
"editor.gotoLocation.multipleDeclarations": "gotoAndPeek",
"editor.gotoLocation.multipleDefinitions": "gotoAndPeek",
"editor.gotoLocation.multipleImplementations": "gotoAndPeek",
"editor.gotoLocation.multipleReferences": "gotoAndPeek",
"editor.gotoLocation.multipleTypeDefinitions": "gotoAndPeek",
//"editor.fontFamily": "Consolas, 'Courier New', monospace",
"editor.fontFamily": "JetBrains Mono",
"editor.fontSize": 13,
"editor.fontLigatures": true,
"python.testing.pytestArgs": [
"backend"
],
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true,
}
+146
View File
@@ -0,0 +1,146 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Git: Save WIP",
"type": "shell",
"command": "git save-wip",
"group": "build",
"presentation": {
"reveal": "always",
"panel": "shared"
}
},
{
"label": "Git: Load WIP",
"type": "shell",
"command": "git load-wip",
"group": "build",
"presentation": {
"reveal": "always",
"panel": "shared"
}
},
{
"label": "Git: Reset Cloud WIP",
"type": "shell",
"command": "git push origin --delete wip-sync",
"presentation": {
"reveal": "always",
"panel": "shared"
}
},
{
"label": "Git: Abort WIP (Reset Local)",
"type": "shell",
"command": "git abort-wip",
"group": "none",
"presentation": {
"reveal": "always",
"panel": "shared",
"echo": true
}
},
{
"label": "PW: Task Modification Tests",
"type": "shell",
"command": "cd \"${workspaceFolder}/frontend\" && npx playwright test e2e/mode_parent/task-modification --project=chromium-task-modification",
"isBackground": false,
"group": "test"
},
{
"label": "PW: Task Modification Tests (PS)",
"type": "shell",
"command": "powershell -Command \"cd '$env:APPDATA/../../../d/Python Utilities/Reward/frontend'; npx playwright test e2e/mode_parent/task-modification --project=chromium-task-modification\"",
"isBackground": false,
"group": "test"
},
{
"label": "PW: Task Modification Tests (cmd)",
"type": "shell",
"command": "cmd /c \"cd /d \\\"D:\\Python Utilities\\Reward\\frontend\\\" && npx playwright test e2e/mode_parent/task-modification --project=chromium-task-modification 2>&1\"",
"isBackground": false,
"group": "test"
},
{
"label": "PW: User Profile Tests",
"type": "shell",
"command": "cd \"${workspaceFolder}/frontend\" && npx playwright test e2e/mode_parent/user-profile --project=chromium-user-profile --project=chromium-user-profile-pin --project=chromium-user-profile-delete",
"isBackground": false,
"group": "test"
},
{
"label": "PW: User Profile Tests 2",
"type": "shell",
"command": "cd \"${workspaceFolder}/frontend\" && npx playwright test e2e/mode_parent/user-profile --project=chromium-user-profile --project=chromium-user-profile-pin --project=chromium-user-profile-delete",
"isBackground": false,
"group": "test"
},
{
"label": "PW: User Profile Tests Final",
"type": "shell",
"command": "cd \"${workspaceFolder}/frontend\" && npx playwright test e2e/mode_parent/user-profile --project=chromium-user-profile --project=chromium-user-profile-pin --project=chromium-user-profile-delete",
"isBackground": false,
"group": "test"
},
{
"label": "PW: Full Test Suite",
"type": "shell",
"command": "cd \"${workspaceFolder}/frontend\" && npx playwright test --reporter=line",
"isBackground": false,
"group": "test"
},
{
"label": "PW: Full Suite",
"type": "shell",
"command": "cd \"${workspaceFolder}/frontend\" && npx playwright test --reporter=line",
"isBackground": false,
"group": "test"
},
{
"label": "PW: Full Suite (Process)",
"type": "process",
"command": "powershell.exe",
"args": [
"-NoProfile",
"-Command",
"cd 'D:\\Python Utilities\\Reward\\frontend'; npx playwright test --reporter=line 2>&1"
],
"group": "test",
"presentation": {
"reveal": "always",
"panel": "shared"
}
},
{
"label": "PW: Penalty Default Test (Process)",
"type": "process",
"command": "powershell.exe",
"args": [
"-NoProfile",
"-Command",
"cd 'D:\\Python Utilities\\Reward\\frontend'; npx playwright test e2e/mode_parent/tasks/penalty-default.spec.ts --project=chromium-default-tasks --reporter=line 2>&1"
],
"group": "test",
"presentation": {
"reveal": "always",
"panel": "shared"
}
},
{
"label": "PW: User Profile Editing Test (Process)",
"type": "process",
"command": "powershell.exe",
"args": [
"-NoProfile",
"-Command",
"cd 'D:\\Python Utilities\\Reward\\frontend'; npx playwright test e2e/mode_parent/user-profile/profile-editing.spec.ts --project=chromium-user-profile --reporter=line 2>&1"
],
"group": "test",
"presentation": {
"reveal": "always",
"panel": "shared"
}
}
]
}
+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.
+172
View File
@@ -0,0 +1,172 @@
# Reward - Chore & Reward Management System
A family-friendly application for managing chores, tasks, and rewards for children.
## 🏗️ Architecture
- **Backend**: Flask (Python) with TinyDB for data persistence
- **Frontend**: Vue 3 (TypeScript) with real-time SSE updates
- **Deployment**: Docker with nginx reverse proxy
## 🚀 Getting Started
### Backend
```bash
cd backend
python -m venv .venv
.venv\Scripts\activate # Windows
source .venv/bin/activate # Linux/Mac
pip install -r requirements.txt
python -m flask run --host=0.0.0.0 --port=5000
```
### Frontend
```bash
cd frontend
npm install
npm run dev
```
## 🔧 Configuration
### Environment Variables
| Variable | Description | Default |
| ---------------------------------- | --------------------------------------------- | ------------- |
| `ACCOUNT_DELETION_THRESHOLD_HOURS` | Hours to wait before deleting marked accounts | 720 (30 days) |
| `DB_ENV` | Database environment (`prod` or `test`) | `prod` |
| `DATA_ENV` | Data directory environment (`prod` or `test`) | `prod` |
### Account Deletion Scheduler
The application includes an automated account deletion scheduler that removes user accounts marked for deletion after a configurable threshold period.
**Key Features:**
- Runs every hour checking for accounts due for deletion
- Configurable threshold between 24 hours (minimum) and 720 hours (maximum)
- Automatic retry on failure (max 3 attempts)
- Restart-safe: recovers from interruptions during deletion
**Deletion Process:**
When an account is marked for deletion, the scheduler will automatically:
1. Remove all pending rewards for the user's children
2. Remove all children belonging to the user
3. Remove all user-created tasks
4. Remove all user-created rewards
5. Remove uploaded images from database
6. Delete user's image directory from filesystem
7. Remove the user account
**Configuration:**
Set the deletion threshold via environment variable:
```bash
export ACCOUNT_DELETION_THRESHOLD_HOURS=168 # 7 days
```
**Monitoring:**
- Logs are written to `logs/account_deletion.log` with rotation (10MB max, 5 backups)
- Check logs for deletion summaries and any errors
## 🔌 API Endpoints
### Admin Endpoints
All admin endpoints require JWT authentication and **admin role**.
**Note:** Admin users must be created manually or via the provided script (`backend/scripts/create_admin.py`). The admin role cannot be assigned through the signup API for security reasons.
**Creating an Admin User:**
```bash
cd backend
python scripts/create_admin.py
```
#### Account Deletion Management
- `GET /api/admin/deletion-queue` - View users pending deletion
- `GET /api/admin/deletion-threshold` - Get current deletion threshold
- `PUT /api/admin/deletion-threshold` - Update deletion threshold (24-720 hours)
- `POST /api/admin/deletion-queue/trigger` - Manually trigger deletion scheduler
### User Endpoints
- `POST /api/user/mark-for-deletion` - Mark current user's account for deletion
- `GET /api/me` - Get current user info
- `POST /api/login` - User login
- `POST /api/logout` - User logout
## 🧪 Testing
### Backend Tests
```bash
cd backend
pytest tests/
```
### Frontend Tests
```bash
cd frontend
npm run test
```
## 📝 Features
- ✅ User authentication with JWT tokens
- ✅ Child profile management
- ✅ Task assignment and tracking
- ✅ Reward system
- ✅ Real-time updates via SSE
- ✅ Image upload and management
- ✅ Account deletion with grace period
- ✅ Automated cleanup scheduler
## 🔒 Security
- JWT tokens stored in HttpOnly, Secure, SameSite=Strict cookies
- **Role-Based Access Control (RBAC)**: Admin endpoints protected by admin role validation
- Admin users can only be created via direct database manipulation or provided script
- Regular users cannot escalate privileges to admin
- Account deletion requires email confirmation
- Marked accounts blocked from login immediately
## 📁 Project Structure
```
.
├── backend/
│ ├── api/ # REST API endpoints
│ ├── config/ # Configuration files
│ ├── db/ # TinyDB setup
│ ├── events/ # SSE event system
│ ├── models/ # Data models
│ ├── tests/ # Backend tests
│ └── utils/ # Utilities (scheduler, etc)
├── frontend/
│ └── src/
│ ├── common/ # Shared utilities
│ ├── components/ # Vue components
│ └── layout/ # Layout components
└── .github/
└── specs/ # Feature specifications
```
## 🛠️ Development
For detailed development patterns and conventions, see [`.github/copilot-instructions.md`](.github/copilot-instructions.md).
## 📚 References
- Reset flow (token validation, JWT invalidation, cross-tab logout sync): [`docs/reset-password-reference.md`](docs/reset-password-reference.md)
## 📄 License
Private project - All rights reserved.
-671
View File
@@ -1,671 +0,0 @@
from flask import Blueprint, request, jsonify
from tinydb import Query
from db.db import child_db, task_db, reward_db, pending_reward_db
from api.reward_status import RewardStatus
from api.child_tasks import ChildTask
from api.child_rewards import ChildReward
from events.sse import send_event_to_user
from events.types.child_modified import ChildModified
from events.types.child_reward_request import ChildRewardRequest
from events.types.child_reward_triggered import ChildRewardTriggered
from events.types.child_rewards_set import ChildRewardsSet
from events.types.child_task_triggered import ChildTaskTriggered
from events.types.child_tasks_set import ChildTasksSet
from events.types.event import Event
from events.types.event_types import EventType
from api.pending_reward import PendingReward as PendingRewardResponse
from models.child import Child
from models.pending_reward import PendingReward
from models.task import Task
from models.reward import Reward
child_api = Blueprint('child_api', __name__)
@child_api.route('/child/<name>', methods=['GET'])
@child_api.route('/child/<id>', methods=['GET'])
def get_child(id):
ChildQuery = Query()
result = child_db.search(ChildQuery.id == id)
if not result:
return jsonify({'error': 'Child not found'}), 404
return jsonify(Child.from_dict(result[0]).to_dict()), 200
@child_api.route('/child/add', methods=['PUT'])
def add_child():
data = request.get_json()
name = data.get('name')
age = data.get('age')
image = data.get('image_id', None)
if not name:
return jsonify({'error': 'Name is required'}), 400
if not image:
image = 'boy01'
child = Child(name=name, age=age, image_id=image)
child_db.insert(child.to_dict())
send_event_to_user("user123", Event(EventType.CHILD_MODIFIED.value, ChildModified(child.id, ChildModified.OPERATION_ADD)))
return jsonify({'message': f'Child {name} added.'}), 201
@child_api.route('/child/<id>/edit', methods=['PUT'])
def edit_child(id):
data = request.get_json()
name = data.get('name', None)
age = data.get('age', None)
points = data.get('points', None)
image = data.get('image_id', None)
ChildQuery = Query()
result = child_db.search(ChildQuery.id == id)
if not result:
return jsonify({'error': 'Child not found'}), 404
child = Child.from_dict(result[0])
if name is not None:
child.name = name
if age is not None:
child.age = age
if points is not None:
child.points = points
if image is not None:
child.image_id = image
# Check if points changed and handle pending rewards
if points is not None:
PendingQuery = Query()
pending_rewards = pending_reward_db.search(PendingQuery.child_id == id)
RewardQuery = Query()
for pr in pending_rewards:
pending = PendingReward.from_dict(pr)
reward_result = reward_db.get(RewardQuery.id == pending.reward_id)
if reward_result:
reward = Reward.from_dict(reward_result)
# If child can no longer afford the reward, remove the pending request
if child.points < reward.cost:
pending_reward_db.remove(
(PendingQuery.child_id == id) & (PendingQuery.reward_id == reward.id)
)
send_event_to_user(
"user123",
Event(
EventType.CHILD_REWARD_REQUEST.value,
ChildRewardRequest(id, reward.id, ChildRewardRequest.REQUEST_CANCELLED)
)
)
child_db.update(child.to_dict(), ChildQuery.id == id)
send_event_to_user("user123", Event(EventType.CHILD_MODIFIED.value, ChildModified(id, ChildModified.OPERATION_EDIT)))
return jsonify({'message': f'Child {id} updated.'}), 200
@child_api.route('/child/list', methods=['GET'])
def list_children():
children = child_db.all()
return jsonify({'children': children}), 200
# Child DELETE
@child_api.route('/child/<id>', methods=['DELETE'])
def delete_child(id):
ChildQuery = Query()
if child_db.remove(ChildQuery.id == id):
send_event_to_user("user123",
Event(EventType.CHILD_MODIFIED.value, ChildModified(id, ChildModified.OPERATION_DELETE)))
return jsonify({'message': f'Child {id} deleted.'}), 200
return jsonify({'error': 'Child not found'}), 404
@child_api.route('/child/<id>/assign-task', methods=['POST'])
def assign_task_to_child(id):
data = request.get_json()
task_id = data.get('task_id')
if not task_id:
return jsonify({'error': 'task_id is required'}), 400
ChildQuery = Query()
result = child_db.search(ChildQuery.id == id)
if not result:
return jsonify({'error': 'Child not found'}), 404
child = result[0]
if task_id not in child.get('tasks', []):
child['tasks'].append(task_id)
child_db.update({'tasks': child['tasks']}, ChildQuery.id == id)
return jsonify({'message': f'Task {task_id} assigned to {child['name']}.'}), 200
# python
@child_api.route('/child/<id>/set-tasks', methods=['PUT'])
def set_child_tasks(id):
data = request.get_json() or {}
task_ids = data.get('task_ids')
if not isinstance(task_ids, list):
return jsonify({'error': 'task_ids must be a list'}), 400
# Deduplicate and drop falsy values
new_task_ids = [tid for tid in dict.fromkeys(task_ids) if tid]
ChildQuery = Query()
result = child_db.search(ChildQuery.id == id)
if not result:
return jsonify({'error': 'Child not found'}), 404
# Optional: validate task IDs exist in the task DB
TaskQuery = Query()
valid_task_ids = []
for tid in new_task_ids:
if task_db.get(TaskQuery.id == tid):
valid_task_ids.append(tid)
# Replace tasks with validated IDs
child_db.update({'tasks': valid_task_ids}, ChildQuery.id == id)
send_event_to_user("user123", Event(EventType.CHILD_TASKS_SET.value, ChildTasksSet(id, valid_task_ids)))
return jsonify({
'message': f'Tasks set for child {id}.',
'task_ids': valid_task_ids,
'count': len(valid_task_ids)
}), 200
@child_api.route('/child/<id>/remove-task', methods=['POST'])
def remove_task_from_child(id):
data = request.get_json()
task_id = data.get('task_id')
if not task_id:
return jsonify({'error': 'task_id is required'}), 400
ChildQuery = Query()
result = child_db.search(ChildQuery.id == id)
if not result:
return jsonify({'error': 'Child not found'}), 404
child = result[0]
if task_id in child.get('tasks', []):
child['tasks'].remove(task_id)
child_db.update({'tasks': child['tasks']}, ChildQuery.id == id)
return jsonify({'message': f'Task {task_id} removed from {child["name"]}.'}), 200
return jsonify({'error': 'Task not assigned to child'}), 400
@child_api.route('/child/<id>/list-tasks', methods=['GET'])
def list_child_tasks(id):
ChildQuery = Query()
result = child_db.search(ChildQuery.id == id)
if not result:
return jsonify({'error': 'Child not found'}), 404
child = result[0]
task_ids = child.get('tasks', [])
TaskQuery = Query()
child_tasks = []
for tid in task_ids:
task = task_db.get(TaskQuery.id == tid)
if not task:
continue
ct = ChildTask(task.get('name'), task.get('is_good'), task.get('points'), task.get('image_id'), task.get('id'))
child_tasks.append(ct.to_dict())
return jsonify({'tasks': child_tasks}), 200
@child_api.route('/child/<id>/list-assignable-tasks', methods=['GET'])
def list_assignable_tasks(id):
ChildQuery = Query()
result = child_db.search(ChildQuery.id == id)
if not result:
return jsonify({'error': 'Child not found'}), 404
child = result[0]
assigned_ids = set(child.get('tasks', []))
# Collect all task ids from the task database
all_task_ids = [t.get('id') for t in task_db.all() if t and t.get('id')]
# Filter out already assigned
assignable_ids = [tid for tid in all_task_ids if tid not in assigned_ids]
# Fetch full task details and wrap in ChildTask
TaskQuery = Query()
assignable_tasks = []
for tid in assignable_ids:
task = task_db.get(TaskQuery.id == tid)
if not task:
continue
ct = ChildTask(task.get('name'), task.get('is_good'), task.get('points'), task.get('image_id'), task.get('id'))
assignable_tasks.append(ct.to_dict())
return jsonify({'tasks': assignable_tasks, 'count': len(assignable_tasks)}), 200
@child_api.route('/child/<id>/list-all-tasks', methods=['GET'])
def list_all_tasks(id):
ChildQuery = Query()
result = child_db.search(ChildQuery.id == id)
if not result:
return jsonify({'error': 'Child not found'}), 404
child = result[0]
assigned_ids = set(child.get('tasks', []))
# Get all tasks from database
all_tasks = task_db.all()
assigned_tasks = []
assignable_tasks = []
for task in all_tasks:
if not task or not task.get('id'):
continue
ct = ChildTask(
task.get('name'),
task.get('is_good'),
task.get('points'),
task.get('image_id'),
task.get('id')
)
if task.get('id') in assigned_ids:
assigned_tasks.append(ct.to_dict())
else:
assignable_tasks.append(ct.to_dict())
return jsonify({
'assigned_tasks': assigned_tasks,
'assignable_tasks': assignable_tasks,
'assigned_count': len(assigned_tasks),
'assignable_count': len(assignable_tasks)
}), 200
@child_api.route('/child/<id>/trigger-task', methods=['POST'])
def trigger_child_task(id):
data = request.get_json()
task_id = data.get('task_id')
if not task_id:
return jsonify({'error': 'task_id is required'}), 400
ChildQuery = Query()
result = child_db.search(ChildQuery.id == id)
if not result:
return jsonify({'error': 'Child not found'}), 404
child: Child = Child.from_dict(result[0])
if task_id not in child.tasks:
return jsonify({'error': f'Task not found assigned to child {child.name}'}), 404
# look up the task and get the details
TaskQuery = Query()
task_result = task_db.search(TaskQuery.id == task_id)
if not task_result:
return jsonify({'error': 'Task not found in task database'}), 404
task: Task = Task.from_dict(task_result[0])
# update the child's points based on task type
if task.is_good:
child.points += task.points
else:
child.points -= task.points
child.points = max(child.points, 0)
# update the child in the database
child_db.update({'points': child.points}, ChildQuery.id == id)
send_event_to_user("user123", Event(EventType.CHILD_TASK_TRIGGERED.value, ChildTaskTriggered(task.id, child.id, child.points)))
return jsonify({'message': f'{task.name} points assigned to {child.name}.', 'points': child.points, 'id': child.id}), 200
@child_api.route('/child/<id>/assign-reward', methods=['POST'])
def assign_reward_to_child(id):
data = request.get_json()
reward_id = data.get('reward_id')
if not reward_id:
return jsonify({'error': 'reward_id is required'}), 400
ChildQuery = Query()
result = child_db.search(ChildQuery.id == id)
if not result:
return jsonify({'error': 'Child not found'}), 404
child = result[0]
if reward_id not in child.get('rewards', []):
child['rewards'].append(reward_id)
child_db.update({'rewards': child['rewards']}, ChildQuery.id == id)
return jsonify({'message': f'Reward {reward_id} assigned to {child["name"]}.'}), 200
@child_api.route('/child/<id>/list-all-rewards', methods=['GET'])
def list_all_rewards(id):
ChildQuery = Query()
result = child_db.search(ChildQuery.id == id)
if not result:
return jsonify({'error': 'Child not found'}), 404
child = result[0]
assigned_ids = set(child.get('rewards', []))
# Get all rewards from database
all_rewards = reward_db.all()
assigned_rewards = []
assignable_rewards = []
for reward in all_rewards:
if not reward or not reward.get('id'):
continue
cr = ChildReward(
reward.get('name'),
reward.get('cost'),
reward.get('image_id'),
reward.get('id')
)
if reward.get('id') in assigned_ids:
assigned_rewards.append(cr.to_dict())
else:
assignable_rewards.append(cr.to_dict())
return jsonify({
'assigned_rewards': assigned_rewards,
'assignable_rewards': assignable_rewards,
'assigned_count': len(assigned_rewards),
'assignable_count': len(assignable_rewards)
}), 200
@child_api.route('/child/<id>/set-rewards', methods=['PUT'])
def set_child_rewards(id):
data = request.get_json() or {}
reward_ids = data.get('reward_ids')
if not isinstance(reward_ids, list):
return jsonify({'error': 'reward_ids must be a list'}), 400
# Deduplicate and drop falsy values
new_reward_ids = [rid for rid in dict.fromkeys(reward_ids) if rid]
ChildQuery = Query()
result = child_db.search(ChildQuery.id == id)
if not result:
return jsonify({'error': 'Child not found'}), 404
# Optional: validate reward IDs exist in the reward DB
RewardQuery = Query()
valid_reward_ids = []
for rid in new_reward_ids:
if reward_db.get(RewardQuery.id == rid):
valid_reward_ids.append(rid)
# Replace rewards with validated IDs
child_db.update({'rewards': valid_reward_ids}, ChildQuery.id == id)
send_event_to_user("user123", Event(EventType.CHILD_REWARDS_SET.value, ChildRewardsSet(id, valid_reward_ids)))
return jsonify({
'message': f'Rewards set for child {id}.',
'reward_ids': valid_reward_ids,
'count': len(valid_reward_ids)
}), 200
@child_api.route('/child/<id>/remove-reward', methods=['POST'])
def remove_reward_from_child(id):
data = request.get_json()
reward_id = data.get('reward_id')
if not reward_id:
return jsonify({'error': 'reward_id is required'}), 400
ChildQuery = Query()
result = child_db.search(ChildQuery.id == id)
if not result:
return jsonify({'error': 'Child not found'}), 404
child = result[0]
if reward_id in child.get('rewards', []):
child['rewards'].remove(reward_id)
child_db.update({'rewards': child['rewards']}, ChildQuery.id == id)
return jsonify({'message': f'Reward {reward_id} removed from {child["name"]}.'}), 200
return jsonify({'error': 'Reward not assigned to child'}), 400
@child_api.route('/child/<id>/list-rewards', methods=['GET'])
def list_child_rewards(id):
ChildQuery = Query()
result = child_db.search(ChildQuery.id == id)
if not result:
return jsonify({'error': 'Child not found'}), 404
child = result[0]
reward_ids = child.get('rewards', [])
RewardQuery = Query()
child_rewards = []
for rid in reward_ids:
reward = reward_db.get(RewardQuery.id == rid)
if not reward:
continue
cr = ChildReward(reward.get('name'), reward.get('cost'), reward.get('image_id'), reward.get('id'))
child_rewards.append(cr.to_dict())
return jsonify({'rewards': child_rewards}), 200
@child_api.route('/child/<id>/list-assignable-rewards', methods=['GET'])
def list_assignable_rewards(id):
ChildQuery = Query()
result = child_db.search(ChildQuery.id == id)
if not result:
return jsonify({'error': 'Child not found'}), 404
child = result[0]
assigned_ids = set(child.get('rewards', []))
all_reward_ids = [r.get('id') for r in reward_db.all() if r and r.get('id')]
assignable_ids = [rid for rid in all_reward_ids if rid not in assigned_ids]
RewardQuery = Query()
assignable_rewards = []
for rid in assignable_ids:
reward = reward_db.get(RewardQuery.id == rid)
if not reward:
continue
cr = ChildReward(reward.get('name'), reward.get('cost'), reward.get('image_id'), reward.get('id'))
assignable_rewards.append(cr.to_dict())
return jsonify({'rewards': assignable_rewards, 'count': len(assignable_rewards)}), 200
@child_api.route('/child/<id>/trigger-reward', methods=['POST'])
def trigger_child_reward(id):
data = request.get_json()
reward_id = data.get('reward_id')
if not reward_id:
return jsonify({'error': 'reward_id is required'}), 400
ChildQuery = Query()
result = child_db.search(ChildQuery.id == id)
if not result:
return jsonify({'error': 'Child not found'}), 404
child: Child = Child.from_dict(result[0])
if reward_id not in child.rewards:
return jsonify({'error': f'Reward not found assigned to child {child.name}'}), 404
# look up the task and get the details
RewardQuery = Query()
reward_result = reward_db.search(RewardQuery.id == reward_id)
if not reward_result:
return jsonify({'error': 'Reward not found in reward database'}), 404
reward: Reward = Reward.from_dict(reward_result[0])
# Remove matching pending reward requests for this child and reward
PendingQuery = Query()
removed = pending_reward_db.remove(
(PendingQuery.child_id == child.id) & (PendingQuery.reward_id == reward.id)
)
if removed:
send_event_to_user("user123", Event(EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest(reward.id, child.id, ChildRewardRequest.REQUEST_GRANTED)))
# update the child's points based on reward cost
child.points -= reward.cost
# update the child in the database
child_db.update({'points': child.points}, ChildQuery.id == id)
send_event_to_user("user123", Event(EventType.CHILD_REWARD_TRIGGERED.value, ChildRewardTriggered(reward.id, child.id, child.points)))
return jsonify({'message': f'{reward.name} assigned to {child.name}.', 'points': child.points, 'id': child.id}), 200
@child_api.route('/child/<id>/affordable-rewards', methods=['GET'])
def list_affordable_rewards(id):
ChildQuery = Query()
result = child_db.search(ChildQuery.id == id)
if not result:
return jsonify({'error': 'Child not found'}), 404
child = Child.from_dict(result[0])
points = child.points
reward_ids = child.rewards
RewardQuery = Query()
affordable = [
Reward.from_dict(reward).to_dict() for reward_id in reward_ids
if (reward := reward_db.get(RewardQuery.id == reward_id)) and points >= Reward.from_dict(reward).cost
]
return jsonify({'affordable_rewards': affordable}), 200
@child_api.route('/child/<id>/reward-status', methods=['GET'])
def reward_status(id):
ChildQuery = Query()
result = child_db.search(ChildQuery.id == id)
if not result:
return jsonify({'error': 'Child not found'}), 404
child = Child.from_dict(result[0])
points = child.points
reward_ids = child.rewards
RewardQuery = Query()
statuses = []
for reward_id in reward_ids:
reward: Reward = Reward.from_dict(reward_db.get(RewardQuery.id == reward_id))
if not reward:
continue
points_needed = max(0, reward.cost - points)
#check to see if this reward id and child id is in the pending rewards db if so set its redeeming flag to true
pending_query = Query()
pending = pending_reward_db.get((pending_query.child_id == child.id) & (pending_query.reward_id == reward.id))
status = RewardStatus(reward.id, reward.name, points_needed, reward.cost, pending is not None, reward.image_id)
statuses.append(status.to_dict())
statuses.sort(key=lambda s: (not s['redeeming'], s['cost']))
return jsonify({'reward_status': statuses}), 200
@child_api.route('/child/<id>/request-reward', methods=['POST'])
def request_reward(id):
data = request.get_json()
reward_id = data.get('reward_id')
if not reward_id:
return jsonify({'error': 'reward_id is required'}), 400
ChildQuery = Query()
result = child_db.search(ChildQuery.id == id)
if not result:
return jsonify({'error': 'Child not found'}), 404
child = Child.from_dict(result[0])
if reward_id not in child.rewards:
return jsonify({'error': f'Reward not assigned to child {child.name}'}), 404
RewardQuery = Query()
reward_result = reward_db.search(RewardQuery.id == reward_id)
if not reward_result:
return jsonify({'error': 'Reward not found in reward database'}), 404
reward = Reward.from_dict(reward_result[0])
# Check if child has enough points
if child.points < reward.cost:
points_needed = reward.cost - child.points
return jsonify({
'error': 'Insufficient points',
'points_needed': points_needed,
'current_points': child.points,
'reward_cost': reward.cost
}), 400
pending = PendingReward(child_id=child.id, reward_id=reward.id)
pending_reward_db.insert(pending.to_dict())
send_event_to_user("user123", Event(EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest(child.id, reward.id, ChildRewardRequest.REQUEST_CREATED)))
return jsonify({
'message': f'Reward request for {reward.name} submitted for {child.name}.',
'reward_id': reward.id,
'reward_name': reward.name,
'child_id': child.id,
'child_name': child.name,
'cost': reward.cost
}), 200
@child_api.route('/child/<id>/cancel-request-reward', methods=['POST'])
def cancel_request_reward(id):
data = request.get_json()
reward_id = data.get('reward_id')
if not reward_id:
return jsonify({'error': 'reward_id is required'}), 400
ChildQuery = Query()
result = child_db.search(ChildQuery.id == id)
if not result:
return jsonify({'error': 'Child not found'}), 404
child = Child.from_dict(result[0])
# Remove matching pending reward request
PendingQuery = Query()
removed = pending_reward_db.remove(
(PendingQuery.child_id == child.id) & (PendingQuery.reward_id == reward_id)
)
if not removed:
return jsonify({'error': 'No pending request found for this reward'}), 404
# Notify user that the request was cancelled
send_event_to_user(
"user123",
Event(
EventType.CHILD_REWARD_REQUEST.value,
ChildRewardRequest(child.id, reward_id, ChildRewardRequest.REQUEST_CANCELLED)
)
)
return jsonify({
'message': f'Reward request cancelled for {child.name}.',
'child_id': child.id,
'reward_id': reward_id,
'removed_count': len(removed)
}), 200
@child_api.route('/pending-rewards', methods=['GET'])
def list_pending_rewards():
pending_rewards = pending_reward_db.all()
reward_responses = []
RewardQuery = Query()
ChildQuery = Query()
for pr in pending_rewards:
pending = PendingReward.from_dict(pr)
# Look up reward details
reward_result = reward_db.get(RewardQuery.id == pending.reward_id)
if not reward_result:
continue
reward = Reward.from_dict(reward_result)
# Look up child details
child_result = child_db.get(ChildQuery.id == pending.child_id)
if not child_result:
continue
child = Child.from_dict(child_result)
# Create response object
response = PendingRewardResponse(
_id=pending.id,
child_id=child.id,
child_name=child.name,
child_image_id=child.image_id,
reward_id=reward.id,
reward_name=reward.name,
reward_image_id=reward.image_id
)
reward_responses.append(response.to_dict())
return jsonify({'rewards': reward_responses}), 200
-104
View File
@@ -1,104 +0,0 @@
from flask import Blueprint, request, jsonify
from tinydb import Query
from events.sse import send_event_to_user
from events.types.event import Event
from events.types.event_types import EventType
from events.types.reward_modified import RewardModified
from models.reward import Reward
from db.db import reward_db, child_db
reward_api = Blueprint('reward_api', __name__)
# Reward endpoints
@reward_api.route('/reward/add', methods=['PUT'])
def add_reward():
data = request.get_json()
name = data.get('name')
description = data.get('description')
cost = data.get('cost')
image = data.get('image_id', '')
if not name or description is None or cost is None:
return jsonify({'error': 'Name, description, and cost are required'}), 400
reward = Reward(name=name, description=description, cost=cost, image_id=image)
reward_db.insert(reward.to_dict())
send_event_to_user("user123", Event(EventType.REWARD_MODIFIED.value,
RewardModified(reward.id, RewardModified.OPERATION_ADD)))
return jsonify({'message': f'Reward {name} added.'}), 201
@reward_api.route('/reward/<id>', methods=['GET'])
def get_reward(id):
RewardQuery = Query()
result = reward_db.search(RewardQuery.id == id)
if not result:
return jsonify({'error': 'Reward not found'}), 404
return jsonify(result[0]), 200
@reward_api.route('/reward/list', methods=['GET'])
def list_rewards():
rewards = reward_db.all()
return jsonify({'rewards': rewards}), 200
@reward_api.route('/reward/<id>', methods=['DELETE'])
def delete_reward(id):
RewardQuery = Query()
removed = reward_db.remove(RewardQuery.id == id)
if removed:
# remove the reward id from any child's reward list
ChildQuery = Query()
for child in child_db.all():
rewards = child.get('rewards', [])
if id in rewards:
rewards.remove(id)
child_db.update({'rewards': rewards}, ChildQuery.id == child.get('id'))
send_event_to_user("user123", Event(EventType.REWARD_MODIFIED.value,
RewardModified(id, RewardModified.OPERATION_DELETE)))
return jsonify({'message': f'Reward {id} deleted.'}), 200
return jsonify({'error': 'Reward not found'}), 404
@reward_api.route('/reward/<id>/edit', methods=['PUT'])
def edit_reward(id):
RewardQuery = Query()
existing = reward_db.get(RewardQuery.id == id)
if not existing:
return jsonify({'error': 'Reward not found'}), 404
data = request.get_json(force=True) or {}
updates = {}
if 'name' in data:
name = (data.get('name') or '').strip()
if not name:
return jsonify({'error': 'Name cannot be empty'}), 400
updates['name'] = name
if 'description' in data:
desc = (data.get('description') or '').strip()
if not desc:
return jsonify({'error': 'Description cannot be empty'}), 400
updates['description'] = desc
if 'cost' in data:
cost = data.get('cost')
if not isinstance(cost, int):
return jsonify({'error': 'Cost must be an integer'}), 400
if cost <= 0:
return jsonify({'error': 'Cost must be a positive integer'}), 400
updates['cost'] = cost
if 'image_id' in data:
updates['image_id'] = data.get('image_id', '')
if not updates:
return jsonify({'error': 'No valid fields to update'}), 400
reward_db.update(updates, RewardQuery.id == id)
updated = reward_db.get(RewardQuery.id == id)
send_event_to_user("user123", Event(EventType.REWARD_MODIFIED.value,
RewardModified(id, RewardModified.OPERATION_EDIT)))
return jsonify(updated), 200
-100
View File
@@ -1,100 +0,0 @@
from flask import Blueprint, request, jsonify
from tinydb import Query
from events.sse import send_event_to_user
from events.types.event import Event
from events.types.event_types import EventType
from events.types.task_modified import TaskModified
from models.task import Task
from db.db import task_db, child_db
task_api = Blueprint('task_api', __name__)
# Task endpoints
@task_api.route('/task/add', methods=['PUT'])
def add_task():
data = request.get_json()
name = data.get('name')
points = data.get('points')
is_good = data.get('is_good')
image = data.get('image_id', '')
if not name or points is None or is_good is None:
return jsonify({'error': 'Name, points, and is_good are required'}), 400
task = Task(name=name, points=points, is_good=is_good, image_id=image)
task_db.insert(task.to_dict())
send_event_to_user("user123", Event(EventType.TASK_MODIFIED.value,
TaskModified(task.id, TaskModified.OPERATION_ADD)))
return jsonify({'message': f'Task {name} added.'}), 201
@task_api.route('/task/<id>', methods=['GET'])
def get_task(id):
TaskQuery = Query()
result = task_db.search(TaskQuery.id == id)
if not result:
return jsonify({'error': 'Task not found'}), 404
return jsonify(result[0]), 200
@task_api.route('/task/list', methods=['GET'])
def list_tasks():
tasks = task_db.all()
return jsonify({'tasks': tasks}), 200
@task_api.route('/task/<id>', methods=['DELETE'])
def delete_task(id):
TaskQuery = Query()
removed = task_db.remove(TaskQuery.id == id)
if removed:
# remove the task id from any child's task list
ChildQuery = Query()
for child in child_db.all():
tasks = child.get('tasks', [])
if id in tasks:
tasks.remove(id)
child_db.update({'tasks': tasks}, ChildQuery.id == child.get('id'))
send_event_to_user("user123", Event(EventType.TASK_MODIFIED.value,
TaskModified(id, TaskModified.OPERATION_DELETE)))
return jsonify({'message': f'Task {id} deleted.'}), 200
return jsonify({'error': 'Task not found'}), 404
@task_api.route('/task/<id>/edit', methods=['PUT'])
def edit_task(id):
TaskQuery = Query()
existing = task_db.get(TaskQuery.id == id)
if not existing:
return jsonify({'error': 'Task not found'}), 404
data = request.get_json(force=True) or {}
updates = {}
if 'name' in data:
name = data.get('name', '').strip()
if not name:
return jsonify({'error': 'Name cannot be empty'}), 400
updates['name'] = name
if 'points' in data:
points = data.get('points')
if not isinstance(points, int):
return jsonify({'error': 'Points must be an integer'}), 400
if points <= 0:
return jsonify({'error': 'Points must be a positive integer'}), 400
updates['points'] = points
if 'is_good' in data:
is_good = data.get('is_good')
if not isinstance(is_good, bool):
return jsonify({'error': 'is_good must be a boolean'}), 400
updates['is_good'] = is_good
if 'image_id' in data:
updates['image_id'] = data.get('image_id', '')
if not updates:
return jsonify({'error': 'No valid fields to update'}), 400
task_db.update(updates, TaskQuery.id == id)
updated = task_db.get(TaskQuery.id == id)
send_event_to_user("user123", Event(EventType.TASK_MODIFIED.value,
TaskModified(id, TaskModified.OPERATION_EDIT)))
return jsonify(updated), 200
+83
View File
@@ -0,0 +1,83 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
PIPFILE.lock
# Virtual Environment
venv/
ENV/
env/
.venv
# PyCharm
.idea/
*.iml
*.iws
.idea_modules/
# VS Code
.vscode/
# Database files
*.db
*.sqlite
*.sqlite3
data/db/*.json
data/images/
test_data/
# Flask
instance/
.webassets-cache
# Environment variables
.env
.env.local
.env.*.local
# Node.js / Vue (web directory)
web/node_modules/
web/npm-debug.log*
web/yarn-debug.log*
web/yarn-error.log*
web/dist/
web/.nuxt/
web/.cache/
# OS files
.DS_Store
Thumbs.db
*.swp
*.swo
*~
# Logs
*.log
logs/
# Testing
.pytest_cache/
.coverage
htmlcov/
.tox/
/chore.bundle
/tree.json
View File
View File
+287
View File
@@ -0,0 +1,287 @@
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, get_validated_user_id
from config.deletion_config import (
ACCOUNT_DELETION_THRESHOLD_HOURS,
MIN_THRESHOLD_HOURS,
MAX_THRESHOLD_HOURS,
validate_threshold
)
from utils.account_deletion_scheduler import trigger_deletion_manually
admin_api = Blueprint('admin_api', __name__)
@admin_api.route('/admin/deletion-queue', methods=['GET'])
@admin_required
def get_deletion_queue():
"""
Get list of users pending deletion.
Returns users marked for deletion with their deletion due dates.
"""
try:
Query_ = Query()
marked_users = users_db.search(Query_.marked_for_deletion == True)
users_data = []
for user_dict in marked_users:
user = User.from_dict(user_dict)
# Calculate deletion_due_at
deletion_due_at = None
if user.marked_for_deletion_at:
try:
marked_at = datetime.fromisoformat(user.marked_for_deletion_at)
due_at = marked_at + timedelta(hours=ACCOUNT_DELETION_THRESHOLD_HOURS)
deletion_due_at = due_at.isoformat()
except (ValueError, TypeError):
pass
users_data.append({
'id': user.id,
'email': user.email,
'marked_for_deletion_at': user.marked_for_deletion_at,
'deletion_due_at': deletion_due_at,
'deletion_in_progress': user.deletion_in_progress,
'deletion_attempted_at': user.deletion_attempted_at
})
return jsonify({
'count': len(users_data),
'users': users_data
}), 200
except Exception as e:
return jsonify({'error': str(e), 'code': 'SERVER_ERROR'}), 500
@admin_api.route('/admin/deletion-threshold', methods=['GET'])
@admin_required
def get_deletion_threshold():
"""
Get current deletion threshold configuration.
"""
return jsonify({
'threshold_hours': ACCOUNT_DELETION_THRESHOLD_HOURS,
'threshold_min': MIN_THRESHOLD_HOURS,
'threshold_max': MAX_THRESHOLD_HOURS
}), 200
@admin_api.route('/admin/deletion-threshold', methods=['PUT'])
@admin_required
def update_deletion_threshold():
"""
Update deletion threshold.
Note: This updates the runtime value but doesn't persist to environment variables.
For permanent changes, update the ACCOUNT_DELETION_THRESHOLD_HOURS env variable.
"""
try:
data = request.get_json()
if not data or 'threshold_hours' not in data:
return jsonify({
'error': 'threshold_hours is required',
'code': 'MISSING_THRESHOLD'
}), 400
new_threshold = data['threshold_hours']
# Validate type
if not isinstance(new_threshold, int):
return jsonify({
'error': 'threshold_hours must be an integer',
'code': 'INVALID_TYPE'
}), 400
# Validate range
if new_threshold < MIN_THRESHOLD_HOURS:
return jsonify({
'error': f'threshold_hours must be at least {MIN_THRESHOLD_HOURS}',
'code': 'THRESHOLD_TOO_LOW'
}), 400
if new_threshold > MAX_THRESHOLD_HOURS:
return jsonify({
'error': f'threshold_hours must be at most {MAX_THRESHOLD_HOURS}',
'code': 'THRESHOLD_TOO_HIGH'
}), 400
# Update the global config
import config.deletion_config as config
config.ACCOUNT_DELETION_THRESHOLD_HOURS = new_threshold
# Validate and log warning if needed
validate_threshold()
return jsonify({
'message': 'Deletion threshold updated successfully',
'threshold_hours': new_threshold
}), 200
except Exception as e:
return jsonify({'error': str(e), 'code': 'SERVER_ERROR'}), 500
@admin_api.route('/admin/deletion-queue/trigger', methods=['POST'])
@admin_required
def trigger_deletion_queue():
"""
Manually trigger the deletion scheduler to process the queue immediately.
Returns stats about the run.
"""
try:
# Trigger the deletion process
result = trigger_deletion_manually()
# Get updated queue stats
Query_ = Query()
marked_users = users_db.search(Query_.marked_for_deletion == True)
# Count users that were just processed (this is simplified)
processed = result.get('queued_users', 0)
# In a real implementation, you'd return actual stats from the deletion run
# For now, we'll return simplified stats
return jsonify({
'message': 'Deletion scheduler triggered',
'processed': processed,
'deleted': 0, # TODO: Track this in the deletion function
'failed': 0 # TODO: Track this in the deletion function
}), 200
except Exception as e:
return jsonify({'error': str(e), 'code': 'SERVER_ERROR'}), 500
# ---------------------------------------------------------------------------
# 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
+645
View File
@@ -0,0 +1,645 @@
import hashlib
import logging
import secrets
import uuid
import jwt
from datetime import datetime, timedelta, timezone
from models.user import User
from models.refresh_token import RefreshToken
from flask import Blueprint, request, jsonify, current_app
from tinydb import Query
import os
import utils.email_sender as email_sender
from werkzeug.security import generate_password_hash, check_password_hash
from api.utils import sanitize_email
from config.paths import get_user_image_dir
from events.sse import send_event_to_user
from events.types.event import Event
from events.types.event_types import EventType
from events.types.payload import Payload
from api.error_codes import (
MISSING_FIELDS, EMAIL_EXISTS, MISSING_TOKEN, INVALID_TOKEN, TOKEN_TIMESTAMP_MISSING,
TOKEN_EXPIRED, ALREADY_VERIFIED, MISSING_EMAIL, USER_NOT_FOUND, MISSING_EMAIL_OR_PASSWORD,
INVALID_CREDENTIALS, NOT_VERIFIED, ACCOUNT_MARKED_FOR_DELETION,
REFRESH_TOKEN_REUSE, REFRESH_TOKEN_EXPIRED, MISSING_REFRESH_TOKEN,
)
from db.db import (
users_db, refresh_tokens_db, child_db, task_db, reward_db, image_db,
pending_reward_db, pending_confirmations_db, tracking_events_db,
child_overrides_db, chore_schedules_db, task_extensions_db,
)
from db.default import initializeImages, createDefaultTasks, createDefaultRewards
from api.utils import normalize_email
logger = logging.getLogger(__name__)
auth_api = Blueprint('auth_api', __name__)
UserQuery = Query()
TokenQuery = Query()
TOKEN_EXPIRY_MINUTES = 60 * 4
RESET_PASSWORD_TOKEN_EXPIRY_MINUTES = 10
try:
ACCESS_TOKEN_EXPIRY_MINUTES = int(os.environ.get('ACCESS_TOKEN_EXPIRY_MINUTES', '15'))
except ValueError:
ACCESS_TOKEN_EXPIRY_MINUTES = 15
try:
REFRESH_TOKEN_REUSE_GRACE_PERIOD_SECONDS = int(
os.environ.get('REFRESH_TOKEN_REUSE_GRACE_PERIOD_SECONDS', '30')
)
except ValueError:
REFRESH_TOKEN_REUSE_GRACE_PERIOD_SECONDS = 30
E2E_TEST_EMAIL = 'e2e@test.com'
E2E_TEST_PASSWORD = 'E2eTestPass1!'
E2E_TEST_PIN = '1234'
E2E_DELETE_EMAIL = 'e2e-delete@test.com'
E2E_DELETE_PASSWORD = 'E2eDeletePass1!'
E2E_DELETE_PIN = '5678'
E2E_CC_EMAIL = 'e2e-cc@test.com'
E2E_CC_PASSWORD = 'E2eCCPass1!'
E2E_CC_PIN = '3456'
E2E_TUTORIAL_EMAIL = 'e2e-tutorial@test.com'
E2E_TUTORIAL_PASSWORD = 'E2eTutorialPass1!'
E2E_TUTORIAL_PIN = '7890'
def send_verification_email(to_email, token):
email_sender.send_verification_email(to_email, token)
def send_reset_password_email(to_email, token):
email_sender.send_reset_password_email(to_email, token)
def _hash_token(raw_token: str) -> str:
"""SHA-256 hash a raw refresh token for secure storage."""
return hashlib.sha256(raw_token.encode('utf-8')).hexdigest()
def _create_access_token(user: User) -> str:
"""Create a short-lived JWT access token."""
payload = {
'email': user.email,
'user_id': user.id,
'token_version': user.token_version,
'exp': datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRY_MINUTES),
}
return jwt.encode(payload, current_app.config['SECRET_KEY'], algorithm='HS256')
def _create_refresh_token(user_id: str, token_family: str | None = None) -> tuple[str, RefreshToken]:
"""
Create a refresh token: returns (raw_token, RefreshToken record).
If token_family is None, a new family is created (login).
Otherwise, the existing family is reused (rotation).
"""
raw_token = secrets.token_urlsafe(32)
expiry_days = current_app.config['REFRESH_TOKEN_EXPIRY_DAYS']
expires_at = (datetime.now(timezone.utc) + timedelta(days=expiry_days)).isoformat()
family = token_family or str(uuid.uuid4())
record = RefreshToken(
user_id=user_id,
token_hash=_hash_token(raw_token),
token_family=family,
expires_at=expires_at,
is_used=False,
)
refresh_tokens_db.insert(record.to_dict())
return raw_token, record
def _set_auth_cookies(resp, access_token: str, raw_refresh_token: str):
"""Set both access and refresh token cookies on a response."""
expiry_days = current_app.config['REFRESH_TOKEN_EXPIRY_DAYS']
resp.set_cookie(
'access_token', access_token,
httponly=True, secure=True, samesite='Lax',
max_age=ACCESS_TOKEN_EXPIRY_MINUTES * 60,
)
resp.set_cookie(
'refresh_token', raw_refresh_token,
httponly=True, secure=True, samesite='Strict',
max_age=expiry_days * 24 * 3600,
path='/api/auth',
)
def _clear_auth_cookies(resp):
"""Clear both access and refresh token cookies."""
resp.set_cookie('access_token', '', expires=0, httponly=True, secure=True, samesite='Strict')
resp.set_cookie('refresh_token', '', expires=0, httponly=True, secure=True, samesite='Strict', path='/api/auth')
def _purge_expired_tokens(user_id: str):
"""Remove expired refresh tokens for a user to prevent unbounded DB growth."""
now = datetime.now(timezone.utc)
all_tokens = refresh_tokens_db.search(TokenQuery.user_id == user_id)
for t in all_tokens:
try:
exp = datetime.fromisoformat(t['expires_at'])
if exp.tzinfo is None:
exp = exp.replace(tzinfo=timezone.utc)
if now > exp:
refresh_tokens_db.remove(TokenQuery.id == t['id'])
except (ValueError, KeyError):
refresh_tokens_db.remove(TokenQuery.id == t['id'])
@auth_api.route('/signup', methods=['POST'])
def signup():
data = request.get_json()
required_fields = ['first_name', 'last_name', 'email', 'password']
if not all(field in data for field in required_fields):
return jsonify({'error': 'Missing required fields', 'code': MISSING_FIELDS}), 400
email = data.get('email', '')
norm_email = normalize_email(email)
existing = users_db.get(UserQuery.email == norm_email)
if existing:
user = User.from_dict(existing)
if user.marked_for_deletion:
return jsonify({'error': 'Account marked for deletion', 'code': ACCOUNT_MARKED_FOR_DELETION}), 403
return jsonify({'error': 'Email already exists', 'code': EMAIL_EXISTS}), 400
token = secrets.token_urlsafe(32)
now_iso = datetime.utcnow().isoformat()
user = User(
first_name=data['first_name'],
last_name=data['last_name'],
email=norm_email,
password=generate_password_hash(data['password']),
verified=False,
verify_token=token,
verify_token_created=now_iso,
image_id="boy01"
)
users_db.insert(user.to_dict())
send_verification_email(norm_email, token)
return jsonify({'message': 'User created, verification email sent'}), 201
@auth_api.route('/verify', methods=['GET'])
def verify():
token = request.args.get('token')
status = 'success'
reason = ''
code = ''
user_dict = None
user = None
if not token:
status = 'error'
reason = 'Missing token'
code = MISSING_TOKEN
else:
user_dict = users_db.get(Query().verify_token == token)
user = User.from_dict(user_dict) if user_dict else None
if not user:
status = 'error'
reason = 'Invalid token'
code = INVALID_TOKEN
elif user.marked_for_deletion:
status = 'error'
reason = 'Account marked for deletion'
code = ACCOUNT_MARKED_FOR_DELETION
else:
created_str = user.verify_token_created
if not created_str:
status = 'error'
reason = 'Token timestamp missing'
code = TOKEN_TIMESTAMP_MISSING
else:
created_dt = datetime.fromisoformat(created_str).replace(tzinfo=timezone.utc)
if datetime.now(timezone.utc) - created_dt > timedelta(minutes=TOKEN_EXPIRY_MINUTES):
status = 'error'
reason = 'Token expired'
code = TOKEN_EXPIRED
else:
user.verified = True
user.verify_token = None
user.verify_token_created = None
users_db.update(user.to_dict(), Query().email == user.email)
http_status = 200 if status == 'success' else 400
if http_status == 200 and user is not None:
if not user.email:
logger.error("Verified user has no email field.")
else:
user_image_dir = get_user_image_dir(user.id)
os.makedirs(user_image_dir, exist_ok=True)
return jsonify({'status': status, 'reason': reason, 'code': code}), http_status
@auth_api.route('/resend-verify', methods=['POST'])
def resend_verify():
data = request.get_json()
email = data.get('email', '')
if not email:
return jsonify({'error': 'Missing email', 'code': MISSING_EMAIL}), 400
norm_email = normalize_email(email)
user_dict = users_db.get(UserQuery.email == norm_email)
user = User.from_dict(user_dict) if user_dict else None
if not user:
return jsonify({'error': 'User not found', 'code': USER_NOT_FOUND}), 404
if user.verified:
return jsonify({'error': 'Account already verified', 'code': ALREADY_VERIFIED}), 400
token = secrets.token_urlsafe(32)
now_iso = datetime.utcnow().isoformat()
user.verify_token = token
user.verify_token_created = now_iso
users_db.update(user.to_dict(), UserQuery.email == norm_email)
send_verification_email(norm_email, token)
return jsonify({'message': 'Verification email resent'}), 200
@auth_api.route('/login', methods=['POST'])
def login():
data = request.get_json()
email = data.get('email', '')
password = data.get('password')
if not email or not password:
return jsonify({'error': 'Missing email or password', 'code': MISSING_EMAIL_OR_PASSWORD}), 400
norm_email = normalize_email(email)
user_dict = users_db.get(UserQuery.email == norm_email)
user = User.from_dict(user_dict) if user_dict else None
if not user or not check_password_hash(user.password, password):
return jsonify({'error': 'Invalid credentials', 'code': INVALID_CREDENTIALS}), 401
if not user.verified:
return jsonify({'error': 'This account has not verified', 'code': NOT_VERIFIED}), 403
# Block login for marked accounts
if user.marked_for_deletion:
return jsonify({'error': 'This account has been marked for deletion and cannot be accessed.', 'code': ACCOUNT_MARKED_FOR_DELETION}), 403
# Purge expired refresh tokens for this user
_purge_expired_tokens(user.id)
# Create access token (short-lived JWT)
access_token = _create_access_token(user)
# Create refresh token (long-lived, new family for fresh login)
raw_refresh, _ = _create_refresh_token(user.id)
resp = jsonify({'message': 'Login successful'})
_set_auth_cookies(resp, access_token, raw_refresh)
return resp, 200
@auth_api.route('/me', methods=['GET'])
def me():
token = request.cookies.get('access_token')
if not token:
return jsonify({'error': 'Missing token', 'code': MISSING_TOKEN}), 401
try:
payload = jwt.decode(token, current_app.config['SECRET_KEY'], algorithms=['HS256'])
user_id = payload.get('user_id', '')
token_version = payload.get('token_version', 0)
user_dict = users_db.get(UserQuery.id == user_id)
user = User.from_dict(user_dict) if user_dict else None
if not user:
return jsonify({'error': 'User not found', 'code': USER_NOT_FOUND}), 404
if token_version != user.token_version:
return jsonify({'error': 'Invalid token', 'code': INVALID_TOKEN}), 401
if user.marked_for_deletion:
return jsonify({'error': 'Account marked for deletion', 'code': ACCOUNT_MARKED_FOR_DELETION}), 403
return jsonify({
'email': user.email,
'id': user_id,
'first_name': user.first_name,
'last_name': user.last_name,
'verified': user.verified
}), 200
except jwt.ExpiredSignatureError:
return jsonify({'error': 'Token expired', 'code': TOKEN_EXPIRED}), 401
except jwt.InvalidTokenError:
return jsonify({'error': 'Invalid token', 'code': INVALID_TOKEN}), 401
@auth_api.route('/request-password-reset', methods=['POST'])
def request_password_reset():
data = request.get_json()
email = data.get('email', '')
norm_email = normalize_email(email)
success_msg = 'If this email is registered, you will receive a password reset link shortly.'
if not email:
return jsonify({'error': 'Missing email', 'code': MISSING_EMAIL}), 400
user_dict = users_db.get(UserQuery.email == norm_email)
user = User.from_dict(user_dict) if user_dict else None
if user:
if user.marked_for_deletion:
return jsonify({'error': 'Account marked for deletion', 'code': ACCOUNT_MARKED_FOR_DELETION}), 403
token = secrets.token_urlsafe(32)
now_iso = datetime.utcnow().isoformat()
user.reset_token = token
user.reset_token_created = now_iso
users_db.update(user.to_dict(), UserQuery.email == norm_email)
send_reset_password_email(norm_email, token)
return jsonify({'message': success_msg}), 200
@auth_api.route('/validate-reset-token', methods=['GET'])
def validate_reset_token():
token = request.args.get('token')
if not token:
return jsonify({'error': 'Missing token', 'code': MISSING_TOKEN}), 400
user_dict = users_db.get(UserQuery.reset_token == token)
user = User.from_dict(user_dict) if user_dict else None
if not user:
return jsonify({'error': 'Invalid token', 'code': INVALID_TOKEN}), 400
created_str = user.reset_token_created
if not created_str:
return jsonify({'error': 'Token timestamp missing', 'code': TOKEN_TIMESTAMP_MISSING}), 400
created_dt = datetime.fromisoformat(created_str).replace(tzinfo=timezone.utc)
if datetime.now(timezone.utc) - created_dt > timedelta(minutes=RESET_PASSWORD_TOKEN_EXPIRY_MINUTES):
return jsonify({'error': 'Token expired', 'code': TOKEN_EXPIRED}), 400
return jsonify({'message': 'Token is valid'}), 200
@auth_api.route('/reset-password', methods=['POST'])
def reset_password():
data = request.get_json()
token = data.get('token')
new_password = data.get('password')
if not token or not new_password:
return jsonify({'error': 'Missing token or password'}), 400
user_dict = users_db.get(UserQuery.reset_token == token)
user = User.from_dict(user_dict) if user_dict else None
if not user:
return jsonify({'error': 'Invalid token', 'code': INVALID_TOKEN}), 400
created_str = user.reset_token_created
if not created_str:
return jsonify({'error': 'Token timestamp missing', 'code': TOKEN_TIMESTAMP_MISSING}), 400
created_dt = datetime.fromisoformat(created_str).replace(tzinfo=timezone.utc)
if datetime.now(timezone.utc) - created_dt > timedelta(minutes=RESET_PASSWORD_TOKEN_EXPIRY_MINUTES):
return jsonify({'error': 'Token expired', 'code': TOKEN_EXPIRED}), 400
user.password = generate_password_hash(new_password)
user.reset_token = None
user.reset_token_created = None
user.token_version += 1
users_db.update(user.to_dict(), UserQuery.email == user.email)
# Invalidate ALL refresh tokens for this user
refresh_tokens_db.remove(TokenQuery.user_id == user.id)
# Notify all active sessions (other tabs/devices) to sign out immediately
send_event_to_user(user.id, Event(EventType.FORCE_LOGOUT.value, Payload({'reason': 'password_reset'})))
resp = jsonify({'message': 'Password has been reset'})
_clear_auth_cookies(resp)
return resp, 200
@auth_api.route('/refresh', methods=['POST'])
def refresh():
raw_token = request.cookies.get('refresh_token')
if not raw_token:
return jsonify({'error': 'Missing refresh token', 'code': MISSING_REFRESH_TOKEN}), 401
token_hash = _hash_token(raw_token)
token_dict = refresh_tokens_db.get(TokenQuery.token_hash == token_hash)
if not token_dict:
# Token not found — could be invalid or already purged
resp = jsonify({'error': 'Invalid token', 'code': INVALID_TOKEN})
_clear_auth_cookies(resp)
return resp, 401
token_record = RefreshToken.from_dict(token_dict)
# Look up the user early (needed for both legitimate rotation and grace-period handling)
user_dict = users_db.get(UserQuery.id == token_record.user_id)
user = User.from_dict(user_dict) if user_dict else None
if not user:
refresh_tokens_db.remove(TokenQuery.id == token_record.id)
resp = jsonify({'error': 'User not found', 'code': USER_NOT_FOUND})
_clear_auth_cookies(resp)
return resp, 401
if user.marked_for_deletion:
refresh_tokens_db.remove(TokenQuery.user_id == user.id)
resp = jsonify({'error': 'Account marked for deletion', 'code': ACCOUNT_MARKED_FOR_DELETION})
_clear_auth_cookies(resp)
return resp, 403
# Check expiry
try:
exp = datetime.fromisoformat(token_record.expires_at)
if exp.tzinfo is None:
exp = exp.replace(tzinfo=timezone.utc)
if datetime.now(timezone.utc) > exp:
refresh_tokens_db.remove(TokenQuery.id == token_record.id)
resp = jsonify({'error': 'Refresh token expired', 'code': REFRESH_TOKEN_EXPIRED})
_clear_auth_cookies(resp)
return resp, 401
except ValueError:
refresh_tokens_db.remove(TokenQuery.id == token_record.id)
resp = jsonify({'error': 'Invalid token', 'code': INVALID_TOKEN})
_clear_auth_cookies(resp)
return resp, 401
# THEFT DETECTION: token was already used (rotated out) but replayed
if token_record.is_used:
# Grace period: tolerate a very recent rotation to avoid false positives
# from legitimate concurrent refresh requests (race conditions).
grace_period = current_app.config.get(
'REFRESH_TOKEN_REUSE_GRACE_PERIOD_SECONDS', REFRESH_TOKEN_REUSE_GRACE_PERIOD_SECONDS
)
rotated_at = token_record.rotated_at
is_race_condition = False
if rotated_at:
try:
rotated_dt = datetime.fromisoformat(rotated_at)
if rotated_dt.tzinfo is None:
rotated_dt = rotated_dt.replace(tzinfo=timezone.utc)
if (datetime.now(timezone.utc) - rotated_dt).total_seconds() <= grace_period:
is_race_condition = True
except ValueError:
pass
if is_race_condition:
logger.info(
'Refresh token replay within grace period treated as race condition. user_id=%s, family=%s, ip=%s',
token_record.user_id, token_record.token_family, request.remote_addr,
)
raw_new_refresh, _ = _create_refresh_token(user.id, token_family=token_record.token_family)
access_token = _create_access_token(user)
resp = jsonify({
'email': user.email,
'id': user.id,
'first_name': user.first_name,
'last_name': user.last_name,
'verified': user.verified,
})
_set_auth_cookies(resp, access_token, raw_new_refresh)
return resp, 200
logger.warning(
'Refresh token reuse detected! user_id=%s, family=%s, ip=%s — killing family sessions',
token_record.user_id, token_record.token_family, request.remote_addr,
)
# Invalidate only the affected family, not every session for the user.
refresh_tokens_db.remove(
(TokenQuery.user_id == token_record.user_id) & (TokenQuery.token_family == token_record.token_family)
)
resp = jsonify({'error': 'Token reuse detected, family sessions invalidated', 'code': REFRESH_TOKEN_REUSE})
_clear_auth_cookies(resp)
return resp, 401
# ROTATION: mark old token as used, create new one in same family
refresh_tokens_db.update(
{'is_used': True, 'rotated_at': datetime.now(timezone.utc).isoformat()},
TokenQuery.id == token_record.id,
)
raw_new_refresh, _ = _create_refresh_token(user.id, token_family=token_record.token_family)
# Issue new access token
access_token = _create_access_token(user)
resp = jsonify({
'email': user.email,
'id': user.id,
'first_name': user.first_name,
'last_name': user.last_name,
'verified': user.verified,
})
_set_auth_cookies(resp, access_token, raw_new_refresh)
return resp, 200
@auth_api.route('/logout', methods=['POST'])
def logout():
# Delete the refresh token from DB if present
raw_token = request.cookies.get('refresh_token')
if raw_token:
token_hash = _hash_token(raw_token)
refresh_tokens_db.remove(TokenQuery.token_hash == token_hash)
resp = jsonify({'message': 'Logged out'})
_clear_auth_cookies(resp)
return resp, 200
@auth_api.route('/e2e-create-delete-user', methods=['POST'])
def e2e_create_delete_user():
"""Create a secondary e2e test user for deletion testing. Only available outside production."""
if os.environ.get('DB_ENV', 'prod') == 'prod':
return jsonify({'error': 'Not available in production'}), 403
norm_email = normalize_email(E2E_DELETE_EMAIL)
users_db.remove(UserQuery.email == norm_email)
user = User(
first_name='E2E',
last_name='Delete',
email=norm_email,
password=generate_password_hash(E2E_DELETE_PASSWORD),
verified=True,
role='user',
pin=E2E_DELETE_PIN,
tutorial_enabled=False,
)
users_db.insert(user.to_dict())
return jsonify({'email': norm_email}), 201
@auth_api.route('/e2e-create-cc-user', methods=['POST'])
def e2e_create_cc_user():
"""Create an isolated e2e test user for create-child tests. Only available outside production."""
if os.environ.get('DB_ENV', 'prod') == 'prod':
return jsonify({'error': 'Not available in production'}), 403
norm_email = normalize_email(E2E_CC_EMAIL)
# Remove old user and all their children so deleteAllChildren() starts clean.
existing = users_db.get(UserQuery.email == norm_email)
if existing:
child_db.remove(Query().user_id == existing.get('id'))
users_db.remove(UserQuery.email == norm_email)
user = User(
first_name='E2E',
last_name='CreateChild',
email=norm_email,
password=generate_password_hash(E2E_CC_PASSWORD),
verified=True,
role='user',
pin=E2E_CC_PIN,
tutorial_enabled=False,
)
users_db.insert(user.to_dict())
return jsonify({'email': norm_email}), 201
@auth_api.route('/e2e-create-tutorial-user', methods=['POST'])
def e2e_create_tutorial_user():
"""Create an isolated e2e test user for tutorial tests. Only available outside production."""
if os.environ.get('DB_ENV', 'prod') == 'prod':
return jsonify({'error': 'Not available in production'}), 403
norm_email = normalize_email(E2E_TUTORIAL_EMAIL)
# Clean up any children from previous tutorial runs.
existing = users_db.get(UserQuery.email == norm_email)
if existing:
child_db.remove(Query().user_id == existing.get('id'))
users_db.remove(UserQuery.email == norm_email)
user = User(
first_name='E2E',
last_name='Tutorial',
email=norm_email,
password=generate_password_hash(E2E_TUTORIAL_PASSWORD),
verified=True,
role='user',
pin=E2E_TUTORIAL_PIN,
tutorial_enabled=False,
)
users_db.insert(user.to_dict())
return jsonify({'email': norm_email}), 201
@auth_api.route('/e2e-seed', methods=['POST'])
def e2e_seed():
"""Reset the database and insert a verified test user. Only available outside production."""
if os.environ.get('DB_ENV', 'prod') == 'prod':
return jsonify({'error': 'Not available in production'}), 403
child_db.truncate()
task_db.truncate()
reward_db.truncate()
image_db.truncate()
pending_reward_db.truncate()
pending_confirmations_db.truncate()
users_db.truncate()
tracking_events_db.truncate()
child_overrides_db.truncate()
chore_schedules_db.truncate()
task_extensions_db.truncate()
refresh_tokens_db.truncate()
# Recreate only baseline defaults for e2e runs.
initializeImages()
createDefaultTasks()
createDefaultRewards()
norm_email = normalize_email(E2E_TEST_EMAIL)
user = User(
first_name='E2E',
last_name='Tester',
email=norm_email,
password=generate_password_hash(E2E_TEST_PASSWORD),
verified=True,
role='user',
pin=E2E_TEST_PIN,
tutorial_enabled=False,
)
users_db.insert(user.to_dict())
return jsonify({'email': norm_email}), 201
+357
View File
@@ -0,0 +1,357 @@
"""Shared business logic for chore confirmation and reward actions.
Called from both child_api.py (JWT-authenticated endpoints) and
digest_action_api.py (token-authenticated endpoints). All functions take
user_id explicitly rather than reading it from the Flask request context.
"""
import logging
from datetime import datetime, timezone
from tinydb import Query
from db.db import child_db, task_db, reward_db, pending_confirmations_db, routine_db
from db.child_overrides import get_override
from db.chore_schedules import get_schedule
from db.routine_schedules import get_schedule as get_routine_schedule
from db.tracking import insert_tracking_event
from events.sse import send_event_to_user
from events.types.child_chore_confirmation import ChildChoreConfirmation
from events.types.child_routine_confirmation import ChildRoutineConfirmation
from events.types.child_reward_request import ChildRewardRequest
from events.types.child_reward_triggered import ChildRewardTriggered
from events.types.child_task_triggered import ChildTaskTriggered
from events.types.tracking_event_created import TrackingEventCreated
from events.types.event import Event
from events.types.event_types import EventType
from models.child import Child
from models.reward import Reward
from models.routine import Routine
from models.task import Task
from models.tracking_event import TrackingEvent
from utils.tracking_logger import log_tracking_event
logger = logging.getLogger(__name__)
def approve_chore(user_id: str, child_id: str, task_id: str) -> dict | None:
"""Award points for a completed chore and mark the pending confirmation approved.
Returns a result dict on success, or None if the confirmation was already resolved.
Raises ValueError if the child or task cannot be found.
"""
ChildQ = Query()
child_result = child_db.get((ChildQ.id == child_id) & (ChildQ.user_id == user_id))
if not child_result:
raise ValueError(f'Child {child_id} not found for user {user_id}')
child = Child.from_dict(child_result)
if task_id not in child.tasks:
logger.info(f'Task {task_id} no longer assigned to child {child_id}; skipping approve')
return None
PendingQ = Query()
existing = pending_confirmations_db.get(
(PendingQ.child_id == child_id) & (PendingQ.entity_id == task_id) &
(PendingQ.entity_type == 'chore') & (PendingQ.status == 'pending') &
(PendingQ.user_id == user_id)
)
if not existing:
logger.info(f'No pending chore for child {child_id}, task {task_id} — already resolved')
return None
TaskQ = Query()
task_result = task_db.get(
(TaskQ.id == task_id) & ((TaskQ.user_id == user_id) | (TaskQ.user_id == None))
)
if not task_result:
raise ValueError(f'Task {task_id} not found')
task = Task.from_dict(task_result)
override = get_override(child_id, task_id)
points_value = override.custom_value if override else task.points
points_before = child.points
child.points += points_value
child_db.update({'points': child.points}, ChildQ.id == child_id)
schedule = get_schedule(child_id, task_id)
if schedule:
now_str = datetime.now(timezone.utc).isoformat()
pending_confirmations_db.update(
{'status': 'approved', 'approved_at': now_str},
(PendingQ.child_id == child_id) & (PendingQ.entity_id == task_id) &
(PendingQ.entity_type == 'chore') & (PendingQ.user_id == user_id)
)
else:
pending_confirmations_db.remove(
(PendingQ.child_id == child_id) & (PendingQ.entity_id == task_id) &
(PendingQ.entity_type == 'chore') & (PendingQ.user_id == user_id)
)
tracking_metadata = {
'task_name': task.name,
'task_type': task.type,
'default_points': task.points,
}
if override:
tracking_metadata['custom_points'] = override.custom_value
tracking_metadata['has_override'] = True
tracking_event = TrackingEvent.create_event(
user_id=user_id, child_id=child_id, entity_type='chore', entity_id=task_id,
action='approved', points_before=points_before, points_after=child.points,
metadata=tracking_metadata,
)
insert_tracking_event(tracking_event)
log_tracking_event(tracking_event)
send_event_to_user(user_id, Event(EventType.TRACKING_EVENT_CREATED.value,
TrackingEventCreated(tracking_event.id, child_id, 'chore', 'approved')))
send_event_to_user(user_id, Event(EventType.CHILD_CHORE_CONFIRMATION.value,
ChildChoreConfirmation(child_id, task_id, ChildChoreConfirmation.OPERATION_APPROVED)))
send_event_to_user(user_id, Event(EventType.CHILD_TASK_TRIGGERED.value,
ChildTaskTriggered(task_id, child_id, child.points)))
return {'task_name': task.name, 'child_name': child.name, 'child_id': child_id, 'points': child.points}
def reject_chore(user_id: str, child_id: str, task_id: str) -> None:
"""Reject a pending chore confirmation. No-op if already resolved."""
PendingQ = Query()
existing = pending_confirmations_db.get(
(PendingQ.child_id == child_id) & (PendingQ.entity_id == task_id) &
(PendingQ.entity_type == 'chore') & (PendingQ.status == 'pending') &
(PendingQ.user_id == user_id)
)
if not existing:
logger.info(f'No pending chore for child {child_id}, task {task_id} — already resolved')
return
pending_confirmations_db.remove(
(PendingQ.child_id == child_id) & (PendingQ.entity_id == task_id) &
(PendingQ.entity_type == 'chore') & (PendingQ.user_id == user_id)
)
ChildQ = Query()
child_result = child_db.get((ChildQ.id == child_id) & (ChildQ.user_id == user_id))
if child_result:
child = Child.from_dict(child_result)
TaskQ = Query()
task_result = task_db.get(
(TaskQ.id == task_id) & ((TaskQ.user_id == user_id) | (TaskQ.user_id == None))
)
task_name = task_result.get('name') if task_result else 'Unknown'
tracking_event = TrackingEvent.create_event(
user_id=user_id, child_id=child_id, entity_type='chore', entity_id=task_id,
action='rejected', points_before=child.points, points_after=child.points,
metadata={'task_name': task_name},
)
insert_tracking_event(tracking_event)
log_tracking_event(tracking_event)
send_event_to_user(user_id, Event(EventType.TRACKING_EVENT_CREATED.value,
TrackingEventCreated(tracking_event.id, child_id, 'chore', 'rejected')))
send_event_to_user(user_id, Event(EventType.CHILD_CHORE_CONFIRMATION.value,
ChildChoreConfirmation(child_id, task_id, ChildChoreConfirmation.OPERATION_REJECTED)))
def approve_reward_request(user_id: str, child_id: str, reward_id: str) -> dict:
"""Approve a child's pending reward request: deduct points and fire SSE events.
Returns a result dict on success.
Raises ValueError if child/reward not found or the child has insufficient points.
"""
ChildQ = Query()
child_result = child_db.get((ChildQ.id == child_id) & (ChildQ.user_id == user_id))
if not child_result:
raise ValueError(f'Child {child_id} not found for user {user_id}')
child = Child.from_dict(child_result)
if reward_id not in child.rewards:
logger.info(f'Reward {reward_id} no longer assigned to child {child_id}; skipping approve')
return None
RewardQ = Query()
reward_result = reward_db.get(
(RewardQ.id == reward_id) & ((RewardQ.user_id == user_id) | (RewardQ.user_id == None))
)
if not reward_result:
raise ValueError(f'Reward {reward_id} not found')
reward = Reward.from_dict(reward_result)
override = get_override(child_id, reward_id)
cost_value = override.custom_value if override else reward.cost
if child.points < cost_value:
raise ValueError(f'Child {child_id} has insufficient points for reward {reward_id}')
PendingQ = Query()
existing = pending_confirmations_db.get(
(PendingQ.child_id == child_id) & (PendingQ.entity_id == reward_id) &
(PendingQ.entity_type == 'reward') & (PendingQ.status == 'pending') &
(PendingQ.user_id == user_id)
)
if not existing:
logger.info(f'No pending reward for child {child_id}, reward {reward_id} — already resolved')
return None
pending_confirmations_db.remove(
(PendingQ.child_id == child_id) & (PendingQ.entity_id == reward_id) &
(PendingQ.entity_type == 'reward') & (PendingQ.user_id == user_id)
)
send_event_to_user(user_id, Event(EventType.CHILD_REWARD_REQUEST.value,
ChildRewardRequest(child_id, reward_id, ChildRewardRequest.REQUEST_GRANTED)))
points_before = child.points
child.points -= cost_value
child_db.update({'points': child.points}, ChildQ.id == child_id)
tracking_metadata = {
'reward_name': reward.name,
'reward_cost': reward.cost,
'default_cost': reward.cost,
}
if override:
tracking_metadata['custom_cost'] = override.custom_value
tracking_metadata['has_override'] = True
tracking_event = TrackingEvent.create_event(
user_id=user_id, child_id=child_id, entity_type='reward', entity_id=reward_id,
action='redeemed', points_before=points_before, points_after=child.points,
metadata=tracking_metadata,
)
insert_tracking_event(tracking_event)
log_tracking_event(tracking_event)
send_event_to_user(user_id, Event(EventType.TRACKING_EVENT_CREATED.value,
TrackingEventCreated(tracking_event.id, child_id, 'reward', 'redeemed')))
send_event_to_user(user_id, Event(EventType.CHILD_REWARD_TRIGGERED.value,
ChildRewardTriggered(reward_id, child_id, child.points)))
return {'reward_name': reward.name, 'child_name': child.name, 'child_id': child_id, 'points': child.points}
def deny_reward(user_id: str, child_id: str, reward_id: str) -> dict | None:
"""Deny a child's pending reward request. No-op if already resolved.
Returns a result dict on success, or None if already resolved.
"""
PendingQ = Query()
existing = pending_confirmations_db.get(
(PendingQ.child_id == child_id) & (PendingQ.entity_id == reward_id) &
(PendingQ.entity_type == 'reward') & (PendingQ.status == 'pending') &
(PendingQ.user_id == user_id)
)
if not existing:
logger.info(f'Reward request for child {child_id}, reward {reward_id} already resolved')
return None
pending_confirmations_db.remove(
(PendingQ.child_id == child_id) & (PendingQ.entity_id == reward_id) &
(PendingQ.entity_type == 'reward') & (PendingQ.user_id == user_id)
)
ChildQ = Query()
child_result = child_db.get((ChildQ.id == child_id) & (ChildQ.user_id == user_id))
child_name = 'Unknown'
if child_result:
child = Child.from_dict(child_result)
child_name = child.name
tracking_event = TrackingEvent.create_event(
user_id=user_id, child_id=child_id, entity_type='reward', entity_id=reward_id,
action='denied', points_before=child.points, points_after=child.points,
metadata={},
)
insert_tracking_event(tracking_event)
log_tracking_event(tracking_event)
send_event_to_user(user_id, Event(EventType.TRACKING_EVENT_CREATED.value,
TrackingEventCreated(tracking_event.id, child_id, 'reward', 'denied')))
send_event_to_user(user_id, Event(EventType.CHILD_REWARD_REQUEST.value,
ChildRewardRequest(child_id, reward_id, ChildRewardRequest.REQUEST_CANCELLED)))
return {'child_name': child_name}
def approve_routine(user_id: str, child_id: str, routine_id: str) -> dict | None:
"""Award points for a completed routine and mark the pending confirmation approved.
Returns a result dict on success, or None if already resolved.
Raises ValueError if the child or routine cannot be found.
"""
ChildQ = Query()
child_result = child_db.get((ChildQ.id == child_id) & (ChildQ.user_id == user_id))
if not child_result:
raise ValueError(f'Child {child_id} not found for user {user_id}')
child = Child.from_dict(child_result)
if routine_id not in child.routines:
logger.info(f'Routine {routine_id} no longer assigned to child {child_id}; skipping approve')
return None
PendingQ = Query()
existing = pending_confirmations_db.get(
(PendingQ.child_id == child_id) & (PendingQ.entity_id == routine_id) &
(PendingQ.entity_type == 'routine') & (PendingQ.status == 'pending') &
(PendingQ.user_id == user_id)
)
if not existing:
logger.info(f'No pending routine for child {child_id}, routine {routine_id} — already resolved')
return None
RoutineQ = Query()
routine_result = routine_db.get(
(RoutineQ.id == routine_id) & ((RoutineQ.user_id == user_id) | (RoutineQ.user_id == None))
)
if not routine_result:
raise ValueError(f'Routine {routine_id} not found')
routine = Routine.from_dict(routine_result)
override = get_override(child_id, routine_id)
points_value = override.custom_value if override and override.entity_type == 'routine' else routine.points
points_before = child.points
child.points += points_value
child_db.update({'points': child.points}, ChildQ.id == child_id)
schedule = get_routine_schedule(child_id, routine_id)
now_str = datetime.now(timezone.utc).isoformat()
if schedule:
pending_confirmations_db.update(
{'status': 'approved', 'approved_at': now_str},
(PendingQ.child_id == child_id) & (PendingQ.entity_id == routine_id) &
(PendingQ.entity_type == 'routine') & (PendingQ.user_id == user_id)
)
else:
pending_confirmations_db.remove(
(PendingQ.child_id == child_id) & (PendingQ.entity_id == routine_id) &
(PendingQ.entity_type == 'routine') & (PendingQ.user_id == user_id)
)
send_event_to_user(user_id, Event(EventType.CHILD_ROUTINE_CONFIRMATION.value,
ChildRoutineConfirmation(child_id, routine_id, ChildRoutineConfirmation.OPERATION_APPROVED)))
return {'routine_name': routine.name, 'child_name': child.name, 'child_id': child_id, 'points': child.points}
def reject_routine(user_id: str, child_id: str, routine_id: str) -> None:
"""Reject a pending routine confirmation. No-op if already resolved."""
PendingQ = Query()
existing = pending_confirmations_db.get(
(PendingQ.child_id == child_id) & (PendingQ.entity_id == routine_id) &
(PendingQ.entity_type == 'routine') & (PendingQ.status == 'pending') &
(PendingQ.user_id == user_id)
)
if not existing:
logger.info(f'No pending routine for child {child_id}, routine {routine_id} — already resolved')
return
pending_confirmations_db.remove(
(PendingQ.child_id == child_id) & (PendingQ.entity_id == routine_id) &
(PendingQ.entity_type == 'routine') & (PendingQ.user_id == user_id)
)
send_event_to_user(user_id, Event(EventType.CHILD_ROUTINE_CONFIRMATION.value,
ChildRoutineConfirmation(child_id, routine_id, ChildRoutineConfirmation.OPERATION_REJECTED)))
File diff suppressed because it is too large Load Diff
+186
View File
@@ -0,0 +1,186 @@
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, routine_db
from db.child_overrides import (
insert_override,
get_override,
get_overrides_for_child,
delete_override
)
from models.child_override import ChildOverride
from events.types.event import Event
from events.types.event_types import EventType
from events.types.child_override_set import ChildOverrideSetPayload
from events.types.child_override_deleted import ChildOverrideDeletedPayload
import logging
child_override_api = Blueprint('child_override_api', __name__)
logger = logging.getLogger(__name__)
@child_override_api.route('/child/<child_id>/override', methods=['PUT'])
def set_child_override(child_id):
"""
Set or update a custom value for a task/reward for a specific child.
"""
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
# Validate child exists and belongs to user
ChildQuery = Query()
child_result = child_db.search((ChildQuery.id == child_id) & (ChildQuery.user_id == user_id))
if not child_result:
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
child_dict = child_result[0]
# Parse request data
data = request.get_json() or {}
entity_id = data.get('entity_id')
entity_type = data.get('entity_type')
custom_value = data.get('custom_value')
# Validate required fields
if not entity_id:
return jsonify({'error': 'entity_id is required', 'code': ErrorCodes.MISSING_FIELD, 'field': 'entity_id'}), 400
if not entity_type:
return jsonify({'error': 'entity_type is required', 'code': ErrorCodes.MISSING_FIELD, 'field': 'entity_type'}), 400
if custom_value is None:
return jsonify({'error': 'custom_value is required', 'code': ErrorCodes.MISSING_FIELD, 'field': 'custom_value'}), 400
# Validate entity_type
if entity_type not in ['task', 'reward', '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:
return jsonify({'error': 'custom_value must be an integer between 0 and 10000', 'code': ErrorCodes.INVALID_VALUE, 'field': 'custom_value'}), 400
# Validate entity exists and is assigned to child
if entity_type == 'task':
EntityQuery = Query()
entity_result = task_db.search(
(EntityQuery.id == entity_id) &
((EntityQuery.user_id == user_id) | (EntityQuery.user_id == None))
)
if not entity_result:
return jsonify({'error': 'Task not found', 'code': ErrorCodes.TASK_NOT_FOUND}), 404
# Check if task is assigned to child
assigned_tasks = child_dict.get('tasks', [])
if entity_id not in assigned_tasks:
return jsonify({'error': 'Task not assigned to child', 'code': ErrorCodes.ENTITY_NOT_ASSIGNED}), 404
elif entity_type == 'reward':
EntityQuery = Query()
entity_result = reward_db.search(
(EntityQuery.id == entity_id) &
((EntityQuery.user_id == user_id) | (EntityQuery.user_id == None))
)
if not entity_result:
return jsonify({'error': 'Reward not found', 'code': ErrorCodes.REWARD_NOT_FOUND}), 404
# Check if reward is assigned to child
assigned_rewards = child_dict.get('rewards', [])
if entity_id not in assigned_rewards:
return jsonify({'error': 'Reward not assigned to child', 'code': ErrorCodes.ENTITY_NOT_ASSIGNED}), 404
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:
override = ChildOverride.create_override(
child_id=child_id,
entity_id=entity_id,
entity_type=entity_type,
custom_value=custom_value
)
insert_override(override)
# Send SSE event
resp = send_event_for_current_user(
Event(EventType.CHILD_OVERRIDE_SET.value, ChildOverrideSetPayload(override))
)
if resp:
return resp
return jsonify({'override': override.to_dict()}), 200
except ValueError as e:
return jsonify({'error': str(e), 'code': ErrorCodes.VALIDATION_ERROR}), 400
except Exception as e:
logger.error(f"Error setting override: {e}")
return jsonify({'error': 'Internal server error', 'code': ErrorCodes.INTERNAL_ERROR}), 500
@child_override_api.route('/child/<child_id>/overrides', methods=['GET'])
def get_child_overrides(child_id):
"""
Get all overrides for a specific child.
"""
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
# Validate child exists and belongs to user
ChildQuery = Query()
child_result = child_db.search((ChildQuery.id == child_id) & (ChildQuery.user_id == user_id))
if not child_result:
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
# Get all overrides for child
overrides = get_overrides_for_child(child_id)
return jsonify({'overrides': [o.to_dict() for o in overrides]}), 200
@child_override_api.route('/child/<child_id>/override/<entity_id>', methods=['DELETE'])
def delete_child_override(child_id, entity_id):
"""
Delete an override (reset to default).
"""
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
# Validate child exists and belongs to user
ChildQuery = Query()
child_result = child_db.search((ChildQuery.id == child_id) & (ChildQuery.user_id == user_id))
if not child_result:
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
# Get override to determine entity_type for event
override = get_override(child_id, entity_id)
if not override:
return jsonify({'error': 'Override not found', 'code': ErrorCodes.OVERRIDE_NOT_FOUND}), 404
entity_type = override.entity_type
# Delete override
deleted = delete_override(child_id, entity_id)
if not deleted:
return jsonify({'error': 'Override not found', 'code': ErrorCodes.OVERRIDE_NOT_FOUND}), 404
# Send SSE event
resp = send_event_for_current_user(
Event(EventType.CHILD_OVERRIDE_DELETED.value,
ChildOverrideDeletedPayload(child_id, entity_id, entity_type))
)
if resp:
return resp
return jsonify({'message': 'Override deleted'}), 200
+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
@@ -1,8 +1,8 @@
class ChildTask: class ChildTask:
def __init__(self, name, is_good, points, image_id, id): def __init__(self, name, task_type, points, image_id, id):
self.id = id self.id = id
self.name = name self.name = name
self.is_good = is_good self.type = task_type
self.points = points self.points = points
self.image_id = image_id self.image_id = image_id
@@ -10,7 +10,7 @@ class ChildTask:
return { return {
'id': self.id, 'id': self.id,
'name': self.name, 'name': self.name,
'is_good': self.is_good, 'type': self.type,
'points': self.points, 'points': self.points,
'image_id': self.image_id 'image_id': self.image_id
} }
+165
View File
@@ -0,0 +1,165 @@
from flask import Blueprint, request, jsonify
from tinydb import Query
from api.utils import send_event_for_current_user, get_validated_user_id
from events.types.child_tasks_set import ChildTasksSet
from db.db import task_db, child_db
from db.child_overrides import delete_overrides_for_entity
from events.types.event import Event
from events.types.event_types import EventType
from events.types.task_modified import TaskModified
from models.task import Task
chore_api = Blueprint('chore_api', __name__)
TASK_TYPE = 'chore'
@chore_api.route('/chore/add', methods=['PUT'])
def add_chore():
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
data = request.get_json()
name = data.get('name')
points = data.get('points')
image = data.get('image_id', '')
if not name or points is None:
return jsonify({'error': 'Name and points are required'}), 400
task = Task(name=name, points=points, type=TASK_TYPE, image_id=image, user_id=user_id)
task_db.insert(task.to_dict())
send_event_for_current_user(Event(EventType.TASK_MODIFIED.value,
TaskModified(task.id, TaskModified.OPERATION_ADD)))
return jsonify({'message': f'Chore {name} added.'}), 201
@chore_api.route('/chore/<id>', methods=['GET'])
def get_chore(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
TaskQuery = Query()
result = task_db.search(
(TaskQuery.id == id) &
((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)) &
(TaskQuery.type == TASK_TYPE)
)
if not result:
return jsonify({'error': 'Chore not found'}), 404
return jsonify(result[0]), 200
@chore_api.route('/chore/list', methods=['GET'])
def list_chores():
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
TaskQuery = Query()
tasks = task_db.search(
((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)) &
(TaskQuery.type == TASK_TYPE)
)
user_tasks = {t['name'].strip().lower(): t for t in tasks if t.get('user_id') == user_id}
filtered_tasks = []
for t in tasks:
if t.get('user_id') is None and t['name'].strip().lower() in user_tasks:
continue
filtered_tasks.append(t)
def sort_user_then_default(tasks_group):
user_created = sorted(
[t for t in tasks_group if t.get('user_id') == user_id],
key=lambda x: x['name'].lower(),
)
default_items = sorted(
[t for t in tasks_group if t.get('user_id') is None],
key=lambda x: x['name'].lower(),
)
return user_created + default_items
sorted_tasks = sort_user_then_default(filtered_tasks)
return jsonify({'tasks': sorted_tasks}), 200
@chore_api.route('/chore/<id>', methods=['DELETE'])
def delete_chore(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
TaskQuery = Query()
task = task_db.get((TaskQuery.id == id) & (TaskQuery.type == TASK_TYPE))
if not task:
return jsonify({'error': 'Chore not found'}), 404
if task.get('user_id') is None:
import logging
logging.warning(f"Forbidden delete attempt on system chore: id={id}, by user_id={user_id}")
return jsonify({'error': 'System chores cannot be deleted.'}), 403
removed = task_db.remove((TaskQuery.id == id) & (TaskQuery.user_id == user_id))
if removed:
deleted_count = delete_overrides_for_entity(id)
if deleted_count > 0:
import logging
logging.info(f"Cascade deleted {deleted_count} overrides for chore {id}")
ChildQuery = Query()
for child in child_db.all():
child_tasks = child.get('tasks', [])
if id in child_tasks:
child_tasks.remove(id)
child_db.update({'tasks': child_tasks}, ChildQuery.id == child.get('id'))
send_event_for_current_user(Event(EventType.CHILD_TASKS_SET.value, ChildTasksSet(id, child_tasks)))
send_event_for_current_user(Event(EventType.TASK_MODIFIED.value, TaskModified(id, TaskModified.OPERATION_DELETE)))
return jsonify({'message': f'Chore {id} deleted.'}), 200
return jsonify({'error': 'Chore not found'}), 404
@chore_api.route('/chore/<id>/edit', methods=['PUT'])
def edit_chore(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
TaskQuery = Query()
existing = task_db.get(
(TaskQuery.id == id) &
((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)) &
(TaskQuery.type == TASK_TYPE)
)
if not existing:
return jsonify({'error': 'Chore not found'}), 404
task = Task.from_dict(existing)
is_dirty = False
data = request.get_json(force=True) or {}
if 'name' in data:
name = data.get('name', '').strip()
if not name:
return jsonify({'error': 'Name cannot be empty'}), 400
task.name = name
is_dirty = True
if 'points' in data:
points = data.get('points')
if not isinstance(points, int) or points <= 0:
return jsonify({'error': 'Points must be a positive integer'}), 400
task.points = points
is_dirty = True
if 'image_id' in data:
task.image_id = data.get('image_id', '')
is_dirty = True
if not is_dirty:
return jsonify({'error': 'No valid fields to update'}), 400
if task.user_id is None:
new_task = Task(name=task.name, points=task.points, type=TASK_TYPE, image_id=task.image_id, user_id=user_id)
task_db.insert(new_task.to_dict())
send_event_for_current_user(Event(EventType.TASK_MODIFIED.value,
TaskModified(new_task.id, TaskModified.OPERATION_ADD)))
return jsonify(new_task.to_dict()), 200
task_db.update(task.to_dict(), (TaskQuery.id == id) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)))
send_event_for_current_user(Event(EventType.TASK_MODIFIED.value,
TaskModified(id, TaskModified.OPERATION_EDIT)))
return jsonify(task.to_dict()), 200
+179
View File
@@ -0,0 +1,179 @@
from flask import Blueprint, request, jsonify
from tinydb import Query
from api.utils import get_validated_user_id, send_event_for_current_user
from api.error_codes import ErrorCodes
from db.db import child_db, pending_confirmations_db
from db.chore_schedules import get_schedule, upsert_schedule, delete_schedule
from db.task_extensions import get_extension, add_extension, delete_extension_for_child_task
from models.chore_schedule import ChoreSchedule
from models.task_extension import TaskExtension
from events.types.event import Event
from events.types.event_types import EventType
from events.types.chore_schedule_modified import ChoreScheduleModified
from events.types.chore_time_extended import ChoreTimeExtended
from events.types.child_chore_confirmation import ChildChoreConfirmation
import logging
chore_schedule_api = Blueprint('chore_schedule_api', __name__)
logger = logging.getLogger(__name__)
def _validate_child(child_id: str, user_id: str):
"""Return child dict if found and owned by user, else None."""
ChildQuery = Query()
result = child_db.search((ChildQuery.id == child_id) & (ChildQuery.user_id == user_id))
return result[0] if result else None
@chore_schedule_api.route('/child/<child_id>/task/<task_id>/schedule', methods=['GET'])
def get_chore_schedule(child_id, task_id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
if not _validate_child(child_id, user_id):
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
schedule = get_schedule(child_id, task_id)
if not schedule:
return jsonify({'error': 'Schedule not found'}), 404
return jsonify(schedule.to_dict()), 200
@chore_schedule_api.route('/child/<child_id>/task/<task_id>/schedule', methods=['PUT'])
def set_chore_schedule(child_id, task_id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
if not _validate_child(child_id, user_id):
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
data = request.get_json() or {}
mode = data.get('mode')
if mode not in ('days', 'interval'):
return jsonify({'error': 'mode must be "days" or "interval"', 'code': ErrorCodes.INVALID_VALUE}), 400
enabled = data.get('enabled', True)
if not isinstance(enabled, bool):
return jsonify({'error': 'enabled must be a boolean', 'code': ErrorCodes.INVALID_VALUE}), 400
if mode == 'days':
day_configs = data.get('day_configs', [])
if not isinstance(day_configs, list):
return jsonify({'error': 'day_configs must be a list', 'code': ErrorCodes.INVALID_VALUE}), 400
default_hour = data.get('default_hour', 8)
default_minute = data.get('default_minute', 0)
default_has_deadline = data.get('default_has_deadline', True)
schedule = ChoreSchedule(
child_id=child_id,
task_id=task_id,
mode='days',
day_configs=day_configs,
default_hour=default_hour,
default_minute=default_minute,
default_has_deadline=default_has_deadline,
enabled=enabled,
)
else:
interval_days = data.get('interval_days', 2)
anchor_date = data.get('anchor_date', '')
interval_has_deadline = data.get('interval_has_deadline', True)
interval_hour = data.get('interval_hour', 0)
interval_minute = data.get('interval_minute', 0)
if not isinstance(interval_days, int) or not (1 <= interval_days <= 7):
return jsonify({'error': 'interval_days must be an integer between 1 and 7', 'code': ErrorCodes.INVALID_VALUE}), 400
if not isinstance(anchor_date, str):
return jsonify({'error': 'anchor_date must be a string', 'code': ErrorCodes.INVALID_VALUE}), 400
if not isinstance(interval_has_deadline, bool):
return jsonify({'error': 'interval_has_deadline must be a boolean', 'code': ErrorCodes.INVALID_VALUE}), 400
schedule = ChoreSchedule(
child_id=child_id,
task_id=task_id,
mode='interval',
interval_days=interval_days,
anchor_date=anchor_date,
interval_has_deadline=interval_has_deadline,
interval_hour=interval_hour,
interval_minute=interval_minute,
enabled=enabled,
)
delete_extension_for_child_task(child_id, task_id)
upsert_schedule(schedule)
# Reset pending chore confirmations when schedule changes (completed chores stay)
PendingQuery = Query()
pending_chores = pending_confirmations_db.search(
(PendingQuery.child_id == child_id) & (PendingQuery.entity_id == task_id) &
(PendingQuery.entity_type == 'chore') & (PendingQuery.status == 'pending')
)
for pc in pending_chores:
pending_confirmations_db.remove(
(PendingQuery.child_id == child_id) & (PendingQuery.entity_id == task_id) &
(PendingQuery.entity_type == 'chore') & (PendingQuery.status == 'pending')
)
send_event_for_current_user(Event(EventType.CHILD_CHORE_CONFIRMATION.value,
ChildChoreConfirmation(child_id, task_id, ChildChoreConfirmation.OPERATION_RESET)))
send_event_for_current_user(Event(
EventType.CHORE_SCHEDULE_MODIFIED.value,
ChoreScheduleModified(child_id, task_id, ChoreScheduleModified.OPERATION_SET)
))
return jsonify(schedule.to_dict()), 200
@chore_schedule_api.route('/child/<child_id>/task/<task_id>/schedule', methods=['DELETE'])
def delete_chore_schedule(child_id, task_id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
if not _validate_child(child_id, user_id):
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
removed = delete_schedule(child_id, task_id)
if not removed:
return jsonify({'error': 'Schedule not found'}), 404
send_event_for_current_user(Event(
EventType.CHORE_SCHEDULE_MODIFIED.value,
ChoreScheduleModified(child_id, task_id, ChoreScheduleModified.OPERATION_DELETED)
))
return jsonify({'message': 'Schedule deleted'}), 200
@chore_schedule_api.route('/child/<child_id>/task/<task_id>/extend', methods=['POST'])
def extend_chore_time(child_id, task_id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
if not _validate_child(child_id, user_id):
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
data = request.get_json() or {}
date = data.get('date')
if not date or not isinstance(date, str):
return jsonify({'error': 'date is required (ISO date string)', 'code': ErrorCodes.MISSING_FIELD}), 400
# 409 if already extended for this exact date
existing = get_extension(child_id, task_id, date)
if existing:
return jsonify({'error': 'Chore already extended for this date', 'code': 'ALREADY_EXTENDED'}), 409
# Clear any prior extension for this child+task before inserting the new one
# so stale records from previous dates do not accumulate.
delete_extension_for_child_task(child_id, task_id)
extension = TaskExtension(child_id=child_id, task_id=task_id, date=date)
add_extension(extension)
send_event_for_current_user(Event(
EventType.CHORE_TIME_EXTENDED.value,
ChoreTimeExtended(child_id, task_id)
))
return jsonify(extension.to_dict()), 200
+113
View File
@@ -0,0 +1,113 @@
import logging
from flask import Blueprint, redirect, make_response, jsonify, request
from tinydb import Query
from utils.digest_token import validate_and_consume_token, validate_unsubscribe_token, peek_token
from db.db import users_db
from api.child_action_helpers import approve_chore, reject_chore, approve_reward_request, deny_reward, approve_routine, reject_routine
from api.utils import get_validated_user_id
digest_action_api = Blueprint('digest_action_api', __name__)
logger = logging.getLogger(__name__)
_ERROR_HTML = """<!DOCTYPE html>
<html><head><title>Link Error</title></head>
<body style="font-family:sans-serif;text-align:center;margin-top:60px;">
<h2>This link is invalid or has expired.</h2>
<p>Action links expire after 24 hours and can only be used once.</p>
</body></html>"""
_UNSUB_HTML = """<!DOCTYPE html>
<html><head><title>Unsubscribed</title></head>
<body style="font-family:sans-serif;text-align:center;margin-top:60px;">
<h2>You have been unsubscribed from daily digest emails.</h2>
<p>To re-enable, visit your profile in the app.</p>
</body></html>"""
_UNSUB_ERROR_HTML = """<!DOCTYPE html>
<html><head><title>Link Error</title></head>
<body style="font-family:sans-serif;text-align:center;margin-top:60px;">
<h2>This unsubscribe link is invalid or has expired.</h2>
</body></html>"""
@digest_action_api.route('/digest-action/<token_id>', methods=['GET'])
def handle_digest_action(token_id: str):
"""
Validate a digest action token (without consuming it) and redirect to the
frontend ParentView with the token embedded so the action executes only
after the user authenticates as a parent.
"""
from flask import current_app
frontend_url = current_app.config.get('FRONTEND_URL', 'https://localhost:5173')
token = peek_token(token_id)
if not token:
return make_response(_ERROR_HTML, 400)
deep_link = (
f"{frontend_url}/parent/{token.child_id}"
f"?digestToken={token_id}&scrollTo={token.entity_id}&entityType={token.entity_type}"
)
return redirect(deep_link, 302)
@digest_action_api.route('/digest-action/<token_id>', methods=['POST'])
def execute_digest_action(token_id: str):
"""
Execute a digest action. Requires the user to be authenticated (JWT cookie).
Validates and consumes the token, then performs the approve/deny action.
"""
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
token = validate_and_consume_token(token_id)
if not token:
return jsonify({'error': 'This link is invalid or has expired.', 'code': 'INVALID_TOKEN'}), 400
if token.user_id != user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 403
try:
if token.entity_type == 'chore' and token.action == 'approve':
approve_chore(user_id, token.child_id, token.entity_id)
elif token.entity_type == 'chore' and token.action == 'deny':
reject_chore(user_id, token.child_id, token.entity_id)
elif token.entity_type == 'reward' and token.action == 'approve':
approve_reward_request(user_id, token.child_id, token.entity_id)
elif token.entity_type == 'reward' and token.action == 'deny':
deny_reward(user_id, token.child_id, token.entity_id)
elif token.entity_type == 'routine' and token.action == 'approve':
approve_routine(user_id, token.child_id, token.entity_id)
elif token.entity_type == 'routine' and token.action == 'deny':
reject_routine(user_id, token.child_id, token.entity_id)
else:
return jsonify({'error': 'Unknown action', 'code': 'INVALID_ACTION'}), 400
except Exception as e:
logger.error(f'Error executing digest action {token.action}/{token.entity_type}: {e}')
return jsonify({'error': 'Failed to execute action', 'code': 'ACTION_FAILED'}), 400
return jsonify({
'success': True,
'child_id': token.child_id,
'entity_id': token.entity_id,
'entity_type': token.entity_type,
'action': token.action,
}), 200
@digest_action_api.route('/digest-unsubscribe/<token>', methods=['GET'])
def handle_digest_unsubscribe(token: str):
user_id = validate_unsubscribe_token(token)
if not user_id:
return make_response(_UNSUB_ERROR_HTML, 400)
UserQ = Query()
users_db.update({'email_digest_enabled': False}, UserQ.id == user_id)
logger.info(f'User {user_id} unsubscribed from digest via email link')
return make_response(_UNSUB_HTML, 200)
+38
View File
@@ -0,0 +1,38 @@
MISSING_FIELDS = "MISSING_FIELDS"
EMAIL_EXISTS = "EMAIL_EXISTS"
MISSING_TOKEN = "MISSING_TOKEN"
INVALID_TOKEN = "INVALID_TOKEN"
TOKEN_TIMESTAMP_MISSING = "TOKEN_TIMESTAMP_MISSING"
TOKEN_EXPIRED = "TOKEN_EXPIRED"
MISSING_EMAIL = "MISSING_EMAIL"
USER_NOT_FOUND = "USER_NOT_FOUND"
ALREADY_VERIFIED = "ALREADY_VERIFIED"
MISSING_EMAIL_OR_PASSWORD = "MISSING_EMAIL_OR_PASSWORD"
INVALID_CREDENTIALS = "INVALID_CREDENTIALS"
NOT_VERIFIED = "NOT_VERIFIED"
ACCOUNT_MARKED_FOR_DELETION = "ACCOUNT_MARKED_FOR_DELETION"
ALREADY_MARKED = "ALREADY_MARKED"
REFRESH_TOKEN_REUSE = "REFRESH_TOKEN_REUSE"
REFRESH_TOKEN_EXPIRED = "REFRESH_TOKEN_EXPIRED"
MISSING_REFRESH_TOKEN = "MISSING_REFRESH_TOKEN"
class ErrorCodes:
"""Centralized error codes for API responses."""
UNAUTHORIZED = "UNAUTHORIZED"
CHILD_NOT_FOUND = "CHILD_NOT_FOUND"
TASK_NOT_FOUND = "TASK_NOT_FOUND"
REWARD_NOT_FOUND = "REWARD_NOT_FOUND"
ENTITY_NOT_ASSIGNED = "ENTITY_NOT_ASSIGNED"
OVERRIDE_NOT_FOUND = "OVERRIDE_NOT_FOUND"
MISSING_FIELD = "MISSING_FIELD"
INVALID_VALUE = "INVALID_VALUE"
VALIDATION_ERROR = "VALIDATION_ERROR"
INTERNAL_ERROR = "INTERNAL_ERROR"
CHORE_EXPIRED = "CHORE_EXPIRED"
CHORE_ALREADY_PENDING = "CHORE_ALREADY_PENDING"
CHORE_ALREADY_COMPLETED = "CHORE_ALREADY_COMPLETED"
PENDING_NOT_FOUND = "PENDING_NOT_FOUND"
INSUFFICIENT_POINTS = "INSUFFICIENT_POINTS"
INVALID_TASK_TYPE = "INVALID_TASK_TYPE"
DUPLICATE_REWARD_REQUEST = "DUPLICATE_REWARD_REQUEST"
+30 -10
View File
@@ -1,15 +1,18 @@
import os import os
UPLOAD_FOLDER = os.path.abspath(os.path.join(os.path.dirname(__file__), '../../data/images'))
import os
from PIL import Image as PILImage, UnidentifiedImageError from PIL import Image as PILImage, UnidentifiedImageError
from flask import Blueprint, request, jsonify, send_file from flask import Blueprint, request, jsonify, send_file
from tinydb import Query from tinydb import Query
from api.utils import get_current_user_id, sanitize_email, get_validated_user_id
from config.paths import get_user_image_dir from config.paths import get_user_image_dir
from db.db import image_db from db.db import image_db
from models.image import Image from models.image import Image
image_api = Blueprint('image_api', __name__) image_api = Blueprint('image_api', __name__)
UPLOAD_FOLDER = get_user_image_dir("user123")
ALLOWED_EXTENSIONS = {'jpg', 'jpeg', 'png'} ALLOWED_EXTENSIONS = {'jpg', 'jpeg', 'png'}
IMAGE_TYPE_PROFILE = 1 IMAGE_TYPE_PROFILE = 1
IMAGE_TYPE_ICON = 2 IMAGE_TYPE_ICON = 2
@@ -20,6 +23,9 @@ def allowed_file(filename):
@image_api.route('/image/upload', methods=['POST']) @image_api.route('/image/upload', methods=['POST'])
def upload(): def upload():
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
if 'file' not in request.files: if 'file' not in request.files:
return jsonify({'error': 'No file part in the request'}), 400 return jsonify({'error': 'No file part in the request'}), 400
file = request.files['file'] file = request.files['file']
@@ -60,13 +66,14 @@ def upload():
format_extension_map = {'JPEG': '.jpg', 'PNG': '.png'} format_extension_map = {'JPEG': '.jpg', 'PNG': '.png'}
extension = format_extension_map.get(original_format, '.png') extension = format_extension_map.get(original_format, '.png')
image_record = Image(extension=extension, permanent=perm, type=image_type, user_id=user_id)
image_record = Image(extension=extension, permanent=perm, type=image_type, user="user123")
filename = image_record.id + extension filename = image_record.id + extension
filepath = os.path.abspath(os.path.join(UPLOAD_FOLDER, filename)) user_image_dir = get_user_image_dir(user_id)
os.makedirs(user_image_dir, exist_ok=True)
filepath = os.path.abspath(os.path.join(get_user_image_dir(sanitize_email(user_id)), filename))
try: try:
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
# Save with appropriate format # Save with appropriate format
save_params = {} save_params = {}
if pil_image.format == 'JPEG': if pil_image.format == 'JPEG':
@@ -82,25 +89,38 @@ def upload():
@image_api.route('/image/request/<id>', methods=['GET']) @image_api.route('/image/request/<id>', methods=['GET'])
def request_image(id): def request_image(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
ImageQuery = Query() ImageQuery = Query()
image: Image = Image.from_dict(image_db.get(ImageQuery.id == id)) image_record = image_db.get(ImageQuery.id == id)
if not image: if not image_record:
return jsonify({'error': 'Image not found'}), 404 return jsonify({'error': 'Image not found'}), 404
image = Image.from_dict(image_record)
# Allow if image.user_id is None (public image), or matches user_id
if image.user_id is not None and image.user_id != user_id:
return jsonify({'error': 'Forbidden: image does not belong to user', 'code': 'FORBIDDEN'}), 403
filename = f"{image.id}{image.extension}" filename = f"{image.id}{image.extension}"
filepath = os.path.abspath(os.path.join(get_user_image_dir(image.user), filename)) if image.user_id is None:
filepath = os.path.abspath(os.path.join(get_user_image_dir("default"), filename))
else:
filepath = os.path.abspath(os.path.join(get_user_image_dir(image.user_id), filename))
if not os.path.exists(filepath): if not os.path.exists(filepath):
return jsonify({'error': 'File not found'}), 404 return jsonify({'error': 'File not found'}), 404
return send_file(filepath) return send_file(filepath)
@image_api.route('/image/list', methods=['GET']) @image_api.route('/image/list', methods=['GET'])
def list_images(): def list_images():
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
image_type = request.args.get('type', type=int) image_type = request.args.get('type', type=int)
ImageQuery = Query() ImageQuery = Query()
if image_type is not None: if image_type is not None:
if image_type not in [IMAGE_TYPE_PROFILE, IMAGE_TYPE_ICON]: if image_type not in [IMAGE_TYPE_PROFILE, IMAGE_TYPE_ICON]:
return jsonify({'error': 'Invalid image type'}), 400 return jsonify({'error': 'Invalid image type'}), 400
images = image_db.search(ImageQuery.type == image_type) images = image_db.search((ImageQuery.type == image_type) & ((ImageQuery.user_id == user_id) | (ImageQuery.user_id == None)))
else: else:
images = image_db.all() images = image_db.search((ImageQuery.user_id == user_id) | (ImageQuery.user_id == None))
image_ids = [img['id'] for img in images] image_ids = [img['id'] for img in images]
return jsonify({'ids': image_ids, 'count': len(image_ids)}), 200 return jsonify({'ids': image_ids, 'count': len(image_ids)}), 200
+165
View File
@@ -0,0 +1,165 @@
from flask import Blueprint, request, jsonify
from tinydb import Query
from api.utils import send_event_for_current_user, get_validated_user_id
from events.types.child_tasks_set import ChildTasksSet
from db.db import task_db, child_db
from db.child_overrides import delete_overrides_for_entity
from events.types.event import Event
from events.types.event_types import EventType
from events.types.task_modified import TaskModified
from models.task import Task
kindness_api = Blueprint('kindness_api', __name__)
TASK_TYPE = 'kindness'
@kindness_api.route('/kindness/add', methods=['PUT'])
def add_kindness():
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
data = request.get_json()
name = data.get('name')
points = data.get('points')
image = data.get('image_id', '')
if not name or points is None:
return jsonify({'error': 'Name and points are required'}), 400
task = Task(name=name, points=points, type=TASK_TYPE, image_id=image, user_id=user_id)
task_db.insert(task.to_dict())
send_event_for_current_user(Event(EventType.TASK_MODIFIED.value,
TaskModified(task.id, TaskModified.OPERATION_ADD)))
return jsonify({'message': f'Kindness {name} added.'}), 201
@kindness_api.route('/kindness/<id>', methods=['GET'])
def get_kindness(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
TaskQuery = Query()
result = task_db.search(
(TaskQuery.id == id) &
((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)) &
(TaskQuery.type == TASK_TYPE)
)
if not result:
return jsonify({'error': 'Kindness act not found'}), 404
return jsonify(result[0]), 200
@kindness_api.route('/kindness/list', methods=['GET'])
def list_kindness():
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
TaskQuery = Query()
tasks = task_db.search(
((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)) &
(TaskQuery.type == TASK_TYPE)
)
user_tasks = {t['name'].strip().lower(): t for t in tasks if t.get('user_id') == user_id}
filtered_tasks = []
for t in tasks:
if t.get('user_id') is None and t['name'].strip().lower() in user_tasks:
continue
filtered_tasks.append(t)
def sort_user_then_default(tasks_group):
user_created = sorted(
[t for t in tasks_group if t.get('user_id') == user_id],
key=lambda x: x['name'].lower(),
)
default_items = sorted(
[t for t in tasks_group if t.get('user_id') is None],
key=lambda x: x['name'].lower(),
)
return user_created + default_items
sorted_tasks = sort_user_then_default(filtered_tasks)
return jsonify({'tasks': sorted_tasks}), 200
@kindness_api.route('/kindness/<id>', methods=['DELETE'])
def delete_kindness(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
TaskQuery = Query()
task = task_db.get((TaskQuery.id == id) & (TaskQuery.type == TASK_TYPE))
if not task:
return jsonify({'error': 'Kindness act not found'}), 404
if task.get('user_id') is None:
import logging
logging.warning(f"Forbidden delete attempt on system kindness: id={id}, by user_id={user_id}")
return jsonify({'error': 'System kindness acts cannot be deleted.'}), 403
removed = task_db.remove((TaskQuery.id == id) & (TaskQuery.user_id == user_id))
if removed:
deleted_count = delete_overrides_for_entity(id)
if deleted_count > 0:
import logging
logging.info(f"Cascade deleted {deleted_count} overrides for kindness {id}")
ChildQuery = Query()
for child in child_db.all():
child_tasks = child.get('tasks', [])
if id in child_tasks:
child_tasks.remove(id)
child_db.update({'tasks': child_tasks}, ChildQuery.id == child.get('id'))
send_event_for_current_user(Event(EventType.CHILD_TASKS_SET.value, ChildTasksSet(id, child_tasks)))
send_event_for_current_user(Event(EventType.TASK_MODIFIED.value, TaskModified(id, TaskModified.OPERATION_DELETE)))
return jsonify({'message': f'Kindness {id} deleted.'}), 200
return jsonify({'error': 'Kindness act not found'}), 404
@kindness_api.route('/kindness/<id>/edit', methods=['PUT'])
def edit_kindness(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
TaskQuery = Query()
existing = task_db.get(
(TaskQuery.id == id) &
((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)) &
(TaskQuery.type == TASK_TYPE)
)
if not existing:
return jsonify({'error': 'Kindness act not found'}), 404
task = Task.from_dict(existing)
is_dirty = False
data = request.get_json(force=True) or {}
if 'name' in data:
name = data.get('name', '').strip()
if not name:
return jsonify({'error': 'Name cannot be empty'}), 400
task.name = name
is_dirty = True
if 'points' in data:
points = data.get('points')
if not isinstance(points, int) or points <= 0:
return jsonify({'error': 'Points must be a positive integer'}), 400
task.points = points
is_dirty = True
if 'image_id' in data:
task.image_id = data.get('image_id', '')
is_dirty = True
if not is_dirty:
return jsonify({'error': 'No valid fields to update'}), 400
if task.user_id is None:
new_task = Task(name=task.name, points=task.points, type=TASK_TYPE, image_id=task.image_id, user_id=user_id)
task_db.insert(new_task.to_dict())
send_event_for_current_user(Event(EventType.TASK_MODIFIED.value,
TaskModified(new_task.id, TaskModified.OPERATION_ADD)))
return jsonify(new_task.to_dict()), 200
task_db.update(task.to_dict(), (TaskQuery.id == id) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)))
send_event_for_current_user(Event(EventType.TASK_MODIFIED.value,
TaskModified(id, TaskModified.OPERATION_EDIT)))
return jsonify(task.to_dict()), 200
+165
View File
@@ -0,0 +1,165 @@
from flask import Blueprint, request, jsonify
from tinydb import Query
from api.utils import send_event_for_current_user, get_validated_user_id
from events.types.child_tasks_set import ChildTasksSet
from db.db import task_db, child_db
from db.child_overrides import delete_overrides_for_entity
from events.types.event import Event
from events.types.event_types import EventType
from events.types.task_modified import TaskModified
from models.task import Task
penalty_api = Blueprint('penalty_api', __name__)
TASK_TYPE = 'penalty'
@penalty_api.route('/penalty/add', methods=['PUT'])
def add_penalty():
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
data = request.get_json()
name = data.get('name')
points = data.get('points')
image = data.get('image_id', '')
if not name or points is None:
return jsonify({'error': 'Name and points are required'}), 400
task = Task(name=name, points=points, type=TASK_TYPE, image_id=image, user_id=user_id)
task_db.insert(task.to_dict())
send_event_for_current_user(Event(EventType.TASK_MODIFIED.value,
TaskModified(task.id, TaskModified.OPERATION_ADD)))
return jsonify({'message': f'Penalty {name} added.'}), 201
@penalty_api.route('/penalty/<id>', methods=['GET'])
def get_penalty(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
TaskQuery = Query()
result = task_db.search(
(TaskQuery.id == id) &
((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)) &
(TaskQuery.type == TASK_TYPE)
)
if not result:
return jsonify({'error': 'Penalty not found'}), 404
return jsonify(result[0]), 200
@penalty_api.route('/penalty/list', methods=['GET'])
def list_penalties():
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
TaskQuery = Query()
tasks = task_db.search(
((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)) &
(TaskQuery.type == TASK_TYPE)
)
user_tasks = {t['name'].strip().lower(): t for t in tasks if t.get('user_id') == user_id}
filtered_tasks = []
for t in tasks:
if t.get('user_id') is None and t['name'].strip().lower() in user_tasks:
continue
filtered_tasks.append(t)
def sort_user_then_default(tasks_group):
user_created = sorted(
[t for t in tasks_group if t.get('user_id') == user_id],
key=lambda x: x['name'].lower(),
)
default_items = sorted(
[t for t in tasks_group if t.get('user_id') is None],
key=lambda x: x['name'].lower(),
)
return user_created + default_items
sorted_tasks = sort_user_then_default(filtered_tasks)
return jsonify({'tasks': sorted_tasks}), 200
@penalty_api.route('/penalty/<id>', methods=['DELETE'])
def delete_penalty(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
TaskQuery = Query()
task = task_db.get((TaskQuery.id == id) & (TaskQuery.type == TASK_TYPE))
if not task:
return jsonify({'error': 'Penalty not found'}), 404
if task.get('user_id') is None:
import logging
logging.warning(f"Forbidden delete attempt on system penalty: id={id}, by user_id={user_id}")
return jsonify({'error': 'System penalties cannot be deleted.'}), 403
removed = task_db.remove((TaskQuery.id == id) & (TaskQuery.user_id == user_id))
if removed:
deleted_count = delete_overrides_for_entity(id)
if deleted_count > 0:
import logging
logging.info(f"Cascade deleted {deleted_count} overrides for penalty {id}")
ChildQuery = Query()
for child in child_db.all():
child_tasks = child.get('tasks', [])
if id in child_tasks:
child_tasks.remove(id)
child_db.update({'tasks': child_tasks}, ChildQuery.id == child.get('id'))
send_event_for_current_user(Event(EventType.CHILD_TASKS_SET.value, ChildTasksSet(id, child_tasks)))
send_event_for_current_user(Event(EventType.TASK_MODIFIED.value, TaskModified(id, TaskModified.OPERATION_DELETE)))
return jsonify({'message': f'Penalty {id} deleted.'}), 200
return jsonify({'error': 'Penalty not found'}), 404
@penalty_api.route('/penalty/<id>/edit', methods=['PUT'])
def edit_penalty(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
TaskQuery = Query()
existing = task_db.get(
(TaskQuery.id == id) &
((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)) &
(TaskQuery.type == TASK_TYPE)
)
if not existing:
return jsonify({'error': 'Penalty not found'}), 404
task = Task.from_dict(existing)
is_dirty = False
data = request.get_json(force=True) or {}
if 'name' in data:
name = data.get('name', '').strip()
if not name:
return jsonify({'error': 'Name cannot be empty'}), 400
task.name = name
is_dirty = True
if 'points' in data:
points = data.get('points')
if not isinstance(points, int) or points <= 0:
return jsonify({'error': 'Points must be a positive integer'}), 400
task.points = points
is_dirty = True
if 'image_id' in data:
task.image_id = data.get('image_id', '')
is_dirty = True
if not is_dirty:
return jsonify({'error': 'No valid fields to update'}), 400
if task.user_id is None:
new_task = Task(name=task.name, points=task.points, type=TASK_TYPE, image_id=task.image_id, user_id=user_id)
task_db.insert(new_task.to_dict())
send_event_for_current_user(Event(EventType.TASK_MODIFIED.value,
TaskModified(new_task.id, TaskModified.OPERATION_ADD)))
return jsonify(new_task.to_dict()), 200
task_db.update(task.to_dict(), (TaskQuery.id == id) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)))
send_event_for_current_user(Event(EventType.TASK_MODIFIED.value,
TaskModified(id, TaskModified.OPERATION_EDIT)))
return jsonify(task.to_dict()), 200
+29
View File
@@ -0,0 +1,29 @@
class PendingConfirmationResponse:
"""Response DTO for hydrated pending confirmation data."""
def __init__(self, _id, child_id, child_name, child_image_id,
entity_id, entity_type, entity_name, entity_image_id,
status='pending', approved_at=None):
self.id = _id
self.child_id = child_id
self.child_name = child_name
self.child_image_id = child_image_id
self.entity_id = entity_id
self.entity_type = entity_type
self.entity_name = entity_name
self.entity_image_id = entity_image_id
self.status = status
self.approved_at = approved_at
def to_dict(self):
return {
'id': self.id,
'child_id': self.child_id,
'child_name': self.child_name,
'child_image_id': self.child_image_id,
'entity_id': self.entity_id,
'entity_type': self.entity_type,
'entity_name': self.entity_name,
'entity_image_id': self.entity_image_id,
'status': self.status,
'approved_at': self.approved_at
}
+59
View File
@@ -0,0 +1,59 @@
from flask import Blueprint, request, jsonify, current_app
from tinydb import Query
from api.utils import get_validated_user_id
from db.push_subscriptions import upsert_subscription, delete_by_endpoint
from db.db import users_db
push_subscription_api = Blueprint('push_subscription_api', __name__)
@push_subscription_api.route('/push-vapid-key', methods=['GET'])
def get_vapid_public_key():
"""Return the VAPID public key for the frontend to use when subscribing."""
public_key = current_app.config.get('VAPID_PUBLIC_KEY', '')
return jsonify({'public_key': public_key}), 200
@push_subscription_api.route('/push-subscription', methods=['POST'])
def subscribe():
"""Upsert a push subscription for the current user."""
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
data = request.get_json() or {}
endpoint = data.get('endpoint')
keys = data.get('keys')
timezone_str = data.get('timezone')
if not endpoint or not isinstance(keys, dict):
return jsonify({'error': 'endpoint and keys are required', 'code': 'MISSING_FIELDS'}), 400
if 'p256dh' not in keys or 'auth' not in keys:
return jsonify({'error': 'keys must contain p256dh and auth', 'code': 'MISSING_FIELDS'}), 400
sub = upsert_subscription(user_id=user_id, endpoint=endpoint, keys=keys)
# Update user timezone if provided
if timezone_str:
UserQ = Query()
users_db.update({'timezone': timezone_str}, UserQ.id == user_id)
return jsonify({'message': 'Subscription saved', 'id': sub.id}), 200
@push_subscription_api.route('/push-subscription', methods=['DELETE'])
def unsubscribe():
"""Remove a push subscription for the current user by endpoint."""
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
data = request.get_json() or {}
endpoint = data.get('endpoint')
if not endpoint:
return jsonify({'error': 'endpoint is required', 'code': 'MISSING_FIELDS'}), 400
removed = delete_by_endpoint(user_id=user_id, endpoint=endpoint)
return jsonify({'message': 'Subscription removed', 'removed': removed}), 200
+184
View File
@@ -0,0 +1,184 @@
from flask import Blueprint, request, jsonify
from tinydb import Query
from api.utils import send_event_for_current_user, get_validated_user_id
from events.types.child_rewards_set import ChildRewardsSet
from db.db import reward_db, child_db, pending_confirmations_db
from db.child_overrides import delete_overrides_for_entity
from events.types.event import Event
from events.types.event_types import EventType
from events.types.reward_modified import RewardModified
from events.types.child_reward_request import ChildRewardRequest
from models.reward import Reward
reward_api = Blueprint('reward_api', __name__)
# Reward endpoints
@reward_api.route('/reward/add', methods=['PUT'])
def add_reward():
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
data = request.get_json()
name = data.get('name')
description = data.get('description')
cost = data.get('cost')
image = data.get('image_id', '')
if not name or description is None or cost is None:
return jsonify({'error': 'Name, description, and cost are required'}), 400
reward = Reward(name=name, description=description, cost=cost, image_id=image, user_id=user_id)
reward_db.insert(reward.to_dict())
send_event_for_current_user(Event(EventType.REWARD_MODIFIED.value, RewardModified(reward.id, RewardModified.OPERATION_ADD)))
return jsonify({'message': f'Reward {name} added.'}), 201
@reward_api.route('/reward/<id>', methods=['GET'])
def get_reward(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
RewardQuery = Query()
result = reward_db.search((RewardQuery.id == id) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None)))
if not result:
return jsonify({'error': 'Reward not found'}), 404
return jsonify(result[0]), 200
@reward_api.route('/reward/list', methods=['GET'])
def list_rewards():
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
ids_param = request.args.get('ids')
RewardQuery = Query()
rewards = reward_db.search((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None))
if ids_param is not None:
if ids_param.strip() == '':
rewards = []
else:
ids = set(ids_param.split(','))
rewards = [reward for reward in rewards if reward.get('id') in ids]
# Filter out default rewards if user-specific version exists (case/whitespace-insensitive)
user_rewards = {r['name'].strip().lower(): r for r in rewards if r.get('user_id') == user_id}
filtered_rewards = []
for r in rewards:
if r.get('user_id') is None and r['name'].strip().lower() in user_rewards:
continue # Skip default if user version exists
filtered_rewards.append(r)
# Sort: user-created items first (by name), then default items (by name)
user_created = sorted([r for r in filtered_rewards if r.get('user_id') == user_id], key=lambda x: x['name'].lower())
default_items = sorted([r for r in filtered_rewards if r.get('user_id') is None], key=lambda x: x['name'].lower())
sorted_rewards = user_created + default_items
return jsonify({'rewards': sorted_rewards}), 200
@reward_api.route('/reward/<id>', methods=['DELETE'])
def delete_reward(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
RewardQuery = Query()
reward = reward_db.get(RewardQuery.id == id)
if not reward:
return jsonify({'error': 'Reward not found'}), 404
if reward.get('user_id') is None:
import logging
logging.warning(f"Forbidden delete attempt on system reward: id={id}, by user_id={user_id}")
return jsonify({'error': 'System rewards cannot be deleted.'}), 403
removed = reward_db.remove((RewardQuery.id == id) & (RewardQuery.user_id == user_id))
if removed:
# Cascade delete overrides for this reward
deleted_count = delete_overrides_for_entity(id)
if deleted_count > 0:
import logging
logging.info(f"Cascade deleted {deleted_count} overrides for reward {id}")
# remove the reward id from any child's reward list
ChildQuery = Query()
for child in child_db.all():
rewards = child.get('rewards', [])
if id in rewards:
rewards.remove(id)
child_db.update({'rewards': rewards}, ChildQuery.id == child.get('id'))
send_event_for_current_user(Event(EventType.CHILD_REWARDS_SET.value, ChildRewardsSet(id, rewards)))
send_event_for_current_user(Event(EventType.REWARD_MODIFIED.value, RewardModified(id, RewardModified.OPERATION_DELETE)))
return jsonify({'message': f'Reward {id} deleted.'}), 200
return jsonify({'error': 'Reward not found'}), 404
@reward_api.route('/reward/<id>/edit', methods=['PUT'])
def edit_reward(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
RewardQuery = Query()
existing = reward_db.get((RewardQuery.id == id) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None)))
if not existing:
return jsonify({'error': 'Reward not found'}), 404
reward = Reward.from_dict(existing)
is_dirty = False
data = request.get_json(force=True) or {}
if 'name' in data:
name = (data.get('name') or '').strip()
if not name:
return jsonify({'error': 'Name cannot be empty'}), 400
reward.name = name
is_dirty = True
if 'description' in data:
# allow empty description (same behavior as add_reward)
# note: front-end often submits an empty string, so don't block edits
reward.description = data.get('description') or ''
is_dirty = True
if 'cost' in data:
cost = data.get('cost')
# allow numeric strings as well
if isinstance(cost, str):
try:
cost = int(cost)
except ValueError:
return jsonify({'error': 'Cost must be an integer'}), 400
if not isinstance(cost, int):
return jsonify({'error': 'Cost must be an integer'}), 400
if cost <= 0:
return jsonify({'error': 'Cost must be a positive integer'}), 400
reward.cost = cost
is_dirty = True
if 'image_id' in data:
reward.image_id = data.get('image_id', '')
is_dirty = True
if not is_dirty:
return jsonify({'error': 'No valid fields to update'}), 400
# Reset pending reward requests when cost changes
if 'cost' in data:
PendingQuery = Query()
pending_rewards = pending_confirmations_db.search(
(PendingQuery.entity_id == id) & (PendingQuery.entity_type == 'reward') &
(PendingQuery.status == 'pending')
)
for pr in pending_rewards:
pending_confirmations_db.remove(
(PendingQuery.child_id == pr['child_id']) & (PendingQuery.entity_id == id) &
(PendingQuery.entity_type == 'reward') & (PendingQuery.status == 'pending')
)
send_event_for_current_user(Event(EventType.CHILD_REWARD_REQUEST.value,
ChildRewardRequest(pr['child_id'], id, ChildRewardRequest.REQUEST_CANCELLED)))
if reward.user_id is None: # public reward
new_reward = Reward(name=reward.name, description=reward.description, cost=reward.cost, image_id=reward.image_id, user_id=user_id)
reward_db.insert(new_reward.to_dict())
send_event_for_current_user(Event(EventType.REWARD_MODIFIED.value,
RewardModified(new_reward.id, RewardModified.OPERATION_ADD)))
return jsonify(new_reward.to_dict()), 200
reward_db.update(reward.to_dict(), (RewardQuery.id == id) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None)))
send_event_for_current_user(Event(EventType.REWARD_MODIFIED.value,
RewardModified(id, RewardModified.OPERATION_EDIT)))
return jsonify(reward.to_dict()), 200
+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
+208
View File
@@ -0,0 +1,208 @@
from flask import Blueprint, request, jsonify
from tinydb import Query
from api.utils import send_event_for_current_user, get_validated_user_id
from events.types.child_tasks_set import ChildTasksSet
from db.db import task_db, child_db, pending_confirmations_db
from db.child_overrides import delete_overrides_for_entity
from events.types.event import Event
from events.types.event_types import EventType
from events.types.task_modified import TaskModified
from events.types.child_chore_confirmation import ChildChoreConfirmation
from models.task import Task
task_api = Blueprint('task_api', __name__)
# Task endpoints
@task_api.route('/task/add', methods=['PUT'])
def add_task():
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
data = request.get_json()
name = data.get('name')
points = data.get('points')
task_type = data.get('type')
# Support legacy is_good field
if task_type is None and 'is_good' in data:
task_type = 'chore' if data['is_good'] else 'penalty'
image = data.get('image_id', '')
if not name or points is None or task_type is None:
return jsonify({'error': 'Name, points, and type are required'}), 400
if task_type not in ['chore', 'kindness', 'penalty']:
return jsonify({'error': 'type must be chore, kindness, or penalty'}), 400
task = Task(name=name, points=points, type=task_type, image_id=image, user_id=user_id)
task_db.insert(task.to_dict())
send_event_for_current_user(Event(EventType.TASK_MODIFIED.value,
TaskModified(task.id, TaskModified.OPERATION_ADD)))
return jsonify({'message': f'Task {name} added.'}), 201
@task_api.route('/task/<id>', methods=['GET'])
def get_task(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
TaskQuery = Query()
result = task_db.search((TaskQuery.id == id) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)))
if not result:
return jsonify({'error': 'Task not found'}), 404
return jsonify(result[0]), 200
@task_api.route('/task/list', methods=['GET'])
def list_tasks():
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
ids_param = request.args.get('ids')
TaskQuery = Query()
tasks = task_db.search((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None))
if ids_param is not None:
if ids_param.strip() == '':
tasks = []
else:
ids = set(ids_param.split(','))
tasks = [task for task in tasks if task.get('id') in ids]
user_tasks = {t['name'].strip().lower(): t for t in tasks if t.get('user_id') == user_id}
filtered_tasks = []
for t in tasks:
if t.get('user_id') is None and t['name'].strip().lower() in user_tasks:
continue # Skip default if user version exists
filtered_tasks.append(t)
# Sort order:
# 1) chore/kindness first, then penalties
# 2) within each group: user-created items first (by name), then default items (by name)
good_tasks = [t for t in filtered_tasks if Task.from_dict(t).type != 'penalty']
not_good_tasks = [t for t in filtered_tasks if Task.from_dict(t).type == 'penalty']
def sort_user_then_default(tasks_group):
user_created = sorted(
[t for t in tasks_group if t.get('user_id') == user_id],
key=lambda x: x['name'].lower(),
)
default_items = sorted(
[t for t in tasks_group if t.get('user_id') is None],
key=lambda x: x['name'].lower(),
)
return user_created + default_items
sorted_tasks = sort_user_then_default(good_tasks) + sort_user_then_default(not_good_tasks)
return jsonify({'tasks': sorted_tasks}), 200
@task_api.route('/task/<id>', methods=['DELETE'])
def delete_task(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
TaskQuery = Query()
task = task_db.get(TaskQuery.id == id)
if not task:
return jsonify({'error': 'Task not found'}), 404
if task.get('user_id') is None:
import logging
logging.warning(f"Forbidden delete attempt on system task: id={id}, by user_id={user_id}")
return jsonify({'error': 'System tasks cannot be deleted.'}), 403
removed = task_db.remove((TaskQuery.id == id) & (TaskQuery.user_id == user_id))
if removed:
# Cascade delete overrides for this task
deleted_count = delete_overrides_for_entity(id)
if deleted_count > 0:
import logging
logging.info(f"Cascade deleted {deleted_count} overrides for task {id}")
# remove the task id from any child's task list
ChildQuery = Query()
for child in child_db.all():
child_tasks = child.get('tasks', [])
if id in child_tasks:
child_tasks.remove(id)
child_db.update({'tasks': child_tasks}, ChildQuery.id == child.get('id'))
send_event_for_current_user(Event(EventType.CHILD_TASKS_SET.value, ChildTasksSet(id, child_tasks)))
send_event_for_current_user(Event(EventType.TASK_MODIFIED.value, TaskModified(id, TaskModified.OPERATION_DELETE)))
return jsonify({'message': f'Task {id} deleted.'}), 200
return jsonify({'error': 'Task not found'}), 404
@task_api.route('/task/<id>/edit', methods=['PUT'])
def edit_task(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
TaskQuery = Query()
existing = task_db.get((TaskQuery.id == id) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)))
if not existing:
return jsonify({'error': 'Task not found'}), 404
task = Task.from_dict(existing)
is_dirty = False
data = request.get_json(force=True) or {}
updates = {}
if 'name' in data:
name = data.get('name', '').strip()
if not name:
return jsonify({'error': 'Name cannot be empty'}), 400
task.name = name
is_dirty = True
if 'points' in data:
points = data.get('points')
if not isinstance(points, int):
return jsonify({'error': 'Points must be an integer'}), 400
if points <= 0:
return jsonify({'error': 'Points must be a positive integer'}), 400
task.points = points
is_dirty = True
if 'is_good' in data:
is_good = data.get('is_good')
if not isinstance(is_good, bool):
return jsonify({'error': 'is_good must be a boolean'}), 400
# Convert to type
task.type = 'chore' if is_good else 'penalty'
is_dirty = True
if 'type' in data:
task_type = data.get('type')
if task_type not in ['chore', 'kindness', 'penalty']:
return jsonify({'error': 'type must be chore, kindness, or penalty'}), 400
task.type = task_type
is_dirty = True
if 'image_id' in data:
task.image_id = data.get('image_id', '')
is_dirty = True
if not is_dirty:
return jsonify({'error': 'No valid fields to update'}), 400
# Reset pending chore confirmations when task is modified (points, type, etc.)
# Completed (approved) chores stay in completed state
if 'points' in data or 'type' in data:
PendingQuery = Query()
pending_chores = pending_confirmations_db.search(
(PendingQuery.entity_id == id) & (PendingQuery.entity_type == 'chore') &
(PendingQuery.status == 'pending')
)
for pc in pending_chores:
pending_confirmations_db.remove(
(PendingQuery.child_id == pc['child_id']) & (PendingQuery.entity_id == id) &
(PendingQuery.entity_type == 'chore') & (PendingQuery.status == 'pending')
)
send_event_for_current_user(Event(EventType.CHILD_CHORE_CONFIRMATION.value,
ChildChoreConfirmation(pc['child_id'], id, ChildChoreConfirmation.OPERATION_RESET)))
if task.user_id is None: # public task
new_task = Task(name=task.name, points=task.points, type=task.type, image_id=task.image_id, user_id=user_id)
task_db.insert(new_task.to_dict())
send_event_for_current_user(Event(EventType.TASK_MODIFIED.value,
TaskModified(new_task.id, TaskModified.OPERATION_ADD)))
return jsonify(new_task.to_dict()), 200
task_db.update(task.to_dict(), (TaskQuery.id == id) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)))
send_event_for_current_user(Event(EventType.TASK_MODIFIED.value,
TaskModified(id, TaskModified.OPERATION_EDIT)))
return jsonify(task.to_dict()), 200
+73
View File
@@ -0,0 +1,73 @@
from flask import Blueprint, request, jsonify
from api.utils import get_validated_user_id, admin_required
from db.tracking import get_tracking_events_by_child, get_tracking_events_by_user
from models.tracking_event import TrackingEvent
tracking_api = Blueprint('tracking_api', __name__)
@tracking_api.route('/admin/tracking', methods=['GET'])
@admin_required
def get_tracking():
"""
Admin endpoint to query tracking events with filters and pagination.
Query params:
- child_id: Filter by child ID (optional)
- user_id: Filter by user ID (optional, admin only)
- entity_type: Filter by entity type (task/reward/penalty) (optional)
- action: Filter by action type (activated/requested/redeemed/cancelled) (optional)
- limit: Max results (default 50, max 500)
- offset: Pagination offset (default 0)
"""
child_id = request.args.get('child_id')
filter_user_id = request.args.get('user_id')
entity_type = request.args.get('entity_type')
action = request.args.get('action')
limit = int(request.args.get('limit', 50))
offset = int(request.args.get('offset', 0))
# Validate limit
limit = min(max(limit, 1), 500)
offset = max(offset, 0)
# Validate filters
if entity_type and entity_type not in ['task', 'reward', 'penalty']:
return jsonify({'error': 'Invalid entity_type', 'code': 'INVALID_ENTITY_TYPE'}), 400
if action and action not in ['activated', 'requested', 'redeemed', 'cancelled']:
return jsonify({'error': 'Invalid action', 'code': 'INVALID_ACTION'}), 400
# Query tracking events
if child_id:
events, total = get_tracking_events_by_child(
child_id=child_id,
limit=limit,
offset=offset,
entity_type=entity_type,
action=action
)
elif filter_user_id:
events, total = get_tracking_events_by_user(
user_id=filter_user_id,
limit=limit,
offset=offset,
entity_type=entity_type
)
else:
return jsonify({
'error': 'Either child_id or user_id is required',
'code': 'MISSING_FILTER'
}), 400
# Convert to dict
events_data = [event.to_dict() for event in events]
return jsonify({
'tracking_events': events_data,
'total': total,
'limit': limit,
'offset': offset,
'count': len(events_data)
}), 200
+311
View File
@@ -0,0 +1,311 @@
from flask import Blueprint, request, jsonify, current_app
from events.types.user_modified import UserModified
from models.user import User
from tinydb import Query
from db.db import users_db
import jwt
import random
import string
import utils.email_sender as email_sender
from datetime import datetime, timedelta, timezone
from api.utils import get_validated_user_id, normalize_email, send_event_for_current_user
from events.sse import send_event_to_user
from events.types.payload import Payload
from api.error_codes import ACCOUNT_MARKED_FOR_DELETION, ALREADY_MARKED
from events.types.event_types import EventType
from events.types.event import Event
from events.types.profile_updated import ProfileUpdated
from utils.tracking_logger import log_tracking_event
from models.tracking_event import TrackingEvent
from db.tracking import insert_tracking_event
user_api = Blueprint('user_api', __name__)
UserQuery = Query()
def get_current_user():
token = request.cookies.get('access_token')
if not token:
return None
try:
payload = jwt.decode(token, current_app.config['SECRET_KEY'], algorithms=['HS256'])
user_id = payload.get('user_id')
user_dict = users_db.get(UserQuery.id == user_id)
return User.from_dict(user_dict) if user_dict else None
except Exception:
return None
@user_api.route('/user/profile', methods=['GET'])
def get_profile():
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
user = get_current_user()
if not user:
return jsonify({'error': 'Unauthorized'}), 401
return jsonify({
'first_name': user.first_name,
'last_name': user.last_name,
'email': user.email,
'image_id': user.image_id,
'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'])
def update_profile():
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
user = get_current_user()
if not user:
return jsonify({'error': 'Unauthorized'}), 401
data = request.get_json()
# Only allow first_name, last_name, image_id, 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
metadata = {}
if first_name is not None:
metadata['first_name_updated'] = True
if last_name is not None:
metadata['last_name_updated'] = True
if image_id is not None:
metadata['image_updated'] = True
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,
child_id=None, # No child for user profile
entity_type='user',
entity_id=user.id,
action='updated',
points_before=0, # Not relevant
points_after=0,
metadata=metadata
)
insert_tracking_event(tracking_event)
log_tracking_event(tracking_event)
# Send SSE event
send_event_for_current_user(Event(EventType.PROFILE_UPDATED.value, ProfileUpdated(user.id)))
return jsonify({'message': 'Profile updated'}), 200
@user_api.route('/user/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()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
user = get_current_user()
if not user:
return jsonify({'error': 'Unauthorized'}), 401
data = request.get_json()
image_id = data.get('image_id')
if not image_id:
return jsonify({'error': 'Missing image_id'}), 400
user.image_id = image_id
users_db.update(user.to_dict(), UserQuery.email == user.email)
return jsonify({'message': 'Image updated', 'image_id': image_id}), 200
@user_api.route('/user/check-pin', methods=['POST'])
def check_pin():
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
user = get_current_user()
if not user:
return jsonify({'error': 'Unauthorized'}), 401
data = request.get_json()
pin = data.get('pin')
if not pin:
return jsonify({'error': 'Missing pin'}), 400
if user.pin and pin == user.pin:
return jsonify({'valid': True}), 200
return jsonify({'valid': False}), 200
@user_api.route('/user/has-pin', methods=['GET'])
def has_pin():
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
user = get_current_user()
if not user:
return jsonify({'error': 'Unauthorized'}), 401
return jsonify({'has_pin': bool(user.pin)}), 200
@user_api.route('/user/request-pin-setup', methods=['POST'])
def request_pin_setup():
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
user = get_current_user()
if not user or not user.verified:
return jsonify({'error': 'Unauthorized'}), 401
# Generate 6-digit/character code
code = ''.join(random.choices(string.ascii_uppercase + string.digits, k=6))
user.pin_setup_code = code
user.pin_setup_code_created = datetime.utcnow().isoformat()
users_db.update(user.to_dict(), UserQuery.email == user.email)
# Send email
send_pin_setup_email(user.email, code)
return jsonify({'message': 'Verification code sent to your email.'}), 200
def send_pin_setup_email(email, code):
# Use the reusable email sender
email_sender.send_pin_setup_email(email, code)
@user_api.route('/user/verify-pin-setup', methods=['POST'])
def verify_pin_setup():
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
user = get_current_user()
if not user or not user.verified:
return jsonify({'error': 'Unauthorized'}), 401
data = request.get_json()
code = data.get('code')
if not code:
return jsonify({'error': 'Missing code'}), 400
if not user.pin_setup_code or not user.pin_setup_code_created:
return jsonify({'error': 'No code requested'}), 400
# Check expiry (10 min)
created = datetime.fromisoformat(user.pin_setup_code_created)
if datetime.utcnow() > created + timedelta(minutes=10):
return jsonify({'error': 'Code expired'}), 400
if code.strip().upper() != user.pin_setup_code.upper():
return jsonify({'error': 'Invalid code'}), 400
return jsonify({'message': 'Code verified'}), 200
@user_api.route('/user/set-pin', methods=['POST'])
def set_pin():
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
user = get_current_user()
if not user or not user.verified:
return jsonify({'error': 'Unauthorized'}), 401
data = request.get_json()
pin = data.get('pin')
if not pin or not pin.isdigit() or not (4 <= len(pin) <= 6):
return jsonify({'error': 'PIN must be 4-6 digits'}), 400
# Only allow if code was recently verified
if not user.pin_setup_code or not user.pin_setup_code_created:
return jsonify({'error': 'No code verified'}), 400
created = datetime.fromisoformat(user.pin_setup_code_created)
if datetime.utcnow() > created + timedelta(minutes=10):
return jsonify({'error': 'Code expired'}), 400
# Set pin, clear code
user.pin = pin
user.pin_setup_code = ''
user.pin_setup_code_created = None
users_db.update(user.to_dict(), UserQuery.email == user.email)
return jsonify({'message': 'Parent PIN set'}), 200
@user_api.route('/user/mark-for-deletion', methods=['POST'])
def mark_for_deletion():
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
user = get_current_user()
if not user:
return jsonify({'error': 'Unauthorized'}), 401
# Validate email from request body
data = request.get_json()
email = data.get('email', '').strip()
if not email:
return jsonify({'error': 'Email is required', 'code': 'EMAIL_REQUIRED'}), 400
# Verify email matches the logged-in user - make sure to normalize the email address first
if normalize_email(email) != normalize_email(user.email):
return jsonify({'error': 'Email does not match your account', 'code': 'EMAIL_MISMATCH'}), 400
# Check if already marked
if user.marked_for_deletion:
return jsonify({'error': 'Account already marked for deletion', 'code': ALREADY_MARKED}), 400
# Mark for deletion
user.marked_for_deletion = True
user.marked_for_deletion_at = datetime.now(timezone.utc).isoformat()
# Invalidate any outstanding verification/reset tokens so they cannot be used after marking
user.verify_token = None
user.verify_token_created = None
user.reset_token = None
user.reset_token_created = None
users_db.update(user.to_dict(), UserQuery.id == user.id)
# Trigger SSE event
send_event_for_current_user(Event(EventType.USER_MARKED_FOR_DELETION.value, UserModified(user.id, UserModified.OPERATION_DELETE)))
# Notify all other active sessions to sign out and go to landing page
send_event_to_user(user.id, Event(EventType.FORCE_LOGOUT.value, Payload({'reason': 'account_deleted'})))
return jsonify({'success': True}), 200
@user_api.route('/user/e2e-get-pin-code', methods=['GET'])
def e2e_get_pin_code():
"""Return the current user's pin_setup_code for e2e testing. Never available in production."""
import os
if os.environ.get('DB_ENV', 'prod') == 'prod':
return jsonify({'error': 'Not available in production'}), 403
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized'}), 401
user = get_current_user()
if not user:
return jsonify({'error': 'Unauthorized'}), 401
return jsonify({'code': user.pin_setup_code or ''}), 200
+97
View File
@@ -0,0 +1,97 @@
import jwt
import re
from functools import wraps
from db.db import users_db
from tinydb import Query
from flask import request, current_app, jsonify
from events.sse import send_event_to_user
from models.user import User
def normalize_email(email: str) -> str:
"""Normalize email for uniqueness checks (Gmail: remove dots and +aliases)."""
email = email.strip().lower()
if '@' not in email:
return email
local, domain = email.split('@', 1)
if domain in ('gmail.com', 'googlemail.com'):
local = local.split('+', 1)[0].replace('.', '')
return f"{local}@{domain}"
def sanitize_email(email):
return email.replace('@', '_at_').replace('.', '_dot_')
def get_current_user_id():
token = request.cookies.get('access_token')
if not token:
return None
try:
payload = jwt.decode(token, current_app.config['SECRET_KEY'], algorithms=['HS256'])
user_id = payload.get('user_id')
if not user_id:
return None
token_version = payload.get('token_version', 0)
user = users_db.get(Query().id == user_id)
if not user:
return None
if token_version != user.get('token_version', 0):
return None
return user_id
except jwt.InvalidTokenError:
return None
def get_validated_user_id():
user_id = get_current_user_id()
if not user_id or not users_db.get(Query().id == user_id):
return None
return user_id
def send_event_for_current_user(event):
user_id = get_current_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized'}), 401
send_event_to_user(user_id, event)
return None
def admin_required(f):
"""
Decorator to require admin role for endpoints.
Validates JWT from access_token cookie and checks admin role.
"""
@wraps(f)
def decorated_function(*args, **kwargs):
token = request.cookies.get('access_token')
if not token:
return jsonify({'error': 'Authentication required', 'code': 'AUTH_REQUIRED'}), 401
try:
payload = jwt.decode(token, current_app.config['SECRET_KEY'], algorithms=['HS256'])
user_id = payload.get('user_id')
if not user_id:
return jsonify({'error': 'Invalid token', 'code': 'INVALID_TOKEN'}), 401
user_dict = users_db.get(Query().id == user_id)
if not user_dict:
return jsonify({'error': 'User not found', 'code': 'USER_NOT_FOUND'}), 404
user = User.from_dict(user_dict)
if user.role != 'admin':
return jsonify({'error': 'Admin access required', 'code': 'ADMIN_REQUIRED'}), 403
# Store user info in request context for the endpoint
request.current_user = user
request.admin_user_id = user_id
except jwt.ExpiredSignatureError:
return jsonify({'error': 'Token expired', 'code': 'TOKEN_EXPIRED'}), 401
except jwt.InvalidTokenError:
return jsonify({'error': 'Invalid token', 'code': 'INVALID_TOKEN'}), 401
return f(*args, **kwargs)
return decorated_function
+61
View File
@@ -0,0 +1,61 @@
import os
import logging
logger = logging.getLogger(__name__)
# Account deletion threshold in hours
# Default: 720 hours (30 days)
# Minimum: 24 hours (1 day)
# Maximum: 720 hours (30 days)
try:
ACCOUNT_DELETION_THRESHOLD_HOURS = int(os.getenv('ACCOUNT_DELETION_THRESHOLD_HOURS', '720'))
except ValueError as e:
raise ValueError(
f"ACCOUNT_DELETION_THRESHOLD_HOURS must be a valid integer. "
f"Invalid value: {os.getenv('ACCOUNT_DELETION_THRESHOLD_HOURS')}"
) from e
# Validation
MIN_THRESHOLD_HOURS = 24
MAX_THRESHOLD_HOURS = 720
def validate_threshold(threshold_hours=None):
"""
Validate the account deletion threshold.
Args:
threshold_hours: Optional threshold value to validate. If None, validates the module's global value.
Returns True if valid, raises ValueError if invalid.
"""
value = threshold_hours if threshold_hours is not None else ACCOUNT_DELETION_THRESHOLD_HOURS
if value < MIN_THRESHOLD_HOURS:
raise ValueError(
f"ACCOUNT_DELETION_THRESHOLD_HOURS must be at least {MIN_THRESHOLD_HOURS} hours. "
f"Current value: {value}"
)
if value > MAX_THRESHOLD_HOURS:
raise ValueError(
f"ACCOUNT_DELETION_THRESHOLD_HOURS must be at most {MAX_THRESHOLD_HOURS} hours. "
f"Current value: {value}"
)
# Warn if threshold is less than 7 days (168 hours)
if value < 168:
logger.warning(
f"Account deletion threshold is set to {value} hours, "
"which is below the recommended minimum of 7 days (168 hours). "
"Users will have limited time to recover their accounts."
)
if threshold_hours is None:
# Only log this when validating the module's global value
logger.info(f"Account deletion threshold: {ACCOUNT_DELETION_THRESHOLD_HOURS} hours")
return True
# Validate on module import
validate_threshold()
+23 -3
View File
@@ -9,19 +9,39 @@ TEST_DATA_DIR_NAME = 'test_data'
# Project root (two levels up from this file) # Project root (two levels up from this file)
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def get_base_data_dir(data_env: str | None = None) -> str:
"""
Return the absolute base data directory path for the given env.
data_env: 'prod' uses `data`, anything else uses `test_data`.
"""
env = (data_env or os.environ.get('DATA_ENV', 'prod')).lower()
base_name = DATA_DIR_NAME if env == 'prod' else TEST_DATA_DIR_NAME
return os.path.join(PROJECT_ROOT, base_name)
def get_database_dir(db_env: str | None = None) -> str: def get_database_dir(db_env: str | None = None) -> str:
""" """
Return the absolute base directory path for the given DB env. Return the absolute base directory path for the given DB env.
db_env: 'prod' uses `data/db`, anything else uses `test_data/db`. db_env: 'prod' uses `data/db`, anything else uses `test_data/db`.
""" """
env = (db_env or os.environ.get('DB_ENV', 'prod')).lower() env = (db_env or os.environ.get('DB_ENV', 'prod')).lower()
base_name = DATA_DIR_NAME if env == 'prod' else TEST_DATA_DIR_NAME return os.path.join(PROJECT_ROOT, get_base_data_dir(env), 'db')
return os.path.join(PROJECT_ROOT, base_name, 'db')
def get_images_dir() -> str:
"""
Return the absolute directory path for storing images.
"""
return os.path.join(PROJECT_ROOT, get_base_data_dir(), 'images')
def get_user_image_dir(username: str | None) -> str: def get_user_image_dir(username: str | None) -> str:
""" """
Return the absolute directory path for storing images for a specific user. Return the absolute directory path for storing images for a specific user.
""" """
if username: if username:
return os.path.join(PROJECT_ROOT, DATA_DIR_NAME, 'images', username) return os.path.join(PROJECT_ROOT, get_base_data_dir(), 'images', username)
return os.path.join(PROJECT_ROOT, 'resources', 'images') return os.path.join(PROJECT_ROOT, 'resources', 'images')
def get_logs_dir() -> str:
"""
Return the absolute directory path for application logs.
"""
return os.path.join(PROJECT_ROOT, 'logs')
@@ -2,7 +2,7 @@
# file: config/version.py # file: config/version.py
import os import os
BASE_VERSION = "1.0.3" # update manually when releasing features BASE_VERSION = "1.0.16" # update manually when releasing features
def get_full_version() -> str: def get_full_version() -> str:
""" """
+146
View File
@@ -0,0 +1,146 @@
"""Helper functions for child override database operations."""
import logging
from typing import Optional, List
from tinydb import Query
from db.db import child_overrides_db
from models.child_override import ChildOverride
logger = logging.getLogger(__name__)
def insert_override(override: ChildOverride) -> str:
"""
Insert or update an override. Only one override per (child_id, entity_id).
Args:
override: ChildOverride instance to insert or update
Returns:
The override ID
"""
try:
OverrideQuery = Query()
existing = child_overrides_db.get(
(OverrideQuery.child_id == override.child_id) &
(OverrideQuery.entity_id == override.entity_id)
)
if existing:
# Update existing override
override.touch() # Update timestamp
child_overrides_db.update(override.to_dict(), doc_ids=[existing.doc_id])
logger.info(f"Override updated: child={override.child_id}, entity={override.entity_id}, value={override.custom_value}")
else:
# Insert new override
child_overrides_db.insert(override.to_dict())
logger.info(f"Override created: child={override.child_id}, entity={override.entity_id}, value={override.custom_value}")
return override.id
except Exception as e:
logger.error(f"Failed to insert override: {e}")
raise
def get_override(child_id: str, entity_id: str) -> Optional[ChildOverride]:
"""
Get override for a specific child and entity.
Args:
child_id: Child ID
entity_id: Entity ID (task or reward)
Returns:
ChildOverride instance or None if not found
"""
OverrideQuery = Query()
result = child_overrides_db.get(
(OverrideQuery.child_id == child_id) &
(OverrideQuery.entity_id == entity_id)
)
return ChildOverride.from_dict(result) if result else None
def get_overrides_for_child(child_id: str) -> List[ChildOverride]:
"""
Get all overrides for a specific child.
Args:
child_id: Child ID
Returns:
List of ChildOverride instances
"""
OverrideQuery = Query()
results = child_overrides_db.search(OverrideQuery.child_id == child_id)
return [ChildOverride.from_dict(r) for r in results]
def delete_override(child_id: str, entity_id: str) -> bool:
"""
Delete a specific override.
Args:
child_id: Child ID
entity_id: Entity ID
Returns:
True if deleted, False if not found
"""
try:
OverrideQuery = Query()
deleted = child_overrides_db.remove(
(OverrideQuery.child_id == child_id) &
(OverrideQuery.entity_id == entity_id)
)
if deleted:
logger.info(f"Override deleted: child={child_id}, entity={entity_id}")
return True
return False
except Exception as e:
logger.error(f"Failed to delete override: {e}")
raise
def delete_overrides_for_child(child_id: str) -> int:
"""
Delete all overrides for a child.
Args:
child_id: Child ID
Returns:
Count of deleted overrides
"""
try:
OverrideQuery = Query()
deleted = child_overrides_db.remove(OverrideQuery.child_id == child_id)
count = len(deleted)
if count > 0:
logger.info(f"Overrides cascade deleted for child: child_id={child_id}, count={count}")
return count
except Exception as e:
logger.error(f"Failed to delete overrides for child: {e}")
raise
def delete_overrides_for_entity(entity_id: str) -> int:
"""
Delete all overrides for an entity.
Args:
entity_id: Entity ID (task or reward)
Returns:
Count of deleted overrides
"""
try:
OverrideQuery = Query()
deleted = child_overrides_db.remove(OverrideQuery.entity_id == entity_id)
count = len(deleted)
if count > 0:
logger.info(f"Overrides cascade deleted for entity: entity_id={entity_id}, count={count}")
return count
except Exception as e:
logger.error(f"Failed to delete overrides for entity: {e}")
raise

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