Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/tame-hounds-swim.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
350 changes: 350 additions & 0 deletions server/public/admin-settings.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,350 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" href="/AAo.svg" type="image/svg+xml">
<title>Admin - System Settings - AgenticAdvertising.org</title>
<script src="/nav.js"></script>
<script src="/admin-sidebar.js"></script>
<link rel="stylesheet" href="/design-system.css">
<style>
body {
background: var(--color-bg-page);
min-height: 100vh;
}
.container {
max-width: var(--container-lg);
margin: var(--space-8) auto;
padding: 0 var(--space-5);
}
.card {
background: var(--color-bg-card);
border-radius: var(--radius-md);
padding: var(--space-6);
margin-bottom: var(--space-5);
box-shadow: var(--shadow-xs);
}
h1 { margin-bottom: var(--space-2.5); color: var(--color-text-heading); }
h2 { margin-bottom: var(--space-4); color: var(--color-text-heading); font-size: var(--text-xl); }
.subtitle {
color: var(--color-text-secondary);
font-size: var(--text-sm);
margin-bottom: var(--space-6);
}
.setting-section {
border: var(--border-1) solid var(--color-gray-200);
border-radius: var(--radius-md);
padding: var(--space-5);
margin-bottom: var(--space-5);
}
.setting-section h3 {
font-size: var(--text-base);
color: var(--color-text-heading);
margin-bottom: var(--space-2);
}
.setting-section p {
font-size: var(--text-sm);
color: var(--color-text-secondary);
margin-bottom: var(--space-4);
line-height: 1.5;
}
.setting-row {
display: flex;
gap: var(--space-4);
align-items: flex-end;
}
.setting-row .field {
flex: 1;
}
.field label {
display: block;
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: var(--color-text-heading);
margin-bottom: var(--space-1);
}
.field select,
.field input {
width: 100%;
padding: var(--space-2.5);
border: var(--border-1) solid var(--color-gray-200);
border-radius: var(--radius-md);
font-size: var(--text-sm);
font-family: inherit;
}
.field select:focus,
.field input:focus {
outline: none;
border-color: var(--color-brand);
}
.field small {
display: block;
margin-top: var(--space-1);
color: var(--color-text-secondary);
font-size: var(--text-xs);
}
.btn {
padding: var(--space-2.5) var(--space-5);
border: none;
border-radius: var(--radius-md);
font-size: var(--text-sm);
cursor: pointer;
transition: var(--transition-all);
white-space: nowrap;
}
.btn-primary { background: var(--color-brand); color: white; }
.btn-primary:hover { background: var(--color-primary-700); }
.btn-primary:disabled { background: var(--color-gray-400); cursor: not-allowed; }
.btn-secondary { background: var(--color-gray-200); color: var(--color-text-heading); }
.btn-secondary:hover { background: var(--color-gray-300); }
.btn-success { background: var(--color-success-500); color: white; }
.current-value {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
background: var(--color-info-50);
border: var(--border-1) solid var(--color-info-200);
border-radius: var(--radius-sm);
font-size: var(--text-sm);
color: var(--color-info-700);
margin-bottom: var(--space-4);
}
.current-value.not-set {
background: var(--color-gray-100);
border-color: var(--color-gray-300);
color: var(--color-text-secondary);
}
.status-message {
padding: var(--space-2.5) var(--space-4);
border-radius: var(--radius-md);
margin-bottom: var(--space-4);
font-size: var(--text-sm);
}
.status-message.success {
background: var(--color-success-100);
color: var(--color-success-700);
}
.status-message.error {
background: var(--color-error-100);
color: var(--color-error-700);
}
.info-box {
background: var(--color-info-50);
border: var(--border-1) solid var(--color-info-200);
border-radius: var(--radius-md);
padding: var(--space-4);
margin-bottom: var(--space-5);
font-size: var(--text-sm);
color: var(--color-info-700);
}
.loading-channels {
padding: var(--space-4);
text-align: center;
color: var(--color-text-secondary);
font-size: var(--text-sm);
}
</style>
</head>
<body>
<div id="adcp-nav"></div>

<div id="loading" style="text-align: center; padding: var(--space-12); color: var(--color-text-secondary);">
Loading...
</div>

<div id="content" style="display: none;">
<div class="container">
<div class="card">
<h1>System Settings</h1>
<p class="subtitle">Configure system-wide settings for AgenticAdvertising.org</p>

<div id="statusMessage" class="status-message" style="display: none;"></div>

<div class="info-box">
These settings control how the system behaves. Changes take effect immediately.
</div>

<!-- Billing Channel Setting -->
<div class="setting-section">
<h3>Billing Notifications Channel</h3>
<p>
Choose which Slack channel receives billing notifications from Addie.
This includes new subscriptions, payments, payment failures, and cancellations.
</p>

<div class="current-value" id="currentChannel">
<span>Loading...</span>
</div>

<div class="setting-row">
<div class="field">
<label for="billingChannel">Select Channel</label>
<select id="billingChannel" disabled>
<option value="">Loading channels...</option>
</select>
<small>Only public channels the bot has access to are shown</small>
</div>
<button class="btn btn-primary" id="saveBtn" onclick="saveBillingChannel()" disabled>
Save
</button>
</div>
</div>

</div>
</div>
</div>

<script>
let currentSettings = null;
let slackChannels = [];

