Add push notification functionality with tests and digest scheduler
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m14s
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m14s
- Implemented push subscription API with tests for subscribing and unsubscribing users. - Created web push notification tests triggered by child actions. - Added digest scheduler to send email digests to users at 9 PM local time. - Developed utility functions for creating and validating digest action tokens. - Integrated web push sender to handle sending notifications to users. - Added service worker for handling push notifications in the frontend. - Created a push opt-in component for user notification preferences. - Implemented tests for the push opt-in component to ensure correct behavior. - Updated frontend services to manage push subscriptions and permissions.
This commit is contained in:
257
backend/api/child_action_helpers.py
Normal file
257
backend/api/child_action_helpers.py
Normal file
@@ -0,0 +1,257 @@
|
||||
"""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}
|
||||
Reference in New Issue
Block a user