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

@@ -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}

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

View File

@@ -0,0 +1,88 @@
import logging
from flask import Blueprint, redirect, make_response
from tinydb import Query
from utils.digest_token import validate_and_consume_token, validate_unsubscribe_token
from db.db import users_db
from api.child_action_helpers import approve_chore, reject_chore, approve_reward_request, deny_reward
digest_action_api = Blueprint('digest_action_api', __name__)
logger = logging.getLogger(__name__)
_ERROR_HTML = """<!DOCTYPE html>
<html><head><title>Link Error</title></head>
<body style="font-family:sans-serif;text-align:center;margin-top:60px;">
<h2>This link is invalid or has expired.</h2>
<p>Action links expire after 24 hours and can only be used once.</p>
</body></html>"""
_UNSUB_HTML = """<!DOCTYPE html>
<html><head><title>Unsubscribed</title></head>
<body style="font-family:sans-serif;text-align:center;margin-top:60px;">
<h2>You have been unsubscribed from daily digest emails.</h2>
<p>To re-enable, visit your profile in the app.</p>
</body></html>"""
_UNSUB_ERROR_HTML = """<!DOCTYPE html>
<html><head><title>Link Error</title></head>
<body style="font-family:sans-serif;text-align:center;margin-top:60px;">
<h2>This unsubscribe link is invalid or has expired.</h2>
</body></html>"""
@digest_action_api.route('/digest-action/<token_id>', methods=['GET'])
def handle_digest_action(token_id: str):
"""
Validate a digest action token and execute the corresponding action.
On success: 302 redirect to the ParentView deep link.
On failure: 400 HTML error page.
"""
from flask import current_app
frontend_url = current_app.config.get('FRONTEND_URL', 'https://localhost:5173')
token = validate_and_consume_token(token_id)
if not token:
return make_response(_ERROR_HTML, 400)
user_id = token.user_id
child_id = token.child_id
entity_id = token.entity_id
entity_type = token.entity_type
action = token.action
try:
if entity_type == 'chore' and action == 'approve':
approve_chore(user_id, child_id, entity_id)
elif entity_type == 'chore' and action == 'deny':
reject_chore(user_id, child_id, entity_id)
elif entity_type == 'reward' and action == 'approve':
approve_reward_request(user_id, child_id, entity_id)
elif entity_type == 'reward' and action == 'deny':
deny_reward(user_id, child_id, entity_id)
else:
return make_response(_ERROR_HTML, 400)
except Exception as e:
logger.error(f'Error executing digest action {action}/{entity_type}: {e}')
return make_response(_ERROR_HTML, 400)
deep_link = (
f"{frontend_url}/parent/{child_id}"
f"?scrollTo={entity_id}&entityType={entity_type}"
)
return redirect(deep_link, 302)
@digest_action_api.route('/digest-unsubscribe/<token>', methods=['GET'])
def handle_digest_unsubscribe(token: str):
user_id = validate_unsubscribe_token(token)
if not user_id:
return make_response(_UNSUB_ERROR_HTML, 400)
UserQ = Query()
users_db.update({'email_digest_enabled': False}, UserQ.id == user_id)
logger.info(f'User {user_id} unsubscribed from digest via email link')
return make_response(_UNSUB_HTML, 200)

View File

@@ -35,3 +35,4 @@ class ErrorCodes:
PENDING_NOT_FOUND = "PENDING_NOT_FOUND"
INSUFFICIENT_POINTS = "INSUFFICIENT_POINTS"
INVALID_TASK_TYPE = "INVALID_TASK_TYPE"
DUPLICATE_REWARD_REQUEST = "DUPLICATE_REWARD_REQUEST"

View File

@@ -0,0 +1,59 @@
from flask import Blueprint, request, jsonify, current_app
from tinydb import Query
from api.utils import get_validated_user_id
from db.push_subscriptions import upsert_subscription, delete_by_endpoint
from db.db import users_db
push_subscription_api = Blueprint('push_subscription_api', __name__)
@push_subscription_api.route('/push-vapid-key', methods=['GET'])
def get_vapid_public_key():
"""Return the VAPID public key for the frontend to use when subscribing."""
public_key = current_app.config.get('VAPID_PUBLIC_KEY', '')
return jsonify({'public_key': public_key}), 200
@push_subscription_api.route('/push-subscription', methods=['POST'])
def subscribe():
"""Upsert a push subscription for the current user."""
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
data = request.get_json() or {}
endpoint = data.get('endpoint')
keys = data.get('keys')
timezone_str = data.get('timezone')
if not endpoint or not isinstance(keys, dict):
return jsonify({'error': 'endpoint and keys are required', 'code': 'MISSING_FIELDS'}), 400
if 'p256dh' not in keys or 'auth' not in keys:
return jsonify({'error': 'keys must contain p256dh and auth', 'code': 'MISSING_FIELDS'}), 400
sub = upsert_subscription(user_id=user_id, endpoint=endpoint, keys=keys)
# Update user timezone if provided
if timezone_str:
UserQ = Query()
users_db.update({'timezone': timezone_str}, UserQ.id == user_id)
return jsonify({'message': 'Subscription saved', 'id': sub.id}), 200
@push_subscription_api.route('/push-subscription', methods=['DELETE'])
def unsubscribe():
"""Remove a push subscription for the current user by endpoint."""
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
data = request.get_json() or {}
endpoint = data.get('endpoint')
if not endpoint:
return jsonify({'error': 'endpoint is required', 'code': 'MISSING_FIELDS'}), 400
removed = delete_by_endpoint(user_id=user_id, endpoint=endpoint)
return jsonify({'message': 'Subscription removed', 'removed': removed}), 200

View File

