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,
|
||||
|
||||
Reference in New Issue
Block a user