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:
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