@@ -46,7 +46,8 @@ def get_profile():
'first_name': user.first_name,
'last_name': user.last_name,
'email': user.email,
'image_id': user.image_id
'image_id': user.image_id,
'email_digest_enabled': user.email_digest_enabled,
}), 200
@user_api.route('/user/profile', methods=['PUT'])
@@ -58,16 +59,19 @@ def update_profile():
if not user:
return jsonify({'error': 'Unauthorized'}), 401
data = request.get_json()
# Only allow first_name, last_name, image_id to be updated
# Only allow first_name, last_name, image_id, email_digest_enabled to be updated
first_name = data.get('first_name')
last_name = data.get('last_name')
image_id = data.get('image_id')
email_digest_enabled = data.get('email_digest_enabled')
if first_name is not None:
user.first_name = first_name
if last_name is not None:
user.last_name = last_name
if image_id is not None:
user.image_id = image_id
if email_digest_enabled is not None:
user.email_digest_enabled = bool(email_digest_enabled)
users_db.update(user.to_dict(), UserQuery.email == user.email)
# Create tracking event
@@ -78,6 +82,8 @@ def update_profile():
metadata['last_name_updated'] = True
if image_id is not None:
metadata['image_updated'] = True
if email_digest_enabled is not None:
metadata['email_digest_enabled_updated'] = True
tracking_event = TrackingEvent.create_event(
user_id=user_id,

View File

@@ -79,6 +79,8 @@ child_overrides_path = os.path.join(base_dir, 'child_overrides.json')
chore_schedules_path = os.path.join(base_dir, 'chore_schedules.json')
task_extensions_path = os.path.join(base_dir, 'task_extensions.json')
refresh_tokens_path = os.path.join(base_dir, 'refresh_tokens.json')
push_subscriptions_path = os.path.join(base_dir, 'push_subscriptions.json')
digest_action_tokens_path = os.path.join(base_dir, 'digest_action_tokens.json')
# Use separate TinyDB instances/files for each collection
_child_db = TinyDB(child_path, indent=2)
@@ -93,6 +95,8 @@ _child_overrides_db = TinyDB(child_overrides_path, indent=2)
_chore_schedules_db = TinyDB(chore_schedules_path, indent=2)
_task_extensions_db = TinyDB(task_extensions_path, indent=2)
_refresh_tokens_db = TinyDB(refresh_tokens_path, indent=2)
_push_subscriptions_db = TinyDB(push_subscriptions_path, indent=2)
_digest_action_tokens_db = TinyDB(digest_action_tokens_path, indent=2)
# Expose table objects wrapped with locking
child_db = LockedTable(_child_db)
@@ -107,6 +111,8 @@ child_overrides_db = LockedTable(_child_overrides_db)
chore_schedules_db = LockedTable(_chore_schedules_db)
task_extensions_db = LockedTable(_task_extensions_db)
refresh_tokens_db = LockedTable(_refresh_tokens_db)
push_subscriptions_db = LockedTable(_push_subscriptions_db)
digest_action_tokens_db = LockedTable(_digest_action_tokens_db)
if os.environ.get('DB_ENV', 'prod') == 'test':
child_db.truncate()
@@ -121,4 +127,6 @@ if os.environ.get('DB_ENV', 'prod') == 'test':
chore_schedules_db.truncate()
task_extensions_db.truncate()
refresh_tokens_db.truncate()
push_subscriptions_db.truncate()
digest_action_tokens_db.truncate()

View File

@@ -0,0 +1,18 @@
from tinydb import Query
from db.db import digest_action_tokens_db
from models.digest_action_token import DigestActionToken
def insert_token(token: DigestActionToken) -> None:
digest_action_tokens_db.insert(token.to_dict())
def get_token_by_id(token_id: str) -> DigestActionToken | None:
Q = Query()
result = digest_action_tokens_db.get(Q.id == token_id)
return DigestActionToken.from_dict(result) if result else None
def mark_token_used(token_id: str) -> None:
Q = Query()
digest_action_tokens_db.update({'used': True}, Q.id == token_id)

View File

@@ -0,0 +1,38 @@
from tinydb import Query
from db.db import push_subscriptions_db
from models.push_subscription import PushSubscription
def get_subscriptions_by_user(user_id: str) -> list[PushSubscription]:
"""Return all push subscriptions for a user."""
Q = Query()
results = push_subscriptions_db.search(Q.user_id == user_id)
return [PushSubscription.from_dict(r) for r in results]
def upsert_subscription(user_id: str, endpoint: str, keys: dict) -> PushSubscription:
"""Insert or update a subscription for the given user+endpoint pair."""
Q = Query()
existing = push_subscriptions_db.get((Q.user_id == user_id) & (Q.endpoint == endpoint))
if existing:
sub = PushSubscription.from_dict(existing)
sub.keys = keys
sub.touch()
push_subscriptions_db.update(sub.to_dict(), (Q.user_id == user_id) & (Q.endpoint == endpoint))
return sub
sub = PushSubscription(user_id=user_id, endpoint=endpoint, keys=keys)
push_subscriptions_db.insert(sub.to_dict())
return sub
def delete_by_endpoint(user_id: str, endpoint: str) -> int:
"""Remove the subscription with the given endpoint for this user. Returns count removed."""
Q = Query()
removed = push_subscriptions_db.remove((Q.user_id == user_id) & (Q.endpoint == endpoint))
return len(removed)
def delete_subscription_by_id(subscription_id: str) -> None:
"""Remove a subscription by its ID (used when push delivery fails)."""
Q = Query()
push_subscriptions_db.remove(Q.id == subscription_id)

View File

@@ -17,6 +17,8 @@ from api.reward_api import reward_api
from api.task_api import task_api
from api.tracking_api import tracking_api
from api.user_api import user_api
from api.push_subscription_api import push_subscription_api
from api.digest_action_api import digest_action_api
from config.version import get_full_version
from db.default import initializeImages, createDefaultTasks, createDefaultRewards
@@ -24,6 +26,7 @@ from events.broadcaster import Broadcaster
from events.sse import sse_response_for_user, send_to_user
from api.utils import get_current_user_id
from utils.account_deletion_scheduler import start_deletion_scheduler
from utils.digest_scheduler import start_digest_scheduler
# Configure logging once at application startup
logging.basicConfig(
@@ -54,6 +57,8 @@ app.register_blueprint(image_api)
app.register_blueprint(auth_api, url_prefix='/auth')
app.register_blueprint(user_api)
app.register_blueprint(tracking_api)
app.register_blueprint(push_subscription_api)
app.register_blueprint(digest_action_api)
app.config.update(
MAIL_SERVER='smtp.gmail.com',
@@ -63,6 +68,7 @@ app.config.update(
MAIL_PASSWORD='ruyj hxjf nmrz buar',
MAIL_DEFAULT_SENDER='ryan.kegel@gmail.com',
FRONTEND_URL=os.environ.get('FRONTEND_URL', 'https://localhost:5173'), # Dynamic via env var, defaults to localhost
VAPID_CLAIMS_EMAIL=os.environ.get('VAPID_CLAIMS_EMAIL', 'admin@reward-app.local'),
)
# Security: require SECRET_KEY and REFRESH_TOKEN_EXPIRY_DAYS from environment
@@ -81,6 +87,24 @@ try:
except ValueError:
raise RuntimeError('REFRESH_TOKEN_EXPIRY_DAYS must be an integer.')
_digest_token_secret = os.environ.get('DIGEST_TOKEN_SECRET')
if not _digest_token_secret:
raise RuntimeError(
'DIGEST_TOKEN_SECRET environment variable is required. '
'Set it to a random string (e.g. python -c "import secrets; print(secrets.token_urlsafe(64))")')
app.config['DIGEST_TOKEN_SECRET'] = _digest_token_secret
_vapid_public_key = os.environ.get('VAPID_PUBLIC_KEY')
_vapid_private_key = os.environ.get('VAPID_PRIVATE_KEY')
if not _vapid_public_key or not _vapid_private_key:
raise RuntimeError(
'VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY environment variables are required. '
'Generate a key pair with: python -c "from py_vapid import Vapid; v=Vapid(); '
'v.generate_keys(); print(v.public_key.public_bytes_raw().hex(), v.private_key.private_bytes_raw().hex())"'
)
app.config['VAPID_PUBLIC_KEY'] = _vapid_public_key
app.config['VAPID_PRIVATE_KEY'] = _vapid_private_key
@app.route("/version")
def api_version():
return jsonify({"version": get_full_version()})
@@ -115,6 +139,7 @@ createDefaultTasks()
createDefaultRewards()
start_background_threads()
start_deletion_scheduler()
start_digest_scheduler(app)
if __name__ == '__main__':
app.run(debug=False, host='0.0.0.0', port=5000, threaded=True)

View File

@@ -0,0 +1,44 @@
from dataclasses import dataclass
from models.base import BaseModel
@dataclass
class DigestActionToken(BaseModel):
user_id: str
child_id: str
entity_id: str
entity_type: str # 'chore' or 'reward'
action: str # 'approve' or 'deny'
expires_at: str # ISO timestamp
used: bool = False
signature: str = ''
@classmethod
def from_dict(cls, d: dict) -> 'DigestActionToken':
return cls(
user_id=d.get('user_id'),
child_id=d.get('child_id'),
entity_id=d.get('entity_id'),
entity_type=d.get('entity_type'),
action=d.get('action'),
expires_at=d.get('expires_at'),
used=d.get('used', False),
signature=d.get('signature', ''),
id=d.get('id'),
created_at=d.get('created_at'),
updated_at=d.get('updated_at'),
)
def to_dict(self) -> dict:
base = super().to_dict()
base.update({
'user_id': self.user_id,
'child_id': self.child_id,
'entity_id': self.entity_id,
'entity_type': self.entity_type,
'action': self.action,
'expires_at': self.expires_at,
'used': self.used,
'signature': self.signature,
})
return base

View File

@@ -0,0 +1,29 @@
from dataclasses import dataclass
from models.base import BaseModel
@dataclass
class PushSubscription(BaseModel):
user_id: str
endpoint: str
keys: dict # {'p256dh': str, 'auth': str}
@classmethod
def from_dict(cls, d: dict) -> 'PushSubscription':
return cls(
user_id=d.get('user_id'),
endpoint=d.get('endpoint'),
keys=d.get('keys', {}),
id=d.get('id'),
created_at=d.get('created_at'),
updated_at=d.get('updated_at'),
)
def to_dict(self) -> dict:
base = super().to_dict()
base.update({
'user_id': self.user_id,
'endpoint': self.endpoint,
'keys': self.keys,
})
return base

View File

@@ -22,6 +22,8 @@ class User(BaseModel):
deletion_attempted_at: str | None = None
role: str = 'user'
token_version: int = 0
timezone: str | None = None
email_digest_enabled: bool = True
@classmethod
def from_dict(cls, d: dict):
@@ -45,6 +47,8 @@ class User(BaseModel):
deletion_attempted_at=d.get('deletion_attempted_at'),
role=d.get('role', 'user'),
token_version=d.get('token_version', 0),
timezone=d.get('timezone'),
email_digest_enabled=d.get('email_digest_enabled', True),
id=d.get('id'),
created_at=d.get('created_at'),
updated_at=d.get('updated_at')
@@ -73,5 +77,7 @@ class User(BaseModel):
'deletion_attempted_at': self.deletion_attempted_at,
'role': self.role,
'token_version': self.token_version,
'timezone': self.timezone,
'email_digest_enabled': self.email_digest_enabled,
})
return base

Binary file not shown.

View File

@@ -2,6 +2,9 @@ import os
os.environ['DB_ENV'] = 'test'
os.environ.setdefault('SECRET_KEY', 'test-secret-key')
os.environ.setdefault('REFRESH_TOKEN_EXPIRY_DAYS', '90')
os.environ.setdefault('DIGEST_TOKEN_SECRET', 'test-digest-secret')
os.environ.setdefault('VAPID_PUBLIC_KEY', 'test-vapid-public-key')
os.environ.setdefault('VAPID_PRIVATE_KEY', 'test-vapid-private-key')
import sys
import pytest

View File

@@ -5,7 +5,7 @@ import os
from flask import Flask
from api.child_api import child_api
from api.auth_api import auth_api
from db.db import child_db, reward_db, task_db, users_db, chore_schedules_db, task_extensions_db
from db.db import child_db, reward_db, task_db, users_db, chore_schedules_db, task_extensions_db, pending_confirmations_db
from tinydb import Query
from models.child import Child
import jwt
@@ -514,4 +514,100 @@ def test_list_child_tasks_no_server_side_filtering(client):
returned_ids = {t['id'] for t in resp.get_json()['tasks']}
# Both good tasks must be present; server never filters based on schedule/time
assert TASK_GOOD_ID in returned_ids
assert extra_id in returned_ids
assert extra_id in returned_ids
# ---------------------------------------------------------------------------
# request-reward: duplicate guard
# ---------------------------------------------------------------------------
def test_request_reward_duplicate_returns_409(client):
"""Requesting the same reward twice without resolution returns 409 Conflict."""
reward_db.insert({'id': 'r_dup', 'name': 'Duplicate Reward', 'cost': 5, 'user_id': 'testuserid'})
child_db.insert({
'id': 'child_dup',
'name': 'Dupe Kid',
'age': 8,
'points': 20,
'tasks': [],
'rewards': ['r_dup'],
'user_id': 'testuserid',
})
first = client.post('/child/child_dup/request-reward', json={'reward_id': 'r_dup'})
assert first.status_code == 200
second = client.post('/child/child_dup/request-reward', json={'reward_id': 'r_dup'})
assert second.status_code == 409
assert second.get_json()['code'] == 'DUPLICATE_REWARD_REQUEST'
# Cleanup
pending_confirmations_db.remove(Query().child_id == 'child_dup')
child_db.remove(Query().id == 'child_dup')
reward_db.remove(Query().id == 'r_dup')
# ---------------------------------------------------------------------------
# deny-reward-request endpoint
# ---------------------------------------------------------------------------
def test_deny_reward_request_removes_pending(client):
"""Denying a reward request removes the pending confirmation and fires REQUEST_CANCELLED."""
reward_db.insert({'id': 'r_deny1', 'name': 'Deny Reward', 'cost': 5, 'user_id': 'testuserid'})
child_db.insert({
'id': 'child_deny1',
'name': 'Deny Kid',
'age': 9,
'points': 10,
'tasks': [],
'rewards': ['r_deny1'],
'user_id': 'testuserid',
})
pending_confirmations_db.insert({
'id': 'pend_deny1',
'child_id': 'child_deny1',
'entity_id': 'r_deny1',
'entity_type': 'reward',
'user_id': 'testuserid',
'status': 'pending',
'approved_at': None,
})
resp = client.post('/child/child_deny1/deny-reward-request', json={'reward_id': 'r_deny1'})
assert resp.status_code == 200
remaining = pending_confirmations_db.get(
(Query().child_id == 'child_deny1') & (Query().entity_id == 'r_deny1')
)
assert remaining is None
# Cleanup
child_db.remove(Query().id == 'child_deny1')
reward_db.remove(Query().id == 'r_deny1')
def test_deny_reward_request_already_resolved_returns_200(client):
"""If no pending request exists, denying returns 200 with an informational message."""
resp = client.post('/child/child_gone/deny-reward-request', json={'reward_id': 'r_gone'})
assert resp.status_code == 200
data = resp.get_json()
assert 'already been resolved' in data['message']
def test_deny_reward_request_requires_auth(client):
"""Deny-reward-request endpoint rejects unauthenticated requests."""
# Create a fresh unauthenticated client
from flask import Flask
from api.child_api import child_api as child_api_bp
from api.auth_api import auth_api as auth_api_bp
from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS
app2 = Flask(__name__)
app2.register_blueprint(child_api_bp)
app2.register_blueprint(auth_api_bp, url_prefix='/auth')
app2.config['TESTING'] = True
app2.config['SECRET_KEY'] = TEST_SECRET_KEY
app2.config['REFRESH_TOKEN_EXPIRY_DAYS'] = TEST_REFRESH_TOKEN_EXPIRY_DAYS
with app2.test_client() as anon:
resp = anon.post('/child/someid/deny-reward-request', json={'reward_id': 'r1'})
assert resp.status_code == 401

View File

@@ -0,0 +1,190 @@
import os
import pytest
from flask import Flask
from werkzeug.security import generate_password_hash
from tinydb import Query
from api.digest_action_api import digest_action_api
from db.db import (
users_db, child_db, task_db, reward_db,
pending_confirmations_db, digest_action_tokens_db,
tracking_events_db,
)
from utils.digest_token import (
create_action_token, create_unsubscribe_token, validate_unsubscribe_token
)
from tests.conftest import TEST_SECRET_KEY
TEST_USER_ID = "daa_user_id"
TEST_CHILD_ID = "daa_child_id"
TEST_TASK_ID = "daa_task_id"
TEST_REWARD_ID = "daa_reward_id"
FRONTEND_URL = "http://localhost:5173"
def seed_data():
users_db.remove(Query().id == TEST_USER_ID)
child_db.remove(Query().id == TEST_CHILD_ID)
task_db.remove(Query().id == TEST_TASK_ID)
reward_db.remove(Query().id == TEST_REWARD_ID)
pending_confirmations_db.remove(Query().user_id == TEST_USER_ID)
digest_action_tokens_db.truncate()
tracking_events_db.remove(Query().user_id == TEST_USER_ID)
users_db.insert({
"id": TEST_USER_ID,
"first_name": "Digest",
"last_name": "Tester",
"email": "digest@example.com",
"password": generate_password_hash("password"),
"verified": True,
"role": "user",
"image_id": None,
"marked_for_deletion": False,
"marked_for_deletion_at": None,
"email_digest_enabled": True,
"timezone": "America/New_York",
})
child_db.insert({
"id": TEST_CHILD_ID,
"user_id": TEST_USER_ID,
"name": "Test Child",
"age": 8,
"tasks": [TEST_TASK_ID],
"rewards": [TEST_REWARD_ID],
"points": 100,
"image_id": None,
})
task_db.insert({
"id": TEST_TASK_ID,
"user_id": TEST_USER_ID,
"name": "Clean Room",
"points": 10,
"type": "chore",
"description": "",
"image_id": None,
})
reward_db.insert({
"id": TEST_REWARD_ID,
"user_id": TEST_USER_ID,
"name": "Extra Screen Time",
"cost": 20,
"description": "",
"image_id": None,
})
def add_pending_chore():
pending_confirmations_db.insert({
"id": "pending_chore_id",
"user_id": TEST_USER_ID,
"child_id": TEST_CHILD_ID,
"entity_id": TEST_TASK_ID,
"entity_type": "chore",
"status": "pending",
"created_at": "2024-01-01T10:00:00+00:00",
})
def add_pending_reward():
pending_confirmations_db.insert({
"id": "pending_reward_id",
"user_id": TEST_USER_ID,
"child_id": TEST_CHILD_ID,
"entity_id": TEST_REWARD_ID,
"entity_type": "reward",
"status": "pending",
"created_at": "2024-01-01T10:00:00+00:00",
})
@pytest.fixture
def client():
app = Flask(__name__)
app.register_blueprint(digest_action_api)
app.config['TESTING'] = True
app.config['SECRET_KEY'] = TEST_SECRET_KEY
app.config['FRONTEND_URL'] = FRONTEND_URL
seed_data()
with app.test_client() as c:
yield c
seed_data()
class TestHandleDigestAction:
def test_approve_chore_redirects_to_child_view(self, client):
add_pending_chore()
token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_TASK_ID, 'chore', 'approve')
res = client.get(f'/digest-action/{token.id}')
assert res.status_code == 302
location = res.headers['Location']
assert f'/parent/{TEST_CHILD_ID}' in location
assert 'scrollTo=' in location
def test_approve_chore_awards_points(self, client):
add_pending_chore()
token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_TASK_ID, 'chore', 'approve')
client.get(f'/digest-action/{token.id}')
child = child_db.get(Query().id == TEST_CHILD_ID)
assert child['points'] == 110 # 100 + 10
def test_deny_chore_removes_pending(self, client):
add_pending_chore()
token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_TASK_ID, 'chore', 'deny')
client.get(f'/digest-action/{token.id}')
pending = pending_confirmations_db.get(
(Query().child_id == TEST_CHILD_ID) & (Query().entity_id == TEST_TASK_ID)
)
assert pending is None
def test_approve_reward_deducts_points(self, client):
add_pending_reward()
token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_REWARD_ID, 'reward', 'approve')
client.get(f'/digest-action/{token.id}')
child = child_db.get(Query().id == TEST_CHILD_ID)
assert child['points'] == 80 # 100 - 20
def test_deny_reward_removes_pending(self, client):
add_pending_reward()
token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_REWARD_ID, 'reward', 'deny')
client.get(f'/digest-action/{token.id}')
pending = pending_confirmations_db.get(
(Query().child_id == TEST_CHILD_ID) & (Query().entity_id == TEST_REWARD_ID)
)
assert pending is None
def test_invalid_token_returns_400(self, client):
res = client.get('/digest-action/nonexistent-token-id')
assert res.status_code == 400
def test_used_token_returns_400(self, client):
add_pending_chore()
token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_TASK_ID, 'chore', 'approve')
client.get(f'/digest-action/{token.id}') # first use
res = client.get(f'/digest-action/{token.id}') # second use
assert res.status_code == 400
def test_approve_already_resolved_chore_still_redirects(self, client):
"""If the chore was already resolved, action still returns a redirect (idempotent)."""
token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_TASK_ID, 'chore', 'approve')
# No pending chore added — already resolved
res = client.get(f'/digest-action/{token.id}')
assert res.status_code == 302
class TestHandleDigestUnsubscribe:
def test_valid_token_unsubscribes_user(self, client):
token = create_unsubscribe_token(TEST_USER_ID)
res = client.get(f'/digest-unsubscribe/{token}')
assert res.status_code == 200
user = users_db.get(Query().id == TEST_USER_ID)
assert user['email_digest_enabled'] is False
def test_invalid_token_returns_400(self, client):
res = client.get('/digest-unsubscribe/garbage-token')
assert res.status_code == 400
def test_response_contains_unsubscribed_message(self, client):
token = create_unsubscribe_token(TEST_USER_ID)
res = client.get(f'/digest-unsubscribe/{token}')
assert b'unsubscribed' in res.data.lower()

View File

@@ -0,0 +1,262 @@
"""Tests for the digest scheduler and email HTML generation."""
import os
import pytest
from unittest.mock import patch, MagicMock
from flask import Flask
from werkzeug.security import generate_password_hash
from tinydb import Query
from utils.digest_scheduler import send_digests
from utils.email_sender import send_digest_email
from db.db import users_db, pending_confirmations_db, child_db, task_db, reward_db
from tests.conftest import TEST_SECRET_KEY
SCHED_USER_ID = "sched_test_user"
SCHED_EMAIL = "schedtest@example.com"
SCHED_CHILD_ID = "sched_child_id"
SCHED_TASK_ID = "sched_task_id"
SCHED_REWARD_ID = "sched_reward_id"
def seed_scheduler_data(
verified=True,
email_digest_enabled=True,
timezone="UTC",
has_pending=True,
):
users_db.remove(Query().id == SCHED_USER_ID)
child_db.remove(Query().id == SCHED_CHILD_ID)
task_db.remove(Query().id == SCHED_TASK_ID)
reward_db.remove(Query().id == SCHED_REWARD_ID)
pending_confirmations_db.remove(Query().user_id == SCHED_USER_ID)
users_db.insert({
"id": SCHED_USER_ID,
"first_name": "Sched",
"last_name": "Tester",
"email": SCHED_EMAIL,
"password": generate_password_hash("schedpass"),
"verified": verified,
"role": "user",
"image_id": None,
"marked_for_deletion": False,
"marked_for_deletion_at": None,
"timezone": timezone,
"email_digest_enabled": email_digest_enabled,
})
child_db.insert({
"id": SCHED_CHILD_ID,
"user_id": SCHED_USER_ID,
"name": "Sched Child",
"age": 8,
"points": 50,
"tasks": [SCHED_TASK_ID],
"rewards": [SCHED_REWARD_ID],
"image_id": None,
})
task_db.insert({
"id": SCHED_TASK_ID,
"user_id": SCHED_USER_ID,
"name": "Clean Room",
"points": 10,
"type": "chore",
"image_id": None,
})
reward_db.insert({
"id": SCHED_REWARD_ID,
"user_id": SCHED_USER_ID,
"name": "Movie Night",
"cost": 20,
"image_id": None,
})
if has_pending:
pending_confirmations_db.insert({
"id": "sched_pending_id",
"user_id": SCHED_USER_ID,
"child_id": SCHED_CHILD_ID,
"entity_id": SCHED_TASK_ID,
"entity_type": "chore",
"status": "pending",
"approved_at": None,
})
def cleanup_scheduler_data():
users_db.remove(Query().id == SCHED_USER_ID)
child_db.remove(Query().id == SCHED_CHILD_ID)
task_db.remove(Query().id == SCHED_TASK_ID)
reward_db.remove(Query().id == SCHED_REWARD_ID)
pending_confirmations_db.remove(Query().user_id == SCHED_USER_ID)
@pytest.fixture
def app():
flask_app = Flask(__name__)
flask_app.config['TESTING'] = True
flask_app.config['SECRET_KEY'] = TEST_SECRET_KEY
flask_app.config['FRONTEND_URL'] = 'http://localhost:5173'
flask_app.config['MAIL_DEFAULT_SENDER'] = 'no-reply@reward-app.local'
return flask_app
class TestSendDigests:
def setup_method(self):
cleanup_scheduler_data()
def teardown_method(self):
cleanup_scheduler_data()
def test_sends_digest_to_eligible_user_at_9pm(self, app):
"""Identifies verified, digest-enabled users whose local time is 9 pm and sends digest."""
seed_scheduler_data()
with patch('utils.digest_scheduler._get_local_hour', return_value=21), \
patch('utils.email_sender.Mail') as mock_mail:
send_digests(app)
assert mock_mail.return_value.send.called
def test_skips_user_with_no_pending_items(self, app):
"""Digest is not sent if there are no pending items."""
seed_scheduler_data(has_pending=False)
with patch('utils.digest_scheduler._get_local_hour', return_value=21), \
patch('utils.email_sender.Mail') as mock_mail:
send_digests(app)
assert not mock_mail.return_value.send.called
def test_skips_unverified_user(self, app):
"""Digest is not sent to unverified users."""
seed_scheduler_data(verified=False)
with patch('utils.digest_scheduler._get_local_hour', return_value=21), \
patch('utils.email_sender.Mail') as mock_mail:
send_digests(app)
assert not mock_mail.return_value.send.called
def test_skips_user_with_digest_disabled(self, app):
"""Digest is not sent to users who have email_digest_enabled == False."""
seed_scheduler_data(email_digest_enabled=False)
with patch('utils.digest_scheduler._get_local_hour', return_value=21), \
patch('utils.email_sender.Mail') as mock_mail:
send_digests(app)
assert not mock_mail.return_value.send.called
def test_skips_user_not_at_9pm(self, app):
"""Digest is not sent when the user's local time is not 21."""
seed_scheduler_data()
with patch('utils.digest_scheduler._get_local_hour', return_value=10), \
patch('utils.email_sender.Mail') as mock_mail:
send_digests(app)
assert not mock_mail.return_value.send.called
def test_reads_timezone_and_falls_back_to_utc(self, app):
"""_get_local_hour uses User.timezone; None falls back to UTC."""
from utils.digest_scheduler import _get_local_hour
from datetime import datetime, timezone as tz
utc_hour = datetime.now(tz.utc).hour
assert _get_local_hour(None) == utc_hour
# A real timezone that differs from UTC (New York is UTC-4 or UTC-5)
result = _get_local_hour("America/New_York")
assert 0 <= result <= 23
def test_skips_when_db_env_is_e2e(self, app):
"""Digest scheduler does nothing in the e2e test environment."""
seed_scheduler_data()
original = os.environ.get('DB_ENV')
try:
os.environ['DB_ENV'] = 'e2e'
with patch('utils.email_sender.Mail') as mock_mail:
send_digests(app)
assert not mock_mail.return_value.send.called
finally:
if original is not None:
os.environ['DB_ENV'] = original
else:
os.environ.pop('DB_ENV', None)
class TestDigestEmailHtml:
"""Tests for the HTML content of send_digest_email."""
@pytest.fixture
def app_ctx(self, app):
with app.app_context():
yield
def _build_items(self):
return [
{
'child_name': 'Alice',
'entity_name': 'Clean Room',
'entity_type': 'chore',
'view_url': 'http://localhost:5173/parent/child1?scrollTo=task1&entityType=chore',
'approve_url': 'http://localhost:5173/api/digest-action/approve_tok',
'deny_url': 'http://localhost:5173/api/digest-action/deny_tok',
'child_id': 'child1',
'entity_id': 'task1',
},
{
'child_name': 'Alice',
'entity_name': 'Movie Night',
'entity_type': 'reward',
'view_url': 'http://localhost:5173/parent/child1?scrollTo=reward1&entityType=reward',
'approve_url': 'http://localhost:5173/api/digest-action/approve_tok2',
'deny_url': 'http://localhost:5173/api/digest-action/deny_tok2',
'child_id': 'child1',
'entity_id': 'reward1',
},
]
def test_email_contains_child_section(self, app_ctx):
"""Email HTML contains a section for each child."""
with patch('utils.email_sender.Mail') as mock_mail:
send_digest_email(SCHED_EMAIL, self._build_items(), 'unsub_tok')
msg = mock_mail.return_value.send.call_args[0][0]
assert 'Alice' in msg.html
def test_email_contains_item_names(self, app_ctx):
"""Email HTML lists each pending item's name."""
with patch('utils.email_sender.Mail') as mock_mail:
send_digest_email(SCHED_EMAIL, self._build_items(), 'unsub_tok')
msg = mock_mail.return_value.send.call_args[0][0]
assert 'Clean Room' in msg.html
assert 'Movie Night' in msg.html
def test_email_contains_approve_and_deny_links(self, app_ctx):
"""Email HTML contains Approve and Deny links for each item."""
with patch('utils.email_sender.Mail') as mock_mail:
send_digest_email(SCHED_EMAIL, self._build_items(), 'unsub_tok')
msg = mock_mail.return_value.send.call_args[0][0]
assert 'approve_tok' in msg.html
assert 'deny_tok' in msg.html
assert 'Approve' in msg.html
assert 'Deny' in msg.html
def test_email_contains_view_links(self, app_ctx):
"""Email HTML contains a View link for each item."""
with patch('utils.email_sender.Mail') as mock_mail:
send_digest_email(SCHED_EMAIL, self._build_items(), 'unsub_tok')
msg = mock_mail.return_value.send.call_args[0][0]
assert 'View' in msg.html
assert 'scrollTo=task1' in msg.html
def test_email_contains_unsubscribe_link(self, app_ctx):
"""Email HTML footer contains the unsubscribe link."""
with patch('utils.email_sender.Mail') as mock_mail:
send_digest_email(SCHED_EMAIL, self._build_items(), 'unsub_tok')
msg = mock_mail.return_value.send.call_args[0][0]
assert 'unsub_tok' in msg.html
assert 'Unsubscribe' in msg.html
def test_approve_link_styled_green(self, app_ctx):
"""Approve links use green color styling."""
with patch('utils.email_sender.Mail') as mock_mail:
send_digest_email(SCHED_EMAIL, self._build_items(), 'unsub_tok')
msg = mock_mail.return_value.send.call_args[0][0]
# Find the Approve link and verify green color is adjacent
assert '#22863a' in msg.html # green used for Approve
def test_deny_link_styled_red(self, app_ctx):
"""Deny links use red color styling."""
with patch('utils.email_sender.Mail') as mock_mail:
send_digest_email(SCHED_EMAIL, self._build_items(), 'unsub_tok')
msg = mock_mail.return_value.send.call_args[0][0]
assert '#cb2431' in msg.html # red used for Deny

View File

@@ -0,0 +1,133 @@
import os
import time
import pytest
from tinydb import Query
from db.db import digest_action_tokens_db
from utils.digest_token import (
create_action_token,
validate_and_consume_token,
create_unsubscribe_token,
validate_unsubscribe_token,
)
def cleanup():
digest_action_tokens_db.truncate()
@pytest.fixture(autouse=True)
def clear_tokens():
cleanup()
yield
cleanup()
class TestCreateActionToken:
def test_returns_token_with_correct_fields(self):
token = create_action_token('u1', 'c1', 'e1', 'chore', 'approve')
assert token.user_id == 'u1'
assert token.child_id == 'c1'
assert token.entity_id == 'e1'
assert token.entity_type == 'chore'
assert token.action == 'approve'
assert token.used is False
assert token.signature
def test_persists_to_db(self):
token = create_action_token('u1', 'c1', 'e1', 'chore', 'approve')
stored = digest_action_tokens_db.get(Query().id == token.id)
assert stored is not None
assert stored['entity_type'] == 'chore'
def test_different_tokens_have_unique_ids(self):
t1 = create_action_token('u1', 'c1', 'e1', 'chore', 'approve')
t2 = create_action_token('u1', 'c1', 'e1', 'reward', 'deny')
assert t1.id != t2.id
class TestValidateAndConsumeToken:
def test_valid_token_is_returned_and_marked_used(self):
token = create_action_token('u1', 'c1', 'e1', 'chore', 'approve')
result = validate_and_consume_token(token.id)
assert result is not None
assert result.id == token.id
stored = digest_action_tokens_db.get(Query().id == token.id)
assert stored['used'] is True
def test_nonexistent_token_returns_none(self):
assert validate_and_consume_token('nonexistent-id') is None
def test_already_used_token_returns_none(self):
token = create_action_token('u1', 'c1', 'e1', 'chore', 'approve')
validate_and_consume_token(token.id) # first use
result = validate_and_consume_token(token.id) # second use
assert result is None
def test_expired_token_returns_none(self):
# Create a token that is already expired
from datetime import datetime, timedelta, timezone
from db.digest_action_tokens import insert_token
from models.digest_action_token import DigestActionToken
import uuid, json, hmac, hashlib
token_id = str(uuid.uuid4())
expires_at = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat()
payload = {
'id': token_id,
'user_id': 'u1',
'child_id': 'c1',
'entity_id': 'e1',
'entity_type': 'chore',
'action': 'approve',
'expires_at': expires_at,
}
secret = os.environ.get('DIGEST_TOKEN_SECRET')
canonical = json.dumps(payload, sort_keys=True, separators=(',', ':'))
sig = hmac.new(secret.encode(), canonical.encode(), hashlib.sha256).hexdigest()
token = DigestActionToken(
id=token_id, user_id='u1', child_id='c1', entity_id='e1',
entity_type='chore', action='approve', expires_at=expires_at,
used=False, signature=sig,
)
insert_token(token)
assert validate_and_consume_token(token_id) is None
def test_tampered_signature_returns_none(self):
token = create_action_token('u1', 'c1', 'e1', 'chore', 'approve')
# Tamper the signature in DB
digest_action_tokens_db.update(
{'signature': 'deadbeef' * 8},
Query().id == token.id
)
assert validate_and_consume_token(token.id) is None
class TestUnsubscribeToken:
def test_create_and_validate(self):
token = create_unsubscribe_token('user123')
assert token
result = validate_unsubscribe_token(token)
assert result == 'user123'
def test_invalid_token_returns_none(self):
assert validate_unsubscribe_token('garbage-token') is None
def test_tampered_token_returns_none(self):
token = create_unsubscribe_token('user123')
# Change one character
tampered = token[:-1] + ('A' if token[-1] != 'A' else 'B')
assert validate_unsubscribe_token(tampered) is None
def test_expired_token_returns_none(self):
"""Build a token with a past expiry timestamp by mocking time."""
import base64, hmac, hashlib
user_id = 'user_expired'
expiry_ts = int(time.time()) - 1 # already expired
payload_str = f"{user_id}:{expiry_ts}"
secret = os.environ.get('DIGEST_TOKEN_SECRET')
sig = hmac.new(secret.encode(), payload_str.encode(), hashlib.sha256).hexdigest()
raw = f"{payload_str}:{sig}"
token = base64.urlsafe_b64encode(raw.encode()).decode()
assert validate_unsubscribe_token(token) is None

