← back to Rentv 2026
public/admin/pr-intelligence/settings.html
153 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>CRE PR Intelligence — Settings</title>
<link rel="stylesheet" href="/admin/pr-intelligence/pr-shared.css">
</head>
<body>
<script src="/admin/pr-intelligence/pr-shared.js"></script>
<script>
(async function () {
const c = PR.frame('settings', 'Settings', 'Sender identity (CAN-SPAM), submission method, refresh cadence, the California quality gate, the Arizona unlock, email provider, suppression, and audit log.');
c.innerHTML = `
<div class="cols2">
<div>
<div class="panel">
<h3>Sender & letter settings</h3>
<div class="form" id="sform"><span class="spin"></span></div>
</div>
<div class="panel">
<h3>Email provider</h3>
<div id="prov"></div>
</div>
<div class="panel">
<h3>Suppression list</h3>
<div class="filters">
<input id="sup-email" placeholder="email or leave blank">
<input id="sup-reason" placeholder="reason" value="manual">
<button class="btn sm" id="sup-add">Add suppression</button>
<a class="btn sm" href="/api/pr/export/suppression">⬇ Export</a>
</div>
<div id="sup-list"><span class="spin"></span></div>
</div>
</div>
<div>
<div class="panel">
<h3>California quality gate</h3>
<button class="btn primary" id="gate-check">Run gate check now</button>
<div id="gate-out" style="margin-top:10px"></div>
</div>
<div class="panel">
<h3>Arizona phase</h3>
<div id="az-state"></div>
<div class="filters" style="margin-top:8px">
<button class="btn" id="az-unlock">Unlock Arizona (requires passing gate)</button>
<button class="btn danger" id="az-override">Admin override unlock (audit-logged)</button>
</div>
</div>
<div class="panel">
<h3>Audit log (latest 50)</h3>
<div id="audit"><span class="spin"></span></div>
</div>
</div>
</div>`;
const EDITABLE = ['sender_name', 'sender_title', 'sender_contact_information', 'sender_postal_address',
'approved_submission_method', 'unsubscribe_text', 'letter_main_purpose', 'refresh_days_high', 'refresh_days_normal',
'coverage_exclude_types'];
async function loadSettings() {
const all = await PR.api('/settings');
document.getElementById('sform').innerHTML = EDITABLE.map((k) => {
const v = all[k] ? (typeof all[k].value === 'string' ? all[k].value : JSON.stringify(all[k].value)) : '';
const req = ['sender_name', 'sender_title', 'sender_contact_information', 'approved_submission_method'].includes(k);
return `<div><label>${k}${req ? ' * <span style="color:var(--warn)">(required before any letter can send)</span>' : ''}</label>
${k === 'letter_main_purpose' || k === 'unsubscribe_text' ? `<textarea data-k="${k}">${PR.esc(v)}</textarea>` : `<input data-k="${k}" value="${PR.esc(v)}">`}</div>`;
}).join('') + '<button class="btn primary" id="s-save">Save settings</button>';
document.getElementById('s-save').addEventListener('click', async () => {
for (const el of document.querySelectorAll('[data-k]')) {
const k = el.dataset.k;
let v = /refresh_days/.test(k) ? Number(el.value) || 0 : el.value;
if (k === 'coverage_exclude_types') { try { v = JSON.parse(el.value); } catch { PR.toast('coverage_exclude_types must be a JSON array, e.g. ["bank","credit_union"]', true); continue; } }
await PR.api('/settings/' + k, { method: 'PUT', body: { value: v } });
}
PR.toast('Settings saved');
});
const meta = await PR.api('/meta');
const p = meta.email_provider;
document.getElementById('prov').innerHTML = `
<div class="gatechk"><span class="s">${p.gmail_configured ? '🟢' : '⚪️'}</span><b>Gmail</b>
<span style="color:var(--muted);font-size:12px">${p.gmail_configured ? 'connected (OAuth refresh token)' : 'missing env: ' + p.gmail_missing_env.join(', ')}</span></div>
<div class="gatechk"><span class="s">${p.active === 'mock' ? '🟡' : '⚪️'}</span><b>Mock</b>
<span style="color:var(--muted);font-size:12px">active when Gmail is unconfigured — drafts stored locally, nothing sends</span></div>
<p style="font-size:12px;color:var(--muted)">Setup: docs/CRE_PR_EMAIL_SETUP.md · vars go in the environment, never in source control.</p>`;
renderAz(all);
}
function renderAz(all) {
const unlocked = all.arizona_unlocked && all.arizona_unlocked.value === true;
document.getElementById('az-state').innerHTML = unlocked
? PR.badge('UNLOCKED — Arizona research jobs may be queued', 'green')
: PR.badge('🔒 LOCKED — AZ discovery jobs refuse to enqueue', 'amber') +
`<p style="font-size:12.5px;color:var(--muted);margin:8px 0 0">${PR.esc((all.arizona_unlock_note || {}).value || '')}</p>`;
}
document.getElementById('gate-check').addEventListener('click', async () => {
const g = await PR.guard(() => PR.api('/gate/check', { method: 'POST' }), 'Gate evaluated');
const item = (ok, label, extra) => `<div class="gatechk"><span class="s">${ok ? '✅' : '❌'}</span><span>${label}${extra ? ' — ' + extra : ''}</span></div>`;
const ck = g.checks;
document.getElementById('gate-out').innerHTML =
(g.passed ? PR.badge('GATE PASSED', 'green') : PR.badge('GATE NOT PASSED', 'red')) +
item(ck.pipeline_resumable, 'Pipeline resumable after interruption') +
item(ck.dedupe_working, 'Org/people deduplication exercised') +
item(ck.org_completeness.pass, '≥80% reviewed CA orgs complete', ck.org_completeness.pct + '% of ' + ck.org_completeness.reviewed + ' reviewed') +
item(ck.contact_coverage.pass, '≥70% high-priority CA orgs have a relevant contact', ck.contact_coverage.pct + '% of ' + ck.contact_coverage.high_priority) +
item(ck.workflows_exercised, 'Review / letter / outreach / opt-out flows exercised') +
item(ck.no_mislabeled_inferred, 'No inferred contact marked sendable') +
item(ck.dashboard_gaps_visible, 'Coverage gaps visible on the dashboard');
});
document.getElementById('az-unlock').addEventListener('click', async () => {
const r = await PR.guard(() => PR.api('/gate/unlock-arizona', { method: 'POST', body: {} }));
if (r.ok) { PR.toast('Arizona unlocked 🎉'); loadSettings(); }
else PR.toast(r.error, true);
});
document.getElementById('az-override').addEventListener('click', async () => {
if (!confirm('Override the California gate and unlock Arizona anyway? This is audit-logged.')) return;
await PR.guard(() => PR.api('/gate/unlock-arizona', { method: 'POST', body: { override: true } }), 'Arizona unlocked by override (audit-logged)');
loadSettings();
});
document.getElementById('sup-add').addEventListener('click', async () => {
const email = document.getElementById('sup-email').value.trim() || null;
if (!email) return PR.toast('Enter an email to suppress', true);
await PR.guard(() => PR.api('/suppression', { method: 'POST', body: { email, reason: document.getElementById('sup-reason').value || 'manual' } }), 'Suppressed');
loadSup();
});
async function loadSup() {
const j = await PR.api('/suppression?limit=50');
document.getElementById('sup-list').innerHTML = j.rows.length ? j.rows.map((s) => `
<div class="gatechk"><b>${PR.esc(s.email || s.domain || ('person #' + s.person_id))}</b>
${PR.badge(s.reason, s.reason === 'opt_out' ? 'red' : 'amber')} ${s.permanent ? PR.badge('permanent', 'red') : PR.badge('temporary')}
<span style="color:var(--muted);font-size:11.5px">${PR.esc(s.full_name || '')} · 🕓 ${PR.date(s.created_at)}</span>
${!(s.permanent && s.reason === 'opt_out') ? `<button class="btn sm" style="margin-left:auto" data-un="${s.id}">Remove</button>` : ''}</div>`).join('')
: '<div class="empty">Suppression list is empty</div>';
document.querySelectorAll('[data-un]').forEach((b) => b.addEventListener('click', async () => {
await PR.guard(() => PR.api('/suppression/' + b.dataset.un, { method: 'DELETE' }), 'Removed');
loadSup();
}));
}
async function loadAudit() {
const j = await PR.api('/audit');
document.getElementById('audit').innerHTML = j.rows.slice(0, 50).map((a) => `
<div class="gatechk"><span style="color:var(--muted);font-size:11px;white-space:nowrap">${PR.date(a.at)}</span>
<b style="font-size:12px">${PR.esc(a.actor)}</b><span style="font-size:12px">${PR.esc(a.action)}</span>
<span style="color:var(--muted);font-size:11.5px">${PR.esc(a.entity_type || '')} ${a.entity_id || ''}</span></div>`).join('') || '<div class="empty">No audit entries</div>';
}
loadSettings(); loadSup(); loadAudit();
})();
</script>
</body>
</html>