318 lines
11 KiB
Go
318 lines
11 KiB
Go
package api
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"marketing-system-data-tool/server/internal/exporter"
|
|
"marketing-system-data-tool/server/internal/schema"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type TemplatesAPI struct {
|
|
meta *sql.DB
|
|
marketing *sql.DB
|
|
}
|
|
|
|
func TemplatesHandler(meta, marketing *sql.DB) http.Handler {
|
|
api := &TemplatesAPI{meta: meta, marketing: marketing}
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
p := strings.TrimPrefix(r.URL.Path, "/api/templates")
|
|
if r.Method == http.MethodPost && p == "" {
|
|
api.createTemplate(w, r)
|
|
return
|
|
}
|
|
if r.Method == http.MethodGet && p == "" {
|
|
api.listTemplates(w, r)
|
|
return
|
|
}
|
|
if strings.HasPrefix(p, "/") {
|
|
id := strings.TrimPrefix(p, "/")
|
|
if r.Method == http.MethodGet {
|
|
api.getTemplate(w, r, id)
|
|
return
|
|
}
|
|
if r.Method == http.MethodPatch {
|
|
api.patchTemplate(w, r, id)
|
|
return
|
|
}
|
|
if r.Method == http.MethodDelete {
|
|
api.deleteTemplate(w, r, id)
|
|
return
|
|
}
|
|
if r.Method == http.MethodPost && strings.HasSuffix(p, "/validate") {
|
|
id = strings.TrimSuffix(id, "/validate")
|
|
api.validateTemplate(w, r, id)
|
|
return
|
|
}
|
|
}
|
|
fail(w, r, http.StatusNotFound, "not found")
|
|
})
|
|
}
|
|
|
|
type TemplatePayload struct {
|
|
Name string `json:"name"`
|
|
Datasource string `json:"datasource"`
|
|
MainTable string `json:"main_table"`
|
|
Fields []string `json:"fields"`
|
|
Filters map[string]interface{} `json:"filters"`
|
|
FileFormat string `json:"file_format"`
|
|
OwnerID uint64 `json:"owner_id"`
|
|
Visibility string `json:"visibility"`
|
|
}
|
|
|
|
func (a *TemplatesAPI) createTemplate(w http.ResponseWriter, r *http.Request) {
|
|
b, _ := io.ReadAll(r.Body)
|
|
var p TemplatePayload
|
|
json.Unmarshal(b, &p)
|
|
r = WithPayload(r, p)
|
|
uidStr := r.URL.Query().Get("userId")
|
|
if uidStr != "" {
|
|
var uid uint64
|
|
_, _ = fmt.Sscan(uidStr, &uid)
|
|
if uid > 0 {
|
|
p.OwnerID = uid
|
|
}
|
|
}
|
|
now := time.Now()
|
|
tplSQL := "INSERT INTO export_templates (name, datasource, main_table, fields_json, filters_json, file_format, visibility, owner_id, enabled, stats_enabled, last_validated_at, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)"
|
|
tplArgs := []interface{}{p.Name, p.Datasource, p.MainTable, toJSON(p.Fields), toJSON(p.Filters), p.FileFormat, p.Visibility, p.OwnerID, 1, 0, now, now, now}
|
|
log.Printf("trace_id=%s sql=%s args=%v", TraceIDFrom(r), tplSQL, tplArgs)
|
|
_, err := a.meta.Exec(tplSQL, tplArgs...)
|
|
if err != nil {
|
|
fail(w, r, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, r, http.StatusCreated, 0, "ok", nil)
|
|
}
|
|
|
|
func (a *TemplatesAPI) listTemplates(w http.ResponseWriter, r *http.Request) {
|
|
uidStr := r.URL.Query().Get("userId")
|
|
sqlText := "SELECT id,name,datasource,main_table,file_format,visibility,owner_id,enabled,last_validated_at,created_at,updated_at, COALESCE(JSON_LENGTH(fields_json),0) AS field_count, (SELECT COUNT(1) FROM export_jobs ej WHERE ej.template_id = export_templates.id) AS exec_count FROM export_templates"
|
|
args := []interface{}{}
|
|
if uidStr != "" {
|
|
sqlText += " WHERE owner_id IN (0, ?)"
|
|
args = append(args, uidStr)
|
|
}
|
|
sqlText += " ORDER BY datasource ASC, id DESC LIMIT 200"
|
|
rows, err := a.meta.Query(sqlText, args...)
|
|
if err != nil {
|
|
fail(w, r, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
out := []map[string]interface{}{}
|
|
for rows.Next() {
|
|
var id uint64
|
|
var name, datasource, mainTable, fileFormat, visibility string
|
|
var ownerID uint64
|
|
var enabled int
|
|
var lastValidatedAt sql.NullTime
|
|
var createdAt, updatedAt time.Time
|
|
var fieldCount, execCount int64
|
|
err := rows.Scan(&id, &name, &datasource, &mainTable, &fileFormat, &visibility, &ownerID, &enabled, &lastValidatedAt, &createdAt, &updatedAt, &fieldCount, &execCount)
|
|
if err != nil {
|
|
fail(w, r, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
m := map[string]interface{}{"id": id, "name": name, "datasource": datasource, "main_table": mainTable, "file_format": fileFormat, "visibility": visibility, "owner_id": ownerID, "enabled": enabled == 1, "last_validated_at": lastValidatedAt.Time, "created_at": createdAt, "updated_at": updatedAt, "field_count": fieldCount, "exec_count": execCount}
|
|
out = append(out, m)
|
|
}
|
|
ok(w, r, out)
|
|
}
|
|
|
|
func (a *TemplatesAPI) getTemplate(w http.ResponseWriter, r *http.Request, id string) {
|
|
row := a.meta.QueryRow("SELECT id,name,datasource,main_table,fields_json,filters_json,file_format,visibility,owner_id,enabled,explain_score,last_validated_at,created_at,updated_at FROM export_templates WHERE id=?", id)
|
|
var m = map[string]interface{}{}
|
|
var tid uint64
|
|
var name, datasource, mainTable, fileFormat, visibility string
|
|
var ownerID uint64
|
|
var enabled int
|
|
var explainScore sql.NullInt64
|
|
var lastValidatedAt sql.NullTime
|
|
var createdAt, updatedAt time.Time
|
|
var fields, filters []byte
|
|
err := row.Scan(&tid, &name, &datasource, &mainTable, &fields, &filters, &fileFormat, &visibility, &ownerID, &enabled, &explainScore, &lastValidatedAt, &createdAt, &updatedAt)
|
|
if err != nil {
|
|
fail(w, r, http.StatusNotFound, "not found")
|
|
return
|
|
}
|
|
m["id"] = tid
|
|
m["name"] = name
|
|
m["datasource"] = datasource
|
|
m["main_table"] = mainTable
|
|
m["file_format"] = fileFormat
|
|
m["visibility"] = visibility
|
|
m["owner_id"] = ownerID
|
|
m["enabled"] = enabled == 1
|
|
m["explain_score"] = explainScore.Int64
|
|
m["last_validated_at"] = lastValidatedAt.Time
|
|
m["created_at"] = createdAt
|
|
m["updated_at"] = updatedAt
|
|
m["fields"] = fromJSON(fields)
|
|
m["filters"] = fromJSON(filters)
|
|
ok(w, r, m)
|
|
}
|
|
|
|
func (a *TemplatesAPI) patchTemplate(w http.ResponseWriter, r *http.Request, id string) {
|
|
b, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
log.Printf("trace_id=%s error reading request body: %v", TraceIDFrom(r), err)
|
|
fail(w, r, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
|
|
log.Printf("trace_id=%s patchTemplate request body: %s", TraceIDFrom(r), string(b))
|
|
|
|
var p map[string]interface{}
|
|
err = json.Unmarshal(b, &p)
|
|
if err != nil {
|
|
log.Printf("trace_id=%s error unmarshaling request body: %v", TraceIDFrom(r), err)
|
|
fail(w, r, http.StatusBadRequest, "invalid JSON format")
|
|
return
|
|
}
|
|
|
|
log.Printf("trace_id=%s patchTemplate parsed payload: %v", TraceIDFrom(r), p)
|
|
log.Printf("trace_id=%s patchTemplate template ID: %s", TraceIDFrom(r), id)
|
|
|
|
set := []string{}
|
|
args := []interface{}{}
|
|
for k, v := range p {
|
|
log.Printf("trace_id=%s patchTemplate processing field: %s, value: %v, type: %T", TraceIDFrom(r), k, v, v)
|
|
switch k {
|
|
case "name", "visibility", "file_format", "main_table":
|
|
if strVal, ok := v.(string); ok {
|
|
set = append(set, k+"=?")
|
|
args = append(args, strVal)
|
|
log.Printf("trace_id=%s patchTemplate added string field: %s, value: %s", TraceIDFrom(r), k, strVal)
|
|
} else {
|
|
log.Printf("trace_id=%s patchTemplate invalid string field: %s, value: %v, type: %T", TraceIDFrom(r), k, v, v)
|
|
}
|
|
case "fields":
|
|
set = append(set, "fields_json=?")
|
|
jsonBytes := toJSON(v)
|
|
args = append(args, jsonBytes)
|
|
log.Printf("trace_id=%s patchTemplate added fields_json: %s", TraceIDFrom(r), string(jsonBytes))
|
|
case "filters":
|
|
set = append(set, "filters_json=?")
|
|
jsonBytes := toJSON(v)
|
|
args = append(args, jsonBytes)
|
|
log.Printf("trace_id=%s patchTemplate added filters_json: %s", TraceIDFrom(r), string(jsonBytes))
|
|
case "enabled":
|
|
set = append(set, "enabled=?")
|
|
if boolVal, ok := v.(bool); ok {
|
|
if boolVal {
|
|
args = append(args, 1)
|
|
} else {
|
|
args = append(args, 0)
|
|
}
|
|
log.Printf("trace_id=%s patchTemplate added enabled: %t", TraceIDFrom(r), boolVal)
|
|
} else {
|
|
log.Printf("trace_id=%s patchTemplate invalid bool field: %s, value: %v, type: %T", TraceIDFrom(r), k, v, v)
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(set) == 0 {
|
|
log.Printf("trace_id=%s patchTemplate no fields to update", TraceIDFrom(r))
|
|
fail(w, r, http.StatusBadRequest, "no patch")
|
|
return
|
|
}
|
|
|
|
// ensure updated_at
|
|
set = append(set, "updated_at=?")
|
|
now := time.Now()
|
|
args = append(args, now, id)
|
|
|
|
sql := "UPDATE export_templates SET "+strings.Join(set, ",")+" WHERE id= ?"
|
|
log.Printf("trace_id=%s patchTemplate executing SQL: %s", TraceIDFrom(r), sql)
|
|
log.Printf("trace_id=%s patchTemplate SQL args: %v", TraceIDFrom(r), args)
|
|
|
|
_, err = a.meta.Exec(sql, args...)
|
|
if err != nil {
|
|
log.Printf("trace_id=%s patchTemplate SQL error: %v", TraceIDFrom(r), err)
|
|
fail(w, r, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
log.Printf("trace_id=%s patchTemplate update successful", TraceIDFrom(r))
|
|
ok(w, r, nil)
|
|
}
|
|
|
|
func (a *TemplatesAPI) deleteTemplate(w http.ResponseWriter, r *http.Request, id string) {
|
|
var cnt int64
|
|
row := a.meta.QueryRow("SELECT COUNT(1) FROM export_jobs WHERE template_id=?", id)
|
|
_ = row.Scan(&cnt)
|
|
if cnt > 0 {
|
|
fail(w, r, http.StatusBadRequest, "template in use")
|
|
return
|
|
}
|
|
_, err := a.meta.Exec("DELETE FROM export_templates WHERE id= ?", id)
|
|
if err != nil {
|
|
fail(w, r, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
ok(w, r, nil)
|
|
}
|
|
|
|
func (a *TemplatesAPI) validateTemplate(w http.ResponseWriter, r *http.Request, id string) {
|
|
row := a.meta.QueryRow("SELECT datasource, main_table, fields_json, filters_json FROM export_templates WHERE id= ?", id)
|
|
var ds string
|
|
var main string
|
|
var fields, filters []byte
|
|
err := row.Scan(&ds, &main, &fields, &filters)
|
|
if err != nil {
|
|
fail(w, r, http.StatusNotFound, "not found")
|
|
return
|
|
}
|
|
var fs []string
|
|
var fl map[string]interface{}
|
|
json.Unmarshal(fields, &fs)
|
|
json.Unmarshal(filters, &fl)
|
|
wl := Whitelist()
|
|
req := exporter.BuildRequest{MainTable: main, Datasource: ds, Fields: fs, Filters: fl}
|
|
q, args, err := exporter.BuildSQL(req, wl)
|
|
if err != nil {
|
|
failCat(w, r, http.StatusBadRequest, err.Error(), "sql_build_error")
|
|
return
|
|
}
|
|
dataDB := a.selectDataDB(ds)
|
|
score, sugg, err := exporter.EvaluateExplain(dataDB, q, args)
|
|
if err != nil {
|
|
failCat(w, r, http.StatusBadRequest, err.Error(), "explain_error")
|
|
return
|
|
}
|
|
idxSugg := exporter.IndexSuggestions(req)
|
|
sugg = append(sugg, idxSugg...)
|
|
_, _ = a.meta.Exec("UPDATE export_templates SET explain_json=?, explain_score=?, last_validated_at=?, updated_at=? WHERE id=?", toJSON(map[string]interface{}{"sql": q, "suggestions": sugg}), score, time.Now(), time.Now(), id)
|
|
ok(w, r, map[string]interface{}{"score": score, "suggestions": sugg})
|
|
}
|
|
|
|
func (a *TemplatesAPI) selectDataDB(ds string) *sql.DB {
|
|
if ds == "ymt" {
|
|
return a.meta
|
|
}
|
|
return a.marketing
|
|
}
|
|
|
|
func toJSON(v interface{}) []byte {
|
|
b, _ := json.Marshal(v)
|
|
return b
|
|
}
|
|
|
|
func fromJSON(b []byte) interface{} {
|
|
var v interface{}
|
|
json.Unmarshal(b, &v)
|
|
return v
|
|
}
|
|
|
|
func Whitelist() map[string]bool { return schema.AllWhitelist() }
|
|
|
|
func FieldLabels() map[string]string {
|
|
return schema.AllLabels()
|
|
}
|