Add push notification functionality with tests and digest scheduler
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m14s

- Implemented push subscription API with tests for subscribing and unsubscribing users.
- Created web push notification tests triggered by child actions.
- Added digest scheduler to send email digests to users at 9 PM local time.
- Developed utility functions for creating and validating digest action tokens.
- Integrated web push sender to handle sending notifications to users.
- Added service worker for handling push notifications in the frontend.
- Created a push opt-in component for user notification preferences.
- Implemented tests for the push opt-in component to ensure correct behavior.
- Updated frontend services to manage push subscriptions and permissions.
This commit is contained in:
2026-04-15 21:56:10 -04:00
parent 0d50a324a3
commit ad2bdf4c4f
47 changed files with 3177 additions and 197 deletions

View File

@@ -0,0 +1,257 @@
"""Shared business logic for chore confirmation and reward actions.
Called from both child_api.py (JWT-authenticated endpoints) and
digest_action_api.py (token-authenticated endpoints). All functions take
user_id explicitly rather than reading it from the Flask request context.
"""
import logging
from datetime import datetime, timezone
from tinydb import Query
from db.db import child_db, task_db, reward_db, pending_confirmations_db
from db.child_overrides import get_override
from db.chore_schedules import get_schedule
from db.tracking import insert_tracking_event
from events.sse import send_event_to_user
from events.types.child_chore_confirmation import ChildChoreConfirmation
from events.types.child_reward_request import ChildRewardRequest
from events.types.child_reward_triggered import ChildRewardTriggered
from events.types.child_task_triggered import ChildTaskTriggered
from events.types.tracking_event_created import TrackingEventCreated
from events.types.event import Event
from events.types.event_types import EventType
from models.child import Child
from models.reward import Reward
from models.task import Task
from models.tracking_event import TrackingEvent
from utils.tracking_logger import log_tracking_event
logger = logging.getLogger(__name__)
def approve_chore(user_id: str, child_id: str, task_id: str) -> dict | None:
"""Award points for a completed chore and mark the pending confirmation approved.
Returns a result dict on success, or None if the confirmation was already resolved.
Raises ValueError if the child or task cannot be found.
"""
ChildQ = Query()
child_result = child_db.get((ChildQ.id == child_id) & (ChildQ.user_id == user_id))
if not child_result:
raise ValueError(f'Child {child_id} not found for user {user_id}')
child = Child.from_dict(child_result)
PendingQ = Query()
existing = pending_confirmations_db.get(
(PendingQ.child_id == child_id) & (PendingQ.entity_id == task_id) &
(PendingQ.entity_type == 'chore') & (PendingQ.status == 'pending') &
(PendingQ.user_id == user_id)
)
if not existing:
logger.info(f'No pending chore for child {child_id}, task {task_id} — already resolved')
return None
TaskQ = Query()
task_result = task_db.get(
(TaskQ.id == task_id) & ((TaskQ.user_id == user_id) | (TaskQ.user_id == None))
)
if not task_result:
raise ValueError(f'Task {task_id} not found')
task = Task.from_dict(task_result)
override = get_override(child_id, task_id)
points_value = override.custom_value if override else task.points
points_before = child.points
child.points += points_value
child_db.update({'points': child.points}, ChildQ.id == child_id)
schedule = get_schedule(child_id, task_id)
if schedule:
now_str = datetime.now(timezone.utc).isoformat()
pending_confirmations_db.update(
{'status': 'approved', 'approved_at': now_str},
(PendingQ.child_id == child_id) & (PendingQ.entity_id == task_id) &
(PendingQ.entity_type == 'chore') & (PendingQ.user_id == user_id)
)
else:
pending_confirmations_db.remove(
(PendingQ.child_id == child_id) & (PendingQ.entity_id == task_id) &
(PendingQ.entity_type == 'chore') & (PendingQ.user_id == user_id)
)
tracking_metadata = {
'task_name': task.name,
'task_type': task.type,
'default_points': task.points,
}
if override:
tracking_metadata['custom_points'] = override.custom_value
tracking_metadata['has_override'] = True
tracking_event = TrackingEvent.create_event(
user_id=user_id, child_id=child_id, entity_type='chore', entity_id=task_id,
action='approved', points_before=points_before, points_after=child.points,
metadata=tracking_metadata,
)
insert_tracking_event(tracking_event)
log_tracking_event(tracking_event)
send_event_to_user(user_id, Event(EventType.TRACKING_EVENT_CREATED.value,
TrackingEventCreated(tracking_event.id, child_id, 'chore', 'approved')))
send_event_to_user(user_id, Event(EventType.CHILD_CHORE_CONFIRMATION.value,
ChildChoreConfirmation(child_id, task_id, ChildChoreConfirmation.OPERATION_APPROVED)))
send_event_to_user(user_id, Event(EventType.CHILD_TASK_TRIGGERED.value,
ChildTaskTriggered(task_id, child_id, child.points)))
return {'task_name': task.name, 'child_name': child.name, 'child_id': child_id, 'points': child.points}
def reject_chore(user_id: str, child_id: str, task_id: str) -> None:
"""Reject a pending chore confirmation. No-op if already resolved."""
PendingQ = Query()
existing = pending_confirmations_db.get(
(PendingQ.child_id == child_id) & (PendingQ.entity_id == task_id) &
(PendingQ.entity_type == 'chore') & (PendingQ.status == 'pending') &
(PendingQ.user_id == user_id)
)
if not existing:
logger.info(f'No pending chore for child {child_id}, task {task_id} — already resolved')
return
pending_confirmations_db.remove(
(PendingQ.child_id == child_id) & (PendingQ.entity_id == task_id) &
(PendingQ.entity_type == 'chore') & (PendingQ.user_id == user_id)
)
ChildQ = Query()
child_result = child_db.get((ChildQ.id == child_id) & (ChildQ.user_id == user_id))
if child_result:
child = Child.from_dict(child_result)
TaskQ = Query()
task_result = task_db.get(
(TaskQ.id == task_id) & ((TaskQ.user_id == user_id) | (TaskQ.user_id == None))
)
task_name = task_result.get('name') if task_result else 'Unknown'
tracking_event = TrackingEvent.create_event(
user_id=user_id, child_id=child_id, entity_type='chore', entity_id=task_id,
action='rejected', points_before=child.points, points_after=child.points,
metadata={'task_name': task_name},
)
insert_tracking_event(tracking_event)
log_tracking_event(tracking_event)
send_event_to_user(user_id, Event(EventType.TRACKING_EVENT_CREATED.value,
TrackingEventCreated(tracking_event.id, child_id, 'chore', 'rejected')))
send_event_to_user(user_id, Event(EventType.CHILD_CHORE_CONFIRMATION.value,
ChildChoreConfirmation(child_id, task_id, ChildChoreConfirmation.OPERATION_REJECTED)))
def approve_reward_request(user_id: str, child_id: str, reward_id: str) -> dict:
"""Approve a child's pending reward request: deduct points and fire SSE events.
Returns a result dict on success.
Raises ValueError if child/reward not found or the child has insufficient points.
"""
ChildQ = Query()
child_result = child_db.get((ChildQ.id == child_id) & (ChildQ.user_id == user_id))
if not child_result:
raise ValueError(f'Child {child_id} not found for user {user_id}')
child = Child.from_dict(child_result)
RewardQ = Query()
reward_result = reward_db.get(
(RewardQ.id == reward_id) & ((RewardQ.user_id == user_id) | (RewardQ.user_id == None))
)
if not reward_result:
raise ValueError(f'Reward {reward_id} not found')
reward = Reward.from_dict(reward_result)
override = get_override(child_id, reward_id)
cost_value = override.custom_value if override else reward.cost
if child.points < cost_value:
raise ValueError(f'Child {child_id} has insufficient points for reward {reward_id}')
PendingQ = Query()
removed = pending_confirmations_db.remove(
(PendingQ.child_id == child_id) & (PendingQ.entity_id == reward_id) &
(PendingQ.entity_type == 'reward')
)
if removed:
send_event_to_user(user_id, Event(EventType.CHILD_REWARD_REQUEST.value,
ChildRewardRequest(child_id, reward_id, ChildRewardRequest.REQUEST_GRANTED)))
points_before = child.points
child.points -= cost_value
child_db.update({'points': child.points}, ChildQ.id == child_id)
tracking_metadata = {
'reward_name': reward.name,
'reward_cost': reward.cost,
'default_cost': reward.cost,
}
if override:
tracking_metadata['custom_cost'] = override.custom_value
tracking_metadata['has_override'] = True
tracking_event = TrackingEvent.create_event(
user_id=user_id, child_id=child_id, entity_type='reward', entity_id=reward_id,
action='redeemed', points_before=points_before, points_after=child.points,
metadata=tracking_metadata,
)
insert_tracking_event(tracking_event)
log_tracking_event(tracking_event)
send_event_to_user(user_id, Event(EventType.TRACKING_EVENT_CREATED.value,
TrackingEventCreated(tracking_event.id, child_id, 'reward', 'redeemed')))
send_event_to_user(user_id, Event(EventType.CHILD_REWARD_TRIGGERED.value,
ChildRewardTriggered(reward_id, child_id, child.points)))
return {'reward_name': reward.name, 'child_name': child.name, 'child_id': child_id, 'points': child.points}
def deny_reward(user_id: str, child_id: str, reward_id: str) -> dict | None:
"""Deny a child's pending reward request. No-op if already resolved.
Returns a result dict on success, or None if already resolved.
"""
PendingQ = Query()
existing = pending_confirmations_db.get(
(PendingQ.child_id == child_id) & (PendingQ.entity_id == reward_id) &
(PendingQ.entity_type == 'reward') & (PendingQ.status == 'pending') &
(PendingQ.user_id == user_id)
)
if not existing:
logger.info(f'Reward request for child {child_id}, reward {reward_id} already resolved')
return None
pending_confirmations_db.remove(
(PendingQ.child_id == child_id) & (PendingQ.entity_id == reward_id) &
(PendingQ.entity_type == 'reward') & (PendingQ.user_id == user_id)
)
ChildQ = Query()
child_result = child_db.get((ChildQ.id == child_id) & (ChildQ.user_id == user_id))
child_name = 'Unknown'
if child_result:
child = Child.from_dict(child_result)
child_name = child.name
tracking_event = TrackingEvent.create_event(
user_id=user_id, child_id=child_id, entity_type='reward', entity_id=reward_id,
action='denied', points_before=child.points, points_after=child.points,
metadata={},
)
insert_tracking_event(tracking_event)
log_tracking_event(tracking_event)
send_event_to_user(user_id, Event(EventType.TRACKING_EVENT_CREATED.value,
TrackingEventCreated(tracking_event.id, child_id, 'reward', 'denied')))
send_event_to_user(user_id, Event(EventType.CHILD_REWARD_REQUEST.value,
ChildRewardRequest(child_id, reward_id, ChildRewardRequest.REQUEST_CANCELLED)))
return {'child_name': child_name}

View File

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

View File

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

View File

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

View File

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

View File

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