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 updated_at 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, _ := io.ReadAll(r.Body) var p map[string]interface{} json.Unmarshal(b, &p) set := []string{} args := []interface{}{} for k, v := range p { switch k { case "name", "visibility", "file_format": set = append(set, k+"=?") args = append(args, v) case "fields": set = append(set, "fields_json=?") args = append(args, toJSON(v)) case "filters": set = append(set, "filters_json=?") args = append(args, toJSON(v)) case "enabled": set = append(set, "enabled=?") if v.(bool) { args = append(args, 1) } else { args = append(args, 0) } } } if len(set) == 0 { fail(w, r, http.StatusBadRequest, "no patch") return } // ensure updated_at set = append(set, "updated_at=?") args = append(args, time.Now(), id) _, err := a.meta.Exec("UPDATE export_templates SET "+strings.Join(set, ",")+" WHERE id= ?", args...) if err != nil { fail(w, r, http.StatusInternalServerError, err.Error()) return } 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() }