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

View File

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

View File

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

View File

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