View File

@@ -0,0 +1,170 @@
import os
import pytest
from flask import Flask
from werkzeug.security import generate_password_hash
from tinydb import Query
from api.push_subscription_api import push_subscription_api
from api.auth_api import auth_api
from db.db import users_db, push_subscriptions_db
from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS
TEST_EMAIL = "pushtest@example.com"
TEST_PASSWORD = "pushpassword123"
TEST_USER_ID = "push_test_user_id"
TEST_ENDPOINT = "https://fcm.googleapis.com/fcm/send/test-endpoint-abc"
TEST_KEYS = {"p256dh": "BNgz3XcMv1", "auth": "abc123"}
def seed_user():
users_db.remove(Query().email == TEST_EMAIL)
users_db.insert({
"id": TEST_USER_ID,
"first_name": "Push",
"last_name": "Tester",
"email": TEST_EMAIL,
"password": generate_password_hash(TEST_PASSWORD),
"verified": True,
"image_id": None,
"marked_for_deletion": False,
"marked_for_deletion_at": None,
"role": "user",
"timezone": None,
"email_digest_enabled": True,
})
def cleanup():
users_db.remove(Query().email == TEST_EMAIL)
push_subscriptions_db.remove(Query().user_id == TEST_USER_ID)
@pytest.fixture
def client():
app = Flask(__name__)
app.register_blueprint(push_subscription_api)
app.register_blueprint(auth_api, url_prefix='/auth')
app.config['TESTING'] = True
app.config['SECRET_KEY'] = TEST_SECRET_KEY
app.config['REFRESH_TOKEN_EXPIRY_DAYS'] = TEST_REFRESH_TOKEN_EXPIRY_DAYS
app.config['VAPID_PUBLIC_KEY'] = 'test-vapid-public-key'
app.config['FRONTEND_URL'] = 'http://localhost:5173'
cleanup()
seed_user()
with app.test_client() as c:
yield c
cleanup()
@pytest.fixture
def auth_client(client):
client.post('/auth/login', json={"email": TEST_EMAIL, "password": TEST_PASSWORD})
return client
class TestVapidKey:
def test_returns_public_key(self, client):
res = client.get('/push-vapid-key')
assert res.status_code == 200
assert res.get_json()['public_key'] == 'test-vapid-public-key'
def test_unauthenticated_ok(self, client):
# VAPID key endpoint is public
res = client.get('/push-vapid-key')
assert res.status_code == 200
class TestSubscribe:
def test_subscribe_stores_subscription(self, auth_client):
res = auth_client.post('/push-subscription', json={
"endpoint": TEST_ENDPOINT,
"keys": TEST_KEYS,
})
assert res.status_code == 200
data = res.get_json()
assert 'id' in data
saved = push_subscriptions_db.get(
(Query().user_id == TEST_USER_ID) & (Query().endpoint == TEST_ENDPOINT)
)
assert saved is not None
def test_subscribe_updates_timezone(self, auth_client):
auth_client.post('/push-subscription', json={
"endpoint": TEST_ENDPOINT,
"keys": TEST_KEYS,
"timezone": "America/New_York",
})
user = users_db.get(Query().id == TEST_USER_ID)
assert user['timezone'] == 'America/New_York'
def test_subscribe_upserts_same_endpoint(self, auth_client):
auth_client.post('/push-subscription', json={"endpoint": TEST_ENDPOINT, "keys": TEST_KEYS})
auth_client.post('/push-subscription', json={"endpoint": TEST_ENDPOINT, "keys": TEST_KEYS})
count = len(push_subscriptions_db.search(
(Query().user_id == TEST_USER_ID) & (Query().endpoint == TEST_ENDPOINT)
))
assert count == 1
def test_subscribe_requires_endpoint(self, auth_client):
res = auth_client.post('/push-subscription', json={"keys": TEST_KEYS})
assert res.status_code == 400
def test_subscribe_requires_keys(self, auth_client):
res = auth_client.post('/push-subscription', json={"endpoint": TEST_ENDPOINT})
assert res.status_code == 400
def test_subscribe_requires_p256dh_and_auth(self, auth_client):
res = auth_client.post('/push-subscription', json={
"endpoint": TEST_ENDPOINT,
"keys": {"p256dh": "only-one-key"},
})
assert res.status_code == 400
def test_subscribe_requires_auth(self, client):
res = client.post('/push-subscription', json={
"endpoint": TEST_ENDPOINT,
"keys": TEST_KEYS,
})
assert res.status_code == 401
def test_subscribe_multiple_endpoints_per_user(self, auth_client):
"""Multiple subscriptions can coexist for the same user (one per device)."""
endpoint2 = "https://fcm.googleapis.com/fcm/send/second-device-endpoint"
keys2 = {"p256dh": "BNgz3XcMv2", "auth": "def456"}
auth_client.post('/push-subscription', json={"endpoint": TEST_ENDPOINT, "keys": TEST_KEYS})
auth_client.post('/push-subscription', json={"endpoint": endpoint2, "keys": keys2})
subs = push_subscriptions_db.search(Query().user_id == TEST_USER_ID)
assert len(subs) == 2
endpoints = {s['endpoint'] for s in subs}
assert TEST_ENDPOINT in endpoints
assert endpoint2 in endpoints
class TestUnsubscribe:
def test_unsubscribe_removes_subscription(self, auth_client):
auth_client.post('/push-subscription', json={"endpoint": TEST_ENDPOINT, "keys": TEST_KEYS})
res = auth_client.delete('/push-subscription', json={"endpoint": TEST_ENDPOINT})
assert res.status_code == 200
data = res.get_json()
assert data['removed'] >= 1
saved = push_subscriptions_db.get(
(Query().user_id == TEST_USER_ID) & (Query().endpoint == TEST_ENDPOINT)
)
assert saved is None
def test_unsubscribe_nonexistent_endpoint_ok(self, auth_client):
res = auth_client.delete('/push-subscription', json={"endpoint": "https://nonexistent"})
assert res.status_code == 200
assert res.get_json()['removed'] == 0
def test_unsubscribe_requires_endpoint(self, auth_client):
res = auth_client.delete('/push-subscription', json={})
assert res.status_code == 400
def test_unsubscribe_requires_auth(self, client):
res = client.delete('/push-subscription', json={"endpoint": TEST_ENDPOINT})
assert res.status_code == 401

