0.99.56

parent 3795e9bf
...@@ -1328,6 +1328,7 @@ class DatabaseManager: ...@@ -1328,6 +1328,7 @@ class DatabaseManager:
cursor.execute(f'DELETE FROM user_autoselects WHERE user_id = {placeholder}', (user_id,)) cursor.execute(f'DELETE FROM user_autoselects WHERE user_id = {placeholder}', (user_id,))
cursor.execute(f'DELETE FROM user_api_tokens WHERE user_id = {placeholder}', (user_id,)) cursor.execute(f'DELETE FROM user_api_tokens WHERE user_id = {placeholder}', (user_id,))
cursor.execute(f'DELETE FROM user_token_usage WHERE user_id = {placeholder}', (user_id,)) cursor.execute(f'DELETE FROM user_token_usage WHERE user_id = {placeholder}', (user_id,))
cursor.execute(f'DELETE FROM user_notifications WHERE user_id = {placeholder}', (user_id,))
# Delete the user # Delete the user
cursor.execute(f'DELETE FROM users WHERE id = {placeholder}', (user_id,)) cursor.execute(f'DELETE FROM users WHERE id = {placeholder}', (user_id,))
conn.commit() conn.commit()
...@@ -1377,6 +1378,95 @@ class DatabaseManager: ...@@ -1377,6 +1378,95 @@ class DatabaseManager:
cursor.execute(query, params) cursor.execute(query, params)
conn.commit() conn.commit()
def create_notification(self, user_id: int, title: str, message: str, notification_type: str = 'message') -> int:
"""Create a notification for a user. Returns the new notification id."""
with self._get_connection() as conn:
cursor = conn.cursor()
placeholder = '?' if self.db_type == 'sqlite' else '%s'
cursor.execute(f'''
INSERT INTO user_notifications (user_id, title, message, notification_type)
VALUES ({placeholder}, {placeholder}, {placeholder}, {placeholder})
''', (user_id, title, message, notification_type))
conn.commit()
return cursor.lastrowid
def get_user_notifications(self, user_id: int, limit: int = 50, unread_only: bool = False) -> List[Dict]:
"""Return notifications for a user, newest first."""
with self._get_connection() as conn:
cursor = conn.cursor()
placeholder = '?' if self.db_type == 'sqlite' else '%s'
where = f'user_id = {placeholder}'
params: list = [user_id]
if unread_only:
where += f' AND is_read = 0'
cursor.execute(f'''
SELECT id, title, message, notification_type, is_read, created_at
FROM user_notifications
WHERE {where}
ORDER BY created_at DESC
LIMIT {placeholder}
''', params + [limit])
rows = cursor.fetchall()
return [
{
'id': r[0],
'title': r[1],
'message': r[2],
'notification_type': r[3],
'is_read': bool(r[4]),
'created_at': str(r[5]),
}
for r in rows
]
def get_unread_notification_count(self, user_id: int) -> int:
"""Return count of unread notifications for a user."""
with self._get_connection() as conn:
cursor = conn.cursor()
placeholder = '?' if self.db_type == 'sqlite' else '%s'
cursor.execute(f'''
SELECT COUNT(*) FROM user_notifications
WHERE user_id = {placeholder} AND is_read = 0
''', (user_id,))
row = cursor.fetchone()
return row[0] if row else 0
def mark_notification_read(self, notification_id: int, user_id: int) -> bool:
"""Mark a single notification as read. Returns True if updated."""
with self._get_connection() as conn:
cursor = conn.cursor()
placeholder = '?' if self.db_type == 'sqlite' else '%s'
cursor.execute(f'''
UPDATE user_notifications SET is_read = 1
WHERE id = {placeholder} AND user_id = {placeholder}
''', (notification_id, user_id))
conn.commit()
return cursor.rowcount > 0
def mark_all_notifications_read(self, user_id: int) -> int:
"""Mark all notifications as read for a user. Returns count updated."""
with self._get_connection() as conn:
cursor = conn.cursor()
placeholder = '?' if self.db_type == 'sqlite' else '%s'
cursor.execute(f'''
UPDATE user_notifications SET is_read = 1
WHERE user_id = {placeholder} AND is_read = 0
''', (user_id,))
conn.commit()
return cursor.rowcount
def delete_notification(self, notification_id: int, user_id: int) -> bool:
"""Delete a notification. Returns True if deleted."""
with self._get_connection() as conn:
cursor = conn.cursor()
placeholder = '?' if self.db_type == 'sqlite' else '%s'
cursor.execute(f'''
DELETE FROM user_notifications
WHERE id = {placeholder} AND user_id = {placeholder}
''', (notification_id, user_id))
conn.commit()
return cursor.rowcount > 0
def verify_user_password(self, user_id: int, password: str) -> bool: def verify_user_password(self, user_id: int, password: str) -> bool:
""" """
Verify a user's plain-text password against the stored hash. Verify a user's plain-text password against the stored hash.
...@@ -4086,6 +4176,46 @@ def DatabaseManager__run_config_migrations(self, cursor, auto_increment, timesta ...@@ -4086,6 +4176,46 @@ def DatabaseManager__run_config_migrations(self, cursor, auto_increment, timesta
except Exception as e: except Exception as e:
logger.warning(f"Migration check for user_api_tokens.scope: {e}") logger.warning(f"Migration check for user_api_tokens.scope: {e}")
# Migration: Create user_notifications table if missing
try:
if self.db_type == 'sqlite':
cursor.execute("PRAGMA table_info(user_notifications)")
if not cursor.fetchall():
cursor.execute(f'''
CREATE TABLE user_notifications (
id INTEGER PRIMARY KEY {auto_increment},
user_id INTEGER NOT NULL,
title VARCHAR(255) NOT NULL,
message TEXT NOT NULL,
notification_type VARCHAR(50) DEFAULT 'message',
is_read {boolean_type} DEFAULT 0,
created_at TIMESTAMP DEFAULT {timestamp_default},
FOREIGN KEY (user_id) REFERENCES users(id)
)
''')
logger.info("✅ Migration: Created user_notifications table")
else:
cursor.execute("""
SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'user_notifications'
""")
if not cursor.fetchone():
cursor.execute(f'''
CREATE TABLE user_notifications (
id INTEGER PRIMARY KEY {auto_increment},
user_id INTEGER NOT NULL,
title VARCHAR(255) NOT NULL,
message TEXT NOT NULL,
notification_type VARCHAR(50) DEFAULT 'message',
is_read {boolean_type} DEFAULT 0,
created_at TIMESTAMP DEFAULT {timestamp_default},
FOREIGN KEY (user_id) REFERENCES users(id)
)
''')
logger.info("✅ Migration: Created user_notifications table")
except Exception as e:
logger.warning(f"Migration check for user_notifications table: {e}")
logger.info("✅ All database migrations completed") logger.info("✅ All database migrations completed")
# Patch the methods # Patch the methods
......
...@@ -439,3 +439,35 @@ AISBF Team ...@@ -439,3 +439,35 @@ AISBF Team
logger.error(f"Exception sending test email to {to_email}: {e}") logger.error(f"Exception sending test email to {to_email}: {e}")
logger.error(f"Traceback: {traceback.format_exc()}") logger.error(f"Traceback: {traceback.format_exc()}")
return False return False
def send_simple_email(to_email: str, subject: str, body_html: str, smtp_config) -> bool:
"""Send a simple HTML email. Returns True on success."""
if not smtp_config or not getattr(smtp_config, 'host', None) or not getattr(smtp_config, 'from_email', None):
logger.warning("send_simple_email: SMTP not configured, skipping")
return False
try:
msg = MIMEMultipart('alternative')
msg['Subject'] = subject
msg['From'] = f"{smtp_config.from_name} <{smtp_config.from_email}>" if getattr(smtp_config, 'from_name', None) else smtp_config.from_email
msg['To'] = to_email
msg.attach(MIMEText(body_html, 'html'))
if getattr(smtp_config, 'use_ssl', False):
with smtplib.SMTP_SSL(smtp_config.host, smtp_config.port) as server:
if getattr(smtp_config, 'username', None) and getattr(smtp_config, 'password', None):
server.login(smtp_config.username, smtp_config.password)
server.send_message(msg)
else:
with smtplib.SMTP(smtp_config.host, smtp_config.port) as server:
server.ehlo()
if getattr(smtp_config, 'use_tls', True):
server.starttls()
server.ehlo()
if getattr(smtp_config, 'username', None) and getattr(smtp_config, 'password', None):
server.login(smtp_config.username, smtp_config.password)
server.send_message(msg)
return True
except Exception as e:
logger.error(f"send_simple_email error: {e}")
return False
...@@ -418,8 +418,48 @@ class EmailNotificationService: ...@@ -418,8 +418,48 @@ class EmailNotificationService:
conn.commit() conn.commit()
def _notify_admin(self, event_key: str, subject: str, body_html: str):
"""Send an email to the admin if that event type is enabled in config."""
try:
import json
from pathlib import Path
config_path = Path.home() / '.aisbf' / 'aisbf.json'
if not config_path.exists():
config_path = Path(__file__).parent.parent.parent / 'config' / 'aisbf.json'
if not config_path.exists():
return
with open(config_path) as f:
cfg = json.load(f)
dashboard = cfg.get('dashboard', {})
admin_email = dashboard.get('email', '')
if not admin_email:
return
notifications = dashboard.get('notifications', {})
if not notifications.get(event_key, False):
return
smtp = cfg.get('smtp', {})
if not smtp.get('enabled', False) or not smtp.get('host'):
return
from aisbf.email_utils import send_simple_email
class _SmtpCfg:
pass
smtp_cfg = _SmtpCfg()
smtp_cfg.host = smtp.get('host', '')
smtp_cfg.port = smtp.get('port', 587)
smtp_cfg.username = smtp.get('username', '')
smtp_cfg.password = smtp.get('password', '')
smtp_cfg.use_tls = smtp.get('use_tls', True)
smtp_cfg.use_ssl = smtp.get('use_ssl', False)
smtp_cfg.from_email = smtp.get('from_email', '')
smtp_cfg.from_name = smtp.get('from_name', 'AISBF')
send_simple_email(admin_email, subject, body_html, smtp_cfg)
except Exception as e:
logger.warning(f"Admin notification ({event_key}): {e}")
# Convenience methods for common notifications # Convenience methods for common notifications
async def notify_payment_success(self, user_id: int, amount: float, currency: str): async def notify_payment_success(self, user_id: int, amount: float, currency: str):
"""Send payment success notification""" """Send payment success notification"""
await self.send_notification( await self.send_notification(
...@@ -431,7 +471,12 @@ class EmailNotificationService: ...@@ -431,7 +471,12 @@ class EmailNotificationService:
'date': datetime.now().strftime('%Y-%m-%d %H:%M:%S') 'date': datetime.now().strftime('%Y-%m-%d %H:%M:%S')
} }
) )
self._notify_admin(
'payment_received',
f"Payment received: {amount} {currency}",
f"<h2>Payment Received</h2><p>User ID {user_id} completed a payment of <b>{amount} {currency}</b>.</p>"
)
async def notify_payment_failed(self, user_id: int, amount: float, currency: str, reason: str): async def notify_payment_failed(self, user_id: int, amount: float, currency: str, reason: str):
"""Send payment failed notification""" """Send payment failed notification"""
await self.send_notification( await self.send_notification(
...@@ -444,7 +489,7 @@ class EmailNotificationService: ...@@ -444,7 +489,7 @@ class EmailNotificationService:
'date': datetime.now().strftime('%Y-%m-%d %H:%M:%S') 'date': datetime.now().strftime('%Y-%m-%d %H:%M:%S')
} }
) )
async def notify_subscription_upgraded(self, user_id: int, old_tier: str, new_tier: str): async def notify_subscription_upgraded(self, user_id: int, old_tier: str, new_tier: str):
"""Send subscription upgraded notification""" """Send subscription upgraded notification"""
await self.send_notification( await self.send_notification(
...@@ -456,7 +501,12 @@ class EmailNotificationService: ...@@ -456,7 +501,12 @@ class EmailNotificationService:
'date': datetime.now().strftime('%Y-%m-%d %H:%M:%S') 'date': datetime.now().strftime('%Y-%m-%d %H:%M:%S')
} }
) )
self._notify_admin(
'tier_upgrade',
f"Subscription upgraded: {old_tier} → {new_tier}",
f"<h2>Subscription Upgraded</h2><p>User ID {user_id} upgraded from <b>{old_tier}</b> to <b>{new_tier}</b>.</p>"
)
async def notify_subscription_downgraded(self, user_id: int, old_tier: str, new_tier: str): async def notify_subscription_downgraded(self, user_id: int, old_tier: str, new_tier: str):
"""Send subscription downgraded notification""" """Send subscription downgraded notification"""
await self.send_notification( await self.send_notification(
...@@ -468,7 +518,12 @@ class EmailNotificationService: ...@@ -468,7 +518,12 @@ class EmailNotificationService:
'date': datetime.now().strftime('%Y-%m-%d %H:%M:%S') 'date': datetime.now().strftime('%Y-%m-%d %H:%M:%S')
} }
) )
self._notify_admin(
'tier_downgrade',
f"Subscription downgraded: {old_tier} → {new_tier}",
f"<h2>Subscription Downgraded</h2><p>User ID {user_id} downgraded from <b>{old_tier}</b> to <b>{new_tier}</b>.</p>"
)
async def notify_subscription_cancelled(self, user_id: int, tier: str, end_date: str): async def notify_subscription_cancelled(self, user_id: int, tier: str, end_date: str):
"""Send subscription cancelled notification""" """Send subscription cancelled notification"""
await self.send_notification( await self.send_notification(
...@@ -480,3 +535,25 @@ class EmailNotificationService: ...@@ -480,3 +535,25 @@ class EmailNotificationService:
'date': datetime.now().strftime('%Y-%m-%d %H:%M:%S') 'date': datetime.now().strftime('%Y-%m-%d %H:%M:%S')
} }
) )
self._notify_admin(
'subscription_expired',
f"Subscription expired/cancelled: {tier}",
f"<h2>Subscription Cancelled</h2><p>User ID {user_id} subscription <b>{tier}</b> was cancelled (expires {end_date}).</p>"
)
async def notify_subscription_renewed(self, user_id: int, tier: str, new_end_date: str):
"""Send subscription renewed notification"""
await self.send_notification(
user_id,
self.SUBSCRIPTION_RENEWED,
{
'tier': tier,
'new_end_date': new_end_date,
'date': datetime.now().strftime('%Y-%m-%d %H:%M:%S')
}
)
self._notify_admin(
'subscription_renewed',
f"Subscription renewed: {tier}",
f"<h2>Subscription Renewed</h2><p>User ID {user_id} renewed <b>{tier}</b> subscription (next renewal: {new_end_date}).</p>"
)
This diff is collapsed.
This diff is collapsed.
...@@ -876,19 +876,75 @@ brew services restart tor # macOS</code></pre> ...@@ -876,19 +876,75 @@ brew services restart tor # macOS</code></pre>
</div><!-- /tab-ratelimit --> </div><!-- /tab-ratelimit -->
<div class="settings-section" id="tab-admin"> <div class="settings-section" id="tab-admin">
<div class="section-title"><i class="fas fa-shield-alt"></i> Admin Password</div> <div class="section-title"><i class="fas fa-shield-alt"></i> Admin Account &amp; Notifications</div>
<div class="form-group"> <div class="form-group">
<label for="new_admin_password">New Admin Password</label> <label for="new_admin_password">New Admin Password</label>
<input type="password" id="new_admin_password" name="new_admin_password" placeholder="Leave blank to keep current password"> <input type="password" id="new_admin_password" name="new_admin_password" placeholder="Leave blank to keep current password">
<small style="color: #666; display: block; margin-top: 5px;">Enter a new password to change the admin dashboard password</small> <small style="color: #666; display: block; margin-top: 5px;">Enter a new password to change the admin dashboard password</small>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="confirm_admin_password">Confirm New Admin Password</label> <label for="confirm_admin_password">Confirm New Admin Password</label>
<input type="password" id="confirm_admin_password" name="confirm_admin_password" placeholder="Confirm new password"> <input type="password" id="confirm_admin_password" name="confirm_admin_password" placeholder="Confirm new password">
<small style="color: #666; display: block; margin-top: 5px;">Re-enter the new password to confirm</small> <small style="color: #666; display: block; margin-top: 5px;">Re-enter the new password to confirm</small>
</div> </div>
<div class="form-group">
<label for="dashboard_email">Admin Email Address</label>
<input type="email" id="dashboard_email" name="dashboard_email"
value="{{ config.dashboard.email if config.dashboard and config.dashboard.email else '' }}"
placeholder="admin@example.com">
<small style="color: #666; display: block; margin-top: 5px;">Email address to receive admin notifications. Requires SMTP to be configured and enabled.</small>
</div>
<div class="section-title" style="margin-top: 24px;"><i class="fas fa-bell"></i> Admin Email Notifications</div>
<small style="color: #666; display: block; margin-bottom: 16px;">Select which events trigger an email to the admin. SMTP must be enabled and an admin email must be set above.</small>
{% set notif = config.dashboard.notifications if config.dashboard and config.dashboard.notifications else {} %}
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px;">
<label style="display:flex; align-items:center; gap:10px; cursor:pointer; background:#0f3460; padding:12px; border-radius:6px;">
<input type="checkbox" name="admin_notify_new_user_signup"
{% if notif.new_user_signup %}checked{% endif %} style="width:auto;">
<span><i class="fas fa-user-plus" style="color:#4ade80;"></i> New user self-registered</span>
</label>
<label style="display:flex; align-items:center; gap:10px; cursor:pointer; background:#0f3460; padding:12px; border-radius:6px;">
<input type="checkbox" name="admin_notify_payment_received"
{% if notif.payment_received %}checked{% endif %} style="width:auto;">
<span><i class="fas fa-credit-card" style="color:#60a5fa;"></i> Payment received</span>
</label>
<label style="display:flex; align-items:center; gap:10px; cursor:pointer; background:#0f3460; padding:12px; border-radius:6px;">
<input type="checkbox" name="admin_notify_tier_upgrade"
{% if notif.tier_upgrade %}checked{% endif %} style="width:auto;">
<span><i class="fas fa-arrow-up" style="color:#f59e0b;"></i> Subscription upgrade</span>
</label>
<label style="display:flex; align-items:center; gap:10px; cursor:pointer; background:#0f3460; padding:12px; border-radius:6px;">
<input type="checkbox" name="admin_notify_tier_downgrade"
{% if notif.tier_downgrade %}checked{% endif %} style="width:auto;">
<span><i class="fas fa-arrow-down" style="color:#f87171;"></i> Subscription downgrade</span>
</label>
<label style="display:flex; align-items:center; gap:10px; cursor:pointer; background:#0f3460; padding:12px; border-radius:6px;">
<input type="checkbox" name="admin_notify_subscription_expired"
{% if notif.subscription_expired %}checked{% endif %} style="width:auto;">
<span><i class="fas fa-calendar-times" style="color:#f87171;"></i> Subscription expired / cancelled</span>
</label>
<label style="display:flex; align-items:center; gap:10px; cursor:pointer; background:#0f3460; padding:12px; border-radius:6px;">
<input type="checkbox" name="admin_notify_subscription_renewed"
{% if notif.subscription_renewed %}checked{% endif %} style="width:auto;">
<span><i class="fas fa-sync" style="color:#4ade80;"></i> Subscription renewed</span>
</label>
<label style="display:flex; align-items:center; gap:10px; cursor:pointer; background:#0f3460; padding:12px; border-radius:6px;">
<input type="checkbox" name="admin_notify_wallet_topup"
{% if notif.wallet_topup %}checked{% endif %} style="width:auto;">
<span><i class="fas fa-wallet" style="color:#a78bfa;"></i> Wallet top-up</span>
</label>
<label style="display:flex; align-items:center; gap:10px; cursor:pointer; background:#0f3460; padding:12px; border-radius:6px;">
<input type="checkbox" name="admin_notify_user_deleted_account"
{% if notif.user_deleted_account %}checked{% endif %} style="width:auto;">
<span><i class="fas fa-user-slash" style="color:#f87171;"></i> User deleted their account</span>
</label>
</div>
</div><!-- /tab-admin --> </div><!-- /tab-admin -->
<div style="display: flex; gap: 10px; margin-top: 30px; padding-top: 20px; border-top: 1px solid #0f3460;"> <div style="display: flex; gap: 10px; margin-top: 30px; padding-top: 20px; border-top: 1px solid #0f3460;">
...@@ -906,6 +962,17 @@ function switchTab(name) { ...@@ -906,6 +962,17 @@ function switchTab(name) {
document.getElementById('tab-' + name).classList.add('active'); document.getElementById('tab-' + name).classList.add('active');
} }
// Validate admin password match before form submit
document.querySelector('form').addEventListener('submit', function(e) {
const np = document.getElementById('new_admin_password').value;
const cp = document.getElementById('confirm_admin_password').value;
if (np && np !== cp) {
e.preventDefault();
switchTab('admin');
showAlert('Admin passwords do not match. Please re-enter.', 'Password Mismatch', '⚠️', 'warn');
}
});
async function testSMTP() { async function testSMTP() {
const host = document.getElementById('smtp_host').value; const host = document.getElementById('smtp_host').value;
const port = document.getElementById('smtp_port').value; const port = document.getElementById('smtp_port').value;
......
This diff is collapsed.
...@@ -107,6 +107,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -107,6 +107,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<button id="bulk-enable" class="btn">Enable Selected</button> <button id="bulk-enable" class="btn">Enable Selected</button>
<button id="bulk-disable" class="btn">Disable Selected</button> <button id="bulk-disable" class="btn">Disable Selected</button>
<button id="bulk-delete" class="btn btn-danger">Delete Selected</button> <button id="bulk-delete" class="btn btn-danger">Delete Selected</button>
<button id="bulk-notify" class="btn btn-secondary" style="background:#7c3aed;"><i class="fas fa-bell"></i> Send Notification</button>
<button id="bulk-clear" class="btn btn-secondary">Clear Selection</button> <button id="bulk-clear" class="btn btn-secondary">Clear Selection</button>
</div> </div>
</div> </div>
...@@ -965,4 +966,89 @@ input:-webkit-autofill:active { ...@@ -965,4 +966,89 @@ input:-webkit-autofill:active {
-webkit-text-fill-color: #e0e0e0 !important; -webkit-text-fill-color: #e0e0e0 !important;
} }
</style> </style>
<!-- Send Notification Modal -->
<div id="notify-modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.7); z-index:1000; align-items:center; justify-content:center;">
<div style="background:#16213e; padding:30px; border-radius:8px; width:90%; max-width:520px; border:1px solid #0f3460;">
<h3 style="margin-bottom:20px;"><i class="fas fa-bell" style="color:#a78bfa;"></i> Send Notification</h3>
<p id="notify-target-label" style="color:#a0a0a0; margin-bottom:16px; font-size:13px;"></p>
<div class="form-group">
<label for="notify-title">Title</label>
<input type="text" id="notify-title" maxlength="200" placeholder="Notification title"
style="background:#1a1a2e; color:#e0e0e0; border:1px solid #0f3460; padding:8px; border-radius:3px; width:100%;">
</div>
<div class="form-group">
<label for="notify-message">Message</label>
<textarea id="notify-message" rows="4" placeholder="Notification message…"
style="background:#1a1a2e; color:#e0e0e0; border:1px solid #0f3460; padding:8px; border-radius:3px; width:100%; resize:vertical;"></textarea>
</div>
<div style="display:flex; gap:10px; margin-top:10px;">
<button class="btn" onclick="submitNotification()"><i class="fas fa-paper-plane"></i> Send</button>
<button class="btn btn-secondary" onclick="closeNotifyModal()">Cancel</button>
</div>
<div id="notify-status" style="margin-top:12px;"></div>
</div>
</div>
<script>
let _notifyUserIds = [];
function openNotifyModal(userIds) {
_notifyUserIds = userIds;
document.getElementById('notify-title').value = '';
document.getElementById('notify-message').value = '';
document.getElementById('notify-status').innerHTML = '';
document.getElementById('notify-target-label').textContent =
`Sending to ${userIds.length} user${userIds.length !== 1 ? 's' : ''}.`;
const modal = document.getElementById('notify-modal');
modal.style.display = 'flex';
}
function closeNotifyModal() {
document.getElementById('notify-modal').style.display = 'none';
}
async function submitNotification() {
const title = document.getElementById('notify-title').value.trim();
const message = document.getElementById('notify-message').value.trim();
const statusEl = document.getElementById('notify-status');
if (!title || !message) {
statusEl.innerHTML = '<div class="alert alert-error">Title and message are required.</div>';
return;
}
try {
const res = await fetch('/dashboard/api/admin/notifications/send', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({user_ids: _notifyUserIds, title, message})
});
const data = await res.json();
if (data.success) {
statusEl.innerHTML = `<div class="alert alert-success">Sent to ${data.sent} user(s).</div>`;
setTimeout(closeNotifyModal, 1500);
} else {
statusEl.innerHTML = `<div class="alert alert-error">${data.error || 'Failed to send.'}</div>`;
}
} catch (e) {
statusEl.innerHTML = '<div class="alert alert-error">Network error.</div>';
}
}
document.getElementById('notify-modal').addEventListener('click', function(e) {
if (e.target === this) closeNotifyModal();
});
// Wire "Send Notification" bulk button
document.addEventListener('DOMContentLoaded', function() {
const notifyBtn = document.getElementById('bulk-notify');
if (notifyBtn) {
notifyBtn.addEventListener('click', function() {
const checked = Array.from(document.querySelectorAll('.user-checkbox:checked'))
.map(cb => parseInt(cb.value));
if (!checked.length) return;
openNotifyModal(checked);
});
}
});
</script>
{% endblock %} {% endblock %}
\ No newline at end of file
...@@ -398,13 +398,17 @@ document.addEventListener('DOMContentLoaded', function () { ...@@ -398,13 +398,17 @@ document.addEventListener('DOMContentLoaded', function () {
refunded: 'background:#6c757d;color:white;', refunded: 'background:#6c757d;color:white;',
}; };
const txLoading = document.getElementById('tx-loading');
fetch('/dashboard/wallet/transactions') fetch('/dashboard/wallet/transactions')
.then(r => r.json()) .then(r => {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
})
.then(transactions => { .then(transactions => {
const list = document.getElementById('transaction-list'); const list = document.getElementById('transaction-list');
document.getElementById('tx-loading').remove(); if (txLoading && txLoading.parentNode) txLoading.remove();
if (!transactions || transactions.length === 0) { if (!Array.isArray(transactions) || transactions.length === 0) {
list.innerHTML = `<tr><td colspan="5" style="padding:40px;text-align:center;color:#a0a0a0;"> list.innerHTML = `<tr><td colspan="5" style="padding:40px;text-align:center;color:#a0a0a0;">
<i class="fas fa-receipt fa-3x" style="margin-bottom:12px;display:block;"></i> <i class="fas fa-receipt fa-3x" style="margin-bottom:12px;display:block;"></i>
No transactions yet. No transactions yet.
...@@ -442,8 +446,10 @@ document.addEventListener('DOMContentLoaded', function () { ...@@ -442,8 +446,10 @@ document.addEventListener('DOMContentLoaded', function () {
}); });
}) })
.catch(() => { .catch(() => {
document.getElementById('tx-loading').innerHTML = const list = document.getElementById('transaction-list');
'<td colspan="5" style="padding:30px;text-align:center;color:#f87171;">Failed to load transactions.</td>'; if (txLoading && txLoading.parentNode) txLoading.remove();
if (list) list.innerHTML =
'<tr><td colspan="5" style="padding:30px;text-align:center;color:#f87171;">Failed to load transactions.</td></tr>';
}); });
}); });
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment