"""Shared business logic for chore confirmation and reward actions. Called from both child_api.py (JWT-authenticated endpoints) and digest_action_api.py (token-authenticated endpoints). All functions take user_id explicitly rather than reading it from the Flask request context. """ import logging from datetime import datetime, timezone from tinydb import Query from db.db import child_db, task_db, reward_db, pending_confirmations_db from db.child_overrides import get_override from db.chore_schedules import get_schedule from db.tracking import insert_tracking_event from events.sse import send_event_to_user from events.types.child_chore_confirmation import ChildChoreConfirmation from events.types.child_reward_request import ChildRewardRequest from events.types.child_reward_triggered import ChildRewardTriggered from events.types.child_task_triggered import ChildTaskTriggered from events.types.tracking_event_created import TrackingEventCreated from events.types.event import Event from events.types.event_types import EventType from models.child import Child from models.reward import Reward from models.task import Task from models.tracking_event import TrackingEvent from utils.tracking_logger import log_tracking_event logger = logging.getLogger(__name__) def approve_chore(user_id: str, child_id: str, task_id: str) -> dict | None: """Award points for a completed chore and mark the pending confirmation approved. Returns a result dict on success, or None if the confirmation was already resolved. Raises ValueError if the child or task cannot be found. """ ChildQ = Query() child_result = child_db.get((ChildQ.id == child_id) & (ChildQ.user_id == user_id)) if not child_result: raise ValueError(f'Child {child_id} not found for user {user_id}') child = Child.from_dict(child_result) PendingQ = Query() existing = pending_confirmations_db.get( (PendingQ.child_id == child_id) & (PendingQ.entity_id == task_id) & (PendingQ.entity_type == 'chore') & (PendingQ.status == 'pending') & (PendingQ.user_id == user_id) ) if not existing: logger.info(f'No pending chore for child {child_id}, task {task_id} — already resolved') return None TaskQ = Query() task_result = task_db.get( (TaskQ.id == task_id) & ((TaskQ.user_id == user_id) | (TaskQ.user_id == None)) ) if not task_result: raise ValueError(f'Task {task_id} not found') task = Task.from_dict(task_result) override = get_override(child_id, task_id) points_value = override.custom_value if override else task.points points_before = child.points child.points += points_value child_db.update({'points': child.points}, ChildQ.id == child_id) schedule = get_schedule(child_id, task_id) if schedule: now_str = datetime.now(timezone.utc).isoformat() pending_confirmations_db.update( {'status': 'approved', 'approved_at': now_str}, (PendingQ.child_id == child_id) & (PendingQ.entity_id == task_id) & (PendingQ.entity_type == 'chore') & (PendingQ.user_id == user_id) ) else: pending_confirmations_db.remove( (PendingQ.child_id == child_id) & (PendingQ.entity_id == task_id) & (PendingQ.entity_type == 'chore') & (PendingQ.user_id == user_id) ) tracking_metadata = { 'task_name': task.name, 'task_type': task.type, 'default_points': task.points, } if override: tracking_metadata['custom_points'] = override.custom_value tracking_metadata['has_override'] = True tracking_event = TrackingEvent.create_event( user_id=user_id, child_id=child_id, entity_type='chore', entity_id=task_id, action='approved', points_before=points_before, points_after=child.points, metadata=tracking_metadata, ) insert_tracking_event(tracking_event) log_tracking_event(tracking_event) send_event_to_user(user_id, Event(EventType.TRACKING_EVENT_CREATED.value, TrackingEventCreated(tracking_event.id, child_id, 'chore', 'approved'))) send_event_to_user(user_id, Event(EventType.CHILD_CHORE_CONFIRMATION.value, ChildChoreConfirmation(child_id, task_id, ChildChoreConfirmation.OPERATION_APPROVED))) send_event_to_user(user_id, Event(EventType.CHILD_TASK_TRIGGERED.value, ChildTaskTriggered(task_id, child_id, child.points))) return {'task_name': task.name, 'child_name': child.name, 'child_id': child_id, 'points': child.points} def reject_chore(user_id: str, child_id: str, task_id: str) -> None: """Reject a pending chore confirmation. No-op if already resolved.""" PendingQ = Query() existing = pending_confirmations_db.get( (PendingQ.child_id == child_id) & (PendingQ.entity_id == task_id) & (PendingQ.entity_type == 'chore') & (PendingQ.status == 'pending') & (PendingQ.user_id == user_id) ) if not existing: logger.info(f'No pending chore for child {child_id}, task {task_id} — already resolved') return pending_confirmations_db.remove( (PendingQ.child_id == child_id) & (PendingQ.entity_id == task_id) & (PendingQ.entity_type == 'chore') & (PendingQ.user_id == user_id) ) ChildQ = Query() child_result = child_db.get((ChildQ.id == child_id) & (ChildQ.user_id == user_id)) if child_result: child = Child.from_dict(child_result) TaskQ = Query() task_result = task_db.get( (TaskQ.id == task_id) & ((TaskQ.user_id == user_id) | (TaskQ.user_id == None)) ) task_name = task_result.get('name') if task_result else 'Unknown' tracking_event = TrackingEvent.create_event( user_id=user_id, child_id=child_id, entity_type='chore', entity_id=task_id, action='rejected', points_before=child.points, points_after=child.points, metadata={'task_name': task_name}, ) insert_tracking_event(tracking_event) log_tracking_event(tracking_event) send_event_to_user(user_id, Event(EventType.TRACKING_EVENT_CREATED.value, TrackingEventCreated(tracking_event.id, child_id, 'chore', 'rejected'))) send_event_to_user(user_id, Event(EventType.CHILD_CHORE_CONFIRMATION.value, ChildChoreConfirmation(child_id, task_id, ChildChoreConfirmation.OPERATION_REJECTED))) def approve_reward_request(user_id: str, child_id: str, reward_id: str) -> dict: """Approve a child's pending reward request: deduct points and fire SSE events. Returns a result dict on success. Raises ValueError if child/reward not found or the child has insufficient points. """ ChildQ = Query() child_result = child_db.get((ChildQ.id == child_id) & (ChildQ.user_id == user_id)) if not child_result: raise ValueError(f'Child {child_id} not found for user {user_id}') child = Child.from_dict(child_result) RewardQ = Query() reward_result = reward_db.get( (RewardQ.id == reward_id) & ((RewardQ.user_id == user_id) | (RewardQ.user_id == None)) ) if not reward_result: raise ValueError(f'Reward {reward_id} not found') reward = Reward.from_dict(reward_result) override = get_override(child_id, reward_id) cost_value = override.custom_value if override else reward.cost if child.points < cost_value: raise ValueError(f'Child {child_id} has insufficient points for reward {reward_id}') PendingQ = Query() removed = pending_confirmations_db.remove( (PendingQ.child_id == child_id) & (PendingQ.entity_id == reward_id) & (PendingQ.entity_type == 'reward') ) if removed: send_event_to_user(user_id, Event(EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest(child_id, reward_id, ChildRewardRequest.REQUEST_GRANTED))) points_before = child.points child.points -= cost_value child_db.update({'points': child.points}, ChildQ.id == child_id) tracking_metadata = { 'reward_name': reward.name, 'reward_cost': reward.cost, 'default_cost': reward.cost, } if override: tracking_metadata['custom_cost'] = override.custom_value tracking_metadata['has_override'] = True tracking_event = TrackingEvent.create_event( user_id=user_id, child_id=child_id, entity_type='reward', entity_id=reward_id, action='redeemed', points_before=points_before, points_after=child.points, metadata=tracking_metadata, ) insert_tracking_event(tracking_event) log_tracking_event(tracking_event) send_event_to_user(user_id, Event(EventType.TRACKING_EVENT_CREATED.value, TrackingEventCreated(tracking_event.id, child_id, 'reward', 'redeemed'))) send_event_to_user(user_id, Event(EventType.CHILD_REWARD_TRIGGERED.value, ChildRewardTriggered(reward_id, child_id, child.points))) return {'reward_name': reward.name, 'child_name': child.name, 'child_id': child_id, 'points': child.points} def deny_reward(user_id: str, child_id: str, reward_id: str) -> dict | None: """Deny a child's pending reward request. No-op if already resolved. Returns a result dict on success, or None if already resolved. """ PendingQ = Query() existing = pending_confirmations_db.get( (PendingQ.child_id == child_id) & (PendingQ.entity_id == reward_id) & (PendingQ.entity_type == 'reward') & (PendingQ.status == 'pending') & (PendingQ.user_id == user_id) ) if not existing: logger.info(f'Reward request for child {child_id}, reward {reward_id} already resolved') return None pending_confirmations_db.remove( (PendingQ.child_id == child_id) & (PendingQ.entity_id == reward_id) & (PendingQ.entity_type == 'reward') & (PendingQ.user_id == user_id) ) ChildQ = Query() child_result = child_db.get((ChildQ.id == child_id) & (ChildQ.user_id == user_id)) child_name = 'Unknown' if child_result: child = Child.from_dict(child_result) child_name = child.name tracking_event = TrackingEvent.create_event( user_id=user_id, child_id=child_id, entity_type='reward', entity_id=reward_id, action='denied', points_before=child.points, points_after=child.points, metadata={}, ) insert_tracking_event(tracking_event) log_tracking_event(tracking_event) send_event_to_user(user_id, Event(EventType.TRACKING_EVENT_CREATED.value, TrackingEventCreated(tracking_event.id, child_id, 'reward', 'denied'))) send_event_to_user(user_id, Event(EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest(child_id, reward_id, ChildRewardRequest.REQUEST_CANCELLED))) return {'child_name': child_name}