View File

@@ -227,3 +227,36 @@ def test_update_profile_success(authenticated_client):
assert user['first_name'] == 'Updated'
assert user['last_name'] == 'Name'
assert user['image_id'] == 'new_image'
def test_get_profile_includes_email_digest_enabled(authenticated_client):
"""GET /user/profile response includes email_digest_enabled field."""
response = authenticated_client.get('/user/profile')
assert response.status_code == 200
data = response.get_json()
assert 'email_digest_enabled' in data
assert isinstance(data['email_digest_enabled'], bool)
def test_update_profile_disables_digest(authenticated_client):
"""PUT /user/profile with email_digest_enabled: false disables the digest."""
# Ensure it starts enabled
users_db.update({'email_digest_enabled': True}, Query().email == TEST_EMAIL)
response = authenticated_client.put('/user/profile', json={'email_digest_enabled': False})
assert response.status_code == 200
user = users_db.search(Query().email == TEST_EMAIL)[0]
assert user['email_digest_enabled'] is False
def test_update_profile_enables_digest(authenticated_client):
"""PUT /user/profile with email_digest_enabled: true re-enables the digest."""
# Start disabled
users_db.update({'email_digest_enabled': False}, Query().email == TEST_EMAIL)
response = authenticated_client.put('/user/profile', json={'email_digest_enabled': True})
assert response.status_code == 200
user = users_db.search(Query().email == TEST_EMAIL)[0]
assert user['email_digest_enabled'] is True

View File

