Admin panel: dashboard, form builder, DeepSeek auto-tag + reply drafts

This commit is contained in:
2026-06-13 10:17:26 +01:00
parent 7a9135320b
commit 7ac1282b13
2 changed files with 981 additions and 40 deletions

488
admin.html Normal file
View 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(' &middot; ');
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')}
// ---- 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
View File

@@ -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")