diff --git a/.github/specs/feat-parent-chore-notifications.md b/.github/specs/archive/feat-parent-chore-notifications.md similarity index 100% rename from .github/specs/feat-parent-chore-notifications.md rename to .github/specs/archive/feat-parent-chore-notifications.md diff --git a/.gitignore b/.gitignore index 5c297ec..effd17b 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ backend/test-results/ .vscode/keybindings.json .DS_Store **/.DS_Store +frontend/vue-app/cert.pem +frontend/vue-app/key.pem diff --git a/backend/api/admin_api.py b/backend/api/admin_api.py index cc8972a..a1d7d8f 100644 --- a/backend/api/admin_api.py +++ b/backend/api/admin_api.py @@ -206,3 +206,44 @@ def create_test_digest_token(): return jsonify({'token': token.id}), 200 except Exception as e: return jsonify({'error': str(e), 'code': 'SERVER_ERROR'}), 500 + + +@admin_api.route('/admin/test/send-digest', methods=['POST']) +def send_test_digest(): + """Trigger a digest email for a specific user by email address. + + Only active when DB_ENV is not 'production'. Requires admin authentication. + Note: actual email delivery is skipped in e2e mode by email_sender. + """ + if os.environ.get('DB_ENV') == 'production': + return jsonify({'error': 'Not found', 'code': 'NOT_FOUND'}), 404 + + user_id = get_validated_user_id() + if not user_id: + return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401 + + # Verify caller is admin + caller_dict = users_db.get(Query().id == user_id) + if not caller_dict or caller_dict.get('role') != 'admin': + return jsonify({'error': 'Admin access required', 'code': 'ADMIN_REQUIRED'}), 403 + + data = request.get_json() or {} + email = data.get('email', '').strip().lower() + if not email: + return jsonify({'error': 'email is required', 'code': 'MISSING_FIELDS'}), 400 + + target = users_db.get(Query().email == email) + if not target: + return jsonify({'error': 'User not found', 'code': 'USER_NOT_FOUND'}), 404 + + target_id = target.get('id') + target_email = target.get('email') + + try: + from flask import current_app + from utils.digest_scheduler import send_digest_for_user + frontend_url = current_app.config.get('FRONTEND_URL', 'https://localhost:5173') + items_sent = send_digest_for_user(target_id, target_email, frontend_url) + return jsonify({'items_sent': items_sent}), 200 + except Exception as e: + return jsonify({'error': str(e), 'code': 'SERVER_ERROR'}), 500 diff --git a/backend/api/digest_action_api.py b/backend/api/digest_action_api.py index 7b62cc8..58608e5 100644 --- a/backend/api/digest_action_api.py +++ b/backend/api/digest_action_api.py @@ -1,11 +1,12 @@ import logging -from flask import Blueprint, redirect, make_response +from flask import Blueprint, redirect, make_response, jsonify, request from tinydb import Query -from utils.digest_token import validate_and_consume_token, validate_unsubscribe_token +from utils.digest_token import validate_and_consume_token, validate_unsubscribe_token, peek_token from db.db import users_db from api.child_action_helpers import approve_chore, reject_chore, approve_reward_request, deny_reward +from api.utils import get_validated_user_id digest_action_api = Blueprint('digest_action_api', __name__) logger = logging.getLogger(__name__) @@ -34,45 +35,65 @@ _UNSUB_ERROR_HTML = """ @digest_action_api.route('/digest-action/', 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. + Validate a digest action token (without consuming it) and redirect to the + frontend ParentView with the token embedded so the action executes only + after the user authenticates as a parent. """ from flask import current_app frontend_url = current_app.config.get('FRONTEND_URL', 'https://localhost:5173') - token = validate_and_consume_token(token_id) + token = peek_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}" + f"{frontend_url}/parent/{token.child_id}" + f"?digestToken={token_id}&scrollTo={token.entity_id}&entityType={token.entity_type}" ) return redirect(deep_link, 302) +@digest_action_api.route('/digest-action/', methods=['POST']) +def execute_digest_action(token_id: str): + """ + Execute a digest action. Requires the user to be authenticated (JWT cookie). + Validates and consumes the token, then performs the approve/deny action. + """ + user_id = get_validated_user_id() + if not user_id: + return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401 + + token = validate_and_consume_token(token_id) + if not token: + return jsonify({'error': 'This link is invalid or has expired.', 'code': 'INVALID_TOKEN'}), 400 + + if token.user_id != user_id: + return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 403 + + try: + if token.entity_type == 'chore' and token.action == 'approve': + approve_chore(user_id, token.child_id, token.entity_id) + elif token.entity_type == 'chore' and token.action == 'deny': + reject_chore(user_id, token.child_id, token.entity_id) + elif token.entity_type == 'reward' and token.action == 'approve': + approve_reward_request(user_id, token.child_id, token.entity_id) + elif token.entity_type == 'reward' and token.action == 'deny': + deny_reward(user_id, token.child_id, token.entity_id) + else: + return jsonify({'error': 'Unknown action', 'code': 'INVALID_ACTION'}), 400 + except Exception as e: + logger.error(f'Error executing digest action {token.action}/{token.entity_type}: {e}') + return jsonify({'error': 'Failed to execute action', 'code': 'ACTION_FAILED'}), 400 + + return jsonify({ + 'success': True, + 'child_id': token.child_id, + 'entity_id': token.entity_id, + 'entity_type': token.entity_type, + 'action': token.action, + }), 200 + + @digest_action_api.route('/digest-unsubscribe/', methods=['GET']) def handle_digest_unsubscribe(token: str): user_id = validate_unsubscribe_token(token) diff --git a/backend/scripts/send_digest.py b/backend/scripts/send_digest.py new file mode 100644 index 0000000..e4d2361 --- /dev/null +++ b/backend/scripts/send_digest.py @@ -0,0 +1,114 @@ +""" +Script to trigger a digest email for a specific user via the admin API. + +Usage: + python scripts/send_digest.py + +The script logs in as an admin using the ADMIN_EMAIL and ADMIN_PASSWORD +environment variables (defaults to localhost:5000). + +Environment variables: + ADMIN_EMAIL - Admin account email (required) + ADMIN_PASSWORD - Admin account password (required) + API_BASE_URL - Base URL of the backend API (default: http://localhost:5000) +""" +import sys +import os +import json +import urllib.request +import urllib.error + +API_BASE_URL = os.environ.get('API_BASE_URL', 'http://localhost:5000').rstrip('/') + + +def _post(path: str, body: dict, cookie: str | None = None) -> tuple[int, dict]: + url = f"{API_BASE_URL}{path}" + data = json.dumps(body).encode() + headers = {'Content-Type': 'application/json'} + if cookie: + headers['Cookie'] = cookie + req = urllib.request.Request(url, data=data, headers=headers, method='POST') + try: + with urllib.request.urlopen(req) as resp: + return resp.status, json.loads(resp.read()) + except urllib.error.HTTPError as e: + try: + return e.code, json.loads(e.read()) + except Exception: + return e.code, {} + + +def main() -> None: + if len(sys.argv) != 2: + print('Usage: python scripts/send_digest.py ') + sys.exit(1) + + target_email = sys.argv[1].strip() + + admin_email = os.environ.get('ADMIN_EMAIL', '').strip() + admin_password = os.environ.get('ADMIN_PASSWORD', '').strip() + + if not admin_email or not admin_password: + print('Error: ADMIN_EMAIL and ADMIN_PASSWORD environment variables are required') + sys.exit(1) + + # Log in to get a session cookie + status, body = _post('/auth/login', {'email': admin_email, 'password': admin_password}) + if status != 200: + print(f'Login failed ({status}): {body.get("error", body)}') + sys.exit(1) + + # urllib doesn't automatically store cookies; we need to capture Set-Cookie manually. + # Re-do the login request to grab the cookie header. + login_url = f"{API_BASE_URL}/auth/login" + login_data = json.dumps({'email': admin_email, 'password': admin_password}).encode() + login_req = urllib.request.Request( + login_url, + data=login_data, + headers={'Content-Type': 'application/json'}, + method='POST', + ) + try: + with urllib.request.urlopen(login_req) as resp: + raw_cookies = resp.headers.get_all('Set-Cookie') or [] + except urllib.error.HTTPError: + print('Login failed (could not retrieve cookie)') + sys.exit(1) + + # Extract access_token cookie value + cookie_header = '; '.join( + part.split(';')[0] for part in raw_cookies + ) + if not cookie_header: + print('Login succeeded but no cookies were returned') + sys.exit(1) + + # Call the send-digest endpoint + status, body = _post( + '/admin/test/send-digest', + {'email': target_email}, + cookie=cookie_header, + ) + + if status == 200: + items_sent = body.get('items_sent', 0) + if items_sent == 0: + print(f'No pending items found for {target_email} — no email sent.') + else: + print(f'Digest sent to {target_email} with {items_sent} item(s).') + elif status == 404 and body.get('code') == 'NOT_FOUND': + print('Error: This endpoint is disabled in the current environment (DB_ENV=production).') + sys.exit(1) + elif status == 404 and body.get('code') == 'USER_NOT_FOUND': + print(f'Error: No user found with email {target_email}') + sys.exit(1) + elif status == 403: + print(f'Error: {admin_email} is not an admin account.') + sys.exit(1) + else: + print(f'Error ({status}): {body.get("error", body)}') + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/backend/utils/digest_scheduler.py b/backend/utils/digest_scheduler.py index fc7ef1f..0538c86 100644 --- a/backend/utils/digest_scheduler.py +++ b/backend/utils/digest_scheduler.py @@ -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}') diff --git a/backend/utils/digest_token.py b/backend/utils/digest_token.py index 49d993b..9bcf6d8 100644 --- a/backend/utils/digest_token.py +++ b/backend/utils/digest_token.py @@ -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). diff --git a/frontend/vue-app/src/components/child/ParentView.vue b/frontend/vue-app/src/components/child/ParentView.vue index ca1abcd..6c76820 100644 --- a/frontend/vue-app/src/components/child/ParentView.vue +++ b/frontend/vue-app/src/components/child/ParentView.vue @@ -651,6 +651,24 @@ onMounted(async () => { const idParam = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id const scrollToId = typeof route.query.scrollTo === 'string' ? route.query.scrollTo : null const entityType = typeof route.query.entityType === 'string' ? route.query.entityType : null + const digestToken = + typeof route.query.digestToken === 'string' ? route.query.digestToken : null + + if (digestToken) { + try { + const res = await fetch(`/api/digest-action/${digestToken}`, { + method: 'POST', + credentials: 'include', + }) + if (!res.ok) { + const data = await res.json().catch(() => ({})) + console.warn('Digest action failed:', data.error || res.status) + } + } catch (e) { + console.warn('Digest action request failed:', e) + } + } + if (idParam !== undefined) { const promise = fetchChildData(idParam) promise.then((data) => {