@@ -0,0 +1,201 @@
"""Tests for web push notifications triggered by child actions.
Patches `utils.push_sender.webpush` so no real HTTP requests are made.
A push subscription is seeded in push_subscriptions_db so send_push_to_user
actually calls webpush (rather than short-circuiting at "no subscriptions").
"""
import json
import pytest
from unittest.mock import patch, MagicMock
from flask import Flask
from werkzeug.security import generate_password_hash
from tinydb import Query
from api.child_api import child_api
from api.auth_api import auth_api
from db.db import (
child_db, task_db, reward_db, users_db,
pending_confirmations_db, push_subscriptions_db,
)
from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS
TEST_USER_ID = "wp_test_user_id"
TEST_EMAIL = "wptest@example.com"
TEST_PASSWORD = "wptestpass"
TEST_CHILD_ID = "wp_child_id"
TEST_TASK_ID = "wp_task_id"
TEST_REWARD_ID_AFFORD = "wp_reward_afford"
TEST_REWARD_ID_CANT = "wp_reward_cant"
TEST_ENDPOINT = "https://push.example.com/wp_endpoint"
def seed_data():
users_db.remove(Query().id == TEST_USER_ID)
child_db.remove(Query().id == TEST_CHILD_ID)
task_db.remove(Query().id == TEST_TASK_ID)
reward_db.remove((Query().id == TEST_REWARD_ID_AFFORD) | (Query().id == TEST_REWARD_ID_CANT))
pending_confirmations_db.remove(Query().user_id == TEST_USER_ID)
push_subscriptions_db.remove(Query().user_id == TEST_USER_ID)
users_db.insert({
"id": TEST_USER_ID,
"first_name": "Push",
"last_name": "Tester",
"email": TEST_EMAIL,
"password": generate_password_hash(TEST_PASSWORD),
"verified": True,
"role": "user",
"image_id": None,
"marked_for_deletion": False,
"marked_for_deletion_at": None,
"timezone": "America/New_York",
"email_digest_enabled": True,
})
child_db.insert({
"id": TEST_CHILD_ID,
"user_id": TEST_USER_ID,
"name": "Push Child",
"age": 8,
"points": 50,
"tasks": [TEST_TASK_ID],
"rewards": [TEST_REWARD_ID_AFFORD, TEST_REWARD_ID_CANT],
"image_id": None,
})
task_db.insert({
"id": TEST_TASK_ID,
"user_id": TEST_USER_ID,
"name": "Clean Room",
"points": 10,
"type": "chore",
"image_id": None,
})
# Affordable reward (cost <= child.points = 50)
reward_db.insert({
"id": TEST_REWARD_ID_AFFORD,
"user_id": TEST_USER_ID,
"name": "Movie Night",
"cost": 20,
"image_id": None,
})
# Unaffordable reward (cost > child.points = 50)
reward_db.insert({
"id": TEST_REWARD_ID_CANT,
"user_id": TEST_USER_ID,
"name": "New Bike",
"cost": 200,
"image_id": None,
})
def seed_subscription():
push_subscriptions_db.insert({
"id": "wp_sub_id",
"user_id": TEST_USER_ID,
"endpoint": TEST_ENDPOINT,
"keys": {"p256dh": "BNgz3test", "auth": "authtest"},
"created_at": "2024-01-01T00:00:00+00:00",
})
def cleanup():
users_db.remove(Query().id == TEST_USER_ID)
child_db.remove(Query().id == TEST_CHILD_ID)
task_db.remove(Query().id == TEST_TASK_ID)
reward_db.remove((Query().id == TEST_REWARD_ID_AFFORD) | (Query().id == TEST_REWARD_ID_CANT))
pending_confirmations_db.remove(Query().user_id == TEST_USER_ID)
push_subscriptions_db.remove(Query().user_id == TEST_USER_ID)
@pytest.fixture
def client():
app = Flask(__name__)
app.register_blueprint(child_api)
app.register_blueprint(auth_api, url_prefix='/auth')
app.config['TESTING'] = True
app.config['SECRET_KEY'] = TEST_SECRET_KEY
app.config['REFRESH_TOKEN_EXPIRY_DAYS'] = TEST_REFRESH_TOKEN_EXPIRY_DAYS
app.config['FRONTEND_URL'] = 'http://localhost:5173'
cleanup()
seed_data()
with app.test_client() as c:
c.post('/auth/login', json={"email": TEST_EMAIL, "password": TEST_PASSWORD})
yield c
cleanup()
class TestWebPushOnChoreConfirm:
def test_push_fired_to_all_subscriptions_on_chore_confirm(self, client):
"""Web push is sent to all stored subscriptions when a chore is confirmed."""
seed_subscription()
with patch('utils.push_sender.webpush') as mock_webpush:
resp = client.post(
f'/child/{TEST_CHILD_ID}/confirm-chore',
json={'task_id': TEST_TASK_ID},
)
assert resp.status_code == 200
assert mock_webpush.called
def test_push_payload_includes_required_fields(self, client):
"""Push payload includes user_id, approve_token, and deny_token."""
seed_subscription()
with patch('utils.push_sender.webpush') as mock_webpush:
client.post(
f'/child/{TEST_CHILD_ID}/confirm-chore',
json={'task_id': TEST_TASK_ID},
)
call_kwargs = mock_webpush.call_args[1]
payload = json.loads(call_kwargs['data'])
assert payload['user_id'] == TEST_USER_ID
assert 'approve_token' in payload
assert 'deny_token' in payload
def test_push_not_fired_when_no_subscriptions(self, client):
"""No error is raised when the parent has no push subscriptions."""
push_subscriptions_db.remove(Query().user_id == TEST_USER_ID)
with patch('utils.push_sender.webpush') as mock_webpush:
resp = client.post(
f'/child/{TEST_CHILD_ID}/confirm-chore',
json={'task_id': TEST_TASK_ID},
)
assert resp.status_code == 200
assert not mock_webpush.called
class TestWebPushOnRewardRequest:
def test_push_fired_for_affordable_reward_request(self, client):
"""Web push is fired when a child requests a reward they can afford."""
seed_subscription()
with patch('utils.push_sender.webpush') as mock_webpush:
resp = client.post(
f'/child/{TEST_CHILD_ID}/request-reward',
json={'reward_id': TEST_REWARD_ID_AFFORD},
)
assert resp.status_code == 200
assert mock_webpush.called
# Cleanup pending
pending_confirmations_db.remove(Query().child_id == TEST_CHILD_ID)
def test_push_not_fired_for_unaffordable_reward(self, client):
"""Web push is NOT fired when a child requests a reward they cannot afford."""
seed_subscription()
with patch('utils.push_sender.webpush') as mock_webpush:
resp = client.post(
f'/child/{TEST_CHILD_ID}/request-reward',
json={'reward_id': TEST_REWARD_ID_CANT},
)
# request-reward rejects unaffordable requests with 400
assert resp.status_code == 400
assert not mock_webpush.called
def test_push_not_fired_for_reward_when_no_subscriptions(self, client):
"""No error is raised for reward requests when the parent has no subscriptions."""
push_subscriptions_db.remove(Query().user_id == TEST_USER_ID)
with patch('utils.push_sender.webpush') as mock_webpush:
resp = client.post(
f'/child/{TEST_CHILD_ID}/request-reward',
json={'reward_id': TEST_REWARD_ID_AFFORD},
)
assert resp.status_code == 200
assert not mock_webpush.called
# Cleanup pending
pending_confirmations_db.remove(Query().child_id == TEST_CHILD_ID)

View File

@@ -0,0 +1,157 @@
import logging
import os
from datetime import datetime, timezone
from apscheduler.schedulers.background import BackgroundScheduler
from tinydb import Query
logger = logging.getLogger(__name__)
def _get_local_hour(tz_str: str | None) -> int:
"""Return the current hour (0-23) in the given IANA timezone, falling back to UTC."""
try:
if tz_str:
from zoneinfo import ZoneInfo # Python 3.9+
local_now = datetime.now(ZoneInfo(tz_str))
return local_now.hour
except Exception:
pass
return datetime.now(timezone.utc).hour
def send_digests(app) -> None:
"""Hourly job: send the 9pm digest to every eligible user whose local time is 21."""
if os.environ.get('DB_ENV') == 'e2e':
return
with app.app_context():
try:
from db.db import users_db, pending_confirmations_db, child_db, task_db, reward_db
from utils.email_sender import send_digest_email
from utils.digest_token import create_action_token, create_unsubscribe_token
from flask import current_app
frontend_url = current_app.config.get('FRONTEND_URL', 'https://localhost:5173')
UserQ = Query()
users = users_db.search(
(UserQ.verified == True) & (UserQ.email_digest_enabled == True)
)
for user_dict in users:
user_id = user_dict.get('id')
user_email = user_dict.get('email')
user_tz = user_dict.get('timezone')
local_hour = _get_local_hour(user_tz)
if local_hour != 21:
continue
# Gather pending items for this user
PendingQ = Query()
pending_items = pending_confirmations_db.search(
(PendingQ.user_id == user_id) & (PendingQ.status == 'pending')
)
if not pending_items:
continue
ChildQ = Query()
TaskQ = Query()
RewardQ = Query()
items = []
for p in pending_items:
child_id = p.get('child_id')
entity_id = p.get('entity_id')
entity_type = p.get('entity_type')
child_result = child_db.get(ChildQ.id == child_id)
if not child_result:
logger.warning(f'Digest: child {child_id} not found for pending item {p.get("id")} (user {user_id})')
continue
child_name = child_result.get('name')
if not child_name:
logger.warning(f'Digest: child {child_id} has no name (user {user_id})')
child_name = 'Unknown'
if entity_type == 'chore':
entity_result = task_db.get(
(TaskQ.id == entity_id) &
((TaskQ.user_id == user_id) | (TaskQ.user_id == None))
)
else:
entity_result = reward_db.get(
(RewardQ.id == entity_id) &
((RewardQ.user_id == user_id) | (RewardQ.user_id == None))
)
if not entity_result:
logger.warning(f'Digest: {entity_type} {entity_id} not found for pending item {p.get("id")} (user {user_id})')
continue
entity_name = entity_result.get('name')
if not entity_name:
logger.warning(f'Digest: {entity_type} {entity_id} has no name (user {user_id})')
entity_name = 'Unknown'
approve_token = create_action_token(
user_id=user_id,
child_id=child_id,
entity_id=entity_id,
entity_type=entity_type,
action='approve',
)
deny_token = create_action_token(
user_id=user_id,
child_id=child_id,
entity_id=entity_id,
entity_type=entity_type,
action='deny',
)
view_url = (
f"{frontend_url}/parent/{child_id}"
f"?scrollTo={entity_id}&entityType={entity_type}"
)
approve_url = f"{frontend_url}/api/digest-action/{approve_token.id}"
deny_url = f"{frontend_url}/api/digest-action/{deny_token.id}"
items.append({
'child_name': child_name,
'entity_name': entity_name,
'entity_type': entity_type,
'view_url': view_url,
'approve_url': approve_url,
'deny_url': deny_url,
'child_id': child_id,
'entity_id': entity_id,
})
if not items:
continue
unsubscribe_token = create_unsubscribe_token(user_id)
try:
send_digest_email(user_email, items, unsubscribe_token)
except Exception as e:
logger.error(f'Failed to send digest to {user_email}: {e}')
except Exception as e:
logger.error(f'Error in send_digests job: {e}')
def start_digest_scheduler(app) -> BackgroundScheduler:
"""Start the hourly digest scheduler. Returns the scheduler instance."""
scheduler = BackgroundScheduler()
scheduler.add_job(
send_digests,
'cron',
minute=0,
args=[app],
id='digest_scheduler',
replace_existing=True,
)
scheduler.start()
logger.info('Digest scheduler started (hourly)')
return scheduler

View File

