feat(admin): persist provider drafts in crm
This commit is contained in:
@@ -9,6 +9,7 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"sub2api-cn-relay-manager/internal/batch"
|
||||
"sub2api-cn-relay-manager/internal/host/sub2api"
|
||||
@@ -26,6 +27,11 @@ type ActionSet struct {
|
||||
GetBatchImportRun func(context.Context, string) (batch.RunSummaryProjection, error)
|
||||
ListBatchImportRunItems func(context.Context, ListBatchImportRunItemsRequest) (ListBatchImportRunItemsResponse, error)
|
||||
GetBatchImportRunItem func(context.Context, GetBatchImportRunItemRequest) (batch.ItemDetailProjection, error)
|
||||
CreateProviderDraft func(context.Context, CreateProviderDraftRequest) (ProviderDraftInfo, error)
|
||||
ListProviderDrafts func(context.Context, ListProviderDraftsRequest) ([]ProviderDraftInfo, error)
|
||||
GetProviderDraft func(context.Context, string) (ProviderDraftInfo, error)
|
||||
UpdateProviderDraft func(context.Context, UpdateProviderDraftRequest) (ProviderDraftInfo, error)
|
||||
DeleteProviderDraft func(context.Context, string) error
|
||||
InstallPack func(context.Context, InstallPackRequest) (provision.PackInstallResult, error)
|
||||
BatchDetail func(context.Context, BatchDetailRequest) (provision.BatchDetailResult, error)
|
||||
GetProviderStatus func(context.Context, ProviderQueryRequest) (provision.ProviderSnapshot, error)
|
||||
@@ -98,6 +104,47 @@ type PackProviderInfo struct {
|
||||
HostOverlays int `json:"host_overlays,omitempty"`
|
||||
}
|
||||
|
||||
type CreateProviderDraftRequest struct {
|
||||
DraftID string `json:"draft_id,omitempty"`
|
||||
PackID string `json:"pack_id"`
|
||||
ProviderID string `json:"provider_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Platform string `json:"platform"`
|
||||
BaseURL string `json:"base_url,omitempty"`
|
||||
SmokeTestModel string `json:"smoke_test_model,omitempty"`
|
||||
SupportedModels []string `json:"supported_models,omitempty"`
|
||||
Manifest json.RawMessage `json:"manifest,omitempty"`
|
||||
SourceHostID string `json:"source_host_id,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
}
|
||||
|
||||
type ListProviderDraftsRequest struct {
|
||||
PackID string
|
||||
ProviderID string
|
||||
Query string
|
||||
}
|
||||
|
||||
type UpdateProviderDraftRequest struct {
|
||||
DraftID string `json:"-"`
|
||||
CreateProviderDraftRequest
|
||||
}
|
||||
|
||||
type ProviderDraftInfo struct {
|
||||
DraftID string `json:"draft_id"`
|
||||
PackID string `json:"pack_id"`
|
||||
ProviderID string `json:"provider_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Platform string `json:"platform"`
|
||||
BaseURL string `json:"base_url,omitempty"`
|
||||
SmokeTestModel string `json:"smoke_test_model,omitempty"`
|
||||
SupportedModels []string `json:"supported_models,omitempty"`
|
||||
Manifest any `json:"manifest,omitempty"`
|
||||
SourceHostID string `json:"source_host_id,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
type AssignAccessSubscriptionsRequest struct {
|
||||
HostID string `json:"host_id,omitempty"`
|
||||
PackPath string `json:"pack_path"`
|
||||
@@ -226,6 +273,21 @@ func NewAPIHandler(adminToken string, actions ActionSet) http.Handler {
|
||||
mux.Handle("GET /api/batch-import/runs/{run_id}/items/{item_id}", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handleGetBatchImportRunItem(w, r, actions.GetBatchImportRunItem)
|
||||
})))
|
||||
mux.Handle("POST /api/provider-drafts", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handleCreateProviderDraft(w, r, actions.CreateProviderDraft)
|
||||
})))
|
||||
mux.Handle("GET /api/provider-drafts", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handleListProviderDrafts(w, r, actions.ListProviderDrafts)
|
||||
})))
|
||||
mux.Handle("GET /api/provider-drafts/{draftID}", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handleGetProviderDraft(w, r, actions.GetProviderDraft)
|
||||
})))
|
||||
mux.Handle("PUT /api/provider-drafts/{draftID}", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handleUpdateProviderDraft(w, r, actions.UpdateProviderDraft)
|
||||
})))
|
||||
mux.Handle("DELETE /api/provider-drafts/{draftID}", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handleDeleteProviderDraft(w, r, actions.DeleteProviderDraft)
|
||||
})))
|
||||
mux.Handle("GET /api/import-batches/{batchID}", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handleBatchDetail(w, r, actions.BatchDetail)
|
||||
})))
|
||||
@@ -297,6 +359,102 @@ func healthz(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}
|
||||
|
||||
func handleCreateProviderDraft(w http.ResponseWriter, r *http.Request, fn func(context.Context, CreateProviderDraftRequest) (ProviderDraftInfo, error)) {
|
||||
if fn == nil {
|
||||
writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "create-provider-draft action is not configured"})
|
||||
return
|
||||
}
|
||||
var req CreateProviderDraftRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeHTTPError(w, err)
|
||||
return
|
||||
}
|
||||
draft, err := fn(r.Context(), req)
|
||||
if err != nil {
|
||||
writeHTTPError(w, classifyError(err))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"draft": draft})
|
||||
}
|
||||
|
||||
func handleListProviderDrafts(w http.ResponseWriter, r *http.Request, fn func(context.Context, ListProviderDraftsRequest) ([]ProviderDraftInfo, error)) {
|
||||
if fn == nil {
|
||||
writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "list-provider-drafts action is not configured"})
|
||||
return
|
||||
}
|
||||
drafts, err := fn(r.Context(), ListProviderDraftsRequest{
|
||||
PackID: strings.TrimSpace(r.URL.Query().Get("pack_id")),
|
||||
ProviderID: strings.TrimSpace(r.URL.Query().Get("provider_id")),
|
||||
Query: strings.TrimSpace(r.URL.Query().Get("q")),
|
||||
})
|
||||
if err != nil {
|
||||
writeHTTPError(w, classifyError(err))
|
||||
return
|
||||
}
|
||||
if drafts == nil {
|
||||
drafts = []ProviderDraftInfo{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"provider_drafts": drafts})
|
||||
}
|
||||
|
||||
func handleGetProviderDraft(w http.ResponseWriter, r *http.Request, fn func(context.Context, string) (ProviderDraftInfo, error)) {
|
||||
if fn == nil {
|
||||
writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "get-provider-draft action is not configured"})
|
||||
return
|
||||
}
|
||||
draftID := strings.TrimSpace(r.PathValue("draftID"))
|
||||
if draftID == "" {
|
||||
writeHTTPError(w, &httpError{StatusCode: http.StatusBadRequest, Code: "bad_request", Message: "draft_id is required"})
|
||||
return
|
||||
}
|
||||
draft, err := fn(r.Context(), draftID)
|
||||
if err != nil {
|
||||
writeHTTPError(w, classifyError(err))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"draft": draft})
|
||||
}
|
||||
|
||||
func handleUpdateProviderDraft(w http.ResponseWriter, r *http.Request, fn func(context.Context, UpdateProviderDraftRequest) (ProviderDraftInfo, error)) {
|
||||
if fn == nil {
|
||||
writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "update-provider-draft action is not configured"})
|
||||
return
|
||||
}
|
||||
var req UpdateProviderDraftRequest
|
||||
if err := decodeJSON(r, &req.CreateProviderDraftRequest); err != nil {
|
||||
writeHTTPError(w, err)
|
||||
return
|
||||
}
|
||||
req.DraftID = strings.TrimSpace(r.PathValue("draftID"))
|
||||
if req.DraftID == "" {
|
||||
writeHTTPError(w, &httpError{StatusCode: http.StatusBadRequest, Code: "bad_request", Message: "draft_id is required"})
|
||||
return
|
||||
}
|
||||
draft, err := fn(r.Context(), req)
|
||||
if err != nil {
|
||||
writeHTTPError(w, classifyError(err))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"draft": draft})
|
||||
}
|
||||
|
||||
func handleDeleteProviderDraft(w http.ResponseWriter, r *http.Request, fn func(context.Context, string) error) {
|
||||
if fn == nil {
|
||||
writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "delete-provider-draft action is not configured"})
|
||||
return
|
||||
}
|
||||
draftID := strings.TrimSpace(r.PathValue("draftID"))
|
||||
if draftID == "" {
|
||||
writeHTTPError(w, &httpError{StatusCode: http.StatusBadRequest, Code: "bad_request", Message: "draft_id is required"})
|
||||
return
|
||||
}
|
||||
if err := fn(r.Context(), draftID); err != nil {
|
||||
writeHTTPError(w, classifyError(err))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func requireAdminToken(token string, next http.Handler) http.Handler {
|
||||
if strings.TrimSpace(token) == "" {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
@@ -912,6 +1070,123 @@ func NewActionSet(sqliteDSN string) ActionSet {
|
||||
GetBatchImportRun: buildGetBatchImportRunAction(sqliteDSN),
|
||||
ListBatchImportRunItems: buildListBatchImportRunItemsAction(sqliteDSN),
|
||||
GetBatchImportRunItem: buildGetBatchImportRunItemAction(sqliteDSN),
|
||||
CreateProviderDraft: func(ctx context.Context, req CreateProviderDraftRequest) (ProviderDraftInfo, error) {
|
||||
store, err := sqlite.Open(ctx, sqliteDSN)
|
||||
if err != nil {
|
||||
return ProviderDraftInfo{}, err
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
draftID := strings.TrimSpace(req.DraftID)
|
||||
if draftID == "" {
|
||||
draftID = fmt.Sprintf("draft_%d", time.Now().UnixNano())
|
||||
}
|
||||
manifestJSON, manifestValue, supportedModels, err := normalizeProviderDraftPayload(req)
|
||||
if err != nil {
|
||||
return ProviderDraftInfo{}, err
|
||||
}
|
||||
|
||||
draftRow := sqlite.ProviderDraft{
|
||||
DraftID: draftID,
|
||||
PackID: strings.TrimSpace(req.PackID),
|
||||
ProviderID: strings.TrimSpace(req.ProviderID),
|
||||
DisplayName: strings.TrimSpace(req.DisplayName),
|
||||
Platform: strings.TrimSpace(req.Platform),
|
||||
BaseURL: strings.TrimSpace(req.BaseURL),
|
||||
SmokeTestModel: strings.TrimSpace(req.SmokeTestModel),
|
||||
SupportedModelsJSON: encodeStringList(supportedModels),
|
||||
ManifestJSON: manifestJSON,
|
||||
SourceHostID: strings.TrimSpace(req.SourceHostID),
|
||||
Notes: strings.TrimSpace(req.Notes),
|
||||
}
|
||||
if _, err := store.ProviderDrafts().Create(ctx, draftRow); err != nil {
|
||||
return ProviderDraftInfo{}, err
|
||||
}
|
||||
persisted, err := store.ProviderDrafts().GetByDraftID(ctx, draftID)
|
||||
if err != nil {
|
||||
return ProviderDraftInfo{}, err
|
||||
}
|
||||
return providerDraftRecordToInfo(persisted, manifestValue, supportedModels)
|
||||
},
|
||||
ListProviderDrafts: func(ctx context.Context, req ListProviderDraftsRequest) ([]ProviderDraftInfo, error) {
|
||||
store, err := sqlite.Open(ctx, sqliteDSN)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
rows, err := store.ProviderDrafts().List(ctx, sqlite.ListProviderDraftsFilter{
|
||||
PackID: req.PackID,
|
||||
ProviderID: req.ProviderID,
|
||||
Query: req.Query,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]ProviderDraftInfo, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
info, err := providerDraftRecordToInfoFromStored(row)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, info)
|
||||
}
|
||||
return result, nil
|
||||
},
|
||||
GetProviderDraft: func(ctx context.Context, draftID string) (ProviderDraftInfo, error) {
|
||||
store, err := sqlite.Open(ctx, sqliteDSN)
|
||||
if err != nil {
|
||||
return ProviderDraftInfo{}, err
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
row, err := store.ProviderDrafts().GetByDraftID(ctx, draftID)
|
||||
if err != nil {
|
||||
return ProviderDraftInfo{}, err
|
||||
}
|
||||
return providerDraftRecordToInfoFromStored(row)
|
||||
},
|
||||
UpdateProviderDraft: func(ctx context.Context, req UpdateProviderDraftRequest) (ProviderDraftInfo, error) {
|
||||
store, err := sqlite.Open(ctx, sqliteDSN)
|
||||
if err != nil {
|
||||
return ProviderDraftInfo{}, err
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
manifestJSON, _, supportedModels, err := normalizeProviderDraftPayload(req.CreateProviderDraftRequest)
|
||||
if err != nil {
|
||||
return ProviderDraftInfo{}, err
|
||||
}
|
||||
|
||||
if err := store.ProviderDrafts().UpdateByDraftID(ctx, sqlite.ProviderDraft{
|
||||
DraftID: strings.TrimSpace(req.DraftID),
|
||||
PackID: strings.TrimSpace(req.PackID),
|
||||
ProviderID: strings.TrimSpace(req.ProviderID),
|
||||
DisplayName: strings.TrimSpace(req.DisplayName),
|
||||
Platform: strings.TrimSpace(req.Platform),
|
||||
BaseURL: strings.TrimSpace(req.BaseURL),
|
||||
SmokeTestModel: strings.TrimSpace(req.SmokeTestModel),
|
||||
SupportedModelsJSON: encodeStringList(supportedModels),
|
||||
ManifestJSON: manifestJSON,
|
||||
SourceHostID: strings.TrimSpace(req.SourceHostID),
|
||||
Notes: strings.TrimSpace(req.Notes),
|
||||
}); err != nil {
|
||||
return ProviderDraftInfo{}, err
|
||||
}
|
||||
row, err := store.ProviderDrafts().GetByDraftID(ctx, req.DraftID)
|
||||
if err != nil {
|
||||
return ProviderDraftInfo{}, err
|
||||
}
|
||||
return providerDraftRecordToInfoFromStored(row)
|
||||
},
|
||||
DeleteProviderDraft: func(ctx context.Context, draftID string) error {
|
||||
store, err := sqlite.Open(ctx, sqliteDSN)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer store.Close()
|
||||
return store.ProviderDrafts().DeleteByDraftID(ctx, draftID)
|
||||
},
|
||||
InstallPack: func(ctx context.Context, req InstallPackRequest) (provision.PackInstallResult, error) {
|
||||
loadedPack, err := pack.LoadPath(req.PackPath)
|
||||
if err != nil {
|
||||
@@ -1650,6 +1925,92 @@ func packRecordToInfo(pack sqlite.Pack) PackInfo {
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeProviderDraftPayload(req CreateProviderDraftRequest) (string, any, []string, error) {
|
||||
supportedModels := normalizeStringList(req.SupportedModels)
|
||||
if len(req.Manifest) > 0 {
|
||||
var manifestValue any
|
||||
if err := json.Unmarshal(req.Manifest, &manifestValue); err != nil {
|
||||
return "", nil, nil, fmt.Errorf("decode manifest: %w", err)
|
||||
}
|
||||
manifestJSON := strings.TrimSpace(string(req.Manifest))
|
||||
if manifestJSON == "" {
|
||||
manifestJSON = "{}"
|
||||
}
|
||||
return manifestJSON, manifestValue, supportedModels, nil
|
||||
}
|
||||
|
||||
manifestValue := map[string]any{
|
||||
"provider_id": strings.TrimSpace(req.ProviderID),
|
||||
"display_name": strings.TrimSpace(req.DisplayName),
|
||||
"platform": strings.TrimSpace(req.Platform),
|
||||
"base_url": strings.TrimSpace(req.BaseURL),
|
||||
"smoke_test_model": strings.TrimSpace(req.SmokeTestModel),
|
||||
"supported_models": supportedModels,
|
||||
}
|
||||
manifestJSONBytes, err := json.Marshal(manifestValue)
|
||||
if err != nil {
|
||||
return "", nil, nil, fmt.Errorf("marshal manifest: %w", err)
|
||||
}
|
||||
return string(manifestJSONBytes), manifestValue, supportedModels, nil
|
||||
}
|
||||
|
||||
func providerDraftRecordToInfo(row sqlite.ProviderDraft, manifestValue any, supportedModels []string) (ProviderDraftInfo, error) {
|
||||
if manifestValue == nil {
|
||||
manifestValue = map[string]any{}
|
||||
}
|
||||
return ProviderDraftInfo{
|
||||
DraftID: row.DraftID,
|
||||
PackID: row.PackID,
|
||||
ProviderID: row.ProviderID,
|
||||
DisplayName: row.DisplayName,
|
||||
Platform: row.Platform,
|
||||
BaseURL: row.BaseURL,
|
||||
SmokeTestModel: row.SmokeTestModel,
|
||||
SupportedModels: append([]string(nil), supportedModels...),
|
||||
Manifest: manifestValue,
|
||||
SourceHostID: row.SourceHostID,
|
||||
Notes: row.Notes,
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func providerDraftRecordToInfoFromStored(row sqlite.ProviderDraft) (ProviderDraftInfo, error) {
|
||||
var manifestValue any
|
||||
if strings.TrimSpace(row.ManifestJSON) != "" {
|
||||
if err := json.Unmarshal([]byte(row.ManifestJSON), &manifestValue); err != nil {
|
||||
return ProviderDraftInfo{}, fmt.Errorf("decode stored provider draft manifest: %w", err)
|
||||
}
|
||||
}
|
||||
supportedModels := []string{}
|
||||
if strings.TrimSpace(row.SupportedModelsJSON) != "" {
|
||||
if err := json.Unmarshal([]byte(row.SupportedModelsJSON), &supportedModels); err != nil {
|
||||
return ProviderDraftInfo{}, fmt.Errorf("decode stored provider draft supported_models: %w", err)
|
||||
}
|
||||
}
|
||||
return providerDraftRecordToInfo(row, manifestValue, supportedModels)
|
||||
}
|
||||
|
||||
func encodeStringList(values []string) string {
|
||||
encoded, err := json.Marshal(normalizeStringList(values))
|
||||
if err != nil {
|
||||
return "[]"
|
||||
}
|
||||
return string(encoded)
|
||||
}
|
||||
|
||||
func normalizeStringList(values []string) []string {
|
||||
normalized := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
normalized = append(normalized, value)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func deriveAccessStatus(gw sub2api.GatewayAccessResult) string {
|
||||
if provision.GatewayAccessReady(gw) {
|
||||
return provision.AccessStatusSubscriptionReady
|
||||
|
||||
Reference in New Issue
Block a user