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

- 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:
2026-04-15 21:56:10 -04:00
parent 0d50a324a3
commit ad2bdf4c4f
47 changed files with 3177 additions and 197 deletions

View File

@@ -9,6 +9,7 @@ from api.child_tasks import ChildTask
from api.pending_confirmation import PendingConfirmationResponse
from api.reward_status import RewardStatus
from api.utils import send_event_for_current_user, get_validated_user_id
import api.child_action_helpers as chore_actions
from db.db import child_db, task_db, reward_db, pending_reward_db, pending_confirmations_db
from db.tracking import insert_tracking_event
from db.child_overrides import get_override, delete_override, delete_overrides_for_child
@@ -29,6 +30,8 @@ from models.reward import Reward
from models.task import Task
from models.tracking_event import TrackingEvent
from utils.tracking_logger import log_tracking_event
from utils.push_sender import send_push_to_user
from utils.digest_token import create_action_token
from collections import defaultdict
from db.chore_schedules import get_schedule
from db.task_extensions import get_extension_for_child_task
@@ -330,7 +333,6 @@ def list_assignable_tasks(id):
all_tasks = [t for t in task_db.all() if t and t.get('id') and t.get('id') not in assigned_ids]
# Group by name
from collections import defaultdict
name_to_tasks = defaultdict(list)
for t in all_tasks:
name_to_tasks[t.get('name')].append(t)
@@ -539,7 +541,6 @@ def list_all_rewards(id):
ChildRewardQuery = Query()
all_rewards = reward_db.search((ChildRewardQuery.user_id == user_id) | (ChildRewardQuery.user_id == None))
from collections import defaultdict
name_to_rewards = defaultdict(list)
for r in all_rewards:
name_to_rewards[r.get('name')].append(r)
@@ -691,7 +692,6 @@ def list_assignable_rewards(id):
all_rewards = [r for r in reward_db.all() if r and r.get('id') and r.get('id') not in assigned_ids]
# Group by name
from collections import defaultdict
name_to_rewards = defaultdict(list)
for r in all_rewards:
name_to_rewards[r.get('name')].append(r)
@@ -895,6 +895,16 @@ def request_reward(id):
'reward_cost': reward.cost
}), 400
# Check for duplicate pending request
DupQuery = Query()
duplicate = pending_confirmations_db.get(
(DupQuery.child_id == child.id) & (DupQuery.entity_id == reward.id) &
(DupQuery.entity_type == 'reward') & (DupQuery.status == 'pending') &
(DupQuery.user_id == user_id)
)
if duplicate:
return jsonify({'error': 'Reward request already pending', 'code': 'DUPLICATE_REWARD_REQUEST'}), 409
pending = PendingConfirmation(child_id=child.id, entity_id=reward.id, entity_type='reward', user_id=user_id)
pending_confirmations_db.insert(pending.to_dict())
logger.info(f'Pending reward request created for child {child.name} for reward {reward.name}')
@@ -917,6 +927,28 @@ def request_reward(id):
send_event_for_current_user(Event(EventType.TRACKING_EVENT_CREATED.value, TrackingEventCreated(tracking_event.id, child.id, 'reward', 'requested')))
send_event_for_current_user(Event(EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest(child.id, reward.id, ChildRewardRequest.REQUEST_CREATED)))
# Fire web push notification to all parent subscriptions
try:
approve_token = create_action_token(user_id, child.id, reward.id, 'reward', 'approve')
deny_token = create_action_token(user_id, child.id, reward.id, 'reward', 'deny')
push_payload = {
'type': 'reward_requested',
'title': f'{child.name} wants a reward',
'body': f'{reward.name} costs {reward.cost} points.',
'user_id': user_id,
'child_id': child.id,
'child_name': child.name,
'entity_id': reward.id,
'entity_type': 'reward',
'entity_name': reward.name,
'approve_token': approve_token.id,
'deny_token': deny_token.id,
}
send_push_to_user(user_id, push_payload)
except Exception as _push_err:
logger.warning(f'Push notification failed for reward request: {_push_err}')
return jsonify({
'message': f'Reward request for {reward.name} submitted for {child.name}.',
'reward_id': reward.id,
@@ -1105,6 +1137,28 @@ def confirm_chore(id):
TrackingEventCreated(tracking_event.id, id, 'chore', 'confirmed')))
send_event_for_current_user(Event(EventType.CHILD_CHORE_CONFIRMATION.value,
ChildChoreConfirmation(id, task_id, ChildChoreConfirmation.OPERATION_CONFIRMED)))
# Fire web push notification to all parent subscriptions
try:
approve_token = create_action_token(user_id, id, task_id, 'chore', 'approve')
deny_token = create_action_token(user_id, id, task_id, 'chore', 'deny')
push_payload = {
'type': 'chore_confirmed',
'title': f'{child.name} completed a chore',
'body': f'{task.name} is waiting for your approval.',
'user_id': user_id,
'child_id': id,
'child_name': child.name,
'entity_id': task_id,
'entity_type': 'chore',
'entity_name': task.name,
'approve_token': approve_token.id,
'deny_token': deny_token.id,
}
send_push_to_user(user_id, push_payload)
except Exception as _push_err:
logger.warning(f'Push notification failed for chore confirmation: {_push_err}')
return jsonify({'message': f'Chore {task.name} confirmed by {child.name}.', 'confirmation_id': confirmation.id}), 200
@@ -1171,77 +1225,18 @@ def approve_chore(id):
if not task_id:
return jsonify({'error': 'task_id is required'}), 400
ChildQuery = Query()
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])
try:
result = chore_actions.approve_chore(user_id, id, task_id)
except ValueError:
return jsonify({'error': 'Child or task not found'}), 404
PendingQuery = Query()
existing = pending_confirmations_db.get(
(PendingQuery.child_id == id) & (PendingQuery.entity_id == task_id) &
(PendingQuery.entity_type == 'chore') & (PendingQuery.status == 'pending') &
(PendingQuery.user_id == user_id)
)
if not existing:
if result is None:
return jsonify({'error': 'No pending confirmation found', 'code': 'PENDING_NOT_FOUND'}), 400
TaskQuery = Query()
task_result = task_db.get((TaskQuery.id == task_id) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)))
if not task_result:
return jsonify({'error': 'Task not found'}), 404
task = Task.from_dict(task_result)
# Award points
override = get_override(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}, ChildQuery.id == id)
# Update confirmation to approved
# For general (non-scheduled) chores, remove the confirmation so chore resets to normal
schedule = get_schedule(id, task_id)
if schedule:
now_str = datetime.now(timezone.utc).isoformat()
pending_confirmations_db.update(
{'status': 'approved', 'approved_at': now_str},
(PendingQuery.child_id == id) & (PendingQuery.entity_id == task_id) &
(PendingQuery.entity_type == 'chore') & (PendingQuery.user_id == user_id)
)
else:
pending_confirmations_db.remove(
(PendingQuery.child_id == id) & (PendingQuery.entity_id == task_id) &
(PendingQuery.entity_type == 'chore') & (PendingQuery.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=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_for_current_user(Event(EventType.TRACKING_EVENT_CREATED.value,
TrackingEventCreated(tracking_event.id, id, 'chore', 'approved')))
send_event_for_current_user(Event(EventType.CHILD_CHORE_CONFIRMATION.value,
ChildChoreConfirmation(id, task_id, ChildChoreConfirmation.OPERATION_APPROVED)))
send_event_for_current_user(Event(EventType.CHILD_TASK_TRIGGERED.value,
ChildTaskTriggered(task_id, id, child.points)))
return jsonify({
'message': f'Chore {task.name} approved for {child.name}.',
'points': child.points,
'id': child.id
'message': f'Chore {result["task_name"]} approved for {result["child_name"]}.',
'points': result['points'],
'id': result['child_id']
}), 200
@@ -1260,7 +1255,6 @@ def reject_chore(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])
PendingQuery = Query()
existing = pending_confirmations_db.get(
@@ -1271,27 +1265,7 @@ def reject_chore(id):
if not existing:
return jsonify({'error': 'No pending confirmation found', 'code': 'PENDING_NOT_FOUND'}), 400
pending_confirmations_db.remove(
(PendingQuery.child_id == id) & (PendingQuery.entity_id == task_id) &
(PendingQuery.entity_type == 'chore') & (PendingQuery.user_id == user_id)
)
TaskQuery = Query()
task_result = task_db.get((TaskQuery.id == task_id) & ((TaskQuery.user_id == user_id) | (TaskQuery.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=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_for_current_user(Event(EventType.TRACKING_EVENT_CREATED.value,
TrackingEventCreated(tracking_event.id, id, 'chore', 'rejected')))
send_event_for_current_user(Event(EventType.CHILD_CHORE_CONFIRMATION.value,
ChildChoreConfirmation(id, task_id, ChildChoreConfirmation.OPERATION_REJECTED)))
chore_actions.reject_chore(user_id, id, task_id)
return jsonify({'message': 'Chore confirmation rejected.'}), 200
@@ -1344,3 +1318,22 @@ def reset_chore(id):
ChildChoreConfirmation(id, task_id, ChildChoreConfirmation.OPERATION_RESET)))
return jsonify({'message': 'Chore reset to available.'}), 200
@child_api.route('/child/<id>/deny-reward-request', methods=['POST'])
def deny_reward_request(id):
"""Parent denies a child's pending reward request."""
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
result = chore_actions.deny_reward(user_id, id, reward_id)
if result is None:
return jsonify({'message': 'This reward request has already been resolved.'}), 200
return jsonify({'message': f'Reward request denied for {result["child_name"]}.'}), 200