// 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 := `
Clavitor Dynamic Admin{{if .IsSandbox}} [SANDBOX]{{end}}
{{if .IsSandbox}}๐งช SANDBOX MODE Test environment - not production
{{end}}
Clavitor Corporate Admin{{if .IsSandbox}} [SANDBOX]{{end}}
Auto-generated from SQLite schema โข 95% Paddle + 5% Extensions{{if .IsSandbox}} โข TEST ENVIRONMENT{{end}}
{{range .Cards}}
{{.Name}}
{{.Count}}
records
{{end}}
๐ฐ USD Pricing (from database)
{{range .Prices}}
{{.ProductName}}
{{.Amount}} {{.Currency}}
{{end}}
`
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 := `
{{.Table}} - List{{if .IsSandbox}} [SANDBOX]{{end}}
{{if .IsSandbox}}๐งช SANDBOX MODE - Test environment
{{end}}
{{.Table}} + New
{{range .Columns}}{{.Name}} {{.Type}}{{if .PK}} ๐{{end}} | {{end}}
Actions |
{{range $record := .Records}}
{{range $.Columns}}
|
{{$val := index $record .Name}}
{{if eq $val ""}}null{{else}}{{$val}}{{end}}
|
{{end}}
View |
{{end}}
`
// 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 := `
{{.Table}} - New{{if .IsSandbox}} [SANDBOX]{{end}}
{{if .IsSandbox}}๐งช SANDBOX MODE - Test environment
{{end}}
New {{.Table}}
`
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 := `
{{.Table}} - {{.ID}}{{if .IsSandbox}} [SANDBOX]{{end}}
{{if .IsSandbox}}๐งช SANDBOX MODE - Test environment
{{end}}
{{.Table}}: {{.ID}}
{{range .Columns}}
| {{.Name}} {{if .PK}}๐{{end}} |
{{$val := index $.Record .Name}}
{{if eq $val ""}}null{{else}}{{$val}}{{end}}
|
{{end}}
`
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 := `
{{.Table}} - Edit {{.ID}}{{if .IsSandbox}} [SANDBOX]{{end}}
{{if .IsSandbox}}๐งช SANDBOX MODE - Test environment
{{end}}
Edit {{.Table}}: {{.ID}}
`
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")
}
}