feat: Implement user validation and ownership checks for image, reward, and task APIs
All checks were successful
Gitea Actions Demo / build-and-push (push) Successful in 36s

- Added `get_validated_user_id` utility function to validate user authentication across multiple APIs.
- Updated image upload, request, and listing endpoints to ensure user ownership and proper error handling.
- Enhanced reward management endpoints to include user validation and ownership checks.
- Modified task management endpoints to enforce user authentication and ownership verification.
- Updated models to include `user_id` for images, rewards, tasks, and children to track ownership.
- Implemented frontend changes to ensure UI reflects the ownership of tasks and rewards.
- Added a new feature specification to prevent deletion of system tasks and rewards.
This commit is contained in:
2026-01-31 19:48:51 -05:00
parent 6f5b61de7f
commit f14de28daa
18 changed files with 361 additions and 121 deletions

View File

@@ -13,22 +13,16 @@
- **Frontend Styling**: Use only `:root` CSS variables from `global.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.
- **Rewards UI**: If `points >= cost`, apply `--item-card-ready-shadow` and `--item-card-ready-border`.
- **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`.
- **Validation**: Use `isEmailValid` and `isPasswordStrong` (min 8 chars, 1 letter, 1 number) from `api.ts` for all user input. Use `sanitize_email()` for directory names and unique IDs (see `backend/api/utils.py`).
- **JWT Auth**: Tokens are stored in HttpOnly, Secure, SameSite=Strict cookies.
## 🚦 Frontend Logic & Event Bus
- **SSE Event Management**: Register listeners in `onMounted`, clean up in `onUnmounted`. Listen for events like `child_task_triggered`, `child_reward_request`, `task_modified`, etc. See `frontend/vue-app/src/common/backendEvents.ts` and `components/BackendEventsListener.vue`.
- **UI Guardrails**:
- Before triggering a task, check for pending rewards. If found, prompt for cancellation before proceeding.
- On `EDIT`, always refetch the full object from the API to ensure state integrity.
- **Layout Hierarchy**: Use `ParentLayout` for admin/management, `ChildLayout` for dashboard/focus views.
## ⚖️ Business Logic & Safeguards
- **Points**: Always enforce `child.points = max(child.points, 0)` after any mutation.
- **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.
@@ -36,7 +30,7 @@
- **Backend**: Run Flask with `python -m flask run --host=0.0.0.0 --port=5000` from the `backend/` directory. Main entry: `backend/main.py`.
- **Frontend**: From `frontend/vue-app/`, run `npm install` then `npm run dev`.
- **Tests**: Run backend tests with `pytest` in `backend/`. Frontend tests: `npm run test` in `frontend/vue-app/`.
- **Tests**: Run backend tests with `pytest` in `backend/tests/`. Frontend component tests: `npm run test` in `frontend/vue-app/components/__tests__/`.
- **Debugging**: Use VS Code launch configs or run Flask/Vue dev servers directly. For SSE, use browser dev tools to inspect event streams.
## 📁 Key Files & Directories
@@ -48,7 +42,7 @@
- `frontend/vue-app/` — Vue 3 frontend (see `src/common/`, `src/components/`, `src/layout/`)
- `frontend/vue-app/src/common/models.ts` — TypeScript interfaces (mirror Python models)
- `frontend/vue-app/src/common/api.ts` — API helpers, error parsing, validation
- `web/vue-app/src/common/backendEvents.ts` — SSE event types and handlers
- `frontend/vue-app/src/common/backendEvents.ts` — SSE event types and handlers
## 🧠 Integration & Cross-Component Patterns

View File

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

View File

