1164 lines
32 KiB
Go
1164 lines
32 KiB
Go
// Dynamic Admin - Auto-generates pages from SQLite schema
|
|
// Q&D: Query sqlite_master for tables, pragma table_info for columns
|
|
|
|
package main
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"html/template"
|
|
"log"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
_ "github.com/mattn/go-sqlite3"
|
|
)
|
|
|
|
var db *sql.DB
|
|
var isSandboxMode bool
|
|
|
|
func main() {
|
|
var err error
|
|
db, err = sql.Open("sqlite3", "corporate.db")
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
defer db.Close()
|
|
|
|
// Detect sandbox mode by IP
|
|
isSandboxMode = !isProductionServer()
|
|
if isSandboxMode {
|
|
fmt.Println("🧪 SANDBOX MODE DETECTED (non-production IP)")
|
|
} else {
|
|
fmt.Println("🏭 PRODUCTION MODE (running on clavitor.ai)")
|
|
}
|
|
|
|
initDB()
|
|
|
|
r := chi.NewRouter()
|
|
|
|
// Dynamic routes - auto-generated from schema
|
|
r.Get("/", handleDynamicHome)
|
|
r.Get("/table/{table}", handleDynamicList)
|
|
r.Get("/table/{table}/new", handleDynamicForm)
|
|
r.Post("/table/{table}", handleDynamicCreate)
|
|
r.Get("/table/{table}/{id}", handleDynamicView)
|
|
r.Get("/table/{table}/{id}/edit", handleDynamicEdit)
|
|
r.Post("/table/{table}/{id}", handleDynamicUpdate)
|
|
|
|
// Static file for checkout
|
|
r.Get("/checkout", handleCheckoutPage)
|
|
|
|
fmt.Println("Dynamic Admin starting...")
|
|
fmt.Println("Local: http://localhost:1985")
|
|
fmt.Println("Tables auto-detected from SQLite schema")
|
|
log.Fatal(http.ListenAndServe(":1985", r))
|
|
}
|
|
|
|
// Schema introspection
|
|
type ColumnInfo struct {
|
|
Name string
|
|
Type string
|
|
NotNull bool
|
|
Default interface{}
|
|
PK bool
|
|
}
|
|
|
|
func getTables() []string {
|
|
rows, err := db.Query("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
|
|
if err != nil {
|
|
return []string{}
|
|
}
|
|
defer rows.Close()
|
|
|
|
var tables []string
|
|
for rows.Next() {
|
|
var name string
|
|
rows.Scan(&name)
|
|
tables = append(tables, name)
|
|
}
|
|
return tables
|
|
}
|
|
|
|
func getTableInfo(table string) []ColumnInfo {
|
|
rows, err := db.Query(fmt.Sprintf("PRAGMA table_info(%s)", table))
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
defer rows.Close()
|
|
|
|
var cols []ColumnInfo
|
|
for rows.Next() {
|
|
var cid int
|
|
var col ColumnInfo
|
|
rows.Scan(&cid, &col.Name, &col.Type, &col.NotNull, &col.Default, &col.PK)
|
|
cols = append(cols, col)
|
|
}
|
|
return cols
|
|
}
|
|
|
|
func getForeignKeys(table string) map[string]string {
|
|
rows, err := db.Query(fmt.Sprintf("PRAGMA foreign_key_list(%s)", table))
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
defer rows.Close()
|
|
|
|
fks := make(map[string]string)
|
|
for rows.Next() {
|
|
var id, seq int
|
|
var tableName, from, to, onUpdate, onDelete, match string
|
|
rows.Scan(&id, &seq, &tableName, &from, &to, &onUpdate, &onDelete, &match)
|
|
fks[from] = tableName
|
|
}
|
|
return fks
|
|
}
|
|
|
|
// Auto-generate home page with all tables
|
|
func handleDynamicHome(w http.ResponseWriter, r *http.Request) {
|
|
tables := getTables()
|
|
|
|
html := `<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Clavitor Dynamic Admin{{if .IsSandbox}} [SANDBOX]{{end}}</title>
|
|
<style>
|
|
body { font-family: system-ui; max-width: 1400px; margin: 0 auto; padding: 20px; {{if .IsSandbox}}background: #fff5f5;{{end}} }
|
|
nav { background: #333; padding: 15px; margin: -20px -20px 30px -20px; }
|
|
nav a { color: white; margin-right: 20px; text-decoration: none; font-weight: 500; }
|
|
h1 { margin-bottom: 10px; }
|
|
.subtitle { color: #666; margin-bottom: 30px; }
|
|
.tables { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 20px; }
|
|
.card { background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 8px; padding: 20px; }
|
|
.card h3 { margin: 0 0 15px 0; font-size: 18px; text-transform: capitalize; }
|
|
.card .count { font-size: 32px; font-weight: bold; color: #0066cc; }
|
|
.card .label { color: #666; font-size: 14px; }
|
|
.card .actions { margin-top: 15px; }
|
|
.card a { color: #0066cc; text-decoration: none; font-size: 14px; }
|
|
.card a:hover { text-decoration: underline; }
|
|
.sandbox-banner { background: #dc3545; color: white; text-align: center; padding: 10px; margin: -20px -20px 20px -20px; font-weight: bold; font-size: 16px; }
|
|
.sandbox-banner small { font-weight: normal; opacity: 0.9; }
|
|
|
|
/* Pricing section */
|
|
.pricing { margin: 40px 0; background: #f0f7ff; border-radius: 8px; padding: 25px; }
|
|
.pricing h2 { margin-top: 0; color: #0066cc; }
|
|
.price-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 15px; margin-top: 20px; }
|
|
.price-card { background: white; border: 1px solid #dee2e6; border-radius: 8px; padding: 15px; }
|
|
.price-card h4 { margin: 0 0 8px 0; color: #333; }
|
|
.price-card .price { font-size: 24px; font-weight: bold; color: #0066cc; }
|
|
.price-card .period { color: #666; font-size: 12px; }
|
|
.price-card .features { color: #666; font-size: 13px; margin-top: 8px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
{{if .IsSandbox}}<div class="sandbox-banner">🧪 SANDBOX MODE <small>Test environment - not production</small></div>{{end}}
|
|
<nav>
|
|
<a href="/">Dashboard</a>
|
|
{{range .Tables}}<a href="/table/{{.}}">{{.}}</a>{{end}}
|
|
</nav>
|
|
<h1>Clavitor Corporate Admin{{if .IsSandbox}} <span style="color:#dc3545">[SANDBOX]</span>{{end}}</h1>
|
|
<p class="subtitle">Auto-generated from SQLite schema • 95% Paddle + 5% Extensions{{if .IsSandbox}} • <strong style="color:#dc3545">TEST ENVIRONMENT</strong>{{end}}</p>
|
|
|
|
<div class="tables">
|
|
{{range .Cards}}
|
|
<div class="card">
|
|
<h3>{{.Name}}</h3>
|
|
<div class="count">{{.Count}}</div>
|
|
<div class="label">records</div>
|
|
<div class="actions">
|
|
<a href="/table/{{.Name}}">Browse →</a> |
|
|
<a href="/table/{{.Name}}/new">+ New</a>
|
|
</div>
|
|
</div>
|
|
{{end}}
|
|
</div>
|
|
|
|
<!-- USD Pricing Section -->
|
|
<div class="pricing">
|
|
<h2>💰 USD Pricing (from database)</h2>
|
|
<div class="price-grid">
|
|
{{range .Prices}}
|
|
<div class="price-card">
|
|
<h4>{{.ProductName}}</h4>
|
|
<div class="price">{{.Amount}}<span style="font-size:14px;font-weight:normal"> {{.Currency}}</span></div>
|
|
</div>
|
|
{{end}}
|
|
</div>
|
|
</div>
|
|
|
|
</body>
|
|
</html>`
|
|
|
|
type Card struct {
|
|
Name string
|
|
Count int
|
|
}
|
|
|
|
var cards []Card
|
|
for _, t := range tables {
|
|
var count int
|
|
db.QueryRow(fmt.Sprintf("SELECT COUNT(*) FROM %s", t)).Scan(&count)
|
|
cards = append(cards, Card{Name: t, Count: count})
|
|
}
|
|
|
|
// Fetch USD prices from database (Paddle JSON format)
|
|
type PriceInfo struct {
|
|
ProductName string
|
|
Amount string
|
|
Currency string
|
|
}
|
|
var prices []PriceInfo
|
|
rows, err := db.Query(`
|
|
SELECT p.name, pr.unit_price
|
|
FROM products p
|
|
JOIN prices pr ON p.id = pr.product_id
|
|
WHERE pr.unit_price LIKE '%USD%'
|
|
ORDER BY p.name
|
|
`)
|
|
if err == nil {
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var name, unitPrice string
|
|
rows.Scan(&name, &unitPrice)
|
|
// Parse Paddle JSON: {"amount": "1200", "currency_code": "USD"}
|
|
amount := "0"
|
|
currency := "USD"
|
|
if idx := strings.Index(unitPrice, `"amount":`); idx >= 0 {
|
|
after := unitPrice[idx+9:]
|
|
if q1 := strings.Index(after, `"`); q1 >= 0 {
|
|
valStart := q1 + 1
|
|
if q2 := strings.Index(after[valStart:], `"`); q2 >= 0 {
|
|
amount = after[valStart : valStart+q2]
|
|
}
|
|
}
|
|
}
|
|
if idx := strings.Index(unitPrice, `"currency_code":`); idx >= 0 {
|
|
after := unitPrice[idx+17:]
|
|
if q1 := strings.Index(after, `"`); q1 >= 0 {
|
|
valStart := q1 + 1
|
|
if q2 := strings.Index(after[valStart:], `"`); q2 >= 0 {
|
|
currency = after[valStart : valStart+q2]
|
|
}
|
|
}
|
|
}
|
|
prices = append(prices, PriceInfo{ProductName: name, Amount: amount, Currency: currency})
|
|
}
|
|
}
|
|
|
|
tmpl := template.Must(template.New("home").Parse(html))
|
|
tmpl.Execute(w, map[string]interface{}{
|
|
"Tables": tables,
|
|
"Cards": cards,
|
|
"Prices": prices,
|
|
"IsSandbox": isSandboxMode,
|
|
})
|
|
}
|
|
|
|
// Auto-generate list view for any table
|
|
func handleDynamicList(w http.ResponseWriter, r *http.Request) {
|
|
table := chi.URLParam(r, "table")
|
|
cols := getTableInfo(table)
|
|
if cols == nil {
|
|
http.Error(w, "Table not found", 404)
|
|
return
|
|
}
|
|
|
|
// Get sample data (limit 100)
|
|
colNames := []string{}
|
|
for _, c := range cols {
|
|
colNames = append(colNames, c.Name)
|
|
}
|
|
|
|
rows, err := db.Query(fmt.Sprintf("SELECT %s FROM %s LIMIT 100", strings.Join(colNames, ", "), table))
|
|
if err != nil {
|
|
http.Error(w, err.Error(), 500)
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
// Build data as []map[string]interface{}
|
|
var records []map[string]interface{}
|
|
for rows.Next() {
|
|
values := make([]interface{}, len(cols))
|
|
valuePtrs := make([]interface{}, len(cols))
|
|
for i := range values {
|
|
valuePtrs[i] = &values[i]
|
|
}
|
|
rows.Scan(valuePtrs...)
|
|
|
|
record := make(map[string]interface{})
|
|
for i, col := range cols {
|
|
val := values[i]
|
|
if val == nil {
|
|
record[col.Name] = ""
|
|
} else {
|
|
record[col.Name] = fmt.Sprintf("%v", val)
|
|
}
|
|
}
|
|
records = append(records, record)
|
|
}
|
|
|
|
// Render dynamic table
|
|
htmlTemplate := `<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>{{.Table}} - List{{if .IsSandbox}} [SANDBOX]{{end}}</title>
|
|
<style>
|
|
body { font-family: system-ui; max-width: 1600px; margin: 0 auto; padding: 20px; {{if .IsSandbox}}background: #fff5f5;{{end}} }
|
|
nav { background: #333; padding: 15px; margin: -20px -20px 30px -20px; }
|
|
nav a { color: white; margin-right: 20px; text-decoration: none; font-weight: 500; }
|
|
h1 { text-transform: capitalize; }
|
|
table { width: 100%; border-collapse: collapse; margin-top: 20px; font-size: 12px; }
|
|
th, td { text-align: left; padding: 8px; border-bottom: 1px solid #ddd; }
|
|
th { background: #f5f5f5; font-weight: 600; }
|
|
tr:hover { background: #f8f9fa; }
|
|
.id { font-family: monospace; font-size: 11px; max-width: 150px; overflow: hidden; text-overflow: ellipsis; }
|
|
.btn { display: inline-block; padding: 4px 12px; background: #0066cc; color: white; text-decoration: none; border-radius: 4px; font-size: 12px; }
|
|
.null { color: #999; font-style: italic; }
|
|
.sandbox-banner { background: #dc3545; color: white; text-align: center; padding: 10px; margin: -20px -20px 20px -20px; font-weight: bold; font-size: 16px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
{{if .IsSandbox}}<div class="sandbox-banner">🧪 SANDBOX MODE - Test environment</div>{{end}}
|
|
<nav>
|
|
<a href="/">Dashboard</a>
|
|
{{range .Tables}}<a href="/table/{{.}}">{{.}}</a>{{end}}
|
|
</nav>
|
|
|
|
<h1>{{.Table}} <a href="/table/{{.Table}}/new" class="btn">+ New</a></h1>
|
|
|
|
<table>
|
|
<tr>
|
|
{{range .Columns}}<th>{{.Name}}<br><small style="color:#999;font-weight:normal">{{.Type}}{{if .PK}} 🔑{{end}}</small></th>{{end}}
|
|
<th>Actions</th>
|
|
</tr>
|
|
{{range $record := .Records}}
|
|
<tr>
|
|
{{range $.Columns}}
|
|
<td class="{{if eq .Type "TEXT"}}id{{end}}">
|
|
{{$val := index $record .Name}}
|
|
{{if eq $val ""}}<span class="null">null</span>{{else}}{{$val}}{{end}}
|
|
</td>
|
|
{{end}}
|
|
<td><a href="/table/{{$.Table}}/{{index $record $.PK}}" class="btn">View</a></td>
|
|
</tr>
|
|
{{end}}
|
|
</table>
|
|
</body>
|
|
</html>`
|
|
|
|
// Find PK column
|
|
pk := "id"
|
|
for _, c := range cols {
|
|
if c.PK {
|
|
pk = c.Name
|
|
break
|
|
}
|
|
}
|
|
|
|
tables := getTables()
|
|
|
|
tmpl := template.Must(template.New("list").Parse(htmlTemplate))
|
|
tmpl.Execute(w, map[string]interface{}{
|
|
"Table": table,
|
|
"Tables": tables,
|
|
"Columns": cols,
|
|
"Records": records,
|
|
"PK": pk,
|
|
"IsSandbox": isSandboxMode,
|
|
})
|
|
}
|
|
|
|
// Auto-generate form view for creating new record
|
|
func handleDynamicForm(w http.ResponseWriter, r *http.Request) {
|
|
table := chi.URLParam(r, "table")
|
|
cols := getTableInfo(table)
|
|
if cols == nil {
|
|
http.Error(w, "Table not found", 404)
|
|
return
|
|
}
|
|
|
|
htmlTemplate := `<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>{{.Table}} - New{{if .IsSandbox}} [SANDBOX]{{end}}</title>
|
|
<style>
|
|
body { font-family: system-ui; max-width: 600px; margin: 50px auto; padding: 20px; {{if .IsSandbox}}background: #fff5f5;{{end}} }
|
|
nav { background: #333; padding: 15px; margin: -20px -20px 30px -20px; }
|
|
nav a { color: white; margin-right: 20px; text-decoration: none; font-weight: 500; }
|
|
h1 { text-transform: capitalize; }
|
|
form { background: #f8f9fa; padding: 25px; border-radius: 8px; }
|
|
label { display: block; margin: 20px 0 5px 0; font-weight: 500; font-size: 14px; }
|
|
label small { color: #666; font-weight: normal; }
|
|
input, select, textarea { width: 100%; padding: 10px; font-size: 14px; border: 1px solid #ccc; border-radius: 4px; box-sizing: border-box; }
|
|
input:focus, select:focus, textarea:focus { border-color: #0066cc; outline: none; }
|
|
button { margin-top: 25px; padding: 12px 30px; font-size: 16px; background: #0066cc; color: white; border: none; border-radius: 4px; cursor: pointer; }
|
|
button:hover { background: #0052a3; }
|
|
.sandbox-banner { background: #dc3545; color: white; text-align: center; padding: 10px; margin: -20px -20px 20px -20px; font-weight: bold; font-size: 16px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
{{if .IsSandbox}}<div class="sandbox-banner">🧪 SANDBOX MODE - Test environment</div>{{end}}
|
|
<nav>
|
|
<a href="/">Dashboard</a>
|
|
<a href="/table/{{.Table}}">{{.Table}}</a>
|
|
</nav>
|
|
|
|
<h1>New {{.Table}}</h1>
|
|
|
|
<form method="post" action="/table/{{.Table}}">
|
|
{{range .Columns}}
|
|
{{if not .PK}}
|
|
<label>
|
|
{{.Name}} {{if .NotNull}}<small>(required)</small>{{end}}
|
|
{{if eq .Type "INTEGER"}}
|
|
<input type="number" name="{{.Name}}" {{if .NotNull}}required{{end}}>
|
|
{{else if eq .Type "TEXT"}}
|
|
<textarea name="{{.Name}}" rows="3" {{if .NotNull}}required{{end}}></textarea>
|
|
{{else}}
|
|
<input type="text" name="{{.Name}}" {{if .NotNull}}required{{end}}>
|
|
{{end}}
|
|
<small style="color:#999">{{.Type}}</small>
|
|
</label>
|
|
{{end}}
|
|
{{end}}
|
|
<button type="submit">Create {{.Table}}</button>
|
|
</form>
|
|
</body>
|
|
</html>`
|
|
|
|
tables := getTables()
|
|
tmpl := template.Must(template.New("form").Parse(htmlTemplate))
|
|
tmpl.Execute(w, map[string]interface{}{
|
|
"Table": table,
|
|
"Tables": tables,
|
|
"Columns": cols,
|
|
"IsSandbox": isSandboxMode,
|
|
})
|
|
}
|
|
|
|
// Handle create - with Dual-Write Layer for Paddle entities
|
|
func handleDynamicCreate(w http.ResponseWriter, r *http.Request) {
|
|
table := chi.URLParam(r, "table")
|
|
cols := getTableInfo(table)
|
|
if cols == nil {
|
|
http.Error(w, "Table not found", 404)
|
|
return
|
|
}
|
|
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, err.Error(), 400)
|
|
return
|
|
}
|
|
|
|
// Build data map from form values
|
|
data := make(map[string]interface{})
|
|
for _, col := range cols {
|
|
if col.PK && col.Type == "INTEGER" {
|
|
continue // Auto-increment
|
|
}
|
|
val := r.FormValue(col.Name)
|
|
if val != "" {
|
|
data[col.Name] = val
|
|
}
|
|
}
|
|
|
|
// Check if this is a Paddle-managed entity
|
|
if _, ok := EntityRegistry[table]; ok {
|
|
// Use Dual-Write Layer (auto-detects sandbox mode by IP)
|
|
sync := NewSyncLayer("", db)
|
|
result := sync.Create(table, data)
|
|
|
|
if !result.Success {
|
|
http.Error(w, fmt.Sprintf("Sync failed: DB: %v, Paddle: %v", result.DBError, result.PaddleError), 500)
|
|
return
|
|
}
|
|
|
|
// Log the sync result
|
|
log.Printf("Created %s: our_id=%s, paddle_id=%s", table, result.OurID, result.PaddleID)
|
|
http.Redirect(w, r, "/table/"+table, 302)
|
|
return
|
|
}
|
|
|
|
// Non-Paddle entity: direct SQL insert
|
|
var colNames []string
|
|
var placeholders []string
|
|
var values []interface{}
|
|
|
|
for _, col := range cols {
|
|
if col.PK && col.Type == "INTEGER" {
|
|
continue
|
|
}
|
|
val := r.FormValue(col.Name)
|
|
if val == "" && col.Default != nil {
|
|
val = fmt.Sprintf("%v", col.Default)
|
|
}
|
|
if val != "" {
|
|
colNames = append(colNames, col.Name)
|
|
placeholders = append(placeholders, "?")
|
|
values = append(values, val)
|
|
}
|
|
}
|
|
|
|
// Add timestamps
|
|
now := time.Now().Unix()
|
|
for _, col := range cols {
|
|
if col.Name == "created_at" {
|
|
colNames = append(colNames, "created_at")
|
|
placeholders = append(placeholders, "?")
|
|
values = append(values, now)
|
|
}
|
|
if col.Name == "updated_at" {
|
|
colNames = append(colNames, "updated_at")
|
|
placeholders = append(placeholders, "?")
|
|
values = append(values, now)
|
|
}
|
|
}
|
|
|
|
sql := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)",
|
|
table,
|
|
strings.Join(colNames, ", "),
|
|
strings.Join(placeholders, ", "))
|
|
|
|
_, err := db.Exec(sql, values...)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), 500)
|
|
return
|
|
}
|
|
|
|
http.Redirect(w, r, "/table/"+table, 302)
|
|
}
|
|
|
|
// Auto-generate detail view
|
|
func handleDynamicView(w http.ResponseWriter, r *http.Request) {
|
|
table := chi.URLParam(r, "table")
|
|
id := chi.URLParam(r, "id")
|
|
cols := getTableInfo(table)
|
|
if cols == nil {
|
|
http.Error(w, "Table not found", 404)
|
|
return
|
|
}
|
|
|
|
// Find PK column
|
|
pk := "id"
|
|
for _, c := range cols {
|
|
if c.PK {
|
|
pk = c.Name
|
|
break
|
|
}
|
|
}
|
|
|
|
// Get single record
|
|
colNames := []string{}
|
|
for _, c := range cols {
|
|
colNames = append(colNames, c.Name)
|
|
}
|
|
|
|
row := db.QueryRow(fmt.Sprintf("SELECT %s FROM %s WHERE %s = ? LIMIT 1",
|
|
strings.Join(colNames, ", "), table, pk), id)
|
|
|
|
values := make([]interface{}, len(cols))
|
|
valuePtrs := make([]interface{}, len(cols))
|
|
for i := range values {
|
|
valuePtrs[i] = &values[i]
|
|
}
|
|
|
|
if err := row.Scan(valuePtrs...); err != nil {
|
|
http.Error(w, "Record not found", 404)
|
|
return
|
|
}
|
|
|
|
record := make(map[string]interface{})
|
|
for i, col := range cols {
|
|
val := values[i]
|
|
if val == nil {
|
|
record[col.Name] = ""
|
|
} else {
|
|
record[col.Name] = fmt.Sprintf("%v", val)
|
|
}
|
|
}
|
|
|
|
htmlTemplate := `<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>{{.Table}} - {{.ID}}{{if .IsSandbox}} [SANDBOX]{{end}}</title>
|
|
<style>
|
|
body { font-family: system-ui; max-width: 800px; margin: 50px auto; padding: 20px; {{if .IsSandbox}}background: #fff5f5;{{end}} }
|
|
nav { background: #333; padding: 15px; margin: -20px -20px 30px -20px; }
|
|
nav a { color: white; margin-right: 20px; text-decoration: none; font-weight: 500; }
|
|
h1 { text-transform: capitalize; }
|
|
table { width: 100%; border-collapse: collapse; margin-top: 20px; }
|
|
tr { border-bottom: 1px solid #eee; }
|
|
td { padding: 12px 8px; }
|
|
td:first-child { font-weight: 500; width: 200px; color: #666; }
|
|
td:last-child { font-family: monospace; font-size: 13px; }
|
|
.null { color: #999; font-style: italic; }
|
|
.btn { display: inline-block; padding: 8px 16px; background: #0066cc; color: white; text-decoration: none; border-radius: 4px; font-size: 14px; margin-right: 10px; }
|
|
.sandbox-banner { background: #dc3545; color: white; text-align: center; padding: 10px; margin: -20px -20px 20px -20px; font-weight: bold; font-size: 16px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
{{if .IsSandbox}}<div class="sandbox-banner">🧪 SANDBOX MODE - Test environment</div>{{end}}
|
|
<nav>
|
|
<a href="/">Dashboard</a>
|
|
<a href="/table/{{.Table}}">{{.Table}}</a>
|
|
</nav>
|
|
|
|
<h1>{{.Table}}: {{.ID}}</h1>
|
|
|
|
<div style="margin: 20px 0;">
|
|
<a href="/table/{{.Table}}/{{.ID}}/edit" class="btn">Edit</a>
|
|
<a href="/table/{{.Table}}" class="btn" style="background:#6c757d">Back to List</a>
|
|
</div>
|
|
|
|
<table>
|
|
{{range .Columns}}
|
|
<tr>
|
|
<td>{{.Name}} {{if .PK}}🔑{{end}}</td>
|
|
<td>
|
|
{{$val := index $.Record .Name}}
|
|
{{if eq $val ""}}<span class="null">null</span>{{else}}{{$val}}{{end}}
|
|
</td>
|
|
</tr>
|
|
{{end}}
|
|
</table>
|
|
</body>
|
|
</html>`
|
|
|
|
tables := getTables()
|
|
tmpl := template.Must(template.New("view").Parse(htmlTemplate))
|
|
tmpl.Execute(w, map[string]interface{}{
|
|
"Table": table,
|
|
"ID": id,
|
|
"Tables": tables,
|
|
"Columns": cols,
|
|
"Record": record,
|
|
"IsSandbox": isSandboxMode,
|
|
})
|
|
}
|
|
|
|
func handleDynamicEdit(w http.ResponseWriter, r *http.Request) {
|
|
table := chi.URLParam(r, "table")
|
|
id := chi.URLParam(r, "id")
|
|
cols := getTableInfo(table)
|
|
if cols == nil {
|
|
http.Error(w, "Table not found", 404)
|
|
return
|
|
}
|
|
|
|
pk := "id"
|
|
for _, c := range cols {
|
|
if c.PK {
|
|
pk = c.Name
|
|
break
|
|
}
|
|
}
|
|
|
|
// Get record
|
|
colNames := []string{}
|
|
for _, c := range cols {
|
|
colNames = append(colNames, c.Name)
|
|
}
|
|
|
|
row := db.QueryRow(fmt.Sprintf("SELECT %s FROM %s WHERE %s = ?",
|
|
strings.Join(colNames, ", "), table, pk), id)
|
|
|
|
values := make([]interface{}, len(cols))
|
|
valuePtrs := make([]interface{}, len(cols))
|
|
for i := range values {
|
|
valuePtrs[i] = &values[i]
|
|
}
|
|
|
|
if err := row.Scan(valuePtrs...); err != nil {
|
|
http.Error(w, "Record not found", 404)
|
|
return
|
|
}
|
|
|
|
record := make(map[string]interface{})
|
|
for i, col := range cols {
|
|
val := values[i]
|
|
if val == nil {
|
|
record[col.Name] = ""
|
|
} else {
|
|
record[col.Name] = fmt.Sprintf("%v", val)
|
|
}
|
|
}
|
|
|
|
htmlTemplate := `<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>{{.Table}} - Edit {{.ID}}{{if .IsSandbox}} [SANDBOX]{{end}}</title>
|
|
<style>
|
|
body { font-family: system-ui; max-width: 600px; margin: 50px auto; padding: 20px; {{if .IsSandbox}}background: #fff5f5;{{end}} }
|
|
nav { background: #333; padding: 15px; margin: -20px -20px 30px -20px; }
|
|
nav a { color: white; margin-right: 20px; text-decoration: none; font-weight: 500; }
|
|
h1 { text-transform: capitalize; }
|
|
form { background: #f8f9fa; padding: 25px; border-radius: 8px; }
|
|
label { display: block; margin: 20px 0 5px 0; font-weight: 500; font-size: 14px; }
|
|
input, select, textarea { width: 100%; padding: 10px; font-size: 14px; border: 1px solid #ccc; border-radius: 4px; }
|
|
button { margin-top: 25px; padding: 12px 30px; font-size: 16px; background: #0066cc; color: white; border: none; border-radius: 4px; cursor: pointer; }
|
|
.sandbox-banner { background: #dc3545; color: white; text-align: center; padding: 10px; margin: -20px -20px 20px -20px; font-weight: bold; font-size: 16px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
{{if .IsSandbox}}<div class="sandbox-banner">🧪 SANDBOX MODE - Test environment</div>{{end}}
|
|
<nav>
|
|
<a href="/">Dashboard</a>
|
|
<a href="/table/{{.Table}}">{{.Table}}</a>
|
|
</nav>
|
|
|
|
<h1>Edit {{.Table}}: {{.ID}}</h1>
|
|
|
|
<form method="post" action="/table/{{.Table}}/{{.ID}}">
|
|
{{range .Columns}}
|
|
{{$val := index $.Record .Name}}
|
|
<label>
|
|
{{.Name}} {{if .PK}}🔑 <small>(readonly)</small>{{end}}
|
|
{{if .PK}}
|
|
<input type="text" value="{{$val}}" disabled>
|
|
{{else if eq .Type "INTEGER"}}
|
|
<input type="number" name="{{.Name}}" value="{{$val}}">
|
|
{{else}}
|
|
<textarea name="{{.Name}}" rows="3">{{$val}}</textarea>
|
|
{{end}}
|
|
</label>
|
|
{{end}}
|
|
<button type="submit">Update {{.Table}}</button>
|
|
</form>
|
|
</body>
|
|
</html>`
|
|
|
|
tables := getTables()
|
|
tmpl := template.Must(template.New("edit").Parse(htmlTemplate))
|
|
tmpl.Execute(w, map[string]interface{}{
|
|
"Table": table,
|
|
"ID": id,
|
|
"Tables": tables,
|
|
"Columns": cols,
|
|
"Record": record,
|
|
"IsSandbox": isSandboxMode,
|
|
})
|
|
}
|
|
|
|
func handleDynamicUpdate(w http.ResponseWriter, r *http.Request) {
|
|
table := chi.URLParam(r, "table")
|
|
id := chi.URLParam(r, "id")
|
|
cols := getTableInfo(table)
|
|
if cols == nil {
|
|
http.Error(w, "Table not found", 404)
|
|
return
|
|
}
|
|
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, err.Error(), 400)
|
|
return
|
|
}
|
|
|
|
pk := "id"
|
|
for _, c := range cols {
|
|
if c.PK {
|
|
pk = c.Name
|
|
break
|
|
}
|
|
}
|
|
|
|
// Build UPDATE
|
|
var sets []string
|
|
var values []interface{}
|
|
|
|
for _, col := range cols {
|
|
if col.PK {
|
|
continue
|
|
}
|
|
val := r.FormValue(col.Name)
|
|
sets = append(sets, fmt.Sprintf("%s = ?", col.Name))
|
|
values = append(values, val)
|
|
}
|
|
|
|
// Add updated_at
|
|
for _, col := range cols {
|
|
if col.Name == "updated_at" {
|
|
sets = append(sets, "updated_at = ?")
|
|
values = append(values, time.Now().Unix())
|
|
}
|
|
}
|
|
|
|
values = append(values, id)
|
|
|
|
sql := fmt.Sprintf("UPDATE %s SET %s WHERE %s = ?",
|
|
table, strings.Join(sets, ", "), pk)
|
|
|
|
_, err := db.Exec(sql, values...)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), 500)
|
|
return
|
|
}
|
|
|
|
http.Redirect(w, r, fmt.Sprintf("/table/%s/%s", table, id), 302)
|
|
}
|
|
|
|
func handleCheckoutPage(w http.ResponseWriter, r *http.Request) {
|
|
http.ServeFile(w, r, "static/checkout.html")
|
|
}
|
|
|
|
func initDB() {
|
|
// Run the full schema from schema.sql
|
|
schemaSQL := `-- Core tables
|
|
CREATE TABLE IF NOT EXISTS products (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
description TEXT,
|
|
tax_category TEXT,
|
|
image_url TEXT,
|
|
custom_data TEXT,
|
|
status TEXT DEFAULT 'active',
|
|
created_at INTEGER,
|
|
updated_at INTEGER
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS prices (
|
|
id TEXT PRIMARY KEY,
|
|
product_id TEXT REFERENCES products(id),
|
|
description TEXT,
|
|
name TEXT,
|
|
billing_cycle TEXT,
|
|
trial_period TEXT,
|
|
tax_mode TEXT DEFAULT 'account_setting',
|
|
unit_price TEXT,
|
|
unit_price_overrides TEXT,
|
|
quantity TEXT,
|
|
status TEXT DEFAULT 'active',
|
|
custom_data TEXT,
|
|
created_at INTEGER,
|
|
updated_at INTEGER
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS discounts (
|
|
id TEXT PRIMARY KEY,
|
|
status TEXT DEFAULT 'active',
|
|
description TEXT,
|
|
enabled_for_checkout INTEGER DEFAULT 0,
|
|
code TEXT,
|
|
type TEXT,
|
|
mode TEXT DEFAULT 'standard',
|
|
amount TEXT,
|
|
currency_code TEXT,
|
|
recur INTEGER DEFAULT 1,
|
|
maximum_recurring_intervals INTEGER,
|
|
usage_limit INTEGER,
|
|
restrict_to TEXT,
|
|
expires_at INTEGER,
|
|
times_used INTEGER DEFAULT 0,
|
|
discount_group_id TEXT,
|
|
custom_data TEXT,
|
|
created_at INTEGER,
|
|
updated_at INTEGER,
|
|
paddle_id TEXT,
|
|
sync_status TEXT DEFAULT 'pending',
|
|
sync_error TEXT,
|
|
last_paddle_sync_at INTEGER,
|
|
pending_since INTEGER
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS customers (
|
|
id TEXT PRIMARY KEY,
|
|
email TEXT NOT NULL,
|
|
name TEXT,
|
|
locale TEXT DEFAULT 'en',
|
|
status TEXT DEFAULT 'active',
|
|
marketing_consent INTEGER DEFAULT 0,
|
|
import_meta TEXT,
|
|
parent_id TEXT REFERENCES customers(id),
|
|
level INTEGER DEFAULT 1,
|
|
internal_notes TEXT,
|
|
custom_data TEXT,
|
|
created_at INTEGER,
|
|
updated_at INTEGER,
|
|
paddle_id TEXT,
|
|
billing_email TEXT,
|
|
sync_status TEXT DEFAULT 'pending',
|
|
sync_error TEXT,
|
|
last_paddle_sync_at INTEGER,
|
|
pending_since INTEGER
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS addresses (
|
|
id TEXT PRIMARY KEY,
|
|
customer_id TEXT NOT NULL REFERENCES customers(id),
|
|
description TEXT,
|
|
city TEXT,
|
|
region TEXT,
|
|
country_code TEXT NOT NULL,
|
|
zip_code TEXT,
|
|
status TEXT DEFAULT 'active',
|
|
created_at INTEGER,
|
|
updated_at INTEGER
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS businesses (
|
|
id TEXT PRIMARY KEY,
|
|
customer_id TEXT NOT NULL REFERENCES customers(id),
|
|
name TEXT NOT NULL,
|
|
company_number TEXT,
|
|
tax_identifier TEXT,
|
|
contacts TEXT,
|
|
status TEXT DEFAULT 'active',
|
|
custom_data TEXT,
|
|
created_at INTEGER,
|
|
updated_at INTEGER
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS subscriptions (
|
|
id TEXT PRIMARY KEY,
|
|
paddle_id TEXT,
|
|
customer_id TEXT NOT NULL REFERENCES customers(id),
|
|
address_id TEXT NOT NULL REFERENCES addresses(id),
|
|
business_id TEXT REFERENCES businesses(id),
|
|
currency_code TEXT NOT NULL,
|
|
status TEXT NOT NULL,
|
|
collection_mode TEXT DEFAULT 'automatic',
|
|
billing_details TEXT,
|
|
discount TEXT,
|
|
started_at INTEGER,
|
|
first_billed_at INTEGER,
|
|
next_billed_at INTEGER,
|
|
paused_at INTEGER,
|
|
canceled_at INTEGER,
|
|
vault_l0 TEXT,
|
|
billing_cycle TEXT,
|
|
current_billing_period TEXT,
|
|
scheduled_change TEXT,
|
|
items TEXT,
|
|
management_urls TEXT,
|
|
custom_data TEXT,
|
|
import_meta TEXT,
|
|
created_at INTEGER,
|
|
updated_at INTEGER,
|
|
consent_requirements TEXT,
|
|
sync_status TEXT DEFAULT 'pending',
|
|
sync_error TEXT,
|
|
last_paddle_sync_at INTEGER,
|
|
pending_since INTEGER
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS subscription_items (
|
|
id TEXT PRIMARY KEY,
|
|
subscription_id TEXT NOT NULL REFERENCES subscriptions(id),
|
|
price_id TEXT NOT NULL,
|
|
product_id TEXT NOT NULL,
|
|
quantity INTEGER DEFAULT 1,
|
|
status TEXT DEFAULT 'active',
|
|
recurring INTEGER DEFAULT 1,
|
|
created_at INTEGER,
|
|
updated_at INTEGER,
|
|
previously_billed_at INTEGER,
|
|
next_billed_at INTEGER,
|
|
trial_dates TEXT,
|
|
price_snapshot TEXT,
|
|
product_snapshot TEXT
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS transactions (
|
|
id TEXT PRIMARY KEY,
|
|
paddle_id TEXT,
|
|
subscription_id TEXT REFERENCES subscriptions(id),
|
|
customer_id TEXT NOT NULL REFERENCES customers(id),
|
|
address_id TEXT REFERENCES addresses(id),
|
|
business_id TEXT REFERENCES businesses(id),
|
|
invoice_id TEXT,
|
|
invoice_number TEXT,
|
|
discount_id TEXT REFERENCES discounts(id),
|
|
status TEXT NOT NULL,
|
|
origin TEXT,
|
|
currency_code TEXT NOT NULL,
|
|
details TEXT,
|
|
payments TEXT,
|
|
adjusted_totals TEXT,
|
|
payout_totals TEXT,
|
|
adjusted_payout_totals TEXT,
|
|
billing_details TEXT,
|
|
additional_information TEXT,
|
|
purchase_order_number TEXT,
|
|
checkout_url TEXT,
|
|
billed_at INTEGER,
|
|
completed_at INTEGER,
|
|
revised_at INTEGER,
|
|
created_at INTEGER,
|
|
updated_at INTEGER,
|
|
processed_at INTEGER,
|
|
processing_error TEXT
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS adjustments (
|
|
id TEXT PRIMARY KEY,
|
|
transaction_id TEXT NOT NULL REFERENCES transactions(id),
|
|
subscription_id TEXT REFERENCES subscriptions(id),
|
|
customer_id TEXT NOT NULL REFERENCES customers(id),
|
|
action TEXT NOT NULL,
|
|
type TEXT,
|
|
amount TEXT NOT NULL,
|
|
currency_code TEXT NOT NULL,
|
|
totals TEXT,
|
|
payout_totals TEXT,
|
|
reason TEXT,
|
|
credit_applied_to TEXT,
|
|
created_at INTEGER
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS vaults (
|
|
l0 TEXT PRIMARY KEY,
|
|
customer_id TEXT NOT NULL REFERENCES customers(id),
|
|
subscription_id TEXT REFERENCES subscriptions(id),
|
|
status TEXT DEFAULT 'active',
|
|
created_at INTEGER,
|
|
last_seen_at INTEGER,
|
|
created_by_human INTEGER
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS credentials (
|
|
id INTEGER PRIMARY KEY,
|
|
customer_id TEXT NOT NULL REFERENCES customers(id),
|
|
p0 TEXT NOT NULL,
|
|
l0 TEXT NOT NULL REFERENCES vaults(l0),
|
|
credential_id BLOB,
|
|
wl3_path TEXT,
|
|
enrolled_by INTEGER,
|
|
created_at INTEGER,
|
|
last_used_at INTEGER
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS roles (
|
|
id INTEGER PRIMARY KEY,
|
|
name TEXT NOT NULL UNIQUE,
|
|
permissions TEXT,
|
|
description TEXT,
|
|
created_at INTEGER
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS humans (
|
|
id INTEGER PRIMARY KEY,
|
|
customer_id TEXT REFERENCES customers(id),
|
|
email TEXT NOT NULL UNIQUE,
|
|
name TEXT NOT NULL,
|
|
status TEXT DEFAULT 'active',
|
|
created_at INTEGER,
|
|
updated_at INTEGER
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS role_assignments (
|
|
id INTEGER PRIMARY KEY,
|
|
human_id INTEGER NOT NULL REFERENCES humans(id),
|
|
customer_id TEXT NOT NULL REFERENCES customers(id),
|
|
role_id INTEGER NOT NULL REFERENCES roles(id),
|
|
granted_by INTEGER REFERENCES humans(id),
|
|
created_at INTEGER
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS pop_sync_state (
|
|
pop_id TEXT PRIMARY KEY,
|
|
last_sync_at INTEGER,
|
|
last_sync_cursor INTEGER,
|
|
status TEXT DEFAULT 'active',
|
|
error_message TEXT,
|
|
updated_at INTEGER
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS events (
|
|
id INTEGER PRIMARY KEY,
|
|
occurred_at INTEGER,
|
|
event_type TEXT,
|
|
entity_type TEXT,
|
|
entity_id TEXT,
|
|
customer_id TEXT REFERENCES customers(id),
|
|
human_id INTEGER REFERENCES humans(id),
|
|
vault_l0 TEXT,
|
|
amount_cents INTEGER,
|
|
currency_code TEXT,
|
|
details TEXT,
|
|
source TEXT DEFAULT 'api',
|
|
ip_address TEXT,
|
|
user_agent TEXT
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS wl3_storage (
|
|
path TEXT PRIMARY KEY,
|
|
content BLOB,
|
|
p0 TEXT,
|
|
customer_id TEXT,
|
|
created_at INTEGER,
|
|
sync_count INTEGER DEFAULT 0,
|
|
last_sync_at INTEGER
|
|
);
|
|
|
|
-- CURRENCY TABLE: Conversion metadata + pretty pricing rules
|
|
-- See schema.sql for full documentation on "Pretty Commercial Prices"
|
|
CREATE TABLE IF NOT EXISTS currencies (
|
|
code TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
decimals INTEGER NOT NULL DEFAULT 2,
|
|
exchange_rate REAL,
|
|
rate_fetched_at INTEGER,
|
|
symbol TEXT,
|
|
symbol_position TEXT DEFAULT 'prefix',
|
|
pretty_pattern TEXT DEFAULT 'x.99',
|
|
is_active INTEGER DEFAULT 1,
|
|
created_at INTEGER,
|
|
updated_at INTEGER
|
|
);
|
|
`
|
|
|
|
_, err := db.Exec(schemaSQL)
|
|
if err != nil {
|
|
log.Printf("Schema init error: %v", err)
|
|
}
|
|
|
|
// Pre-populate roles if empty
|
|
var roleCount int
|
|
db.QueryRow("SELECT COUNT(*) FROM roles").Scan(&roleCount)
|
|
if roleCount == 0 {
|
|
now := time.Now().Unix()
|
|
db.Exec(`INSERT INTO roles (id, name, permissions, description, created_at) VALUES
|
|
(1, 'SuperAdmin', '{"all": true}', 'Full system access', ?),
|
|
(2, 'MSPAdmin', '{"customer_admin": true, "billing_view": true}', 'MSP-level admin', ?),
|
|
(3, 'TenantAdmin', '{"vault_create": true, "agent_create": true, "device_enroll": true}', 'Customer admin', ?),
|
|
(4, 'VaultUser', '{"vault_read": true, "vault_write": true}', 'Regular user', ?),
|
|
(5, 'BillingAdmin', '{"billing_view": true, "invoices": true}', 'Can view/pay invoices', ?),
|
|
(6, 'ReadOnly', '{"vault_read": true}', 'View only', ?)`,
|
|
now, now, now, now, now, now)
|
|
}
|
|
|
|
// Pre-populate products if empty
|
|
var prodCount int
|
|
db.QueryRow("SELECT COUNT(*) FROM products").Scan(&prodCount)
|
|
if prodCount == 0 {
|
|
now := time.Now().Unix()
|
|
products := []struct {
|
|
ID string
|
|
Name string
|
|
Description string
|
|
}{
|
|
{"pro_personal", "Clavitor Personal", "1 vault, 5 agents, 2 devices"},
|
|
{"pro_family", "Clavitor Family", "1 vault, 15 agents, 6 devices"},
|
|
{"pro_pro", "Clavitor Pro", "1 vault, 50 agents, unlimited devices"},
|
|
{"pro_team10", "Clavitor Team 10", "Up to 10 employees"},
|
|
{"pro_team25", "Clavitor Team 25", "Up to 25 employees"},
|
|
{"pro_team100", "Clavitor Team 100", "Up to 100 employees"},
|
|
{"pro_team250", "Clavitor Team 250", "Up to 250 employees"},
|
|
{"pro_team500", "Clavitor Team 500", "Up to 500 employees"},
|
|
}
|
|
for _, p := range products {
|
|
db.Exec("INSERT INTO products (id, name, description, status, created_at) VALUES (?, ?, ?, 'active', ?)",
|
|
p.ID, p.Name, p.Description, now)
|
|
}
|
|
}
|
|
|
|
// Pre-populate currencies if empty
|
|
var currCount int
|
|
db.QueryRow("SELECT COUNT(*) FROM currencies").Scan(&currCount)
|
|
if currCount == 0 {
|
|
log.Println("Currencies table empty. Run: go run scripts/fetch_rates.go")
|
|
}
|
|
}
|