// Package control exposes the contract-defined internal control/query/replay // HTTP surface. Call execution itself remains a RabbitMQ command path. package control import ( "database/sql" "encoding/json" "errors" "fmt" "io" "net/http" "strings" "time" "git.ipao.vip/rogee/go-sip/internal/store" ) type Handler struct { Store *store.Store BearerToken string Now func() time.Time } type controlRequest struct { CommandID string `json:"command_id"` Action string `json:"action"` ExpectedTaskRevision int64 `json:"expected_task_revision"` ActiveCallPolicy string `json:"active_call_policy,omitempty"` Reason string `json:"reason"` } type replayRequest struct { CommandID string `json:"command_id"` Reason string `json:"reason"` } func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if h.Store == nil { writeProblem(w, r, http.StatusInternalServerError, "store_unavailable", "store is not configured", true) return } if !h.authorized(r) { writeProblem(w, r, http.StatusUnauthorized, "unauthorized", "bearer authentication is required", false) return } if r.Header.Get("X-Tenant-ID") == "" || r.Header.Get("X-Request-ID") == "" { writeProblem(w, r, http.StatusBadRequest, "missing_header", "X-Tenant-ID and X-Request-ID are required", false) return } parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") switch { case r.Method == http.MethodPost && len(parts) == 6 && parts[0] == "internal" && parts[1] == "v1" && parts[2] == "outbound" && parts[3] == "tasks" && parts[5] == "controls": h.controlTask(w, r, parts[4]) case r.Method == http.MethodGet && len(parts) == 5 && parts[0] == "internal" && parts[1] == "v1" && parts[2] == "outbound" && parts[3] == "commands": h.getCommand(w, r, parts[4]) case r.Method == http.MethodGet && len(parts) == 5 && parts[0] == "internal" && parts[1] == "v1" && parts[2] == "outbound" && parts[3] == "calls": writeProblem(w, r, http.StatusNotFound, "not_found", "call snapshot is not available", false) case r.Method == http.MethodPost && len(parts) == 6 && parts[0] == "internal" && parts[1] == "v1" && parts[2] == "outbound" && parts[3] == "calls" && parts[5] == "replays": writeProblem(w, r, http.StatusNotFound, "not_found", "call snapshot is not available", false) case r.Method == http.MethodPost && len(parts) == 6 && parts[0] == "internal" && parts[1] == "v1" && parts[2] == "outbound" && parts[3] == "commands" && parts[5] == "replays": h.replayCommand(w, r, parts[4]) default: writeProblem(w, r, http.StatusNotFound, "not_found", "resource not found", false) } } func (h Handler) authorized(r *http.Request) bool { value := r.Header.Get("Authorization") if !strings.HasPrefix(value, "Bearer ") || strings.TrimSpace(strings.TrimPrefix(value, "Bearer ")) == "" { return false } return h.BearerToken == "" || strings.TrimSpace(strings.TrimPrefix(value, "Bearer ")) == h.BearerToken } func (h Handler) controlTask(w http.ResponseWriter, r *http.Request, taskID string) { var req controlRequest if err := decodeStrict(r, &req); err != nil || req.CommandID == "" || req.Reason == "" || req.ExpectedTaskRevision < 1 || (req.Action != "pause" && req.Action != "resume" && req.Action != "stop") || (req.ActiveCallPolicy != "" && req.ActiveCallPolicy != "drain" && req.ActiveCallPolicy != "hangup") || len(req.Reason) > 512 { writeProblem(w, r, http.StatusBadRequest, "invalid_control", "invalid control request", false) return } task, err := h.Store.FindTask(r.Header.Get("X-Tenant-ID"), taskID) if errors.Is(err, sql.ErrNoRows) { writeProblem(w, r, http.StatusNotFound, "not_found", "task not found", false) return } if err != nil { writeProblem(w, r, http.StatusInternalServerError, "lookup_failed", err.Error(), true) return } if err := h.Store.ApplyControlDetailed(task.ExecutionID, req.ExpectedTaskRevision, req.Action, req.ActiveCallPolicy, req.Reason, r.Header.Get("Idempotency-Key")); err != nil { if errors.Is(err, store.ErrCASConflict) { writeProblem(w, r, http.StatusConflict, "revision_conflict", err.Error(), false) return } writeProblem(w, r, http.StatusInternalServerError, "control_failed", err.Error(), true) return } acceptedAt := h.now().UTC().Format(time.RFC3339Nano) writeJSON(w, http.StatusAccepted, map[string]any{"command_id": req.CommandID, "tenant_id": task.TenantID, "tenant_key": task.TenantKey, "task_id": task.TaskID, "status": "accepted", "requested_task_revision": req.ExpectedTaskRevision, "accepted_at": acceptedAt}) } func (h Handler) getCommand(w http.ResponseWriter, r *http.Request, commandID string) { record, err := h.Store.GetCommand(r.Header.Get("X-Tenant-ID"), commandID) if errors.Is(err, sql.ErrNoRows) { writeProblem(w, r, http.StatusNotFound, "not_found", "command not found", false) return } if err != nil { writeProblem(w, r, http.StatusInternalServerError, "lookup_failed", err.Error(), true) return } var envelope struct { SchemaVersion string `json:"schema_version"` CommandType string `json:"command_type"` CommandID string `json:"command_id"` TenantID string `json:"tenant_id"` TenantKey string `json:"tenant_key"` TraceID string `json:"trace_id"` IssuedAt string `json:"issued_at"` NotAfter string `json:"not_after"` Payload json.RawMessage `json:"payload"` } if err := json.Unmarshal(record.Body, &envelope); err != nil { writeProblem(w, r, http.StatusInternalServerError, "decode_failed", err.Error(), true) return } var taskID, executionID string var requestedRevision any var payload struct { TaskID string `json:"task_id"` ExecutionID string `json:"execution_id"` TaskRevision int64 `json:"task_revision"` } if err := json.Unmarshal(envelope.Payload, &payload); err == nil { taskID, executionID, requestedRevision = payload.TaskID, payload.ExecutionID, payload.TaskRevision } writeJSON(w, http.StatusOK, map[string]any{ "command_id": record.CommandID, "command_type": record.CommandType, "tenant_id": record.TenantID, "tenant_key": record.TenantKey, "task_id": taskID, "execution_id": executionID, "call_id": nil, "status": record.Status, "reason_code": nil, "wait_reason_code": nil, "accepted_at": record.PersistedAt, "waiting_since": nil, "admission_deadline": envelope.NotAfter, "requested_task_revision": requestedRevision, "applied_task_revision": nil, "task_state": nil, "aggregate_version": 1, "updated_at": record.ReceivedAt, }) } func (h Handler) replayCommand(w http.ResponseWriter, r *http.Request, sourceCommandID string) { var req replayRequest if err := decodeStrict(r, &req); err != nil || req.CommandID == "" || req.Reason == "" || len(req.Reason) > 512 { writeProblem(w, r, http.StatusBadRequest, "invalid_replay", "invalid replay request", false) return } key := r.Header.Get("Idempotency-Key") if key == "" { writeProblem(w, r, http.StatusBadRequest, "missing_idempotency_key", "Idempotency-Key is required", false) return } if err := h.Store.ReplayCommand(key, r.Header.Get("X-Tenant-ID"), sourceCommandID, req.Reason); err != nil { if errors.Is(err, sql.ErrNoRows) { writeProblem(w, r, http.StatusNotFound, "not_found", "source command not found", false) return } writeProblem(w, r, http.StatusInternalServerError, "replay_failed", err.Error(), true) return } writeJSON(w, http.StatusAccepted, map[string]any{"command_id": req.CommandID, "status": "accepted", "snapshot_cutoff": h.now().UTC().Format(time.RFC3339Nano)}) } func decodeStrict(r *http.Request, dst any) error { decoder := json.NewDecoder(io.LimitReader(r.Body, 64<<10)) decoder.DisallowUnknownFields() if err := decoder.Decode(dst); err != nil { return err } var extra any if err := decoder.Decode(&extra); err != io.EOF { return fmt.Errorf("request has multiple JSON values") } return nil } func writeJSON(w http.ResponseWriter, status int, value any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(value) } func writeProblem(w http.ResponseWriter, r *http.Request, status int, code, detail string, retryable bool) { writeJSON(w, status, map[string]any{"type": "about:blank", "title": http.StatusText(status), "status": status, "code": code, "detail": detail, "request_id": r.Header.Get("X-Request-ID"), "retryable": retryable}) } func (h Handler) now() time.Time { if h.Now != nil { return h.Now() } return time.Now() }