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
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:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -8,3 +8,5 @@ backend/test-results/
|
||||
.vscode/keybindings.json
|
||||
.DS_Store
|
||||
**/.DS_Store
|
||||
frontend/vue-app/cert.pem
|
||||
frontend/vue-app/key.pem
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = """<!DOCTYPE html>
|
||||
@digest_action_api.route('/digest-action/<token_id>', 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/<token_id>', 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/<token>', methods=['GET'])
|
||||
def handle_digest_unsubscribe(token: str):
|
||||
user_id = validate_unsubscribe_token(token)
|
||||
|
||||
114
backend/scripts/send_digest.py
Normal file
114
backend/scripts/send_digest.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""
|
||||
Script to trigger a digest email for a specific user via the admin API.
|
||||
|
||||
Usage:
|
||||
python scripts/send_digest.py <email>
|
||||
|
||||
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 <email>')
|
||||
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()
|
||||
@@ -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}')
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user