@@ -100,7 +100,7 @@ def verify():
if not user.email:
logger.error("Verified user has no email field.")
else:
user_image_dir = get_user_image_dir(sanitize_email(user.email))
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
@@ -148,6 +148,7 @@ def login():
payload = {
'email': norm_email,
'user_id': user.id,
'exp': datetime.utcnow() + timedelta(hours=24*7)
}
token = jwt.encode(payload, current_app.config['SECRET_KEY'], algorithm='HS256')
@@ -164,14 +165,14 @@ def me():
try:
payload = jwt.decode(token, current_app.config['SECRET_KEY'], algorithms=['HS256'])
email = payload.get('email')
user_dict = users_db.get(UserQuery.email == email)
user_id = payload.get('user_id', '')
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
return jsonify({
'email': user.email,
'id': sanitize_email(user.email),
'id': user_id,
'first_name': user.first_name,
'last_name': user.last_name,
'verified': user.verified
@@ -253,3 +254,10 @@ def reset_password():
users_db.update(user.to_dict(), UserQuery.email == user.email)
return jsonify({'message': 'Password has been reset'}), 200
@auth_api.route('/logout', methods=['POST'])
def logout():
resp = jsonify({'message': 'Logged out'})
# Remove the token cookie by setting it to empty and expiring it
resp.set_cookie('token', '', expires=0, httponly=True, secure=True, samesite='Strict')
return resp, 200

View File

@@ -21,6 +21,7 @@ from models.child import Child
from models.pending_reward import PendingReward
from models.reward import Reward
from models.task import Task
from api.utils import get_validated_user_id
import logging
child_api = Blueprint('child_api', __name__)
@@ -29,14 +30,20 @@ logger = logging.getLogger(__name__)
@child_api.route('/child/<name>', methods=['GET'])
@child_api.route('/child/<id>', methods=['GET'])
def get_child(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
ChildQuery = Query()
result = child_db.search(ChildQuery.id == id)
result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id))
if not result:
return jsonify({'error': 'Child not found'}), 404
return jsonify(Child.from_dict(result[0]).to_dict()), 200
@child_api.route('/child/add', methods=['PUT'])
def add_child():
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
data = request.get_json()
name = data.get('name')
age = data.get('age')
@@ -46,7 +53,7 @@ def add_child():
if not image:
image = 'boy01'
child = Child(name=name, age=age, image_id=image)
child = Child(name=name, age=age, image_id=image, user_id=user_id)
child_db.insert(child.to_dict())
resp = send_event_for_current_user(
Event(EventType.CHILD_MODIFIED.value, ChildModified(child.id, ChildModified.OPERATION_ADD)))
@@ -56,13 +63,16 @@ def add_child():
@child_api.route('/child/<id>/edit', methods=['PUT'])
def edit_child(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
data = request.get_json()
name = data.get('name', None)
age = data.get('age', None)
points = data.get('points', None)
image = data.get('image_id', None)
ChildQuery = Query()
result = child_db.search(ChildQuery.id == id)
result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id))
if not result:
return jsonify({'error': 'Child not found'}), 404
child = Child.from_dict(result[0])
@@ -79,18 +89,18 @@ def edit_child(id):
# 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)
pending_rewards = pending_reward_db.search((PendingQuery.child_id == id) & (PendingQuery.user_id == user_id))
RewardQuery = Query()
for pr in pending_rewards:
pending = PendingReward.from_dict(pr)
reward_result = reward_db.get(RewardQuery.id == pending.reward_id)
reward_result = reward_db.get((RewardQuery.id == pending.reward_id) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None)))
if reward_result:
reward = Reward.from_dict(reward_result)
# If child can no longer afford the reward, remove the pending request
if child.points < reward.cost:
pending_reward_db.remove(
(PendingQuery.child_id == id) & (PendingQuery.reward_id == reward.id)
(PendingQuery.child_id == id) & (PendingQuery.reward_id == reward.id) & (PendingQuery.user_id == user_id)
)
resp = send_event_for_current_user(
Event(EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest(id, reward.id, ChildRewardRequest.REQUEST_CANCELLED)))
@@ -104,14 +114,21 @@ def edit_child(id):
@child_api.route('/child/list', methods=['GET'])
def list_children():
children = child_db.all()
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
ChildQuery = Query()
children = child_db.search(ChildQuery.user_id == user_id)
return jsonify({'children': children}), 200
# Child DELETE
@child_api.route('/child/<id>', methods=['DELETE'])
def delete_child(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
ChildQuery = Query()
if child_db.remove(ChildQuery.id == id):
if child_db.remove((ChildQuery.id == id) & (ChildQuery.user_id == user_id)):
resp = send_event_for_current_user(Event(EventType.CHILD_MODIFIED.value, ChildModified(id, ChildModified.OPERATION_DELETE)))
if resp:
return resp
@@ -120,13 +137,16 @@ def delete_child(id):
@child_api.route('/child/<id>/assign-task', methods=['POST'])
def assign_task_to_child(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
data = request.get_json()
task_id = data.get('task_id')
if not task_id:
return jsonify({'error': 'task_id is required'}), 400
ChildQuery = Query()
result = child_db.search(ChildQuery.id == id)
result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id))
if not result:
return jsonify({'error': 'Child not found'}), 404
@@ -139,6 +159,9 @@ def assign_task_to_child(id):
# python
@child_api.route('/child/<id>/set-tasks', methods=['PUT'])
def set_child_tasks(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
data = request.get_json() or {}
task_ids = data.get('task_ids')
if 'type' not in data:
@@ -151,7 +174,7 @@ def set_child_tasks(id):
return jsonify({'error': 'task_ids must be a list'}), 400
ChildQuery = Query()
result = child_db.search(ChildQuery.id == id)
result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id))
if not result:
return jsonify({'error': 'Child not found'}), 404
child = Child.from_dict(result[0])
@@ -179,13 +202,16 @@ def set_child_tasks(id):
@child_api.route('/child/<id>/remove-task', methods=['POST'])
def remove_task_from_child(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
data = request.get_json()
task_id = data.get('task_id')
if not task_id:
return jsonify({'error': 'task_id is required'}), 400
ChildQuery = Query()
result = child_db.search(ChildQuery.id == id)
result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id))
if not result:
return jsonify({'error': 'Child not found'}), 404
@@ -198,8 +224,11 @@ def remove_task_from_child(id):
@child_api.route('/child/<id>/list-tasks', methods=['GET'])
def list_child_tasks(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
ChildQuery = Query()
result = child_db.search(ChildQuery.id == id)
result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id))
if not result:
return jsonify({'error': 'Child not found'}), 404
@@ -209,7 +238,7 @@ def list_child_tasks(id):
TaskQuery = Query()
child_tasks = []
for tid in task_ids:
task = task_db.get(TaskQuery.id == tid)
task = task_db.get((TaskQuery.id == tid) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)))
if not task:
continue
ct = ChildTask(task.get('name'), task.get('is_good'), task.get('points'), task.get('image_id'), task.get('id'))
@@ -219,8 +248,11 @@ def list_child_tasks(id):
@child_api.route('/child/<id>/list-assignable-tasks', methods=['GET'])
def list_assignable_tasks(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
ChildQuery = Query()
result = child_db.search(ChildQuery.id == id)
result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id))
if not result:
return jsonify({'error': 'Child not found'}), 404
@@ -237,7 +269,7 @@ def list_assignable_tasks(id):
TaskQuery = Query()
assignable_tasks = []
for tid in assignable_ids:
task = task_db.get(TaskQuery.id == tid)
task = task_db.get((TaskQuery.id == tid) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)))
if not task:
continue
ct = ChildTask(task.get('name'), task.get('is_good'), task.get('points'), task.get('image_id'), task.get('id'))
@@ -248,8 +280,11 @@ def list_assignable_tasks(id):
@child_api.route('/child/<id>/list-all-tasks', methods=['GET'])
def list_all_tasks(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
ChildQuery = Query()
result = child_db.search(ChildQuery.id == id)
result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id))
if not result:
return jsonify({'error': 'Child not found'}), 404
has_type = "type" in request.args
@@ -261,14 +296,12 @@ def list_all_tasks(id):
assigned_ids = set(child.get('tasks', []))
# Get all tasks from database
all_tasks = task_db.all()
ChildTaskQuery = Query()
all_tasks = task_db.search((ChildTaskQuery.user_id == user_id) | (ChildTaskQuery.user_id == None))
tasks = []
for task in all_tasks:
if not task or not task.get('id'):
continue
ct = ChildTask(
task.get('name'),
task.get('is_good'),
@@ -289,13 +322,16 @@ def list_all_tasks(id):
@child_api.route('/child/<id>/trigger-task', methods=['POST'])
def trigger_child_task(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
data = request.get_json()
task_id = data.get('task_id')
if not task_id:
return jsonify({'error': 'task_id is required'}), 400
ChildQuery = Query()
result = child_db.search(ChildQuery.id == id)
result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id))
if not result:
return jsonify({'error': 'Child not found'}), 404
@@ -304,7 +340,7 @@ def trigger_child_task(id):
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)
task_result = task_db.search((TaskQuery.id == task_id) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)))
if not task_result:
return jsonify({'error': 'Task not found in task database'}), 404
task: Task = Task.from_dict(task_result[0])
@@ -323,13 +359,16 @@ def trigger_child_task(id):
@child_api.route('/child/<id>/assign-reward', methods=['POST'])
def assign_reward_to_child(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
data = request.get_json()
reward_id = data.get('reward_id')
if not reward_id:
return jsonify({'error': 'reward_id is required'}), 400
ChildQuery = Query()
result = child_db.search(ChildQuery.id == id)
result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id))
if not result:
return jsonify({'error': 'Child not found'}), 404
@@ -341,22 +380,23 @@ def assign_reward_to_child(id):
@child_api.route('/child/<id>/list-all-rewards', methods=['GET'])
def list_all_rewards(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
ChildQuery = Query()
result = child_db.search(ChildQuery.id == id)
result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id))
if not result:
return jsonify({'error': 'Child not found'}), 404
child = result[0]
assigned_ids = set(child.get('rewards', []))
child = Child.from_dict(result[0])
assigned_ids = set(child.rewards)
# Get all rewards from database
all_rewards = reward_db.all()
ChildRewardQuery = Query()
all_rewards = reward_db.search((ChildRewardQuery.user_id == user_id) | (ChildRewardQuery.user_id == None))
rewards = []
for reward in all_rewards:
if not reward or not reward.get('id'):
continue
cr = ChildReward(
reward.get('name'),
reward.get('cost'),
@@ -379,6 +419,9 @@ def list_all_rewards(id):
@child_api.route('/child/<id>/set-rewards', methods=['PUT'])
def set_child_rewards(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
data = request.get_json() or {}
reward_ids = data.get('reward_ids')
if not isinstance(reward_ids, list):
@@ -388,7 +431,7 @@ def set_child_rewards(id):
new_reward_ids = [rid for rid in dict.fromkeys(reward_ids) if rid]
ChildQuery = Query()
result = child_db.search(ChildQuery.id == id)
result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id))
if not result:
return jsonify({'error': 'Child not found'}), 404
@@ -396,7 +439,7 @@ def set_child_rewards(id):
RewardQuery = Query()
valid_reward_ids = []
for rid in new_reward_ids:
if reward_db.get(RewardQuery.id == rid):
if reward_db.get((RewardQuery.id == rid) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None))):
valid_reward_ids.append(rid)
# Replace rewards with validated IDs
@@ -411,13 +454,16 @@ def set_child_rewards(id):
@child_api.route('/child/<id>/remove-reward', methods=['POST'])
def remove_reward_from_child(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
data = request.get_json()
reward_id = data.get('reward_id')
if not reward_id:
return jsonify({'error': 'reward_id is required'}), 400
ChildQuery = Query()
result = child_db.search(ChildQuery.id == id)
result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id))
if not result:
return jsonify({'error': 'Child not found'}), 404
@@ -430,8 +476,11 @@ def remove_reward_from_child(id):
@child_api.route('/child/<id>/list-rewards', methods=['GET'])
def list_child_rewards(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
ChildQuery = Query()
result = child_db.search(ChildQuery.id == id)
result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id))
if not result:
return jsonify({'error': 'Child not found'}), 404
@@ -441,7 +490,7 @@ def list_child_rewards(id):
RewardQuery = Query()
child_rewards = []
for rid in reward_ids:
reward = reward_db.get(RewardQuery.id == rid)
reward = reward_db.get((RewardQuery.id == rid) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None)))
if not reward:
continue
cr = ChildReward(reward.get('name'), reward.get('cost'), reward.get('image_id'), reward.get('id'))
@@ -451,8 +500,11 @@ def list_child_rewards(id):
@child_api.route('/child/<id>/list-assignable-rewards', methods=['GET'])
def list_assignable_rewards(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
ChildQuery = Query()
result = child_db.search(ChildQuery.id == id)
result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id))
if not result:
return jsonify({'error': 'Child not found'}), 404
@@ -465,7 +517,7 @@ def list_assignable_rewards(id):
RewardQuery = Query()
assignable_rewards = []
for rid in assignable_ids:
reward = reward_db.get(RewardQuery.id == rid)
reward = reward_db.get((RewardQuery.id == rid) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None)))
if not reward:
continue
cr = ChildReward(reward.get('name'), reward.get('cost'), reward.get('image_id'), reward.get('id'))
@@ -475,13 +527,16 @@ def list_assignable_rewards(id):
@child_api.route('/child/<id>/trigger-reward', methods=['POST'])
def trigger_child_reward(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
data = request.get_json()
reward_id = data.get('reward_id')
if not reward_id:
return jsonify({'error': 'reward_id is required'}), 400
ChildQuery = Query()
result = child_db.search(ChildQuery.id == id)
result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id))
if not result:
return jsonify({'error': 'Child not found'}), 404
@@ -490,7 +545,7 @@ def trigger_child_reward(id):
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)
reward_result = reward_db.search((RewardQuery.id == reward_id) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None)))
if not reward_result:
return jsonify({'error': 'Reward not found in reward database'}), 404
reward: Reward = Reward.from_dict(reward_result[0])
@@ -523,8 +578,11 @@ def trigger_child_reward(id):
@child_api.route('/child/<id>/affordable-rewards', methods=['GET'])
def list_affordable_rewards(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
ChildQuery = Query()
result = child_db.search(ChildQuery.id == id)
result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id))
if not result:
return jsonify({'error': 'Child not found'}), 404
@@ -534,14 +592,17 @@ def list_affordable_rewards(id):
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
if (reward := reward_db.get((RewardQuery.id == reward_id) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None)))) and points >= Reward.from_dict(reward).cost
]
return jsonify({'affordable_rewards': affordable}), 200
@child_api.route('/child/<id>/reward-status', methods=['GET'])
def reward_status(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
ChildQuery = Query()
result = child_db.search(ChildQuery.id == id)
result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id))
if not result:
return jsonify({'error': 'Child not found'}), 404
@@ -552,13 +613,13 @@ def reward_status(id):
RewardQuery = Query()
statuses = []
for reward_id in reward_ids:
reward: Reward = Reward.from_dict(reward_db.get(RewardQuery.id == reward_id))
reward: Reward = Reward.from_dict(reward_db.get((RewardQuery.id == reward_id) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None))))
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))
pending = pending_reward_db.get((pending_query.child_id == child.id) & (pending_query.reward_id == reward.id) & (pending_query.user_id == user_id))
status = RewardStatus(reward.id, reward.name, points_needed, reward.cost, pending is not None, reward.image_id)
statuses.append(status.to_dict())
@@ -568,13 +629,16 @@ def reward_status(id):
@child_api.route('/child/<id>/request-reward', methods=['POST'])
def request_reward(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
data = request.get_json()
reward_id = data.get('reward_id')
if not reward_id:
return jsonify({'error': 'reward_id is required'}), 400
ChildQuery = Query()
result = child_db.search(ChildQuery.id == id)
result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id))
if not result:
return jsonify({'error': 'Child not found'}), 404
@@ -600,7 +664,7 @@ def request_reward(id):
'reward_cost': reward.cost
}), 400
pending = PendingReward(child_id=child.id, reward_id=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}')
send_event_for_current_user(Event(EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest(child.id, reward.id, ChildRewardRequest.REQUEST_CREATED)))
@@ -615,13 +679,16 @@ def request_reward(id):
@child_api.route('/child/<id>/cancel-request-reward', methods=['POST'])
def cancel_request_reward(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
data = request.get_json()
reward_id = data.get('reward_id')
if not reward_id:
return jsonify({'error': 'reward_id is required'}), 400
ChildQuery = Query()
result = child_db.search(ChildQuery.id == id)
result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id))
if not result:
return jsonify({'error': 'Child not found'}), 404
@@ -630,7 +697,7 @@ def cancel_request_reward(id):
# Remove matching pending reward request
PendingQuery = Query()
removed = pending_reward_db.remove(
(PendingQuery.child_id == child.id) & (PendingQuery.reward_id == reward_id)
(PendingQuery.child_id == child.id) & (PendingQuery.reward_id == reward_id) & (PendingQuery.user_id == user_id)
)
if not removed:
@@ -651,7 +718,11 @@ def cancel_request_reward(id):
@child_api.route('/pending-rewards', methods=['GET'])
def list_pending_rewards():
pending_rewards = pending_reward_db.all()
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
PendingQuery = Query()
pending_rewards = pending_reward_db.search(PendingQuery.user_id == user_id)
reward_responses = []
RewardQuery = Query()
@@ -661,7 +732,7 @@ def list_pending_rewards():
pending = PendingReward.from_dict(pr)
# Look up reward details
reward_result = reward_db.get(RewardQuery.id == pending.reward_id)
reward_result = reward_db.get((RewardQuery.id == pending.reward_id) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None)))
if not reward_result:
continue
reward = Reward.from_dict(reward_result)

