feat: add admin endpoint to send digest emails for users
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 1m37s

- Implemented a new endpoint `/admin/test/send-digest` in `admin_api.py` to trigger digest emails for specific users.
- Added a script `send_digest.py` to facilitate sending digest emails via the admin API.
- Enhanced the digest action handling in `digest_action_api.py` to support token peeking without consuming it.
- Updated the `send_digests` function in `digest_scheduler.py` to utilize the new `send_digest_for_user` function for sending emails.
- Introduced a new utility function `peek_token` in `digest_token.py` to validate tokens without consuming them.
- Modified the `ParentView.vue` component to handle digest actions upon receiving a digest token in the URL.
- Updated `.gitignore` to exclude sensitive certificate files.
This commit is contained in:
2026-04-21 13:08:03 -04:00
parent f48845c1d0
commit 2c7e9b8b5e
8 changed files with 352 additions and 117 deletions

View File

@@ -20,6 +20,97 @@ def _get_local_hour(tz_str: str | None) -> int:
return datetime.now(timezone.utc).hour
def send_digest_for_user(user_id: str, user_email: str, frontend_url: str) -> int:
"""Gather pending items for a user and send them a digest email.
Returns the number of items included in the digest (0 if nothing was sent).
Must be called within a Flask app context with all DB imports already available.
"""
from db.db import 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
PendingQ = Query()
pending_items = pending_confirmations_db.search(
(PendingQ.user_id == user_id) & (PendingQ.status == 'pending')
)
if not pending_items:
return 0
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') or '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') or '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:
return 0
unsubscribe_token = create_unsubscribe_token(user_id)
send_digest_email(user_email, items, unsubscribe_token)
return len(items)
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':
@@ -27,9 +118,7 @@ def send_digests(app) -> None:
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 db.db import users_db
from flask import current_app
frontend_url = current_app.config.get('FRONTEND_URL', 'https://localhost:5173')
@@ -48,92 +137,8 @@ def send_digests(app) -> None:
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)
send_digest_for_user(user_id, user_email, frontend_url)
except Exception as e:
logger.error(f'Failed to send digest to {user_email}: {e}')

View File

@@ -101,6 +101,40 @@ def validate_and_consume_token(token_id: str) -> DigestActionToken | None:
return token
def peek_token(token_id: str) -> DigestActionToken | None:
"""
Validate a token (signature, expiry, not-used) WITHOUT consuming it.
Returns the token if valid, None otherwise.
"""
token = get_token_by_id(token_id)
if not token or token.used:
return None
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
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
return token
def create_unsubscribe_token(user_id: str) -> str:
"""
Create a simple signed unsubscribe token string (not persisted in DB).