@@ -0,0 +1,141 @@
import hashlib
import hmac
import json
import os
import time
import uuid
from datetime import datetime, timedelta, timezone
from db.digest_action_tokens import insert_token, get_token_by_id, mark_token_used
from models.digest_action_token import DigestActionToken
def _get_secret() -> bytes:
secret = os.environ.get('DIGEST_TOKEN_SECRET')
if not secret:
raise RuntimeError('DIGEST_TOKEN_SECRET environment variable is not set.')
return secret.encode('utf-8')
def _sign(payload: dict) -> str:
"""Return HMAC-SHA256 hex digest of the canonical JSON payload."""
canonical = json.dumps(payload, sort_keys=True, separators=(',', ':'))
return hmac.new(_get_secret(), canonical.encode('utf-8'), hashlib.sha256).hexdigest()
def create_action_token(
user_id: str,
child_id: str,
entity_id: str,
entity_type: str,
action: str,
expiry_hours: int = 24,
) -> DigestActionToken:
"""Create and persist a signed action token. Returns the saved token."""
token_id = str(uuid.uuid4())
expires_at = (datetime.now(timezone.utc) + timedelta(hours=expiry_hours)).isoformat()
payload = {
'id': token_id,
'user_id': user_id,
'child_id': child_id,
'entity_id': entity_id,
'entity_type': entity_type,
'action': action,
'expires_at': expires_at,
}
signature = _sign(payload)
token = DigestActionToken(
id=token_id,
user_id=user_id,
child_id=child_id,
entity_id=entity_id,
entity_type=entity_type,
action=action,
expires_at=expires_at,
used=False,
signature=signature,
)
insert_token(token)
return token
def validate_and_consume_token(token_id: str) -> DigestActionToken | None:
"""
Validate a token. Returns the token if valid and marks it as used.
Returns None if token is missing, expired, used, or has an invalid signature.
"""
token = get_token_by_id(token_id)
if not token:
return None
if token.used:
return None
# Check expiry
try:
expires_at = datetime.fromisoformat(token.expires_at)
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
if datetime.now(timezone.utc) > expires_at:
return None
except (ValueError, TypeError):
return None
# Verify HMAC signature
payload = {
'id': token.id,
'user_id': token.user_id,
'child_id': token.child_id,
'entity_id': token.entity_id,
'entity_type': token.entity_type,
'action': token.action,
'expires_at': token.expires_at,
}
expected_sig = _sign(payload)
if not hmac.compare_digest(token.signature, expected_sig):
return None
mark_token_used(token_id)
return token
def create_unsubscribe_token(user_id: str) -> str:
"""
Create a simple signed unsubscribe token string (not persisted in DB).
Format: '<user_id>.<expiry_ts>.<signature>'
"""
expiry_ts = int(time.time()) + 30 * 24 * 3600 # 30 days
payload_str = f"{user_id}:{expiry_ts}"
sig = hmac.new(_get_secret(), payload_str.encode('utf-8'), hashlib.sha256).hexdigest()
import base64
token = base64.urlsafe_b64encode(f"{payload_str}:{sig}".encode()).decode()
return token
def validate_unsubscribe_token(token: str) -> str | None:
"""
Validate an unsubscribe token.
Returns user_id if valid, None otherwise.
"""
import base64
try:
decoded = base64.urlsafe_b64decode(token.encode()).decode()
parts = decoded.rsplit(':', 1)
if len(parts) != 2:
return None
payload_str, sig = parts
expected_sig = hmac.new(_get_secret(), payload_str.encode('utf-8'), hashlib.sha256).hexdigest()
if not hmac.compare_digest(sig, expected_sig):
return None
sub_parts = payload_str.split(':', 1)
if len(sub_parts) != 2:
return None
user_id, expiry_str = sub_parts
expiry_ts = int(expiry_str)
if time.time() > expiry_ts:
return None
return user_id
except Exception:
return None

View File

@@ -60,4 +60,83 @@ def send_pin_setup_email(to_email: str, code: str) -> None:
Mail(current_app).send(msg)
print(f"[EMAIL to {to_email}] Parent PIN setup code: {code}")
except Exception:
print(f"Failed to send email to {to_email}. Parent PIN setup code: {code}")
print(f"Failed to send email to {to_email}. Parent PIN setup code: {code}")
def send_digest_email(to_email: str, items: list, unsubscribe_token: str) -> None:
"""
Send the nightly digest email listing pending chore/reward items.
Each item in `items` is a dict with keys:
child_name, entity_name, entity_type, view_url, approve_url, deny_url
Items are grouped by child_name in the email.
"""
frontend_url = current_app.config.get('FRONTEND_URL', 'https://localhost:5173')
unsubscribe_url = f"{frontend_url}/api/digest-unsubscribe/{unsubscribe_token}"
# Group items by child
from collections import defaultdict
by_child: dict = defaultdict(list)
for item in items:
by_child[item['child_name']].append(item)
child_sections = ''
for child_name, child_items in by_child.items():
rows = ''
for item in child_items:
entity_label = 'Chore' if item['entity_type'] == 'chore' else 'Reward'
rows += f"""
<tr>
<td style="padding:8px 4px;border-bottom:1px solid #eee;">
<span style="font-weight:500;">{item['entity_name']}</span>
<span style="color:#888;font-size:0.85em;margin-left:6px;">{entity_label}</span>
</td>
<td style="padding:8px 4px;border-bottom:1px solid #eee;text-align:center;">
<a href="{item['view_url']}"
style="color:#0066cc;text-decoration:underline;margin-right:8px;">View</a>
<a href="{item['approve_url']}"
style="color:#22863a;font-weight:bold;text-decoration:underline;margin-right:8px;">Approve</a>
<a href="{item['deny_url']}"
style="color:#cb2431;font-weight:bold;text-decoration:underline;">Deny</a>
</td>
</tr>"""
child_sections += f"""
<div style="margin-bottom:24px;">
<h3 style="color:#333;margin:0 0 8px 0;font-size:1rem;">{child_name}</h3>
<table style="width:100%;border-collapse:collapse;font-size:0.92rem;">
<tbody>{rows}</tbody>
</table>
</div>"""
html_body = f"""
<div style="font-family:sans-serif;max-width:600px;margin:0 auto;">
<div style="background:#4a90e2;color:#fff;padding:18px 24px;border-radius:6px 6px 0 0;">
<h2 style="margin:0;font-size:1.2rem;">Reward App &mdash; Daily Summary</h2>
</div>
<div style="background:#fff;padding:24px;border:1px solid #ddd;border-top:none;border-radius:0 0 6px 6px;">
<p style="color:#555;margin:0 0 20px 0;">
You have pending items that need your attention:
</p>
{child_sections}
</div>
<div style="color:#aaa;font-size:0.8rem;padding:12px 0;text-align:center;">
Reward App &bull;
<a href="{unsubscribe_url}" style="color:#aaa;">Unsubscribe from daily digest</a>
</div>
</div>
"""
msg = Message(
subject='Reward App — Daily Summary',
recipients=[to_email],
html=html_body,
sender=current_app.config.get('MAIL_DEFAULT_SENDER', 'no-reply@reward-app.local'),
)
try:
if os.environ.get('DB_ENV') == 'e2e':
return
Mail(current_app).send(msg)
print(f"[EMAIL to {to_email}] Daily digest sent ({len(items)} items)")
except Exception:
print(f"Failed to send digest email to {to_email}")

View File

@@ -0,0 +1,68 @@
import json
import logging
import os
from pywebpush import webpush, WebPushException
from db.push_subscriptions import get_subscriptions_by_user, delete_subscription_by_id
logger = logging.getLogger(__name__)
def _get_vapid_private_key() -> str | None:
return os.environ.get('VAPID_PRIVATE_KEY')
def _get_vapid_claims_email() -> str:
return os.environ.get('VAPID_CLAIMS_EMAIL', 'admin@reward-app.local')
def send_push_to_user(user_id: str, payload: dict) -> int:
"""
Send a web push notification to all stored subscriptions for the user.
Returns the number of subscriptions successfully notified.
Stale/expired subscriptions (HTTP 404/410) are removed automatically.
"""
private_key = _get_vapid_private_key()
if not private_key:
logger.warning('VAPID_PRIVATE_KEY not configured; skipping push notification')
return 0
subscriptions = get_subscriptions_by_user(user_id)
if not subscriptions:
return 0
claims_email = _get_vapid_claims_email()
sent = 0
for sub in subscriptions:
try:
subscription_info = {
'endpoint': sub.endpoint,
'keys': sub.keys,
}
webpush(
subscription_info=subscription_info,
data=json.dumps(payload),
vapid_private_key=private_key,
vapid_claims={'sub': f'mailto:{claims_email}'},
ttl=86400,
)
sent += 1
except WebPushException as e:
status_code = e.response.status_code if e.response is not None else None
if status_code in (404, 410):
# Subscription is gone — clean it up
logger.info(
f'Removing stale push subscription {sub.id} for user {user_id} (HTTP {status_code})'
)
delete_subscription_by_id(sub.id)
else:
logger.error(
f'WebPushException for subscription {sub.id} (user {user_id}): {e}'
)
except Exception as e:
logger.error(f'Unexpected error sending push to subscription {sub.id}: {e}')
return sent