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.
158 lines
5.9 KiB
Python
158 lines
5.9 KiB
Python
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
|