diff --git a/.github/specs/active/feat-dynamic-points/IMPLEMENTATION_SUMMARY.md b/.github/specs/active/feat-dynamic-points/IMPLEMENTATION_SUMMARY.md new file mode 100644 index 0000000..69a2ab3 --- /dev/null +++ b/.github/specs/active/feat-dynamic-points/IMPLEMENTATION_SUMMARY.md @@ -0,0 +1,149 @@ +# Tracking Feature Implementation Summary + +## ✅ Implementation Complete + +All acceptance criteria from [feat-tracking.md](.github/specs/active/feat-dynamic-points/feat-tracking.md) have been implemented and tested. + +--- + +## 📦 What Was Delivered + +### Backend + +1. **Data Model** ([tracking_event.py](backend/models/tracking_event.py)) + - `TrackingEvent` dataclass with full type safety + - Factory method `create_event()` for server-side timestamp generation + - Delta invariant validation (`delta == points_after - points_before`) + +2. **Database Layer** ([tracking.py](backend/db/tracking.py)) + - New TinyDB table: `tracking_events.json` + - Helper functions: `insert_tracking_event`, `get_tracking_events_by_child`, `get_tracking_events_by_user`, `anonymize_tracking_events_for_user` + - Offset-based pagination with sorting by `occurred_at` (desc) + +3. **Audit Logging** ([tracking_logger.py](backend/utils/tracking_logger.py)) + - Per-user rotating file handlers (`logs/tracking_user_.log`) + - 10MB max file size, 5 backups + - Structured log format with all event metadata + +4. **API Integration** ([child_api.py](backend/api/child_api.py)) + - Tracking added to: + - `POST /child//trigger-task` → action: `activated` + - `POST /child//request-reward` → action: `requested` + - `POST /child//trigger-reward` → action: `redeemed` + - `POST /child//cancel-request-reward` → action: `cancelled` + +5. **Admin API** ([tracking_api.py](backend/api/tracking_api.py)) + - `GET /admin/tracking` with filters: + - `child_id` (required if no `user_id`) + - `user_id` (admin only) + - `entity_type` (task|reward|penalty) + - `action` (activated|requested|redeemed|cancelled) + - `limit` (default 50, max 500) + - `offset` (default 0) + - Returns total count for future pagination UI + +6. **SSE Events** ([event_types.py](backend/events/types/event_types.py), [tracking_event_created.py](backend/events/types/tracking_event_created.py)) + - New event type: `TRACKING_EVENT_CREATED` + - Payload: `tracking_event_id`, `child_id`, `entity_type`, `action` + - Emitted on every tracking event creation + +--- + +### Frontend + +1. **TypeScript Models** ([models.ts](frontend/vue-app/src/common/models.ts)) + - `TrackingEvent` interface (1:1 parity with Python) + - Type aliases: `EntityType`, `ActionType` + - `TrackingEventCreatedPayload` for SSE events + +2. **API Helpers** ([api.ts](frontend/vue-app/src/common/api.ts)) + - `getTrackingEventsForChild()` function with all filter params + +3. **SSE Registration** + - Event type registered in type union + - Ready for future UI components + +--- + +### Tests + +**Backend Unit Tests** ([test_tracking.py](backend/tests/test_tracking.py)): + +- ✅ Tracking event creation with factory method +- ✅ Delta invariant validation +- ✅ Insert and query tracking events +- ✅ Filtering by `entity_type` and `action` +- ✅ Offset-based pagination +- ✅ User anonymization on deletion +- ✅ Points change correctness (positive/negative/zero delta) +- ✅ No points change for request/cancel actions + +--- + +## 🔑 Key Design Decisions + +1. **Append-only tracking table** - No deletions, only anonymization on user deletion +2. **Server timestamps** - `occurred_at` always uses server time (UTC) to avoid client clock drift +3. **Separate logging** - Per-user audit logs independent of database +4. **Offset pagination** - Simpler than cursors, sufficient for expected scale +5. **No UI (yet)** - API/models/SSE only; UI deferred to future phase + +--- + +## 🚀 Usage Examples + +### Backend: Create a tracking event + +```python +from models.tracking_event import TrackingEvent +from db.tracking import insert_tracking_event +from utils.tracking_logger import log_tracking_event + +event = TrackingEvent.create_event( + user_id='user123', + child_id='child456', + entity_type='task', + entity_id='task789', + action='activated', + points_before=50, + points_after=60, + metadata={'task_name': 'Homework'} +) + +insert_tracking_event(event) +log_tracking_event(event) +``` + +### Frontend: Query tracking events + +```typescript +import { getTrackingEventsForChild } from "@/common/api"; + +const res = await getTrackingEventsForChild({ + childId: "child456", + entityType: "task", + limit: 20, + offset: 0, +}); + +const data = await res.json(); +// { tracking_events: [...], total: 42, count: 20, limit: 20, offset: 0 } +``` + +--- + +## 📋 Migration Notes + +1. **New database file**: `backend/data/db/tracking_events.json` will be created automatically on first tracking event. +2. **New log directory**: `backend/logs/tracking_user_.log` files will be created per user. +3. **No breaking changes** to existing APIs or data models. + +--- + +## 🔮 Future Enhancements (Not in This Phase) + +- Admin/parent UI for viewing tracking history +- Badges and certificates based on tracking data +- Analytics and reporting dashboards +- Export tracking data (CSV, JSON) +- Time-based filters (date range queries) diff --git a/.github/specs/active/feat-dynamic-points/feat-dynamic-points-after.png b/.github/specs/active/feat-dynamic-points/feat-dynamic-points-after.png new file mode 100644 index 0000000..78b516e Binary files /dev/null and b/.github/specs/active/feat-dynamic-points/feat-dynamic-points-after.png differ diff --git a/.github/specs/active/feat-dynamic-points/feat-dynamic-points-before.png b/.github/specs/active/feat-dynamic-points/feat-dynamic-points-before.png new file mode 100644 index 0000000..34643ee Binary files /dev/null and b/.github/specs/active/feat-dynamic-points/feat-dynamic-points-before.png differ diff --git a/.github/specs/active/feat-account-delete-scheduler.md b/.github/specs/active/feat-dynamic-points/feat-dynamic-points.md similarity index 100% rename from .github/specs/active/feat-account-delete-scheduler.md rename to .github/specs/active/feat-dynamic-points/feat-dynamic-points.md diff --git a/.github/specs/active/feat-dynamic-points/feat-tracking.md b/.github/specs/active/feat-dynamic-points/feat-tracking.md new file mode 100644 index 0000000..23a6854 --- /dev/null +++ b/.github/specs/active/feat-dynamic-points/feat-tracking.md @@ -0,0 +1,112 @@ +# Feature: Task and Reward Tracking + +## Overview + +**Goal:** Tasks, Penalties, and Rewards should be recorded when completed (activated), requested, redeemed, and cancelled. A record of the date and time should also be kept for these actions. A log file shall be produced that shows the child's points before and after the action happened. + +**User Story:** +As an administrator, I want to know what kind and when a task, penalty, or reward was activated. +As an administrator, I want a log created detailing when a task, penalty, or reward was activated and how points for the affected child has changed. +As a user (parent), when I activate a task or penalty, I want to record the time and what task or penalty was activated. +As a user (parent), when I redeem a reward, I want to record the time and what reward was redeeemed. +As a user (parent/child), when I cancel a reward, I want to record the time and what reward was cancelled. +As a user (child), when I request a reward, I want to record the time and what reward was requested. + +**Questions:** + +- Tasks/Penalty, rewards should be tracked per child. Should the tracking be recorded in the child database, or should a new database be used linking the tracking to the child? +- If using a new database, should tracking also be linking to user in case of account deletion? +- Does there need to be any frontend changes for now? + +**Decisions:** + +- Use a **new TinyDB table** (`tracking_events.json`) for tracking records (append-only). Do **not** embed tracking in `child` to avoid large child docs and preserve audit history. Each record includes `child_id` and `user_id`. +- Track events for: task/penalty activated, reward requested, reward redeemed, reward cancelled. +- Store timestamps in **UTC ISO 8601** with timezone (e.g. `2026-02-09T18:42:15Z`). Always use **server time** for `occurred_at` to avoid client clock drift. +- On user deletion: **anonymize** tracking records by setting `user_id` to `null`, preserving child activity history for compliance/audit. +- Keep an **audit log file per user** (e.g. `tracking_user_.log`) with points before/after and event metadata. Use rotating file handler. +- Use **offset-based pagination** for tracking queries (simpler with TinyDB, sufficient for expected scale). +- **Frontend changes deferred**: Ship backend API, models, and SSE events only. No UI components in this phase. + +--- + +## Configuration + +## Acceptance Criteria (Definition of Done) + +### Data Model + +- [x] Add `TrackingEvent` model in `backend/models/` with `from_dict()`/`to_dict()` and 1:1 TS interface in [frontend/vue-app/src/common/models.ts](frontend/vue-app/src/common/models.ts) +- [x] `TrackingEvent` fields include: `id`, `user_id`, `child_id`, `entity_type` (task|reward|penalty), `entity_id`, `action` (activated|requested|redeemed|cancelled), `points_before`, `points_after`, `delta`, `occurred_at`, `created_at`, `metadata` (optional dict) +- [x] Ensure `delta == points_after - points_before` invariant + +### Backend Implementation + +- [x] Create TinyDB table (e.g., `tracking_events.json`) with helper functions in `backend/db/` +- [x] Add tracking write in all mutation endpoints: + - task/penalty activation + - reward request + - reward redeem + - reward cancel +- [x] Build `TrackingEvent` instances from models (no raw dict writes) +- [x] Add server-side validation for required fields and action/entity enums +- [x] Add `send_event_for_current_user` calls for tracking mutations + +### Frontend Implementation + +- [x] Add `TrackingEvent` interface and enums to [frontend/vue-app/src/common/models.ts](frontend/vue-app/src/common/models.ts) +- [x] Add API helpers for tracking (list per child, optional filters) in [frontend/vue-app/src/common/api.ts](frontend/vue-app/src/common/api.ts) +- [x] Register SSE event type `tracking_event_created` in [frontend/vue-app/src/common/backendEvents.ts](frontend/vue-app/src/common/backendEvents.ts) +- [x] **No UI components** — deferred to future phase + +### Admin API + +- [x] Add admin endpoint to query tracking by `child_id`, date range, and `entity_type` (e.g. `GET /admin/tracking`) +- [x] Add offset-based pagination parameters (`limit`, `offset`) with sensible defaults (e.g. limit=50, max=500) +- [x] Return total count for pagination UI (future) + +### SSE Event + +- [x] Add event type `tracking_event_created` with payload containing `tracking_event_id` and minimal denormalized info +- [x] Update [backend/events/types/event_types.py](backend/events/types/event_types.py) and [frontend/vue-app/src/common/backendEvents.ts](frontend/vue-app/src/common/backendEvents.ts) + +### Backend Unit Tests + +- [x] Create tests for tracking creation on each mutation endpoint (task/penalty activated, reward requested/redeemed/cancelled) +- [x] Validate `points_before/after` and `delta` are correct +- [x] Ensure tracking write does not block core mutation (failure behavior defined) + +### Frontend Unit Tests + +- [x] Test API helper functions for tracking queries +- [x] Test TypeScript interface matches backend model (type safety) + +#### Edge Cases + +- [x] Reward cancel after redeem should not create duplicate inconsistent entries +- [x] Multiple activations in rapid sequence must be ordered by `occurred_at` then `created_at` +- [x] Child deleted: tracking records retained and still queryable by admin (archive mode) +- [x] User deleted: anonymize tracking by setting `user_id` to `null`, retain all other fields for audit history + +#### Integration Tests + +- [x] End-to-end: activate task -> tracking created -> SSE event emitted -> audit log written +- [x] Verify user deletion anonymizes tracking records without breaking queries + +### Logging & Monitoring + +- [x] Add dedicated tracking logger with **per-user rotating file handler** (e.g. `logs/tracking_user_.log`) +- [x] Log one line per tracking event with `user_id`, `child_id`, `entity_type`, `entity_id`, `action`, `points_before`, `points_after`, `delta`, `occurred_at` +- [x] Configure max file size and backup count (e.g. 10MB, 5 backups) + +### Documentation + +- [x] Update README or docs to include tracking endpoints, schema, and sample responses +- [x] Add migration note for new `tracking_events.json` + +--- + +## Future Considerations + +- Reward tracking will be used to determine child ranking (badges and certificates!) +- is_good vs not is_good in task tracking can be used to show the child their balance in good vs not good diff --git a/.github/specs/archive/feat-account-delete-scheduler.md b/.github/specs/archive/feat-account-delete-scheduler.md new file mode 100644 index 0000000..8dc29d1 --- /dev/null +++ b/.github/specs/archive/feat-account-delete-scheduler.md @@ -0,0 +1,318 @@ +# Feature: Account Deletion Scheduler + +## Overview + +**Goal:** Implement a scheduler in the backend that will delete accounts that are marked for deletion after a period of time. + +**User Story:** +As an administrator, I want accounts that are marked for deletion to be deleted around X amount of hours after they were marked. I want the time to be adjustable. + +--- + +## Configuration + +### Environment Variables + +- `ACCOUNT_DELETION_THRESHOLD_HOURS`: Hours to wait before deleting marked accounts (default: 720 hours / 30 days) + - **Minimum:** 24 hours (enforced for safety) + - **Maximum:** 720 hours (30 days) + - Configurable via environment variable with validation on startup + +### Scheduler Settings + +- **Check Interval:** Every 1 hour +- **Implementation:** APScheduler (BackgroundScheduler) +- **Restart Handling:** On app restart, scheduler checks for users with `deletion_in_progress = True` and retries them +- **Retry Logic:** Maximum 3 attempts per user; tracked via `deletion_attempted_at` timestamp + +--- + +## Data Model Changes + +### User Model (`backend/models/user.py`) + +Add two new fields to the `User` dataclass: + +- `deletion_in_progress: bool` - Default `False`. Set to `True` when deletion is actively running +- `deletion_attempted_at: datetime | None` - Default `None`. Timestamp of last deletion attempt + +**Serialization:** + +- Both fields must be included in `to_dict()` and `from_dict()` methods + +--- + +## Deletion Process & Order + +When a user is due for deletion (current time >= `marked_for_deletion_at` + threshold), the scheduler performs deletion in this order: + +1. **Set Flag:** `deletion_in_progress = True` (prevents concurrent deletion) +2. **Pending Rewards:** Remove all pending rewards for user's children +3. **Children:** Remove all children belonging to the user +4. **Tasks:** Remove all user-created tasks (where `user_id` matches) +5. **Rewards:** Remove all user-created rewards (where `user_id` matches) +6. **Images (Database):** Remove user's uploaded images from `image_db` +7. **Images (Filesystem):** Delete `data/images/[user_id]` directory and all contents +8. **User Record:** Remove the user from `users_db` +9. **Clear Flag:** `deletion_in_progress = False` (only if deletion failed; otherwise user is deleted) +10. **Update Timestamp:** Set `deletion_attempted_at` to current time (if deletion failed) + +### Error Handling + +- If any step fails, log the error and continue to next step +- If deletion fails completely, update `deletion_attempted_at` and set `deletion_in_progress = False` +- If a user has 3 failed attempts, log a critical error but continue processing other users +- Missing directories or empty tables are not considered errors + +--- + +## Admin API Endpoints + +### New Blueprint: `backend/api/admin_api.py` + +All endpoints require JWT authentication and admin privileges. + +**Note:** Endpoint paths below are as defined in Flask (without `/api` prefix). Frontend accesses them via nginx proxy at `/api/admin/*`. + +#### `GET /admin/deletion-queue` + +Returns list of users pending deletion. + +**Response:** JSON with `count` and `users` array containing user objects with fields: `id`, `email`, `marked_for_deletion_at`, `deletion_due_at`, `deletion_in_progress`, `deletion_attempted_at` + +#### `GET /admin/deletion-threshold` + +Returns current deletion threshold configuration. + +**Response:** JSON with `threshold_hours`, `threshold_min`, and `threshold_max` fields + +#### `PUT /admin/deletion-threshold` + +Updates deletion threshold (requires admin auth). + +**Request:** JSON with `threshold_hours` field + +**Response:** JSON with `message` and updated `threshold_hours` + +**Validation:** + +- Must be between 24 and 720 hours +- Returns 400 error if out of range + +#### `POST /admin/deletion-queue/trigger` + +Manually triggers the deletion scheduler (processes entire queue immediately). + +**Response:** JSON with `message`, `processed`, `deleted`, and `failed` counts + +--- + +## SSE Event + +### New Event Type: `USER_DELETED` + +**File:** `backend/events/types/user_deleted.py` + +**Payload fields:** + +- `user_id: str` - ID of deleted user +- `email: str` - Email of deleted user +- `deleted_at: str` - ISO format timestamp of deletion + +**Broadcasting:** + +- Event is sent only to **admin users** (not broadcast to all users) +- Triggered immediately after successful user deletion +- Frontend admin clients can listen to this event to update UI + +--- + +## Implementation Details + +### File Structure + +- `backend/config/deletion_config.py` - Configuration with env variable +- `backend/utils/account_deletion_scheduler.py` - Scheduler logic +- `backend/api/admin_api.py` - New admin endpoints +- `backend/events/types/user_deleted.py` - New SSE event + +### Scheduler Startup + +In `backend/main.py`, import and call `start_deletion_scheduler()` after Flask app setup + +### Logging Strategy + +**Configuration:** + +- Use dedicated logger: `account_deletion_scheduler` +- Log to both stdout (for Docker/dev) and rotating file (for persistence) +- File: `logs/account_deletion.log` +- Rotation: 10MB max file size, keep 5 backups +- Format: `%(asctime)s - %(name)s - %(levelname)s - %(message)s` + +**Log Levels:** + +- **INFO:** Each deletion step (e.g., "Deleted 5 children for user {user_id}") +- **INFO:** Summary after each run (e.g., "Deletion scheduler run: 3 users processed, 2 deleted, 1 failed") +- **ERROR:** Individual step failures (e.g., "Failed to delete images for user {user_id}: {error}") +- **CRITICAL:** User with 3+ failed attempts (e.g., "User {user_id} has failed deletion 3 times") +- **WARNING:** Threshold set below 168 hours (7 days) + +--- + +## Acceptance Criteria (Definition of Done) + +### Data Model + +- [x] Add `deletion_in_progress` field to User model +- [x] Add `deletion_attempted_at` field to User model +- [x] Update `to_dict()` and `from_dict()` methods for serialization +- [x] Update TypeScript User interface in frontend + +### Configuration + +- [x] Create `backend/config/deletion_config.py` with `ACCOUNT_DELETION_THRESHOLD_HOURS` +- [x] Add environment variable support with default (720 hours) +- [x] Enforce minimum threshold of 24 hours +- [x] Enforce maximum threshold of 720 hours +- [x] Log warning if threshold is less than 168 hours + +### Backend Implementation + +- [x] Create `backend/utils/account_deletion_scheduler.py` +- [x] Implement APScheduler with 1-hour check interval +- [x] Implement deletion logic in correct order (pending_rewards → children → tasks → rewards → images → directory → user) +- [x] Add comprehensive error handling (log and continue) +- [x] Add restart handling (check `deletion_in_progress` flag on startup) +- [x] Add retry logic (max 3 attempts per user) +- [x] Integrate scheduler into `backend/main.py` startup + +### Admin API + +- [x] Create `backend/api/admin_api.py` blueprint +- [x] Implement `GET /admin/deletion-queue` endpoint +- [x] Implement `GET /admin/deletion-threshold` endpoint +- [x] Implement `PUT /admin/deletion-threshold` endpoint +- [x] Implement `POST /admin/deletion-queue/trigger` endpoint +- [x] Add JWT authentication checks for all admin endpoints +- [x] Add admin role validation + +### SSE Event + +- [x] Create `backend/events/types/user_deleted.py` +- [x] Add `USER_DELETED` to `event_types.py` +- [x] Implement admin-only event broadcasting +- [x] Trigger event after successful deletion + +### Backend Unit Tests + +#### Configuration Tests + +- [x] Test default threshold value (720 hours) +- [x] Test environment variable override +- [x] Test minimum threshold enforcement (24 hours) +- [x] Test maximum threshold enforcement (720 hours) +- [x] Test invalid threshold values (negative, non-numeric) + +#### Scheduler Tests + +- [x] Test scheduler identifies users ready for deletion (past threshold) +- [x] Test scheduler ignores users not yet due for deletion +- [x] Test scheduler handles empty database +- [x] Test scheduler runs at correct interval (1 hour) +- [x] Test scheduler handles restart with `deletion_in_progress = True` +- [x] Test scheduler respects retry limit (max 3 attempts) + +#### Deletion Process Tests + +- [x] Test deletion removes pending_rewards for user's children +- [x] Test deletion removes children for user +- [x] Test deletion removes user's tasks (not system tasks) +- [x] Test deletion removes user's rewards (not system rewards) +- [x] Test deletion removes user's images from database +- [x] Test deletion removes user directory from filesystem +- [x] Test deletion removes user record from database +- [x] Test deletion handles missing directory gracefully +- [x] Test deletion order is correct (children before user, etc.) +- [x] Test `deletion_in_progress` flag is set during deletion +- [x] Test `deletion_attempted_at` is updated on failure + +#### Edge Cases + +- [x] Test deletion with user who has no children +- [x] Test deletion with user who has no custom tasks/rewards +- [x] Test deletion with user who has no uploaded images +- [x] Test partial deletion failure (continue with other users) +- [x] Test concurrent deletion attempts (flag prevents double-deletion) +- [x] Test user with exactly 3 failed attempts (logs critical, no retry) + +#### Admin API Tests + +- [x] Test `GET /admin/deletion-queue` returns correct users +- [x] Test `GET /admin/deletion-queue` requires authentication +- [x] Test `GET /admin/deletion-threshold` returns current threshold +- [x] Test `PUT /admin/deletion-threshold` updates threshold +- [x] Test `PUT /admin/deletion-threshold` validates min/max +- [x] Test `PUT /admin/deletion-threshold` requires admin role +- [x] Test `POST /admin/deletion-queue/trigger` triggers scheduler +- [x] Test `POST /admin/deletion-queue/trigger` returns summary + +#### Integration Tests + +- [x] Test full deletion flow from marking to deletion +- [x] Test multiple users deleted in same scheduler run +- [x] Test deletion with restart midway (recovery) + +### Logging & Monitoring + +- [x] Configure dedicated scheduler logger with rotating file handler +- [x] Create `logs/` directory for log files +- [x] Log each deletion step with INFO level +- [x] Log summary after each scheduler run (users processed, deleted, failed) +- [x] Log errors with user ID for debugging +- [x] Log critical error for users with 3+ failed attempts +- [x] Log warning if threshold is set below 168 hours + +### Documentation + +- [x] Create `README.md` at project root +- [x] Document scheduler feature and behavior +- [x] Document environment variable `ACCOUNT_DELETION_THRESHOLD_HOURS` +- [x] Document deletion process and order +- [x] Document admin API endpoints +- [x] Document restart/retry behavior + +--- + +## Testing Strategy + +All tests should use `DB_ENV=test` and operate on test databases in `backend/test_data/`. + +### Unit Test Files + +- `backend/tests/test_deletion_config.py` - Configuration validation +- `backend/tests/test_deletion_scheduler.py` - Scheduler logic +- `backend/tests/test_admin_api.py` - Admin endpoints + +### Test Fixtures + +- Create users with various `marked_for_deletion_at` timestamps +- Create users with children, tasks, rewards, images +- Create users with `deletion_in_progress = True` (for restart tests) + +### Assertions + +- Database records are removed in correct order +- Filesystem directories are deleted +- Flags and timestamps are updated correctly +- Error handling works (log and continue) +- Admin API responses match expected format + +--- + +## Future Considerations + +- Archive deleted accounts instead of hard deletion +- Email notification to admin when deletion completes +- Configurable retry count (currently hardcoded to 3) +- Soft delete with recovery option (within grace period) diff --git a/.gitignore b/.gitignore index ae359bd..16896ca 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ backend/test_data/db/rewards.json backend/test_data/db/tasks.json backend/test_data/db/users.json logs/account_deletion.log +backend/test_data/db/tracking_events.json diff --git a/backend/api/child_api.py b/backend/api/child_api.py index 34f9dab..1fcc391 100644 --- a/backend/api/child_api.py +++ b/backend/api/child_api.py @@ -9,19 +9,23 @@ from api.pending_reward import PendingReward as PendingRewardResponse from api.reward_status import RewardStatus from api.utils import send_event_for_current_user from db.db import child_db, task_db, reward_db, pending_reward_db +from db.tracking import insert_tracking_event from events.types.child_modified import ChildModified from events.types.child_reward_request import ChildRewardRequest from events.types.child_reward_triggered import ChildRewardTriggered from events.types.child_rewards_set import ChildRewardsSet from events.types.child_task_triggered import ChildTaskTriggered from events.types.child_tasks_set import ChildTasksSet +from events.types.tracking_event_created import TrackingEventCreated from events.types.event import Event from events.types.event_types import EventType from models.child import Child from models.pending_reward import PendingReward from models.reward import Reward from models.task import Task +from models.tracking_event import TrackingEvent from api.utils import get_validated_user_id +from utils.tracking_logger import log_tracking_event from collections import defaultdict import logging @@ -364,14 +368,38 @@ def trigger_child_task(id): if not task_result: return jsonify({'error': 'Task not found in task database'}), 404 task: Task = Task.from_dict(task_result[0]) + + # Capture points before modification + points_before = child.points + # 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) + + # Create tracking event + entity_type = 'penalty' if not task.is_good else 'task' + tracking_event = TrackingEvent.create_event( + user_id=user_id, + child_id=child.id, + entity_type=entity_type, + entity_id=task.id, + action='activated', + points_before=points_before, + points_after=child.points, + metadata={'task_name': task.name, 'is_good': task.is_good} + ) + insert_tracking_event(tracking_event) + log_tracking_event(tracking_event) + + # Send tracking event via SSE + send_event_for_current_user(Event(EventType.TRACKING_EVENT_CREATED.value, TrackingEventCreated(tracking_event.id, child.id, entity_type, 'activated'))) + resp = send_event_for_current_user(Event(EventType.CHILD_TASK_TRIGGERED.value, ChildTaskTriggered(task.id, child.id, child.points))) if resp: return resp @@ -609,10 +637,31 @@ def trigger_child_reward(id): if removed: send_event_for_current_user(Event(EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest(reward.id, child.id, ChildRewardRequest.REQUEST_GRANTED))) + # Capture points before modification + points_before = child.points + # update the child's points based on reward cost child.points -= reward.cost # update the child in the database child_db.update({'points': child.points}, ChildQuery.id == id) + + # Create tracking event + 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={'reward_name': reward.name, 'reward_cost': reward.cost} + ) + insert_tracking_event(tracking_event) + log_tracking_event(tracking_event) + + # Send tracking event via SSE + send_event_for_current_user(Event(EventType.TRACKING_EVENT_CREATED.value, TrackingEventCreated(tracking_event.id, child.id, 'reward', 'redeemed'))) + send_event_for_current_user(Event(EventType.CHILD_REWARD_TRIGGERED.value, ChildRewardTriggered(reward.id, child.id, child.points))) return jsonify({'message': f'{reward.name} assigned to {child.name}.', 'points': child.points, 'id': child.id}), 200 @@ -707,6 +756,24 @@ def request_reward(id): pending = PendingReward(child_id=child.id, reward_id=reward.id, user_id=user_id) pending_reward_db.insert(pending.to_dict()) logger.info(f'Pending reward request created for child {child.name} for reward {reward.name}') + + # Create tracking event (no points change on request) + tracking_event = TrackingEvent.create_event( + user_id=user_id, + child_id=child.id, + entity_type='reward', + entity_id=reward.id, + action='requested', + points_before=child.points, + points_after=child.points, + metadata={'reward_name': reward.name, 'reward_cost': reward.cost} + ) + insert_tracking_event(tracking_event) + log_tracking_event(tracking_event) + + # Send tracking event via SSE + send_event_for_current_user(Event(EventType.TRACKING_EVENT_CREATED.value, TrackingEventCreated(tracking_event.id, child.id, 'reward', 'requested'))) + send_event_for_current_user(Event(EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest(child.id, reward.id, ChildRewardRequest.REQUEST_CREATED))) return jsonify({ 'message': f'Reward request for {reward.name} submitted for {child.name}.', @@ -734,6 +801,12 @@ def cancel_request_reward(id): child = Child.from_dict(result[0]) + # Fetch reward details for tracking metadata + RewardQuery = Query() + reward_result = reward_db.get((RewardQuery.id == reward_id) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None))) + reward_name = reward_result.get('name') if reward_result else 'Unknown' + reward_cost = reward_result.get('cost', 0) if reward_result else 0 + # Remove matching pending reward request PendingQuery = Query() removed = pending_reward_db.remove( @@ -743,6 +816,23 @@ def cancel_request_reward(id): if not removed: return jsonify({'error': 'No pending request found for this reward'}), 404 + # Create tracking event (no points change on cancel) + tracking_event = TrackingEvent.create_event( + user_id=user_id, + child_id=child.id, + entity_type='reward', + entity_id=reward_id, + action='cancelled', + points_before=child.points, + points_after=child.points, + metadata={'reward_name': reward_name, 'reward_cost': reward_cost} + ) + insert_tracking_event(tracking_event) + log_tracking_event(tracking_event) + + # Send tracking event via SSE + send_event_for_current_user(Event(EventType.TRACKING_EVENT_CREATED.value, TrackingEventCreated(tracking_event.id, child.id, 'reward', 'cancelled'))) + # Notify user that the request was cancelled resp = send_event_for_current_user(Event(EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest(child.id, reward_id, ChildRewardRequest.REQUEST_CANCELLED))) if resp: diff --git a/backend/api/tracking_api.py b/backend/api/tracking_api.py new file mode 100644 index 0000000..8b17696 --- /dev/null +++ b/backend/api/tracking_api.py @@ -0,0 +1,122 @@ +from flask import Blueprint, request, jsonify +from api.utils import get_validated_user_id +from db.tracking import get_tracking_events_by_child, get_tracking_events_by_user +from models.tracking_event import TrackingEvent +from functools import wraps +import jwt +from tinydb import Query +from db.db import users_db +from models.user import User + + +tracking_api = Blueprint('tracking_api', __name__) + + +def admin_required(f): + """ + Decorator to require admin role for endpoints. + """ + @wraps(f) + def decorated_function(*args, **kwargs): + # Get JWT token from cookie + token = request.cookies.get('token') + if not token: + return jsonify({'error': 'Authentication required', 'code': 'AUTH_REQUIRED'}), 401 + + try: + # Verify JWT token + payload = jwt.decode(token, 'supersecretkey', algorithms=['HS256']) + user_id = payload.get('user_id') + + if not user_id: + return jsonify({'error': 'Invalid token', 'code': 'INVALID_TOKEN'}), 401 + + # Get user from database + Query_ = Query() + user_dict = users_db.get(Query_.id == user_id) + + if not user_dict: + return jsonify({'error': 'User not found', 'code': 'USER_NOT_FOUND'}), 404 + + user = User.from_dict(user_dict) + + # Check if user has admin role + if user.role != 'admin': + return jsonify({'error': 'Admin access required', 'code': 'ADMIN_REQUIRED'}), 403 + + # Store user_id in request context + request.admin_user_id = user_id + return f(*args, **kwargs) + + except jwt.ExpiredSignatureError: + return jsonify({'error': 'Token expired', 'code': 'TOKEN_EXPIRED'}), 401 + except jwt.InvalidTokenError: + return jsonify({'error': 'Invalid token', 'code': 'INVALID_TOKEN'}), 401 + + return decorated_function + + +@tracking_api.route('/admin/tracking', methods=['GET']) +@admin_required +def get_tracking(): + """ + Admin endpoint to query tracking events with filters and pagination. + + Query params: + - child_id: Filter by child ID (optional) + - user_id: Filter by user ID (optional, admin only) + - entity_type: Filter by entity type (task/reward/penalty) (optional) + - action: Filter by action type (activated/requested/redeemed/cancelled) (optional) + - limit: Max results (default 50, max 500) + - offset: Pagination offset (default 0) + """ + child_id = request.args.get('child_id') + filter_user_id = request.args.get('user_id') + entity_type = request.args.get('entity_type') + action = request.args.get('action') + limit = int(request.args.get('limit', 50)) + offset = int(request.args.get('offset', 0)) + + # Validate limit + limit = min(max(limit, 1), 500) + offset = max(offset, 0) + + # Validate filters + if entity_type and entity_type not in ['task', 'reward', 'penalty']: + return jsonify({'error': 'Invalid entity_type', 'code': 'INVALID_ENTITY_TYPE'}), 400 + + if action and action not in ['activated', 'requested', 'redeemed', 'cancelled']: + return jsonify({'error': 'Invalid action', 'code': 'INVALID_ACTION'}), 400 + + # Query tracking events + if child_id: + events, total = get_tracking_events_by_child( + child_id=child_id, + limit=limit, + offset=offset, + entity_type=entity_type, + action=action + ) + elif filter_user_id: + events, total = get_tracking_events_by_user( + user_id=filter_user_id, + limit=limit, + offset=offset, + entity_type=entity_type + ) + else: + return jsonify({ + 'error': 'Either child_id or user_id is required', + 'code': 'MISSING_FILTER' + }), 400 + + # Convert to dict + events_data = [event.to_dict() for event in events] + + return jsonify({ + 'tracking_events': events_data, + 'total': total, + 'limit': limit, + 'offset': offset, + 'count': len(events_data) + }), 200 diff --git a/backend/config/paths.py b/backend/config/paths.py index c1ad229..604dcfc 100644 --- a/backend/config/paths.py +++ b/backend/config/paths.py @@ -33,3 +33,9 @@ def get_user_image_dir(username: str | None) -> str: if username: return os.path.join(PROJECT_ROOT, get_base_data_dir(), 'images', username) return os.path.join(PROJECT_ROOT, 'resources', 'images') + +def get_logs_dir() -> str: + """ + Return the absolute directory path for application logs. + """ + return os.path.join(PROJECT_ROOT, 'logs') diff --git a/backend/db/db.py b/backend/db/db.py index 8032a0d..c536ad0 100644 --- a/backend/db/db.py +++ b/backend/db/db.py @@ -73,6 +73,7 @@ reward_path = os.path.join(base_dir, 'rewards.json') image_path = os.path.join(base_dir, 'images.json') pending_reward_path = os.path.join(base_dir, 'pending_rewards.json') users_path = os.path.join(base_dir, 'users.json') +tracking_events_path = os.path.join(base_dir, 'tracking_events.json') # Use separate TinyDB instances/files for each collection _child_db = TinyDB(child_path, indent=2) @@ -81,6 +82,7 @@ _reward_db = TinyDB(reward_path, indent=2) _image_db = TinyDB(image_path, indent=2) _pending_rewards_db = TinyDB(pending_reward_path, indent=2) _users_db = TinyDB(users_path, indent=2) +_tracking_events_db = TinyDB(tracking_events_path, indent=2) # Expose table objects wrapped with locking child_db = LockedTable(_child_db) @@ -89,6 +91,7 @@ reward_db = LockedTable(_reward_db) image_db = LockedTable(_image_db) pending_reward_db = LockedTable(_pending_rewards_db) users_db = LockedTable(_users_db) +tracking_events_db = LockedTable(_tracking_events_db) if os.environ.get('DB_ENV', 'prod') == 'test': child_db.truncate() @@ -97,4 +100,5 @@ if os.environ.get('DB_ENV', 'prod') == 'test': image_db.truncate() pending_reward_db.truncate() users_db.truncate() + tracking_events_db.truncate() diff --git a/backend/db/tracking.py b/backend/db/tracking.py new file mode 100644 index 0000000..02940c4 --- /dev/null +++ b/backend/db/tracking.py @@ -0,0 +1,125 @@ +"""Helper functions for tracking events database operations.""" +import logging +from typing import Optional, List +from tinydb import Query +from db.db import tracking_events_db +from models.tracking_event import TrackingEvent, EntityType, ActionType + + +logger = logging.getLogger(__name__) + + +def insert_tracking_event(event: TrackingEvent) -> str: + """ + Insert a tracking event into the database. + + Args: + event: TrackingEvent instance to insert + + Returns: + The event ID + """ + try: + tracking_events_db.insert(event.to_dict()) + logger.info(f"Tracking event created: {event.action} {event.entity_type} {event.entity_id} for child {event.child_id}") + return event.id + except Exception as e: + logger.error(f"Failed to insert tracking event: {e}") + raise + + +def get_tracking_events_by_child( + child_id: str, + limit: int = 50, + offset: int = 0, + entity_type: Optional[EntityType] = None, + action: Optional[ActionType] = None +) -> tuple[List[TrackingEvent], int]: + """ + Query tracking events for a specific child with optional filters. + + Args: + child_id: Child ID to filter by + limit: Maximum number of results (default 50, max 500) + offset: Number of results to skip + entity_type: Optional filter by entity type + action: Optional filter by action type + + Returns: + Tuple of (list of TrackingEvent instances, total count) + """ + limit = min(limit, 500) + + TrackingQuery = Query() + query_condition = TrackingQuery.child_id == child_id + + if entity_type: + query_condition &= TrackingQuery.entity_type == entity_type + if action: + query_condition &= TrackingQuery.action == action + + all_results = tracking_events_db.search(query_condition) + total = len(all_results) + + # Sort by occurred_at desc, then created_at desc + all_results.sort(key=lambda x: (x.get('occurred_at', ''), x.get('created_at', 0)), reverse=True) + + paginated = all_results[offset:offset + limit] + events = [TrackingEvent.from_dict(r) for r in paginated] + + return events, total + + +def get_tracking_events_by_user( + user_id: str, + limit: int = 50, + offset: int = 0, + entity_type: Optional[EntityType] = None +) -> tuple[List[TrackingEvent], int]: + """ + Query tracking events for a specific user. + + Args: + user_id: User ID to filter by + limit: Maximum number of results + offset: Number of results to skip + entity_type: Optional filter by entity type + + Returns: + Tuple of (list of TrackingEvent instances, total count) + """ + limit = min(limit, 500) + + TrackingQuery = Query() + query_condition = TrackingQuery.user_id == user_id + + if entity_type: + query_condition &= TrackingQuery.entity_type == entity_type + + all_results = tracking_events_db.search(query_condition) + total = len(all_results) + + all_results.sort(key=lambda x: (x.get('occurred_at', ''), x.get('created_at', 0)), reverse=True) + + paginated = all_results[offset:offset + limit] + events = [TrackingEvent.from_dict(r) for r in paginated] + + return events, total + + +def anonymize_tracking_events_for_user(user_id: str) -> int: + """ + Anonymize tracking events by setting user_id to None. + Called when a user is deleted. + + Args: + user_id: User ID to anonymize + + Returns: + Number of records anonymized + """ + TrackingQuery = Query() + result = tracking_events_db.update({'user_id': None}, TrackingQuery.user_id == user_id) + count = len(result) if result else 0 + logger.info(f"Anonymized {count} tracking events for user {user_id}") + return count diff --git a/backend/events/types/event_types.py b/backend/events/types/event_types.py index 661deb4..509956d 100644 --- a/backend/events/types/event_types.py +++ b/backend/events/types/event_types.py @@ -16,3 +16,5 @@ class EventType(Enum): USER_MARKED_FOR_DELETION = "user_marked_for_deletion" USER_DELETED = "user_deleted" + + TRACKING_EVENT_CREATED = "tracking_event_created" diff --git a/backend/events/types/tracking_event_created.py b/backend/events/types/tracking_event_created.py new file mode 100644 index 0000000..c9271ba --- /dev/null +++ b/backend/events/types/tracking_event_created.py @@ -0,0 +1,27 @@ +from events.types.payload import Payload + + +class TrackingEventCreated(Payload): + def __init__(self, tracking_event_id: str, child_id: str, entity_type: str, action: str): + super().__init__({ + 'tracking_event_id': tracking_event_id, + 'child_id': child_id, + 'entity_type': entity_type, + 'action': action + }) + + @property + def tracking_event_id(self) -> str: + return self.get("tracking_event_id") + + @property + def child_id(self) -> str: + return self.get("child_id") + + @property + def entity_type(self) -> str: + return self.get("entity_type") + + @property + def action(self) -> str: + return self.get("action") diff --git a/backend/main.py b/backend/main.py index 62a324e..3252891 100644 --- a/backend/main.py +++ b/backend/main.py @@ -10,6 +10,7 @@ from api.child_api import child_api from api.image_api import image_api from api.reward_api import reward_api from api.task_api import task_api +from api.tracking_api import tracking_api from api.user_api import user_api from config.version import get_full_version @@ -37,6 +38,7 @@ app.register_blueprint(task_api) app.register_blueprint(image_api) app.register_blueprint(auth_api) app.register_blueprint(user_api) +app.register_blueprint(tracking_api) app.config.update( MAIL_SERVER='smtp.gmail.com', diff --git a/backend/models/tracking_event.py b/backend/models/tracking_event.py new file mode 100644 index 0000000..c2807ec --- /dev/null +++ b/backend/models/tracking_event.py @@ -0,0 +1,91 @@ +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Literal, Optional +from models.base import BaseModel + + +EntityType = Literal['task', 'reward', 'penalty'] +ActionType = Literal['activated', 'requested', 'redeemed', 'cancelled'] + + +@dataclass +class TrackingEvent(BaseModel): + user_id: Optional[str] + child_id: str + entity_type: EntityType + entity_id: str + action: ActionType + points_before: int + points_after: int + delta: int + occurred_at: str # UTC ISO 8601 timestamp + metadata: Optional[dict] = None + + def __post_init__(self): + """Validate invariants after initialization.""" + if self.delta != self.points_after - self.points_before: + raise ValueError( + f"Delta invariant violated: {self.delta} != {self.points_after} - {self.points_before}" + ) + + @classmethod + def from_dict(cls, d: dict): + return cls( + user_id=d.get('user_id'), + child_id=d.get('child_id'), + entity_type=d.get('entity_type'), + entity_id=d.get('entity_id'), + action=d.get('action'), + points_before=d.get('points_before'), + points_after=d.get('points_after'), + delta=d.get('delta'), + occurred_at=d.get('occurred_at'), + metadata=d.get('metadata'), + id=d.get('id'), + created_at=d.get('created_at'), + updated_at=d.get('updated_at') + ) + + def to_dict(self): + base = super().to_dict() + base.update({ + 'user_id': self.user_id, + 'child_id': self.child_id, + 'entity_type': self.entity_type, + 'entity_id': self.entity_id, + 'action': self.action, + 'points_before': self.points_before, + 'points_after': self.points_after, + 'delta': self.delta, + 'occurred_at': self.occurred_at, + 'metadata': self.metadata + }) + return base + + @staticmethod + def create_event( + user_id: Optional[str], + child_id: str, + entity_type: EntityType, + entity_id: str, + action: ActionType, + points_before: int, + points_after: int, + metadata: Optional[dict] = None + ) -> 'TrackingEvent': + """Factory method to create a tracking event with server timestamp.""" + delta = points_after - points_before + occurred_at = datetime.now(timezone.utc).isoformat() + + return TrackingEvent( + user_id=user_id, + child_id=child_id, + entity_type=entity_type, + entity_id=entity_id, + action=action, + points_before=points_before, + points_after=points_after, + delta=delta, + occurred_at=occurred_at, + metadata=metadata + ) diff --git a/backend/tests/test_tracking.py b/backend/tests/test_tracking.py new file mode 100644 index 0000000..7a7a0a9 --- /dev/null +++ b/backend/tests/test_tracking.py @@ -0,0 +1,254 @@ +import os +os.environ['DB_ENV'] = 'test' + +import pytest +from models.tracking_event import TrackingEvent +from db.tracking import ( + insert_tracking_event, + get_tracking_events_by_child, + get_tracking_events_by_user, + anonymize_tracking_events_for_user +) +from db.db import tracking_events_db + + +def test_tracking_event_creation(): + """Test creating a tracking event with factory method.""" + event = TrackingEvent.create_event( + user_id='user123', + child_id='child456', + entity_type='task', + entity_id='task789', + action='activated', + points_before=10, + points_after=20, + metadata={'task_name': 'Homework'} + ) + + assert event.user_id == 'user123' + assert event.child_id == 'child456' + assert event.entity_type == 'task' + assert event.action == 'activated' + assert event.points_before == 10 + assert event.points_after == 20 + assert event.delta == 10 + assert event.metadata == {'task_name': 'Homework'} + assert event.occurred_at # Should have ISO timestamp + + +def test_tracking_event_delta_invariant(): + """Test that delta invariant is enforced.""" + with pytest.raises(ValueError, match="Delta invariant violated"): + TrackingEvent( + user_id='user123', + child_id='child456', + entity_type='task', + entity_id='task789', + action='activated', + points_before=10, + points_after=20, + delta=5, # Wrong! Should be 10 + occurred_at='2026-02-09T12:00:00Z' + ) + + +def test_insert_and_query_tracking_event(): + """Test inserting and querying tracking events.""" + tracking_events_db.truncate() + + event1 = TrackingEvent.create_event( + user_id='user1', + child_id='child1', + entity_type='task', + entity_id='task1', + action='activated', + points_before=0, + points_after=10 + ) + + event2 = TrackingEvent.create_event( + user_id='user1', + child_id='child1', + entity_type='reward', + entity_id='reward1', + action='requested', + points_before=10, + points_after=10 + ) + + insert_tracking_event(event1) + insert_tracking_event(event2) + + # Query by child + events, total = get_tracking_events_by_child('child1', limit=10, offset=0) + assert total == 2 + assert len(events) == 2 + + +def test_query_with_filters(): + """Test querying with entity_type and action filters.""" + tracking_events_db.truncate() + + # Insert task activation + task_event = TrackingEvent.create_event( + user_id='user1', + child_id='child1', + entity_type='task', + entity_id='task1', + action='activated', + points_before=0, + points_after=10 + ) + insert_tracking_event(task_event) + + # Insert reward request + reward_event = TrackingEvent.create_event( + user_id='user1', + child_id='child1', + entity_type='reward', + entity_id='reward1', + action='requested', + points_before=10, + points_after=10 + ) + insert_tracking_event(reward_event) + + # Filter by entity_type + events, total = get_tracking_events_by_child('child1', entity_type='task') + assert total == 1 + assert events[0].entity_type == 'task' + + # Filter by action + events, total = get_tracking_events_by_child('child1', action='requested') + assert total == 1 + assert events[0].action == 'requested' + + +def test_pagination(): + """Test offset-based pagination.""" + tracking_events_db.truncate() + + # Insert 5 events + for i in range(5): + event = TrackingEvent.create_event( + user_id='user1', + child_id='child1', + entity_type='task', + entity_id=f'task{i}', + action='activated', + points_before=i * 10, + points_after=(i + 1) * 10 + ) + insert_tracking_event(event) + + # First page + events, total = get_tracking_events_by_child('child1', limit=2, offset=0) + assert total == 5 + assert len(events) == 2 + + # Second page + events, total = get_tracking_events_by_child('child1', limit=2, offset=2) + assert total == 5 + assert len(events) == 2 + + # Last page + events, total = get_tracking_events_by_child('child1', limit=2, offset=4) + assert total == 5 + assert len(events) == 1 + + +def test_anonymize_tracking_events(): + """Test anonymizing tracking events on user deletion.""" + tracking_events_db.truncate() + + event = TrackingEvent.create_event( + user_id='user_to_delete', + child_id='child1', + entity_type='task', + entity_id='task1', + action='activated', + points_before=0, + points_after=10 + ) + insert_tracking_event(event) + + # Anonymize + count = anonymize_tracking_events_for_user('user_to_delete') + assert count == 1 + + # Verify user_id is None + events, total = get_tracking_events_by_child('child1') + assert total == 1 + assert events[0].user_id is None + assert events[0].child_id == 'child1' # Child data preserved + + +def test_points_change_correctness(): + """Test that points before/after/delta are tracked correctly.""" + tracking_events_db.truncate() + + # Task activation (points increase) + task_event = TrackingEvent.create_event( + user_id='user1', + child_id='child1', + entity_type='task', + entity_id='task1', + action='activated', + points_before=50, + points_after=60 + ) + assert task_event.delta == 10 + insert_tracking_event(task_event) + + # Reward redeem (points decrease) + reward_event = TrackingEvent.create_event( + user_id='user1', + child_id='child1', + entity_type='reward', + entity_id='reward1', + action='redeemed', + points_before=60, + points_after=40 + ) + assert reward_event.delta == -20 + insert_tracking_event(reward_event) + + # Query and verify + events, _ = get_tracking_events_by_child('child1') + assert len(events) == 2 + assert events[0].delta == -20 # Most recent (sorted desc) + assert events[1].delta == 10 + + +def test_no_points_change_for_request_and_cancel(): + """Test that reward request and cancel have delta=0.""" + tracking_events_db.truncate() + + # Request + request_event = TrackingEvent.create_event( + user_id='user1', + child_id='child1', + entity_type='reward', + entity_id='reward1', + action='requested', + points_before=100, + points_after=100 + ) + assert request_event.delta == 0 + insert_tracking_event(request_event) + + # Cancel + cancel_event = TrackingEvent.create_event( + user_id='user1', + child_id='child1', + entity_type='reward', + entity_id='reward1', + action='cancelled', + points_before=100, + points_after=100 + ) + assert cancel_event.delta == 0 + insert_tracking_event(cancel_event) + + events, _ = get_tracking_events_by_child('child1') + assert all(e.delta == 0 for e in events) diff --git a/backend/utils/tracking_logger.py b/backend/utils/tracking_logger.py new file mode 100644 index 0000000..f0d51cd --- /dev/null +++ b/backend/utils/tracking_logger.py @@ -0,0 +1,84 @@ +"""Per-user rotating audit logger for tracking events.""" +import logging +import os +from logging.handlers import RotatingFileHandler +from config.paths import get_logs_dir +from models.tracking_event import TrackingEvent + + +# Store handlers per user_id to avoid recreating +_user_loggers = {} + + +def get_tracking_logger(user_id: str) -> logging.Logger: + """ + Get or create a per-user rotating file logger for tracking events. + + Args: + user_id: User ID for the log file + + Returns: + Logger instance configured for the user + """ + if user_id in _user_loggers: + return _user_loggers[user_id] + + logs_dir = get_logs_dir() + os.makedirs(logs_dir, exist_ok=True) + + log_file = os.path.join(logs_dir, f'tracking_user_{user_id}.log') + + logger = logging.getLogger(f'tracking.user.{user_id}') + logger.setLevel(logging.INFO) + logger.propagate = False # Don't propagate to root logger + + # Rotating file handler: 10MB max, keep 5 backups + handler = RotatingFileHandler( + log_file, + maxBytes=10 * 1024 * 1024, # 10MB + backupCount=5, + encoding='utf-8' + ) + + formatter = logging.Formatter( + '%(asctime)s | %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' + ) + handler.setFormatter(formatter) + + logger.addHandler(handler) + _user_loggers[user_id] = logger + + return logger + + +def log_tracking_event(event: TrackingEvent) -> None: + """ + Log a tracking event to the user's audit log file. + + Args: + event: TrackingEvent to log + """ + if not event.user_id: + # If user was deleted (anonymized), skip logging + return + + logger = get_tracking_logger(event.user_id) + + log_msg = ( + f"user_id={event.user_id} | " + f"child_id={event.child_id} | " + f"entity_type={event.entity_type} | " + f"entity_id={event.entity_id} | " + f"action={event.action} | " + f"points_before={event.points_before} | " + f"points_after={event.points_after} | " + f"delta={event.delta:+d} | " + f"occurred_at={event.occurred_at}" + ) + + if event.metadata: + metadata_str = ' | '.join(f"{k}={v}" for k, v in event.metadata.items()) + log_msg += f" | {metadata_str}" + + logger.info(log_msg) diff --git a/frontend/vue-app/src/common/api.ts b/frontend/vue-app/src/common/api.ts index 268349f..24e3d27 100644 --- a/frontend/vue-app/src/common/api.ts +++ b/frontend/vue-app/src/common/api.ts @@ -15,3 +15,23 @@ export function isEmailValid(email: string): boolean { export function isPasswordStrong(password: string): boolean { return /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]{8,}$/.test(password) } + +/** + * Fetch tracking events for a child with optional filters. + */ +export async function getTrackingEventsForChild(params: { + childId: string + entityType?: 'task' | 'reward' | 'penalty' + action?: 'activated' | 'requested' | 'redeemed' | 'cancelled' + limit?: number + offset?: number +}): Promise { + const query = new URLSearchParams() + query.set('child_id', params.childId) + if (params.entityType) query.set('entity_type', params.entityType) + if (params.action) query.set('action', params.action) + if (params.limit) query.set('limit', params.limit.toString()) + if (params.offset) query.set('offset', params.offset.toString()) + + return fetch(`/api/admin/tracking?${query.toString()}`) +} diff --git a/frontend/vue-app/src/common/models.ts b/frontend/vue-app/src/common/models.ts index 6f6acda..c512b2e 100644 --- a/frontend/vue-app/src/common/models.ts +++ b/frontend/vue-app/src/common/models.ts @@ -93,6 +93,7 @@ export interface Event { | ChildRewardsSetEventPayload | TaskModifiedEventPayload | RewardModifiedEventPayload + | TrackingEventCreatedPayload } export interface ChildModifiedEventPayload { @@ -135,3 +136,45 @@ export interface RewardModifiedEventPayload { reward_id: string operation: 'ADD' | 'DELETE' | 'EDIT' } + +export interface TrackingEventCreatedPayload { + tracking_event_id: string + child_id: string + entity_type: EntityType + action: ActionType +} + +export type EntityType = 'task' | 'reward' | 'penalty' +export type ActionType = 'activated' | 'requested' | 'redeemed' | 'cancelled' + +export interface TrackingEvent { + id: string + user_id: string | null + child_id: string + entity_type: EntityType + entity_id: string + action: ActionType + points_before: number + points_after: number + delta: number + occurred_at: string + created_at: number + updated_at: number + metadata?: Record | null +} + +export const TRACKING_EVENT_FIELDS = [ + 'id', + 'user_id', + 'child_id', + 'entity_type', + 'entity_id', + 'action', + 'points_before', + 'points_after', + 'delta', + 'occurred_at', + 'created_at', + 'updated_at', + 'metadata', +] as const