View File

@@ -4,7 +4,7 @@ from PIL import Image as PILImage, UnidentifiedImageError
from flask import Blueprint, request, jsonify, send_file
from tinydb import Query
from api.utils import get_current_user_id, sanitize_email
from api.utils import get_current_user_id, sanitize_email, get_validated_user_id
from config.paths import get_user_image_dir
from db.db import image_db
@@ -21,9 +21,9 @@ def allowed_file(filename):
@image_api.route('/image/upload', methods=['POST'])
def upload():
user_id = get_current_user_id()
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'User not authenticated'}), 401
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
if 'file' not in request.files:
return jsonify({'error': 'No file part in the request'}), 400
file = request.files['file']
@@ -64,8 +64,11 @@ def upload():
format_extension_map = {'JPEG': '.jpg', 'PNG': '.png'}
extension = format_extension_map.get(original_format, '.png')
image_record = Image(extension=extension, permanent=perm, type=image_type, user=user_id)
image_record = Image(extension=extension, permanent=perm, type=image_type, user_id=user_id)
filename = image_record.id + extension
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:
@@ -84,25 +87,35 @@ def upload():
@image_api.route('/image/request/<id>', methods=['GET'])
def request_image(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
ImageQuery = Query()
image: Image = Image.from_dict(image_db.get(ImageQuery.id == id))
if not image:
image_record = image_db.get(ImageQuery.id == id)
if not image_record:
return jsonify({'error': 'Image not found'}), 404
image = Image.from_dict(image_record)
# Allow if image.user_id is None (public image), or matches user_id
if image.user_id is not None and image.user_id != user_id:
return jsonify({'error': 'Forbidden: image does not belong to user', 'code': 'FORBIDDEN'}), 403
filename = f"{image.id}{image.extension}"
filepath = os.path.abspath(os.path.join(get_user_image_dir(image.user), filename))
filepath = os.path.abspath(os.path.join(get_user_image_dir(image.user_id or user_id), filename))
if not os.path.exists(filepath):
return jsonify({'error': 'File not found'}), 404
return send_file(filepath)
@image_api.route('/image/list', methods=['GET'])
def list_images():
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
image_type = request.args.get('type', type=int)
ImageQuery = Query()
if image_type is not None:
if image_type not in [IMAGE_TYPE_PROFILE, IMAGE_TYPE_ICON]:
return jsonify({'error': 'Invalid image type'}), 400
images = image_db.search(ImageQuery.type == image_type)
images = image_db.search((ImageQuery.type == image_type) & ((ImageQuery.user_id == user_id) | (ImageQuery.user_id == None)))
else:
images = image_db.all()
images = image_db.search((ImageQuery.user_id == user_id) | (ImageQuery.user_id == None))
image_ids = [img['id'] for img in images]
return jsonify({'ids': image_ids, 'count': len(image_ids)}), 200

View File

@@ -1,7 +1,7 @@
from flask import Blueprint, request, jsonify
from tinydb import Query
from api.utils import send_event_for_current_user
from api.utils import send_event_for_current_user, get_validated_user_id
from backend.events.types.child_rewards_set import ChildRewardsSet
from db.db import reward_db, child_db
from events.types.event import Event
@@ -14,6 +14,9 @@ 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')
@@ -21,7 +24,7 @@ def add_reward():
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 = 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
@@ -30,28 +33,46 @@ def add_reward():
@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)
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')
rewards = reward_db.all()
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]
return jsonify({'rewards': rewards}), 200
# 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)
return jsonify({'rewards': filtered_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()
removed = reward_db.remove(RewardQuery.id == id)
removed = reward_db.remove((RewardQuery.id == id) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None)))
if removed:
# remove the reward id from any child's reward list
ChildQuery = Query()
@@ -67,25 +88,31 @@ def delete_reward(id):
@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)
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
data = request.get_json(force=True) or {}
updates = {}
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
updates['name'] = name
reward.name = name
is_dirty = True
if 'description' in data:
desc = (data.get('description') or '').strip()
if not desc:
return jsonify({'error': 'Description cannot be empty'}), 400
updates['description'] = desc
reward.description = desc
is_dirty = True
if 'cost' in data:
cost = data.get('cost')
@@ -93,17 +120,24 @@ def edit_reward(id):
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
reward.cost = cost
is_dirty = True
if 'image_id' in data:
updates['image_id'] = data.get('image_id', '')
reward.image_id = data.get('image_id', '')
is_dirty = True
if not updates:
if not is_dirty:
return jsonify({'error': 'No valid fields to update'}), 400
reward_db.update(updates, RewardQuery.id == id)
updated = reward_db.get(RewardQuery.id == id)
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(updated), 200
return jsonify(reward.to_dict()), 200

