Admin panel: dashboard, form builder, DeepSeek auto-tag + reply drafts
This commit is contained in:
488
admin.html
Normal file
488
admin.html
Normal file
@@ -0,0 +1,488 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Form Admin</title>
|
||||||
|
<style>
|
||||||
|
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||||
|
:root{
|
||||||
|
--bg:#0b0f1a;--card:#131827;--card2:#1a1f30;--fg:#e4e6ec;--fg2:#9399a8;
|
||||||
|
--blue:#0071e3;--blue-h:#0077ed;--green:#34c759;--red:#e03131;--yellow:#ffcc00;
|
||||||
|
--border:rgba(255,255,255,0.06);--radius:10px
|
||||||
|
}
|
||||||
|
body{
|
||||||
|
font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--fg);
|
||||||
|
min-height:100vh;line-height:1.5;-webkit-font-smoothing:antialiased
|
||||||
|
}
|
||||||
|
.login-screen{display:flex;align-items:center;justify-content:center;min-height:100vh}
|
||||||
|
.login-card{background:var(--card);padding:40px;border-radius:16px;width:100%;max-width:400px;
|
||||||
|
box-shadow:0 4px 24px rgba(0,0,0,0.3)}
|
||||||
|
.login-card h1{font-size:24px;font-weight:600;margin-bottom:8px}
|
||||||
|
.login-card p{color:var(--fg2);font-size:14px;margin-bottom:24px}
|
||||||
|
.app{max-width:1200px;margin:0 auto;padding:24px;display:none}
|
||||||
|
.nav{display:flex;align-items:center;justify-content:space-between;margin-bottom:24px;
|
||||||
|
padding-bottom:16px;border-bottom:1px solid var(--border)}
|
||||||
|
.nav h1{font-size:22px;font-weight:600}
|
||||||
|
.nav-tabs{display:flex;gap:4px}
|
||||||
|
.nav-tab{padding:8px 16px;border-radius:6px;cursor:pointer;font-size:14px;font-weight:500;
|
||||||
|
color:var(--fg2);border:none;background:none;transition:all .2s}
|
||||||
|
.nav-tab:hover{background:var(--card2);color:var(--fg)}
|
||||||
|
.nav-tab.active{background:var(--blue);color:#fff}
|
||||||
|
.stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:16px;margin-bottom:24px}
|
||||||
|
.stat-card{background:var(--card);padding:20px;border-radius:var(--radius)}
|
||||||
|
.stat-label{font-size:13px;color:var(--fg2);margin-bottom:4px}
|
||||||
|
.stat-value{font-size:28px;font-weight:600}
|
||||||
|
.section{background:var(--card);border-radius:var(--radius);padding:24px;margin-bottom:24px}
|
||||||
|
.section h2{font-size:18px;font-weight:600;margin-bottom:16px}
|
||||||
|
.section-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px}
|
||||||
|
.btn{padding:10px 20px;border-radius:6px;font-size:14px;font-weight:500;cursor:pointer;
|
||||||
|
border:none;transition:all .15s;font-family:inherit}
|
||||||
|
.btn-primary{background:var(--blue);color:#fff}.btn-primary:hover{background:var(--blue-h)}
|
||||||
|
.btn-green{background:var(--green);color:#000}.btn-green:hover{opacity:.9}
|
||||||
|
.btn-outline{background:transparent;border:1px solid var(--border);color:var(--fg)}.btn-outline:hover{background:var(--card2)}
|
||||||
|
.btn-sm{padding:6px 12px;font-size:12px}
|
||||||
|
.btn-xs{padding:4px 8px;font-size:11px;border-radius:4px}
|
||||||
|
table{width:100%;border-collapse:collapse}
|
||||||
|
th,td{padding:12px;text-align:left;border-bottom:1px solid var(--border);font-size:14px}
|
||||||
|
th{color:var(--fg2);font-weight:500;font-size:13px;text-transform:uppercase;letter-spacing:.5px}
|
||||||
|
tr:hover{background:rgba(255,255,255,0.02)}
|
||||||
|
.tag{display:inline-block;padding:2px 8px;margin:2px;border-radius:4px;font-size:11px;font-weight:500;
|
||||||
|
background:rgba(0,113,227,0.15);color:var(--blue)}
|
||||||
|
.tag.green{background:rgba(52,199,89,0.15);color:var(--green)}
|
||||||
|
.reply-draft{font-size:13px;color:var(--fg2);margin-top:8px;padding:8px;background:var(--card2);border-radius:6px;max-width:400px}
|
||||||
|
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,0.6);display:none;align-items:center;
|
||||||
|
justify-content:center;z-index:100;backdrop-filter:blur(4px)}
|
||||||
|
.modal-overlay.active{display:flex}
|
||||||
|
.modal{background:var(--card);border-radius:16px;padding:32px;max-width:560px;width:90%;max-height:80vh;overflow-y:auto}
|
||||||
|
.modal h3{font-size:20px;margin-bottom:16px}
|
||||||
|
.input{width:100%;padding:12px;background:var(--card2);border:1px solid var(--border);border-radius:6px;
|
||||||
|
color:var(--fg);font-size:14px;font-family:inherit;outline:none}
|
||||||
|
.input:focus{border-color:var(--blue)}
|
||||||
|
.field-row{display:flex;gap:8px;margin-bottom:8px;align-items:center}
|
||||||
|
.field-row select,.field-row input{padding:8px;background:var(--card2);border:1px solid var(--border);
|
||||||
|
border-radius:4px;color:var(--fg);font-size:13px}
|
||||||
|
.field-row input{flex:1}
|
||||||
|
.field-row select{width:120px}
|
||||||
|
.mr-8{margin-right:8px}.mt-16{margin-top:16px}.mb-16{margin-bottom:16px}.mb-8{margin-bottom:8px}
|
||||||
|
.flex{display:flex}.items-center{align-items:center}.gap-8{gap:8px}.justify-between{justify-content:space-between}
|
||||||
|
.text-sm{font-size:13px}.text-muted{color:var(--fg2)}.text-center{text-align:center}
|
||||||
|
.spinner{display:inline-block;width:16px;height:16px;border:2px solid var(--border);border-top-color:var(--blue);
|
||||||
|
border-radius:50%;animation:spin .6s linear infinite;vertical-align:middle;margin-right:8px}
|
||||||
|
@keyframes spin{to{transform:rotate(360deg)}}
|
||||||
|
.toast{position:fixed;bottom:24px;right:24px;padding:12px 20px;border-radius:8px;font-size:14px;
|
||||||
|
font-weight:500;z-index:200;animation:slideUp .3s ease;box-shadow:0 4px 20px rgba(0,0,0,0.4)}
|
||||||
|
.toast-success{background:var(--green);color:#000}
|
||||||
|
.toast-error{background:var(--red);color:#fff}
|
||||||
|
@keyframes slideUp{from{transform:translateY(20px);opacity:0}to{transform:translateY(0);opacity:1}}
|
||||||
|
.empty-state{padding:48px;text-align:center;color:var(--fg2)}
|
||||||
|
.pulse{animation:pulse 2s infinite}@keyframes pulse{0%,100%{opacity:1}50%{opacity:.5}}
|
||||||
|
.tab-content{display:none}.tab-content.active{display:block}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- Login Screen -->
|
||||||
|
<div class="login-screen" id="loginScreen">
|
||||||
|
<div class="login-card">
|
||||||
|
<h1>Form Admin</h1>
|
||||||
|
<p>Enter your admin password to continue.</p>
|
||||||
|
<input type="password" class="input" id="passInput" placeholder="Admin password" autofocus>
|
||||||
|
<button class="btn btn-primary mt-16" style="width:100%" onclick="doLogin()">Sign In</button>
|
||||||
|
<p class="text-sm text-muted text-center mt-16" id="loginMsg"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- App -->
|
||||||
|
<div class="app" id="app">
|
||||||
|
<nav class="nav">
|
||||||
|
<h1>Form Admin</h1>
|
||||||
|
<div class="nav-tabs">
|
||||||
|
<button class="nav-tab active" onclick="switchTab('dashboard')">Dashboard</button>
|
||||||
|
<button class="nav-tab" onclick="switchTab('builder')">Form Builder</button>
|
||||||
|
<button class="nav-tab" onclick="switchTab('settings')">Settings</button>
|
||||||
|
<button class="nav-tab" onclick="logout()" style="color:var(--red)">Logout</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Dashboard Tab -->
|
||||||
|
<div class="tab-content active" id="tab-dashboard">
|
||||||
|
<div class="stats-grid" id="statsGrid"></div>
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>Submissions</h2>
|
||||||
|
<div class="flex gap-8">
|
||||||
|
<button class="btn btn-outline btn-sm" onclick="tagAll()">🏷 Tag All</button>
|
||||||
|
<button class="btn btn-sm btn-outline" onclick="loadSubmissions()">↻ Refresh</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="subsTable"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Builder Tab -->
|
||||||
|
<div class="tab-content" id="tab-builder">
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>Forms</h2>
|
||||||
|
<button class="btn btn-primary btn-sm" onclick="showNewForm()">+ New Form</button>
|
||||||
|
</div>
|
||||||
|
<div id="formsList"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Settings Tab -->
|
||||||
|
<div class="tab-content" id="tab-settings">
|
||||||
|
<div class="section" style="max-width:500px">
|
||||||
|
<h2>Settings</h2>
|
||||||
|
<div class="mb-16">
|
||||||
|
<label class="text-sm" style="display:block;margin-bottom:4px;color:var(--fg2)">DeepSeek API Key</label>
|
||||||
|
<input type="password" class="input" id="apiKeyInput" placeholder="sk-...">
|
||||||
|
<p class="text-sm text-muted">Current: <span id="apiKeyMask">—</span></p>
|
||||||
|
</div>
|
||||||
|
<div class="mb-16">
|
||||||
|
<label class="text-sm" style="display:block;margin-bottom:4px;color:var(--fg2)">DeepSeek Model</label>
|
||||||
|
<input type="text" class="input" id="modelInput" placeholder="deepseek-chat">
|
||||||
|
</div>
|
||||||
|
<div class="mb-16">
|
||||||
|
<label class="text-sm" style="display:block;margin-bottom:4px;color:var(--fg2)">Admin Password</label>
|
||||||
|
<input type="password" class="input" id="adminPwInput" placeholder="New admin password">
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" onclick="saveSettings()">Save Settings</button>
|
||||||
|
<p class="text-sm text-muted mt-16" id="settingsMsg"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Reply Modal -->
|
||||||
|
<div class="modal-overlay" id="replyModal">
|
||||||
|
<div class="modal">
|
||||||
|
<h3>Reply Draft</h3>
|
||||||
|
<div id="replyContent" class="mb-16"></div>
|
||||||
|
<button class="btn btn-outline btn-sm" onclick="closeModal('replyModal')">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Form Editor Modal -->
|
||||||
|
<div class="modal-overlay" id="formModal">
|
||||||
|
<div class="modal">
|
||||||
|
<h3 id="formModalTitle">New Form</h3>
|
||||||
|
<div class="mb-8">
|
||||||
|
<label class="text-sm" style="color:var(--fg2)">Form Name</label>
|
||||||
|
<input type="text" class="input" id="formNameInput" placeholder="My Form">
|
||||||
|
</div>
|
||||||
|
<div class="mb-8">
|
||||||
|
<span class="text-sm" style="color:var(--fg2)">Fields</span>
|
||||||
|
<div id="formFieldsEditor" class="mt-8"></div>
|
||||||
|
<button class="btn btn-outline btn-sm mt-8" onclick="addFieldRow()">+ Add Field</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-8 mt-16">
|
||||||
|
<button class="btn btn-primary btn-sm" onclick="saveForm()">Save Form</button>
|
||||||
|
<button class="btn btn-outline btn-sm" onclick="closeModal('formModal')">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const API = '';
|
||||||
|
let token = '';
|
||||||
|
|
||||||
|
// ---- Auth ----
|
||||||
|
async function doLogin(){
|
||||||
|
const pw = document.getElementById('passInput').value;
|
||||||
|
const msg = document.getElementById('loginMsg');
|
||||||
|
msg.textContent = '';
|
||||||
|
try{
|
||||||
|
const r = await fetch(API+'/admin/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({password:pw})});
|
||||||
|
if(!r.ok){msg.textContent='Wrong password';return}
|
||||||
|
const d = await r.json();
|
||||||
|
token = d.token;
|
||||||
|
localStorage.setItem('form_admin_token', token);
|
||||||
|
document.getElementById('loginScreen').style.display='none';
|
||||||
|
document.getElementById('app').style.display='block';
|
||||||
|
initApp();
|
||||||
|
}catch(e){msg.textContent='Connection error'}
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout(){
|
||||||
|
fetch(API+'/admin/logout',{method:'POST',headers:{Authorization:'Bearer '+token}}).catch(()=>{});
|
||||||
|
localStorage.removeItem('form_admin_token');
|
||||||
|
token='';
|
||||||
|
document.getElementById('loginScreen').style.display='flex';
|
||||||
|
document.getElementById('app').style.display='none';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(method, path, body=null){
|
||||||
|
const opts = {method,headers:{Authorization:'Bearer '+token}};
|
||||||
|
if(body){opts.headers['Content-Type']='application/json';opts.body=JSON.stringify(body)}
|
||||||
|
const r = await fetch(API+path, opts);
|
||||||
|
if(r.status===401){logout();throw new Error('Unauthorized')}
|
||||||
|
if(!r.ok){const e = await r.json().catch(()=>({}));throw new Error(e.detail||e.error||'Request failed')}
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Toast ----
|
||||||
|
function toast(msg, type='success'){
|
||||||
|
const t = document.createElement('div');
|
||||||
|
t.className = 'toast toast-'+type;
|
||||||
|
t.textContent = msg;
|
||||||
|
document.body.appendChild(t);
|
||||||
|
setTimeout(()=>{t.style.opacity='0';setTimeout(()=>t.remove(),300)},2500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Tabs ----
|
||||||
|
function switchTab(name){
|
||||||
|
document.querySelectorAll('.nav-tab').forEach(b=>b.classList.remove('active'));
|
||||||
|
document.querySelectorAll('.tab-content').forEach(c=>c.classList.remove('active'));
|
||||||
|
event.target.classList.add('active');
|
||||||
|
document.getElementById('tab-'+name).classList.add('active');
|
||||||
|
if(name==='dashboard'){loadStats();loadSubmissions()}
|
||||||
|
if(name==='builder')loadForms();
|
||||||
|
if(name==='settings')loadSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Modal ----
|
||||||
|
function closeModal(id){document.getElementById(id).classList.remove('active')}
|
||||||
|
|
||||||
|
// ---- Stats ----
|
||||||
|
async function loadStats(){
|
||||||
|
try{
|
||||||
|
const s = await api('GET','/admin/stats');
|
||||||
|
document.getElementById('statsGrid').innerHTML = `
|
||||||
|
<div class="stat-card"><div class="stat-label">Total Submissions</div><div class="stat-value">${s.total_submissions}</div></div>
|
||||||
|
<div class="stat-card"><div class="stat-label">Today</div><div class="stat-value">${s.today}</div></div>
|
||||||
|
<div class="stat-card"><div class="stat-label">Forms</div><div class="stat-value">${s.forms}</div></div>
|
||||||
|
<div class="stat-card"><div class="stat-label">Tagged</div><div class="stat-value">${s.tagged}</div></div>`;
|
||||||
|
}catch(e){}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Submissions ----
|
||||||
|
async function loadSubmissions(){
|
||||||
|
try{
|
||||||
|
const data = await api('GET','/admin/submissions?per_page=100');
|
||||||
|
const subs = data.submissions;
|
||||||
|
if(!subs.length){
|
||||||
|
document.getElementById('subsTable').innerHTML = '<div class="empty-state">No submissions yet.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let html = '<table><thead><tr><th>ID</th><th>Data</th><th>Form</th><th>Tags</th><th>Reply</th><th>Actions</th></tr></thead><tbody>';
|
||||||
|
for(const s of subs){
|
||||||
|
const fields = s.data.map(d=>`<b>${d.field_label}:</b> ${d.field_value}`).join(' · ');
|
||||||
|
const tags = s.tags.map(t=>`<span class="tag">${t}</span>`).join(' ') || '<span class="text-muted text-sm">—</span>';
|
||||||
|
const replyHtml = s.reply
|
||||||
|
? `<div class="reply-draft">${escapeHtml(s.reply.draft_text)}</div>`
|
||||||
|
: '<span class="text-muted text-sm">—</span>';
|
||||||
|
html += `<tr>
|
||||||
|
<td>#${s.id}</td>
|
||||||
|
<td style="max-width:300px;word-break:break-word">${fields}</td>
|
||||||
|
<td>${escapeHtml(s.form_name)}</td>
|
||||||
|
<td>${tags}</td>
|
||||||
|
<td>${replyHtml}</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-outline btn-xs mr-8" onclick="genReply(${s.id})">✉ Reply</button>
|
||||||
|
<button class="btn btn-outline btn-xs" onclick="tagOne(${s.id})">🏷 Tag</button>
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
html += '</tbody></table>';
|
||||||
|
document.getElementById('subsTable').innerHTML = html;
|
||||||
|
}catch(e){document.getElementById('subsTable').innerHTML='<div class="empty-state">Error loading.</div>'}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')}
|
||||||
|
|
||||||
|
// ---- DeepSeek actions ----
|
||||||
|
async function tagAll(){
|
||||||
|
if(!confirm('Tag all submissions with DeepSeek?'))return;
|
||||||
|
toast('Analyzing submissions...', 'success');
|
||||||
|
try{
|
||||||
|
const r = await api('POST','/admin/tag',{all:true});
|
||||||
|
toast(`Tagged ${r.tagged} submissions!`, 'success');
|
||||||
|
loadSubmissions();loadStats();
|
||||||
|
}catch(e){toast(e.message,'error')}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tagOne(id){
|
||||||
|
toast('Analyzing...','success');
|
||||||
|
try{
|
||||||
|
const r = await api('POST','/admin/tag',{submission_ids:[id]});
|
||||||
|
toast(`Tagged!`, 'success');
|
||||||
|
loadSubmissions();loadStats();
|
||||||
|
}catch(e){toast(e.message,'error')}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function genReply(id){
|
||||||
|
toast('Generating reply...','success');
|
||||||
|
try{
|
||||||
|
const r = await api('POST','/admin/reply/'+id);
|
||||||
|
document.getElementById('replyContent').innerHTML = `<pre style="white-space:pre-wrap;font-family:inherit;font-size:14px;line-height:1.6;color:var(--fg)">${escapeHtml(r.draft)}</pre>`;
|
||||||
|
document.getElementById('replyModal').classList.add('active');
|
||||||
|
loadSubmissions();
|
||||||
|
}catch(e){toast(e.message,'error')}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Forms / Builder ----
|
||||||
|
async function loadForms(){
|
||||||
|
try{
|
||||||
|
const forms = await api('GET','/forms');
|
||||||
|
let html='';
|
||||||
|
for(const f of forms){
|
||||||
|
const fieldList = f.fields.map(ff=>`${ff.label} (${ff.field_type}${ff.required?'*':''})`).join(', ');
|
||||||
|
html += `<div class="section mb-16" style="padding:16px">
|
||||||
|
<div class="flex justify-between items-center mb-8">
|
||||||
|
<div><b>${escapeHtml(f.name)}</b> <span class="text-sm text-muted">/${f.slug}</span></div>
|
||||||
|
<div class="flex gap-8">
|
||||||
|
<button class="btn btn-outline btn-xs" onclick="editForm(${f.id})">Edit</button>
|
||||||
|
${f.id!==1?`<button class="btn btn-outline btn-xs" onclick="delForm(${f.id})" style="color:var(--red)">Delete</button>`:''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-muted">${fieldList || 'No fields'}</div>
|
||||||
|
<div class="text-sm text-muted mt-8">📋 <code>http://192.168.1.121:8080/submit/${f.slug}</code></div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
document.getElementById('formsList').innerHTML = html;
|
||||||
|
}catch(e){}
|
||||||
|
}
|
||||||
|
|
||||||
|
let editingFormId = null;
|
||||||
|
|
||||||
|
function showNewForm(){
|
||||||
|
editingFormId = null;
|
||||||
|
document.getElementById('formModalTitle').textContent = 'New Form';
|
||||||
|
document.getElementById('formNameInput').value = '';
|
||||||
|
document.getElementById('formFieldsEditor').innerHTML = '';
|
||||||
|
addFieldRow();addFieldRow();
|
||||||
|
document.getElementById('formModal').classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function editForm(id){
|
||||||
|
editingFormId = id;
|
||||||
|
try{
|
||||||
|
const forms = await api('GET','/forms');
|
||||||
|
const f = forms.find(x=>x.id===id);
|
||||||
|
if(!f)return;
|
||||||
|
document.getElementById('formModalTitle').textContent = 'Edit: '+f.name;
|
||||||
|
document.getElementById('formNameInput').value = f.name;
|
||||||
|
const editor = document.getElementById('formFieldsEditor');
|
||||||
|
editor.innerHTML = '';
|
||||||
|
for(const ff of f.fields){
|
||||||
|
addFieldRow(ff.label, ff.field_type, ff.required);
|
||||||
|
}
|
||||||
|
if(!f.fields.length){addFieldRow();addFieldRow()}
|
||||||
|
document.getElementById('formModal').classList.add('active');
|
||||||
|
}catch(e){toast('Error loading form','error')}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addFieldRow(label='', type='text', required='1'){
|
||||||
|
const req = String(required) === '1';
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'field-row';
|
||||||
|
div.innerHTML = `
|
||||||
|
<input type="text" placeholder="Label" value="${escapeHtml(label)}" class="field-label">
|
||||||
|
<select class="field-type">
|
||||||
|
<option value="text" ${type==='text'?'selected':''}>Text</option>
|
||||||
|
<option value="email" ${type==='email'?'selected':''}>Email</option>
|
||||||
|
<option value="number" ${type==='number'?'selected':''}>Number</option>
|
||||||
|
<option value="tel" ${type==='tel'?'selected':''}>Phone</option>
|
||||||
|
<option value="url" ${type==='url'?'selected':''}>URL</option>
|
||||||
|
<option value="textarea" ${type==='textarea'?'selected':''}>Long Text</option>
|
||||||
|
</select>
|
||||||
|
<label style="font-size:12px;color:var(--fg2);white-space:nowrap">
|
||||||
|
<input type="checkbox" class="field-required" ${req?'checked':''}> Required
|
||||||
|
</label>
|
||||||
|
<button class="btn btn-outline btn-xs" onclick="this.parentElement.remove()" style="color:var(--red)">✕</button>`;
|
||||||
|
document.getElementById('formFieldsEditor').appendChild(div);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveForm(){
|
||||||
|
const name = document.getElementById('formNameInput').value.trim();
|
||||||
|
if(!name){toast('Form name required','error');return}
|
||||||
|
const rows = document.querySelectorAll('#formFieldsEditor .field-row');
|
||||||
|
const fields = [];
|
||||||
|
rows.forEach(r=>{
|
||||||
|
const label = r.querySelector('.field-label').value.trim();
|
||||||
|
if(!label)return;
|
||||||
|
fields.push({
|
||||||
|
label,
|
||||||
|
field_type: r.querySelector('.field-type').value,
|
||||||
|
required: r.querySelector('.field-required').checked
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if(!fields.length){toast('Add at least one field','error');return}
|
||||||
|
try{
|
||||||
|
if(editingFormId){
|
||||||
|
await api('PUT','/forms/'+editingFormId,{name,fields});
|
||||||
|
toast('Form updated!');
|
||||||
|
}else{
|
||||||
|
await api('POST','/forms',{name,fields});
|
||||||
|
toast('Form created!');
|
||||||
|
}
|
||||||
|
closeModal('formModal');
|
||||||
|
loadForms();
|
||||||
|
}catch(e){toast(e.message,'error')}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function delForm(id){
|
||||||
|
if(!confirm('Delete this form and all its submissions?'))return;
|
||||||
|
try{
|
||||||
|
await api('DELETE','/forms/'+id);
|
||||||
|
toast('Form deleted');
|
||||||
|
loadForms();
|
||||||
|
}catch(e){toast(e.message,'error')}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Settings ----
|
||||||
|
async function loadSettings(){
|
||||||
|
try{
|
||||||
|
const s = await api('GET','/admin/settings');
|
||||||
|
document.getElementById('apiKeyInput').placeholder = s.deepseek_api_key ? '(set — leave blank to keep)' : 'sk-...';
|
||||||
|
document.getElementById('apiKeyMask').textContent = s.deepseek_api_key_masked || '—';
|
||||||
|
document.getElementById('modelInput').value = s.deepseek_model || 'deepseek-chat';
|
||||||
|
document.getElementById('adminPwInput').placeholder = '(set to change)';
|
||||||
|
}catch(e){}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSettings(){
|
||||||
|
const body = {};
|
||||||
|
const ak = document.getElementById('apiKeyInput').value.trim();
|
||||||
|
if(ak){body.deepseek_api_key = ak}
|
||||||
|
const model = document.getElementById('modelInput').value.trim();
|
||||||
|
if(model){body.deepseek_model = model}
|
||||||
|
const pw = document.getElementById('adminPwInput').value.trim();
|
||||||
|
if(pw){body.admin_password = pw}
|
||||||
|
if(!Object.keys(body).length){toast('No changes','error');return}
|
||||||
|
try{
|
||||||
|
await api('POST','/admin/settings',body);
|
||||||
|
document.getElementById('apiKeyInput').value = '';
|
||||||
|
document.getElementById('adminPwInput').value = '';
|
||||||
|
document.getElementById('settingsMsg').textContent = 'Saved!';
|
||||||
|
toast('Settings saved');
|
||||||
|
loadSettings();
|
||||||
|
}catch(e){toast(e.message,'error')}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Init ----
|
||||||
|
function initApp(){
|
||||||
|
loadStats();
|
||||||
|
loadSubmissions();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-login with saved token
|
||||||
|
(async function(){
|
||||||
|
const saved = localStorage.getItem('form_admin_token');
|
||||||
|
if(saved){
|
||||||
|
token = saved;
|
||||||
|
try{
|
||||||
|
await api('GET','/admin/stats');
|
||||||
|
document.getElementById('loginScreen').style.display='none';
|
||||||
|
document.getElementById('app').style.display='block';
|
||||||
|
initApp();
|
||||||
|
}catch(e){localStorage.removeItem('form_admin_token');token=''}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Enter key for login
|
||||||
|
document.getElementById('passInput').addEventListener('keydown',function(e){if(e.key==='Enter')doLogin()});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
533
server.py
533
server.py
@@ -1,75 +1,528 @@
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
import os
|
import os
|
||||||
|
import json
|
||||||
|
import secrets
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI, HTTPException, Request
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
DB_PATH = os.environ.get("DB_PATH", "/srv/form-backend/submissions.db")
|
DB_PATH = os.environ.get("DB_PATH", "/srv/form-backend/submissions.db")
|
||||||
|
|
||||||
|
# ---- DB Helpers ----
|
||||||
|
|
||||||
def get_db():
|
def get_db():
|
||||||
conn = sqlite3.connect(DB_PATH)
|
conn = sqlite3.connect(DB_PATH)
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
conn.execute("PRAGMA journal_mode=WAL")
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
conn.execute("PRAGMA foreign_keys=ON")
|
||||||
return conn
|
return conn
|
||||||
|
|
||||||
|
def init_schema(db):
|
||||||
|
db.executescript("""
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO settings (key, value) VALUES ('admin_password', 'admin123');
|
||||||
|
INSERT OR IGNORE INTO settings (key, value) VALUES ('deepseek_api_key', '');
|
||||||
|
INSERT OR IGNORE INTO settings (key, value) VALUES ('deepseek_model', 'deepseek-chat');
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS forms (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO forms (id, name, slug)
|
||||||
|
SELECT 1, 'Customer Form', 'default'
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM forms WHERE id = 1);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS form_fields (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
form_id INTEGER NOT NULL REFERENCES forms(id) ON DELETE CASCADE,
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
field_type TEXT NOT NULL DEFAULT 'text',
|
||||||
|
required INTEGER NOT NULL DEFAULT 1,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO form_fields (form_id, label, field_type, required, sort_order)
|
||||||
|
SELECT 1, 'Email', 'email', 1, 1
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM form_fields WHERE form_id = 1 AND sort_order = 1);
|
||||||
|
|
||||||
|
INSERT INTO form_fields (form_id, label, field_type, required, sort_order)
|
||||||
|
SELECT 1, 'Name', 'text', 1, 2
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM form_fields WHERE form_id = 1 AND sort_order = 2);
|
||||||
|
|
||||||
|
INSERT INTO form_fields (form_id, label, field_type, required, sort_order)
|
||||||
|
SELECT 1, 'Domain', 'text', 1, 3
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM form_fields WHERE form_id = 1 AND sort_order = 3);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS submissions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
form_id INTEGER NOT NULL DEFAULT 1 REFERENCES forms(id),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS submission_data (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
submission_id INTEGER NOT NULL REFERENCES submissions(id) ON DELETE CASCADE,
|
||||||
|
field_label TEXT NOT NULL,
|
||||||
|
field_value TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS submission_tags (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
submission_id INTEGER NOT NULL REFERENCES submissions(id) ON DELETE CASCADE,
|
||||||
|
tag TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS reply_drafts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
submission_id INTEGER NOT NULL UNIQUE REFERENCES submissions(id) ON DELETE CASCADE,
|
||||||
|
draft_text TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS admin_tokens (
|
||||||
|
token TEXT PRIMARY KEY,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
def get_setting(db, key, default=""):
|
||||||
|
row = db.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone()
|
||||||
|
return row["value"] if row else default
|
||||||
|
|
||||||
|
def set_setting(db, key, value):
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||||
|
(key, value)
|
||||||
|
)
|
||||||
|
|
||||||
|
def check_auth(request: Request):
|
||||||
|
token = request.headers.get("Authorization", "").replace("Bearer ", "")
|
||||||
|
if not token:
|
||||||
|
raise HTTPException(401, "Missing token")
|
||||||
|
db = get_db()
|
||||||
|
row = db.execute("SELECT 1 FROM admin_tokens WHERE token = ?", (token,)).fetchone()
|
||||||
|
db.close()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(401, "Invalid token")
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
db = get_db()
|
db = get_db()
|
||||||
db.execute("""
|
init_schema(db)
|
||||||
CREATE TABLE IF NOT EXISTS submissions (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
email TEXT NOT NULL,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
domain TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
db.commit()
|
|
||||||
db.close()
|
db.close()
|
||||||
yield
|
yield
|
||||||
|
|
||||||
app = FastAPI(title="Form Backend", lifespan=lifespan)
|
app = FastAPI(title="Form Backend", lifespan=lifespan)
|
||||||
|
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
|
||||||
|
|
||||||
app.add_middleware(
|
# ---- Models ----
|
||||||
CORSMiddleware,
|
|
||||||
allow_origins=["*"],
|
|
||||||
allow_methods=["*"],
|
|
||||||
allow_headers=["*"],
|
|
||||||
)
|
|
||||||
|
|
||||||
class Submission(BaseModel):
|
class FieldDef(BaseModel):
|
||||||
email: str
|
id: Optional[int] = None
|
||||||
|
label: str
|
||||||
|
field_type: str = "text"
|
||||||
|
required: bool = True
|
||||||
|
|
||||||
|
class FormDef(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
domain: str
|
fields: list[FieldDef] = []
|
||||||
|
|
||||||
|
class LoginBody(BaseModel):
|
||||||
|
password: str
|
||||||
|
|
||||||
|
class AnalyzeBody(BaseModel):
|
||||||
|
submission_ids: list[int] = []
|
||||||
|
all: bool = False
|
||||||
|
|
||||||
|
# ---- Auth ----
|
||||||
|
|
||||||
|
@app.post("/admin/login")
|
||||||
|
def admin_login(body: LoginBody):
|
||||||
|
db = get_db()
|
||||||
|
pw = get_setting(db, "admin_password", "admin123")
|
||||||
|
if body.password != pw:
|
||||||
|
db.close()
|
||||||
|
raise HTTPException(401, "Wrong password")
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
db.execute("INSERT INTO admin_tokens (token) VALUES (?)", (token,))
|
||||||
|
db.commit()
|
||||||
|
db.close()
|
||||||
|
return {"token": token}
|
||||||
|
|
||||||
|
@app.post("/admin/logout")
|
||||||
|
def admin_logout(request: Request):
|
||||||
|
check_auth(request)
|
||||||
|
token = request.headers.get("Authorization", "").replace("Bearer ", "")
|
||||||
|
db = get_db()
|
||||||
|
db.execute("DELETE FROM admin_tokens WHERE token = ?", (token,))
|
||||||
|
db.commit()
|
||||||
|
db.close()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
# ---- Settings ----
|
||||||
|
|
||||||
|
@app.get("/admin/settings")
|
||||||
|
def get_settings(request: Request):
|
||||||
|
check_auth(request)
|
||||||
|
db = get_db()
|
||||||
|
keys = ["deepseek_api_key", "deepseek_model", "admin_password"]
|
||||||
|
result = {}
|
||||||
|
for k in keys:
|
||||||
|
result[k] = get_setting(db, k)
|
||||||
|
ak = result.get("deepseek_api_key", "")
|
||||||
|
if ak and len(ak) > 8:
|
||||||
|
result["deepseek_api_key_masked"] = ak[:4] + "***" + ak[-4:]
|
||||||
|
elif ak:
|
||||||
|
result["deepseek_api_key_masked"] = "***"
|
||||||
|
else:
|
||||||
|
result["deepseek_api_key_masked"] = ""
|
||||||
|
db.close()
|
||||||
|
return result
|
||||||
|
|
||||||
|
@app.post("/admin/settings")
|
||||||
|
def update_settings(body: dict, request: Request):
|
||||||
|
check_auth(request)
|
||||||
|
db = get_db()
|
||||||
|
allowed = {"deepseek_api_key", "deepseek_model", "admin_password"}
|
||||||
|
for k, v in body.items():
|
||||||
|
if k in allowed:
|
||||||
|
set_setting(db, k, str(v))
|
||||||
|
db.commit()
|
||||||
|
db.close()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
# ---- Stats ----
|
||||||
|
|
||||||
|
@app.get("/admin/stats")
|
||||||
|
def admin_stats(request: Request):
|
||||||
|
check_auth(request)
|
||||||
|
db = get_db()
|
||||||
|
total = db.execute("SELECT COUNT(*) as c FROM submissions").fetchone()["c"]
|
||||||
|
today = db.execute("SELECT COUNT(*) as c FROM submissions WHERE date(created_at) = date('now')").fetchone()["c"]
|
||||||
|
forms_count = db.execute("SELECT COUNT(*) as c FROM forms").fetchone()["c"]
|
||||||
|
tagged = db.execute("SELECT COUNT(DISTINCT submission_id) as c FROM submission_tags").fetchone()["c"]
|
||||||
|
db.close()
|
||||||
|
return {"total_submissions": total, "today": today, "forms": forms_count, "tagged": tagged}
|
||||||
|
|
||||||
|
# ---- Forms CRUD ----
|
||||||
|
|
||||||
|
@app.get("/forms")
|
||||||
|
def list_forms():
|
||||||
|
db = get_db()
|
||||||
|
rows = db.execute("SELECT * FROM forms ORDER BY id").fetchall()
|
||||||
|
result = []
|
||||||
|
for f in rows:
|
||||||
|
fields = db.execute(
|
||||||
|
"SELECT * FROM form_fields WHERE form_id = ? ORDER BY sort_order", (f["id"],)
|
||||||
|
).fetchall()
|
||||||
|
result.append({
|
||||||
|
"id": f["id"], "name": f["name"], "slug": f["slug"],
|
||||||
|
"created_at": f["created_at"],
|
||||||
|
"fields": [dict(ff) for ff in fields]
|
||||||
|
})
|
||||||
|
db.close()
|
||||||
|
return result
|
||||||
|
|
||||||
|
@app.get("/forms/{slug}")
|
||||||
|
def get_form(slug: str):
|
||||||
|
db = get_db()
|
||||||
|
form = db.execute("SELECT * FROM forms WHERE slug = ?", (slug,)).fetchone()
|
||||||
|
if not form:
|
||||||
|
db.close()
|
||||||
|
raise HTTPException(404, "Form not found")
|
||||||
|
fields = db.execute(
|
||||||
|
"SELECT * FROM form_fields WHERE form_id = ? ORDER BY sort_order", (form["id"],)
|
||||||
|
).fetchall()
|
||||||
|
db.close()
|
||||||
|
return {
|
||||||
|
"id": form["id"], "name": form["name"], "slug": form["slug"],
|
||||||
|
"fields": [dict(f) for f in fields]
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.post("/forms", status_code=201)
|
||||||
|
def create_form(body: FormDef, request: Request):
|
||||||
|
check_auth(request)
|
||||||
|
slug = body.name.lower().replace(" ", "-")
|
||||||
|
db = get_db()
|
||||||
|
cur = db.execute("INSERT INTO forms (name, slug) VALUES (?, ?)", (body.name, slug))
|
||||||
|
form_id = cur.lastrowid
|
||||||
|
for i, f in enumerate(body.fields):
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO form_fields (form_id, label, field_type, required, sort_order) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(form_id, f.label, f.field_type, 1 if f.required else 0, i + 1)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
db.close()
|
||||||
|
return {"ok": True, "id": form_id, "slug": slug}
|
||||||
|
|
||||||
|
@app.put("/forms/{form_id}")
|
||||||
|
def update_form(form_id: int, body: FormDef, request: Request):
|
||||||
|
check_auth(request)
|
||||||
|
db = get_db()
|
||||||
|
db.execute("UPDATE forms SET name = ? WHERE id = ?", (body.name, form_id))
|
||||||
|
db.execute("DELETE FROM form_fields WHERE form_id = ?", (form_id,))
|
||||||
|
for i, f in enumerate(body.fields):
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO form_fields (form_id, label, field_type, required, sort_order) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(form_id, f.label, f.field_type, 1 if f.required else 0, i + 1)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
db.close()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
@app.delete("/forms/{form_id}")
|
||||||
|
def delete_form(form_id: int, request: Request):
|
||||||
|
check_auth(request)
|
||||||
|
if form_id == 1:
|
||||||
|
raise HTTPException(400, "Cannot delete default form")
|
||||||
|
db = get_db()
|
||||||
|
db.execute("DELETE FROM forms WHERE id = ?", (form_id,))
|
||||||
|
db.commit()
|
||||||
|
db.close()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
# ---- Public submit ----
|
||||||
|
|
||||||
|
@app.post("/submit/{slug}", status_code=201)
|
||||||
|
async def submit_form(slug: str, body: dict, request: Request):
|
||||||
|
db = get_db()
|
||||||
|
form = db.execute("SELECT id FROM forms WHERE slug = ?", (slug,)).fetchone()
|
||||||
|
if not form:
|
||||||
|
db.close()
|
||||||
|
raise HTTPException(404, "Form not found")
|
||||||
|
form_id = form["id"]
|
||||||
|
cur = db.execute("INSERT INTO submissions (form_id) VALUES (?)", (form_id,))
|
||||||
|
sub_id = cur.lastrowid
|
||||||
|
for key, val in body.items():
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO submission_data (submission_id, field_label, field_value) VALUES (?, ?, ?)",
|
||||||
|
(sub_id, key, str(val))
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
db.close()
|
||||||
|
return {"ok": True, "id": sub_id}
|
||||||
|
|
||||||
@app.post("/api/submit", status_code=201)
|
@app.post("/api/submit", status_code=201)
|
||||||
def submit(data: Submission):
|
async def submit_legacy(body: dict, request: Request):
|
||||||
if not data.email or "@" not in data.email:
|
return await submit_form("default", body, request)
|
||||||
return {"error": "Invalid email"}, 422
|
|
||||||
if not data.name.strip():
|
|
||||||
return {"error": "Name required"}, 422
|
|
||||||
if not data.domain.strip():
|
|
||||||
return {"error": "Domain required"}, 422
|
|
||||||
|
|
||||||
db = get_db()
|
# ---- Admin Submissions ----
|
||||||
cur = db.execute(
|
|
||||||
"INSERT INTO submissions (email, name, domain, created_at) VALUES (?, ?, ?, datetime(\"now\"))",
|
|
||||||
(data.email.strip(), data.name.strip(), data.domain.strip())
|
|
||||||
)
|
|
||||||
db.commit()
|
|
||||||
row_id = cur.lastrowid
|
|
||||||
db.close()
|
|
||||||
return {"ok": True, "id": row_id}
|
|
||||||
|
|
||||||
@app.get("/api/submissions")
|
@app.get("/admin/submissions")
|
||||||
def list_submissions():
|
def admin_submissions(
|
||||||
|
request: Request,
|
||||||
|
form_id: Optional[int] = None,
|
||||||
|
page: int = 1,
|
||||||
|
per_page: int = 50
|
||||||
|
):
|
||||||
|
check_auth(request)
|
||||||
db = get_db()
|
db = get_db()
|
||||||
rows = db.execute("SELECT id, email, name, domain, created_at FROM submissions ORDER BY id DESC LIMIT 100").fetchall()
|
offset = (page - 1) * per_page
|
||||||
|
query = "SELECT s.*, f.name as form_name FROM submissions s JOIN forms f ON s.form_id = f.id"
|
||||||
|
count_query = "SELECT COUNT(*) as c FROM submissions"
|
||||||
|
params = []
|
||||||
|
if form_id:
|
||||||
|
query += " WHERE s.form_id = ?"
|
||||||
|
count_query += " WHERE form_id = ?"
|
||||||
|
params.append(form_id)
|
||||||
|
query += " ORDER BY s.id DESC LIMIT ? OFFSET ?"
|
||||||
|
total = db.execute(count_query, params).fetchone()["c"]
|
||||||
|
rows = db.execute(query, params + [per_page, offset]).fetchall()
|
||||||
|
result = []
|
||||||
|
for r in rows:
|
||||||
|
data = db.execute(
|
||||||
|
"SELECT * FROM submission_data WHERE submission_id = ?", (r["id"],)
|
||||||
|
).fetchall()
|
||||||
|
tags = db.execute(
|
||||||
|
"SELECT tag FROM submission_tags WHERE submission_id = ?", (r["id"],)
|
||||||
|
).fetchall()
|
||||||
|
reply = db.execute(
|
||||||
|
"SELECT draft_text, created_at FROM reply_drafts WHERE submission_id = ?", (r["id"],)
|
||||||
|
).fetchone()
|
||||||
|
result.append({
|
||||||
|
"id": r["id"], "form_id": r["form_id"], "form_name": r["form_name"],
|
||||||
|
"created_at": r["created_at"],
|
||||||
|
"data": [dict(d) for d in data],
|
||||||
|
"tags": [t["tag"] for t in tags],
|
||||||
|
"reply": dict(reply) if reply else None
|
||||||
|
})
|
||||||
db.close()
|
db.close()
|
||||||
return [dict(r) for r in rows]
|
return {
|
||||||
|
"submissions": result, "total": total, "page": page,
|
||||||
|
"per_page": per_page, "pages": (total + per_page - 1) // per_page
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- DeepSeek: Auto-tag ----
|
||||||
|
|
||||||
|
@app.post("/admin/tag")
|
||||||
|
def auto_tag(body: AnalyzeBody, request: Request):
|
||||||
|
check_auth(request)
|
||||||
|
db = get_db()
|
||||||
|
api_key = get_setting(db, "deepseek_api_key")
|
||||||
|
if not api_key:
|
||||||
|
db.close()
|
||||||
|
raise HTTPException(400, "DeepSeek API key not configured. Add it in Settings.")
|
||||||
|
|
||||||
|
ids = body.submission_ids
|
||||||
|
if body.all:
|
||||||
|
rows = db.execute("SELECT id FROM submissions").fetchall()
|
||||||
|
ids = [r["id"] for r in rows]
|
||||||
|
|
||||||
|
if not ids:
|
||||||
|
db.close()
|
||||||
|
return {"tagged": 0}
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
for sid in ids:
|
||||||
|
data = db.execute(
|
||||||
|
"SELECT * FROM submission_data WHERE submission_id = ?", (sid,)
|
||||||
|
).fetchall()
|
||||||
|
fields = {d["field_label"]: d["field_value"] for d in data}
|
||||||
|
email = fields.get("Email", fields.get("email", "unknown"))
|
||||||
|
name = fields.get("Name", fields.get("name", "unknown"))
|
||||||
|
domain = fields.get("Domain", fields.get("domain", ""))
|
||||||
|
lines.append(f"ID={sid} | {name} | {email} | domain={domain}")
|
||||||
|
|
||||||
|
joiner = "\n"
|
||||||
|
prompt = f"""Analyze these customer form submissions and assign 1-3 short tags to each (like enterprise, startup, personal, creative, tech, finance, education, spam, etc). Return ONLY a JSON object mapping submission IDs to arrays of tags.
|
||||||
|
|
||||||
|
Submissions:
|
||||||
|
{joiner.join(lines[:50])}
|
||||||
|
|
||||||
|
Output format: {{"1": ["tag1", "tag2"], "2": ["tag3"]}}"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
import httpx
|
||||||
|
model = get_setting(db, "deepseek_model", "deepseek-chat")
|
||||||
|
resp = httpx.post(
|
||||||
|
"https://api.deepseek.com/v1/chat/completions",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"model": model,
|
||||||
|
"messages": [{"role": "user", "content": prompt}],
|
||||||
|
"temperature": 0.3,
|
||||||
|
"max_tokens": 1000
|
||||||
|
},
|
||||||
|
timeout=30
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
content = resp.json()["choices"][0]["message"]["content"].strip()
|
||||||
|
|
||||||
|
# Clean markdown fences
|
||||||
|
if content.startswith("```"):
|
||||||
|
content = "\n".join(content.split("\n")[1:])
|
||||||
|
if content.endswith("```"):
|
||||||
|
content = "\n".join(content.split("\n")[:-1])
|
||||||
|
content = content.strip()
|
||||||
|
|
||||||
|
tags_map = json.loads(content)
|
||||||
|
tagged = 0
|
||||||
|
for sid_str, tags in tags_map.items():
|
||||||
|
sid = int(sid_str)
|
||||||
|
db.execute("DELETE FROM submission_tags WHERE submission_id = ?", (sid,))
|
||||||
|
for tag in tags:
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO submission_tags (submission_id, tag) VALUES (?, ?)",
|
||||||
|
(sid, tag.strip())
|
||||||
|
)
|
||||||
|
tagged += 1
|
||||||
|
db.commit()
|
||||||
|
db.close()
|
||||||
|
return {"tagged": tagged, "tags_map": tags_map}
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
db.close()
|
||||||
|
raise HTTPException(500, f"DeepSeek returned unparseable response: {content[:300]}")
|
||||||
|
except Exception as e:
|
||||||
|
db.close()
|
||||||
|
raise HTTPException(500, f"DeepSeek API error: {str(e)}")
|
||||||
|
|
||||||
|
# ---- DeepSeek: Reply draft ----
|
||||||
|
|
||||||
|
@app.post("/admin/reply/{submission_id}")
|
||||||
|
def generate_reply(submission_id: int, request: Request):
|
||||||
|
check_auth(request)
|
||||||
|
db = get_db()
|
||||||
|
api_key = get_setting(db, "deepseek_api_key")
|
||||||
|
if not api_key:
|
||||||
|
db.close()
|
||||||
|
raise HTTPException(400, "DeepSeek API key not configured. Add it in Settings.")
|
||||||
|
|
||||||
|
data = db.execute(
|
||||||
|
"SELECT * FROM submission_data WHERE submission_id = ?", (submission_id,)
|
||||||
|
).fetchall()
|
||||||
|
fields = {d["field_label"]: d["field_value"] for d in data}
|
||||||
|
name = fields.get("Name", fields.get("name", "there"))
|
||||||
|
email = fields.get("Email", fields.get("email", ""))
|
||||||
|
domain = fields.get("Domain", fields.get("domain", ""))
|
||||||
|
extra = {k: v for k, v in fields.items() if k.lower() not in ("name", "email", "domain")}
|
||||||
|
|
||||||
|
prompt = f"""Write a short, warm professional email reply to a customer who filled out our form.
|
||||||
|
|
||||||
|
Customer: {name} ({email})
|
||||||
|
Domain: {domain}
|
||||||
|
{json.dumps(extra) if extra else ''}
|
||||||
|
|
||||||
|
The reply should:
|
||||||
|
- Thank them by name
|
||||||
|
- Mention their domain name naturally
|
||||||
|
- Say we will be in touch within 24 hours
|
||||||
|
- Keep it under 4 sentences
|
||||||
|
- Sign as "The Team"
|
||||||
|
|
||||||
|
Subject: Thanks for reaching out, {name}!
|
||||||
|
|
||||||
|
Body:"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
import httpx
|
||||||
|
model = get_setting(db, "deepseek_model", "deepseek-chat")
|
||||||
|
resp = httpx.post(
|
||||||
|
"https://api.deepseek.com/v1/chat/completions",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"model": model,
|
||||||
|
"messages": [{"role": "user", "content": prompt}],
|
||||||
|
"temperature": 0.7,
|
||||||
|
"max_tokens": 500
|
||||||
|
},
|
||||||
|
timeout=30
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
draft = resp.json()["choices"][0]["message"]["content"].strip()
|
||||||
|
|
||||||
|
db.execute("DELETE FROM reply_drafts WHERE submission_id = ?", (submission_id,))
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO reply_drafts (submission_id, draft_text) VALUES (?, ?)",
|
||||||
|
(submission_id, draft)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
db.close()
|
||||||
|
return {"ok": True, "draft": draft}
|
||||||
|
except Exception as e:
|
||||||
|
db.close()
|
||||||
|
raise HTTPException(500, f"DeepSeek API error: {str(e)}")
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
def health():
|
def health():
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
# Static frontend (must be last)
|
||||||
|
app.mount("/", StaticFiles(directory="/srv/form-backend/static", html=True), name="static")
|
||||||
|
|||||||
Reference in New Issue
Block a user