Add push notification functionality with tests and digest scheduler
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m14s
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m14s
- Implemented push subscription API with tests for subscribing and unsubscribing users. - Created web push notification tests triggered by child actions. - Added digest scheduler to send email digests to users at 9 PM local time. - Developed utility functions for creating and validating digest action tokens. - Integrated web push sender to handle sending notifications to users. - Added service worker for handling push notifications in the frontend. - Created a push opt-in component for user notification preferences. - Implemented tests for the push opt-in component to ensure correct behavior. - Updated frontend services to manage push subscriptions and permissions.
This commit is contained in:
257
backend/api/child_action_helpers.py
Normal file
257
backend/api/child_action_helpers.py
Normal file
@@ -0,0 +1,257 @@
|
||||
"""Shared business logic for chore confirmation and reward actions.
|
||||
|
||||
Called from both child_api.py (JWT-authenticated endpoints) and
|
||||
digest_action_api.py (token-authenticated endpoints). All functions take
|
||||
user_id explicitly rather than reading it from the Flask request context.
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from tinydb import Query
|
||||
|
||||
from db.db import child_db, task_db, reward_db, pending_confirmations_db
|
||||
from db.child_overrides import get_override
|
||||
from db.chore_schedules import get_schedule
|
||||
from db.tracking import insert_tracking_event
|
||||
from events.sse import send_event_to_user
|
||||
from events.types.child_chore_confirmation import ChildChoreConfirmation
|
||||
from events.types.child_reward_request import ChildRewardRequest
|
||||
from events.types.child_reward_triggered import ChildRewardTriggered
|
||||
from events.types.child_task_triggered import ChildTaskTriggered
|
||||
from events.types.tracking_event_created import TrackingEventCreated
|
||||
from events.types.event import Event
|
||||
from events.types.event_types import EventType
|
||||
from models.child import Child
|
||||
from models.reward import Reward
|
||||
from models.task import Task
|
||||
from models.tracking_event import TrackingEvent
|
||||
from utils.tracking_logger import log_tracking_event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def approve_chore(user_id: str, child_id: str, task_id: str) -> dict | None:
|
||||
"""Award points for a completed chore and mark the pending confirmation approved.
|
||||
|
||||
Returns a result dict on success, or None if the confirmation was already resolved.
|
||||
Raises ValueError if the child or task cannot be found.
|
||||
"""
|
||||
ChildQ = Query()
|
||||
child_result = child_db.get((ChildQ.id == child_id) & (ChildQ.user_id == user_id))
|
||||
if not child_result:
|
||||
raise ValueError(f'Child {child_id} not found for user {user_id}')
|
||||
child = Child.from_dict(child_result)
|
||||
|
||||
PendingQ = Query()
|
||||
existing = pending_confirmations_db.get(
|
||||
(PendingQ.child_id == child_id) & (PendingQ.entity_id == task_id) &
|
||||
(PendingQ.entity_type == 'chore') & (PendingQ.status == 'pending') &
|
||||
(PendingQ.user_id == user_id)
|
||||
)
|
||||
if not existing:
|
||||
logger.info(f'No pending chore for child {child_id}, task {task_id} — already resolved')
|
||||
return None
|
||||
|
||||
TaskQ = Query()
|
||||
task_result = task_db.get(
|
||||
(TaskQ.id == task_id) & ((TaskQ.user_id == user_id) | (TaskQ.user_id == None))
|
||||
)
|
||||
if not task_result:
|
||||
raise ValueError(f'Task {task_id} not found')
|
||||
task = Task.from_dict(task_result)
|
||||
|
||||
override = get_override(child_id, task_id)
|
||||
points_value = override.custom_value if override else task.points
|
||||
points_before = child.points
|
||||
child.points += points_value
|
||||
child_db.update({'points': child.points}, ChildQ.id == child_id)
|
||||
|
||||
schedule = get_schedule(child_id, task_id)
|
||||
if schedule:
|
||||
now_str = datetime.now(timezone.utc).isoformat()
|
||||
pending_confirmations_db.update(
|
||||
{'status': 'approved', 'approved_at': now_str},
|
||||
(PendingQ.child_id == child_id) & (PendingQ.entity_id == task_id) &
|
||||
(PendingQ.entity_type == 'chore') & (PendingQ.user_id == user_id)
|
||||
)
|
||||
else:
|
||||
pending_confirmations_db.remove(
|
||||
(PendingQ.child_id == child_id) & (PendingQ.entity_id == task_id) &
|
||||
(PendingQ.entity_type == 'chore') & (PendingQ.user_id == user_id)
|
||||
)
|
||||
|
||||
tracking_metadata = {
|
||||
'task_name': task.name,
|
||||
'task_type': task.type,
|
||||
'default_points': task.points,
|
||||
}
|
||||
if override:
|
||||
tracking_metadata['custom_points'] = override.custom_value
|
||||
tracking_metadata['has_override'] = True
|
||||
|
||||
tracking_event = TrackingEvent.create_event(
|
||||
user_id=user_id, child_id=child_id, entity_type='chore', entity_id=task_id,
|
||||
action='approved', points_before=points_before, points_after=child.points,
|
||||
metadata=tracking_metadata,
|
||||
)
|
||||
insert_tracking_event(tracking_event)
|
||||
log_tracking_event(tracking_event)
|
||||
|
||||
send_event_to_user(user_id, Event(EventType.TRACKING_EVENT_CREATED.value,
|
||||
TrackingEventCreated(tracking_event.id, child_id, 'chore', 'approved')))
|
||||
send_event_to_user(user_id, Event(EventType.CHILD_CHORE_CONFIRMATION.value,
|
||||
ChildChoreConfirmation(child_id, task_id, ChildChoreConfirmation.OPERATION_APPROVED)))
|
||||
send_event_to_user(user_id, Event(EventType.CHILD_TASK_TRIGGERED.value,
|
||||
ChildTaskTriggered(task_id, child_id, child.points)))
|
||||
|
||||
return {'task_name': task.name, 'child_name': child.name, 'child_id': child_id, 'points': child.points}
|
||||
|
||||
|
||||
def reject_chore(user_id: str, child_id: str, task_id: str) -> None:
|
||||
"""Reject a pending chore confirmation. No-op if already resolved."""
|
||||
PendingQ = Query()
|
||||
existing = pending_confirmations_db.get(
|
||||
(PendingQ.child_id == child_id) & (PendingQ.entity_id == task_id) &
|
||||
(PendingQ.entity_type == 'chore') & (PendingQ.status == 'pending') &
|
||||
(PendingQ.user_id == user_id)
|
||||
)
|
||||
if not existing:
|
||||
logger.info(f'No pending chore for child {child_id}, task {task_id} — already resolved')
|
||||
return
|
||||
|
||||
pending_confirmations_db.remove(
|
||||
(PendingQ.child_id == child_id) & (PendingQ.entity_id == task_id) &
|
||||
(PendingQ.entity_type == 'chore') & (PendingQ.user_id == user_id)
|
||||
)
|
||||
|
||||
ChildQ = Query()
|
||||
child_result = child_db.get((ChildQ.id == child_id) & (ChildQ.user_id == user_id))
|
||||
if child_result:
|
||||
child = Child.from_dict(child_result)
|
||||
TaskQ = Query()
|
||||
task_result = task_db.get(
|
||||
(TaskQ.id == task_id) & ((TaskQ.user_id == user_id) | (TaskQ.user_id == None))
|
||||
)
|
||||
task_name = task_result.get('name') if task_result else 'Unknown'
|
||||
|
||||
tracking_event = TrackingEvent.create_event(
|
||||
user_id=user_id, child_id=child_id, entity_type='chore', entity_id=task_id,
|
||||
action='rejected', points_before=child.points, points_after=child.points,
|
||||
metadata={'task_name': task_name},
|
||||
)
|
||||
insert_tracking_event(tracking_event)
|
||||
log_tracking_event(tracking_event)
|
||||
|
||||
send_event_to_user(user_id, Event(EventType.TRACKING_EVENT_CREATED.value,
|
||||
TrackingEventCreated(tracking_event.id, child_id, 'chore', 'rejected')))
|
||||
|
||||
send_event_to_user(user_id, Event(EventType.CHILD_CHORE_CONFIRMATION.value,
|
||||
ChildChoreConfirmation(child_id, task_id, ChildChoreConfirmation.OPERATION_REJECTED)))
|
||||
|
||||
|
||||
def approve_reward_request(user_id: str, child_id: str, reward_id: str) -> dict:
|
||||
"""Approve a child's pending reward request: deduct points and fire SSE events.
|
||||
|
||||
Returns a result dict on success.
|
||||
Raises ValueError if child/reward not found or the child has insufficient points.
|
||||
"""
|
||||
ChildQ = Query()
|
||||
child_result = child_db.get((ChildQ.id == child_id) & (ChildQ.user_id == user_id))
|
||||
if not child_result:
|
||||
raise ValueError(f'Child {child_id} not found for user {user_id}')
|
||||
child = Child.from_dict(child_result)
|
||||
|
||||
RewardQ = Query()
|
||||
reward_result = reward_db.get(
|
||||
(RewardQ.id == reward_id) & ((RewardQ.user_id == user_id) | (RewardQ.user_id == None))
|
||||
)
|
||||
if not reward_result:
|
||||
raise ValueError(f'Reward {reward_id} not found')
|
||||
reward = Reward.from_dict(reward_result)
|
||||
|
||||
override = get_override(child_id, reward_id)
|
||||
cost_value = override.custom_value if override else reward.cost
|
||||
|
||||
if child.points < cost_value:
|
||||
raise ValueError(f'Child {child_id} has insufficient points for reward {reward_id}')
|
||||
|
||||
PendingQ = Query()
|
||||
removed = pending_confirmations_db.remove(
|
||||
(PendingQ.child_id == child_id) & (PendingQ.entity_id == reward_id) &
|
||||
(PendingQ.entity_type == 'reward')
|
||||
)
|
||||
if removed:
|
||||
send_event_to_user(user_id, Event(EventType.CHILD_REWARD_REQUEST.value,
|
||||
ChildRewardRequest(child_id, reward_id, ChildRewardRequest.REQUEST_GRANTED)))
|
||||
|
||||
points_before = child.points
|
||||
child.points -= cost_value
|
||||
child_db.update({'points': child.points}, ChildQ.id == child_id)
|
||||
|
||||
tracking_metadata = {
|
||||
'reward_name': reward.name,
|
||||
'reward_cost': reward.cost,
|
||||
'default_cost': reward.cost,
|
||||
}
|
||||
if override:
|
||||
tracking_metadata['custom_cost'] = override.custom_value
|
||||
tracking_metadata['has_override'] = True
|
||||
|
||||
tracking_event = TrackingEvent.create_event(
|
||||
user_id=user_id, child_id=child_id, entity_type='reward', entity_id=reward_id,
|
||||
action='redeemed', points_before=points_before, points_after=child.points,
|
||||
metadata=tracking_metadata,
|
||||
)
|
||||
insert_tracking_event(tracking_event)
|
||||
log_tracking_event(tracking_event)
|
||||
|
||||
send_event_to_user(user_id, Event(EventType.TRACKING_EVENT_CREATED.value,
|
||||
TrackingEventCreated(tracking_event.id, child_id, 'reward', 'redeemed')))
|
||||
send_event_to_user(user_id, Event(EventType.CHILD_REWARD_TRIGGERED.value,
|
||||
ChildRewardTriggered(reward_id, child_id, child.points)))
|
||||
|
||||
return {'reward_name': reward.name, 'child_name': child.name, 'child_id': child_id, 'points': child.points}
|
||||
|
||||
|
||||
def deny_reward(user_id: str, child_id: str, reward_id: str) -> dict | None:
|
||||
"""Deny a child's pending reward request. No-op if already resolved.
|
||||
|
||||
Returns a result dict on success, or None if already resolved.
|
||||
"""
|
||||
PendingQ = Query()
|
||||
existing = pending_confirmations_db.get(
|
||||
(PendingQ.child_id == child_id) & (PendingQ.entity_id == reward_id) &
|
||||
(PendingQ.entity_type == 'reward') & (PendingQ.status == 'pending') &
|
||||
(PendingQ.user_id == user_id)
|
||||
)
|
||||
if not existing:
|
||||
logger.info(f'Reward request for child {child_id}, reward {reward_id} already resolved')
|
||||
return None
|
||||
|
||||
pending_confirmations_db.remove(
|
||||
(PendingQ.child_id == child_id) & (PendingQ.entity_id == reward_id) &
|
||||
(PendingQ.entity_type == 'reward') & (PendingQ.user_id == user_id)
|
||||
)
|
||||
|
||||
ChildQ = Query()
|
||||
child_result = child_db.get((ChildQ.id == child_id) & (ChildQ.user_id == user_id))
|
||||
child_name = 'Unknown'
|
||||
if child_result:
|
||||
child = Child.from_dict(child_result)
|
||||
child_name = child.name
|
||||
|
||||
tracking_event = TrackingEvent.create_event(
|
||||
user_id=user_id, child_id=child_id, entity_type='reward', entity_id=reward_id,
|
||||
action='denied', points_before=child.points, points_after=child.points,
|
||||
metadata={},
|
||||
)
|
||||
insert_tracking_event(tracking_event)
|
||||
log_tracking_event(tracking_event)
|
||||
|
||||
send_event_to_user(user_id, Event(EventType.TRACKING_EVENT_CREATED.value,
|
||||
TrackingEventCreated(tracking_event.id, child_id, 'reward', 'denied')))
|
||||
|
||||
send_event_to_user(user_id, Event(EventType.CHILD_REWARD_REQUEST.value,
|
||||
ChildRewardRequest(child_id, reward_id, ChildRewardRequest.REQUEST_CANCELLED)))
|
||||
|
||||
return {'child_name': child_name}
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
88
backend/api/digest_action_api.py
Normal file
88
backend/api/digest_action_api.py
Normal 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)
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
59
backend/api/push_subscription_api.py
Normal file
59
backend/api/push_subscription_api.py
Normal 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
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
18
backend/db/digest_action_tokens.py
Normal file
18
backend/db/digest_action_tokens.py
Normal 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)
|
||||
38
backend/db/push_subscriptions.py
Normal file
38
backend/db/push_subscriptions.py
Normal 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)
|
||||
@@ -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)
|
||||
44
backend/models/digest_action_token.py
Normal file
44
backend/models/digest_action_token.py
Normal 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
|
||||
29
backend/models/push_subscription.py
Normal file
29
backend/models/push_subscription.py
Normal 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
|
||||
@@ -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.
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
190
backend/tests/test_digest_action_api.py
Normal file
190
backend/tests/test_digest_action_api.py
Normal 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()
|
||||
262
backend/tests/test_digest_scheduler.py
Normal file
262
backend/tests/test_digest_scheduler.py
Normal 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
|
||||
133
backend/tests/test_digest_token.py
Normal file
133
backend/tests/test_digest_token.py
Normal 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
|
||||
170
backend/tests/test_push_subscription_api.py
Normal file
170
backend/tests/test_push_subscription_api.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
201
backend/tests/test_web_push.py
Normal file
201
backend/tests/test_web_push.py
Normal 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)
|
||||
157
backend/utils/digest_scheduler.py
Normal file
157
backend/utils/digest_scheduler.py
Normal 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
|
||||
141
backend/utils/digest_token.py
Normal file
141
backend/utils/digest_token.py
Normal 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
|
||||
@@ -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 — 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 •
|
||||
<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}")
|
||||
68
backend/utils/push_sender.py
Normal file
68
backend/utils/push_sender.py
Normal 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
|
||||
Reference in New Issue
Block a user