View File

@@ -1,7 +1,7 @@
from flask import Blueprint, request, jsonify
from tinydb import Query
from api.utils import send_event_for_current_user
from api.utils import send_event_for_current_user, get_validated_user_id
from backend.events.types.child_tasks_set import ChildTasksSet
from db.db import task_db, child_db
from events.types.event import Event
@@ -14,6 +14,9 @@ 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')
@@ -21,7 +24,7 @@ def add_task():
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 = Task(name=name, points=points, is_good=is_good, image_id=image, user_id=user_id)
task_db.insert(task.to_dict())
send_event_for_current_user(Event(EventType.TASK_MODIFIED.value,
TaskModified(task.id, TaskModified.OPERATION_ADD)))
@@ -29,28 +32,45 @@ def add_task():
@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)
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')
tasks = task_db.all()
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]
return jsonify({'tasks': tasks}), 200
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)
return jsonify({'tasks': filtered_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()
removed = task_db.remove(TaskQuery.id == id)
removed = task_db.remove((TaskQuery.id == id) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)))
if removed:
# remove the task id from any child's task list
ChildQuery = Query()
@@ -66,11 +86,17 @@ def delete_task(id):
@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)
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 = {}
@@ -78,7 +104,8 @@ def edit_task(id):
name = data.get('name', '').strip()
if not name:
return jsonify({'error': 'Name cannot be empty'}), 400
updates['name'] = name
task.name = name
is_dirty = True
if 'points' in data:
points = data.get('points')
@@ -86,22 +113,32 @@ def edit_task(id):
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
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
updates['is_good'] = is_good
task.is_good = is_good
is_dirty = True
if 'image_id' in data:
updates['image_id'] = data.get('image_id', '')
task.image_id = data.get('image_id', '')
is_dirty = True
if not updates:
if not is_dirty:
return jsonify({'error': 'No valid fields to update'}), 400
if task.user_id is None: # public task
new_task = Task(name=task.name, points=task.points, is_good=task.is_good, image_id=task.image_id, user_id=user_id)
task_db.insert(new_task.to_dict())
send_event_for_current_user(Event(EventType.TASK_MODIFIED.value,
TaskModified(new_task.id, TaskModified.OPERATION_ADD)))
task_db.update(updates, TaskQuery.id == id)
updated = task_db.get(TaskQuery.id == id)
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(updated), 200
return jsonify(task.to_dict()), 200

View File

@@ -8,6 +8,7 @@ import string
import smtplib
from backend.utils.email_instance import email_sender
from datetime import datetime, timedelta
from api.utils import get_validated_user_id
user_api = Blueprint('user_api', __name__)
UserQuery = Query()
@@ -18,14 +19,17 @@ def get_current_user():
return None
try:
payload = jwt.decode(token, current_app.config['SECRET_KEY'], algorithms=['HS256'])
email = payload.get('email')
user_dict = users_db.get(UserQuery.email == email)
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
@@ -38,6 +42,9 @@ def get_profile():
@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
@@ -57,6 +64,9 @@ def update_profile():
@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
@@ -70,6 +80,9 @@ def update_image():
@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
@@ -83,6 +96,9 @@ def check_pin():
@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
@@ -90,6 +106,9 @@ def has_pin():
@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
@@ -108,6 +127,9 @@ def 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
@@ -127,6 +149,9 @@ def verify_pin_setup():
@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

View File

@@ -1,5 +1,7 @@
import jwt
import re
from db.db import users_db
from tinydb import Query
from flask import request, current_app, jsonify
from events.sse import send_event_to_user
@@ -24,12 +26,18 @@ def get_current_user_id():
return None
try:
payload = jwt.decode(token, current_app.config['SECRET_KEY'], algorithms=['HS256'])
email = payload.get('email')
if not email:
user_id = payload.get('user_id')
if not user_id:
return None
return sanitize_email(email)
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()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -48,6 +48,7 @@ function handleRewardTriggered(event: Event) {
const payload = event.payload as ChildRewardTriggeredEventPayload
if (child.value && payload.child_id == child.value.id) {
child.value.points = payload.points
childRewardListRef.value?.refresh()
}
}