// Load current settings
async function loadSettings() {
try {
const response = await fetch('/api/admin/settings');
if (!response.ok) {
if (response.status === 401) {
window.AdminSidebar.redirectToLogin();
return;
}
throw new Error('Failed to load settings');
}

currentSettings = await response.json();
updateCurrentChannelDisplay();

document.getElementById('loading').style.display = 'none';
document.getElementById('content').style.display = 'block';

// Load Slack channels for picker
loadSlackChannels();
} catch (error) {
console.error('Error loading settings:', error);
document.getElementById('loading').innerHTML =
'<p style="color: var(--color-error-600);">Failed to load settings. <a href="/auth/login">Try logging in again</a></p>';
}
}

// Load Slack channels for picker
async function loadSlackChannels() {
const select = document.getElementById('billingChannel');

try {
const response = await fetch('/api/admin/settings/slack-channels');
if (!response.ok) {
const data = await response.json();
throw new Error(data.message || 'Failed to load channels');
}

const data = await response.json();
slackChannels = data.channels;

// Build options
let html = '<option value="">-- No channel (disabled) --</option>';
slackChannels.forEach(ch => {
const selected = currentSettings?.billing_channel?.channel_id === ch.id ? 'selected' : '';
html += `<option value="${ch.id}" ${selected}>#${escapeHtml(ch.name)}</option>`;
});

select.innerHTML = html;
select.disabled = false;
document.getElementById('saveBtn').disabled = false;
} catch (error) {
console.error('Error loading Slack channels:', error);
select.innerHTML = '<option value="">Error loading channels</option>';
showStatusMessage('Failed to load Slack channels: ' + error.message, 'error');
}
}

// Update the current channel display
function updateCurrentChannelDisplay() {
const el = document.getElementById('currentChannel');
const channel = currentSettings?.billing_channel;

if (channel?.channel_id && channel?.channel_name) {
el.className = 'current-value';
el.innerHTML = `<strong>Current:</strong> #${escapeHtml(channel.channel_name)}`;
} else {
el.className = 'current-value not-set';
el.innerHTML = '<strong>Current:</strong> Not configured (notifications disabled)';
}
}

// Save billing channel
async function saveBillingChannel() {
const select = document.getElementById('billingChannel');
const saveBtn = document.getElementById('saveBtn');
const channelId = select.value || null;

// Find channel name
let channelName = null;
if (channelId) {
const channel = slackChannels.find(c => c.id === channelId);
channelName = channel?.name || null;
}

saveBtn.disabled = true;
saveBtn.textContent = 'Saving...';

try {
const response = await fetch('/api/admin/settings/billing-channel', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ channel_id: channelId, channel_name: channelName }),
});

if (!response.ok) {
const data = await response.json();
throw new Error(data.message || 'Failed to save');
}

const data = await response.json();
currentSettings.billing_channel = data.billing_channel;
updateCurrentChannelDisplay();

saveBtn.textContent = 'Saved!';
saveBtn.classList.add('btn-success');
showStatusMessage('Billing channel updated successfully', 'success');

setTimeout(() => {
saveBtn.textContent = 'Save';
saveBtn.classList.remove('btn-success');
saveBtn.disabled = false;
}, 2000);
} catch (error) {
console.error('Error saving billing channel:', error);
showStatusMessage('Failed to save: ' + error.message, 'error');
saveBtn.textContent = 'Save';
saveBtn.disabled = false;
}
}

// Status message helpers
function showStatusMessage(message, type) {
const el = document.getElementById('statusMessage');
el.textContent = message;
el.className = `status-message ${type}`;
el.style.display = 'block';

if (type === 'success') {
setTimeout(() => {
el.style.display = 'none';
}, 5000);
}
}

// Utility: escape HTML
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}

// Initialize
loadSettings();
</script>
</body>
</html>
1 change: 1 addition & 0 deletions server/public/admin-sidebar.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
{
label: 'Settings',
items: [
{ href: '/admin/settings', label: 'System Settings', icon: '⚙️' },
{ href: '/admin/insight-types', label: 'Insight Types', icon: '🏷️' },
{ href: '/admin/outreach', label: 'Outreach Config', icon: '📣' },
{ href: '/admin/insights', label: 'Raw Insights', icon: '🧠' },
Expand Down
24 changes: 24 additions & 0 deletions server/src/db/migrations/106_system_settings.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
-- System settings for configurable application behavior
-- Stores key-value settings like Slack channel IDs for various notification types

CREATE TABLE IF NOT EXISTS system_settings (
key VARCHAR(100) PRIMARY KEY,
value JSONB NOT NULL,
description TEXT,
updated_at TIMESTAMPTZ DEFAULT NOW(),
updated_by VARCHAR(255) -- workos_user_id of admin who last updated
);

-- Index for fast lookups
CREATE INDEX IF NOT EXISTS idx_system_settings_updated_at ON system_settings(updated_at);

-- Insert default settings
INSERT INTO system_settings (key, value, description) VALUES
('billing_slack_channel', '{"channel_id": null, "channel_name": null}', 'Slack channel for billing notifications (payments, invoices, subscriptions)')
ON CONFLICT (key) DO NOTHING;

-- Comments for documentation
COMMENT ON TABLE system_settings IS 'Key-value store for system-wide configuration settings';
COMMENT ON COLUMN system_settings.key IS 'Unique setting identifier';
COMMENT ON COLUMN system_settings.value IS 'JSON value for the setting';
COMMENT ON COLUMN system_settings.description IS 'Human-readable description of what this setting controls';
Loading