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

@@ -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()