View File

@@ -113,6 +113,7 @@ function onAddImage({ id, file }: { id: string; file: File }) {
async function updateAvatar(imageId: string) {
errorMsg.value = ''
successMsg.value = ''
//todo update avatar loading state
try {
const res = await fetch('/api/user/avatar', {
method: 'PUT',
@@ -123,7 +124,9 @@ async function updateAvatar(imageId: string) {
initialData.value.image_id = imageId
successMsg.value = 'Avatar updated!'
} catch {
errorMsg.value = 'Failed to update avatar.'
//errorMsg.value = 'Failed to update avatar.'
//todo update avatar error handling
errorMsg.value = ''
}
}

View File

@@ -2,7 +2,12 @@
import { ref, nextTick, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { eventBus } from '@/common/eventBus'
import { authenticateParent, isParentAuthenticated, logoutParent } from '../../stores/auth'
import {
authenticateParent,
isParentAuthenticated,
logoutParent,
logoutUser,
} from '../../stores/auth'
import '@/assets/styles.css'
import '@/assets/colors.css'
import ModalDialog from './ModalDialog.vue'
@@ -91,7 +96,7 @@ function closeDropdown() {
async function signOut() {
try {
await fetch('/api/logout', { method: 'POST' })
logoutParent()
logoutUser()
router.push('/auth')
} catch {
// Optionally show error

View File

@@ -24,6 +24,8 @@ export function loginUser() {
export function logoutUser() {
isUserLoggedIn.value = false
currentUserId.value = ''
logoutParent()
}
export async function checkAuth() {