364 lines
12 KiB
Go
364 lines
12 KiB
Go
package api
|
|
|
|
import (
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
historysvc "reanimator/internal/history"
|
|
)
|
|
|
|
type HistoryDependencies struct {
|
|
Service *historysvc.Service
|
|
}
|
|
|
|
type historyHandlers struct {
|
|
deps HistoryDependencies
|
|
}
|
|
|
|
func RegisterHistoryRoutes(mux *http.ServeMux, deps HistoryDependencies) {
|
|
h := historyHandlers{deps: deps}
|
|
mux.HandleFunc("/api/history/jobs/", h.handleJob)
|
|
mux.HandleFunc("/api/history/components/", h.handleComponents)
|
|
mux.HandleFunc("/api/history/assets/", h.handleAssets)
|
|
}
|
|
|
|
func (h historyHandlers) handleJob(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
if h.deps.Service == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "history unavailable")
|
|
return
|
|
}
|
|
id := strings.TrimSpace(strings.TrimPrefix(r.URL.Path, "/api/history/jobs/"))
|
|
if id == "" || strings.Contains(id, "/") {
|
|
writeError(w, http.StatusNotFound, "job not found")
|
|
return
|
|
}
|
|
job, err := h.deps.Service.GetJob(r.Context(), id)
|
|
if err != nil {
|
|
if err == historysvc.ErrNotFound {
|
|
writeError(w, http.StatusNotFound, "job not found")
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "get job failed")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, job)
|
|
}
|
|
|
|
func (h historyHandlers) handleComponents(w http.ResponseWriter, r *http.Request) {
|
|
h.handleEntity(w, r, "component", "/api/history/components/")
|
|
}
|
|
|
|
func (h historyHandlers) handleAssets(w http.ResponseWriter, r *http.Request) {
|
|
h.handleEntity(w, r, "asset", "/api/history/assets/")
|
|
}
|
|
|
|
func (h historyHandlers) handleEntity(w http.ResponseWriter, r *http.Request, entityType, prefix string) {
|
|
if h.deps.Service == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "history unavailable")
|
|
return
|
|
}
|
|
trimmed := strings.TrimPrefix(r.URL.Path, prefix)
|
|
if trimmed == r.URL.Path {
|
|
writeError(w, http.StatusNotFound, "history route not found")
|
|
return
|
|
}
|
|
parts := strings.Split(strings.Trim(trimmed, "/"), "/")
|
|
if len(parts) < 2 {
|
|
writeError(w, http.StatusNotFound, "history route not found")
|
|
return
|
|
}
|
|
id := parts[0]
|
|
if !entityIDPattern.MatchString(id) {
|
|
writeError(w, http.StatusNotFound, "invalid entity id")
|
|
return
|
|
}
|
|
switch {
|
|
case len(parts) == 2 && parts[1] == "events" && r.Method == http.MethodGet:
|
|
h.handleListEvents(w, r, entityType, id)
|
|
case len(parts) == 4 && parts[1] == "events" && parts[3] == "detail" && r.Method == http.MethodGet:
|
|
h.handleTimelineEventDetail(w, r, entityType, id, parts[2])
|
|
case len(parts) == 2 && parts[1] == "apply" && r.Method == http.MethodPost:
|
|
h.handleApply(w, r, entityType, id)
|
|
case len(parts) == 2 && parts[1] == "timeline" && r.Method == http.MethodGet:
|
|
h.handleTimeline(w, r, entityType, id)
|
|
case len(parts) == 5 && parts[1] == "timeline" && parts[2] == "cards" && parts[4] == "events" && r.Method == http.MethodGet:
|
|
h.handleTimelineCardEvents(w, r, entityType, id, parts[3])
|
|
case len(parts) == 2 && parts[1] == "rollback" && r.Method == http.MethodPost:
|
|
h.handleRollback(w, r, entityType, id)
|
|
case len(parts) == 3 && parts[1] == "events" && r.Method == http.MethodDelete:
|
|
h.handleDeleteEvent(w, r, entityType, id, parts[2])
|
|
default:
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
func (h historyHandlers) handleListEvents(w http.ResponseWriter, r *http.Request, entityType, entityID string) {
|
|
limit := 50
|
|
if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" {
|
|
parsed, err := strconv.Atoi(raw)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid limit")
|
|
return
|
|
}
|
|
limit = parsed
|
|
}
|
|
includeDeleted := strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("include_deleted")), "true")
|
|
|
|
var (
|
|
items []historysvc.EventRecord
|
|
err error
|
|
)
|
|
if entityType == "component" {
|
|
items, err = h.deps.Service.ListComponentEvents(r.Context(), entityID, limit, includeDeleted)
|
|
} else {
|
|
items, err = h.deps.Service.ListAssetEvents(r.Context(), entityID, limit, includeDeleted)
|
|
}
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "list history events failed")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"entity_type": entityType,
|
|
"entity_id": entityID,
|
|
"items": items,
|
|
})
|
|
}
|
|
|
|
func (h historyHandlers) handleApply(w http.ResponseWriter, r *http.Request, entityType, entityID string) {
|
|
var req struct {
|
|
ChangeType string `json:"change_type"`
|
|
EffectiveAt *string `json:"effective_at"`
|
|
SourceType string `json:"source_type"`
|
|
SourceRef *string `json:"source_ref"`
|
|
ActorType string `json:"actor_type"`
|
|
ActorID *string `json:"actor_id"`
|
|
CorrelationID *string `json:"correlation_id"`
|
|
IdempotencyKey *string `json:"idempotency_key"`
|
|
Patch []historysvc.PatchOp `json:"patch_json"`
|
|
}
|
|
if err := decodeJSON(r, &req); err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
var effectiveAt *time.Time
|
|
if req.EffectiveAt != nil && strings.TrimSpace(*req.EffectiveAt) != "" {
|
|
parsed, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(*req.EffectiveAt))
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "effective_at must be RFC3339")
|
|
return
|
|
}
|
|
parsed = parsed.UTC()
|
|
effectiveAt = &parsed
|
|
}
|
|
idem := req.IdempotencyKey
|
|
if header := strings.TrimSpace(r.Header.Get("Idempotency-Key")); header != "" {
|
|
idem = &header
|
|
}
|
|
cmd := historysvc.ApplyPatchCommand{
|
|
EntityID: entityID,
|
|
ChangeType: strings.TrimSpace(req.ChangeType),
|
|
EffectiveAt: effectiveAt,
|
|
SourceType: strings.TrimSpace(req.SourceType),
|
|
SourceRef: normalizeOptionalString(req.SourceRef),
|
|
ActorType: strings.TrimSpace(req.ActorType),
|
|
ActorID: normalizeOptionalString(req.ActorID),
|
|
CorrelationID: normalizeOptionalString(req.CorrelationID),
|
|
IdempotencyKey: normalizeOptionalString(idem),
|
|
Patch: req.Patch,
|
|
}
|
|
var (
|
|
result historysvc.ApplyPatchResult
|
|
err error
|
|
)
|
|
if entityType == "component" {
|
|
result, err = h.deps.Service.ApplyComponentPatch(r.Context(), cmd)
|
|
} else {
|
|
result, err = h.deps.Service.ApplyAssetPatch(r.Context(), cmd)
|
|
}
|
|
if err != nil {
|
|
switch err {
|
|
case historysvc.ErrNotFound:
|
|
writeError(w, http.StatusNotFound, "entity not found")
|
|
case historysvc.ErrConflict:
|
|
writeError(w, http.StatusConflict, "history conflict")
|
|
default:
|
|
if strings.Contains(err.Error(), "invalid patch") {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "apply patch failed")
|
|
}
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, result)
|
|
}
|
|
|
|
func (h historyHandlers) handleTimeline(w http.ResponseWriter, r *http.Request, entityType, entityID string) {
|
|
limitCards := 30
|
|
if raw := strings.TrimSpace(r.URL.Query().Get("limit_cards")); raw != "" {
|
|
parsed, err := strconv.Atoi(raw)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid limit_cards")
|
|
return
|
|
}
|
|
limitCards = parsed
|
|
} else if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" {
|
|
parsed, err := strconv.Atoi(raw)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid limit")
|
|
return
|
|
}
|
|
limitCards = parsed
|
|
}
|
|
tz := strings.TrimSpace(r.URL.Query().Get("tz"))
|
|
if tz == "" {
|
|
tz = "UTC"
|
|
}
|
|
includeDeleted := strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("include_deleted")), "true")
|
|
q := historysvc.TimelineCardsQuery{
|
|
Timezone: tz,
|
|
LimitCards: limitCards,
|
|
IncludeDeleted: includeDeleted,
|
|
DateFrom: normalizeOptionalStringPtr(strings.TrimSpace(r.URL.Query().Get("date_from"))),
|
|
DateTo: normalizeOptionalStringPtr(strings.TrimSpace(r.URL.Query().Get("date_to"))),
|
|
Actions: r.URL.Query()["action"],
|
|
Sources: r.URL.Query()["source"],
|
|
Device: normalizeOptionalStringPtr(strings.TrimSpace(r.URL.Query().Get("device"))),
|
|
Slot: normalizeOptionalStringPtr(strings.TrimSpace(r.URL.Query().Get("slot"))),
|
|
PartNumber: normalizeOptionalStringPtr(strings.TrimSpace(r.URL.Query().Get("part_number"))),
|
|
Serial: normalizeOptionalStringPtr(strings.TrimSpace(r.URL.Query().Get("serial"))),
|
|
}
|
|
if raw := strings.TrimSpace(r.URL.Query().Get("cursor")); raw != "" {
|
|
q.Cursor = &raw
|
|
}
|
|
resp, err := h.deps.Service.ListTimelineCards(r.Context(), entityType, entityID, q)
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "invalid timezone") {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "list timeline failed")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
func (h historyHandlers) handleTimelineCardEvents(w http.ResponseWriter, r *http.Request, entityType, entityID, cardID string) {
|
|
includeDeleted := strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("include_deleted")), "true")
|
|
tz := strings.TrimSpace(r.URL.Query().Get("tz"))
|
|
if tz == "" {
|
|
tz = "UTC"
|
|
}
|
|
resp, err := h.deps.Service.ListTimelineCardEvents(r.Context(), entityType, entityID, cardID, tz, includeDeleted)
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "invalid card id") || strings.Contains(err.Error(), "invalid timezone") {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "list timeline card events failed")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
func (h historyHandlers) handleTimelineEventDetail(w http.ResponseWriter, r *http.Request, entityType, entityID, timelineEventID string) {
|
|
if !entityIDPattern.MatchString(timelineEventID) {
|
|
writeError(w, http.StatusNotFound, "event not found")
|
|
return
|
|
}
|
|
resp, err := h.deps.Service.GetTimelineEventDetail(r.Context(), entityType, entityID, timelineEventID)
|
|
if err != nil {
|
|
switch err {
|
|
case historysvc.ErrNotFound:
|
|
writeError(w, http.StatusNotFound, "event not found")
|
|
default:
|
|
writeError(w, http.StatusInternalServerError, "get timeline event detail failed")
|
|
}
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
func normalizeOptionalStringPtr(value string) *string {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
return nil
|
|
}
|
|
return &value
|
|
}
|
|
|
|
func (h historyHandlers) handleDeleteEvent(w http.ResponseWriter, r *http.Request, entityType, entityID, eventID string) {
|
|
if !entityIDPattern.MatchString(eventID) {
|
|
writeError(w, http.StatusNotFound, "event not found")
|
|
return
|
|
}
|
|
var req struct {
|
|
Reason *string `json:"reason"`
|
|
RequestedBy *string `json:"requested_by"`
|
|
}
|
|
if r.Body != nil && r.ContentLength != 0 {
|
|
if err := decodeJSON(r, &req); err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
}
|
|
job, err := h.deps.Service.QueueDeleteEventRecompute(r.Context(), entityType, entityID, eventID, normalizeOptionalString(req.Reason), normalizeOptionalString(req.RequestedBy))
|
|
if err != nil {
|
|
switch err {
|
|
case historysvc.ErrNotFound:
|
|
writeError(w, http.StatusNotFound, "event not found")
|
|
case historysvc.ErrConflict:
|
|
writeError(w, http.StatusConflict, "history conflict")
|
|
default:
|
|
writeError(w, http.StatusInternalServerError, "queue delete event failed")
|
|
}
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusAccepted, map[string]any{"job_id": job.ID, "status": job.Status})
|
|
}
|
|
|
|
func (h historyHandlers) handleRollback(w http.ResponseWriter, r *http.Request, entityType, entityID string) {
|
|
var req struct {
|
|
TargetVersion *int64 `json:"target_version"`
|
|
TargetSnapshotID *string `json:"target_snapshot_id"`
|
|
Mode string `json:"mode"`
|
|
Reason *string `json:"reason"`
|
|
RequestedBy *string `json:"requested_by"`
|
|
}
|
|
if err := decodeJSON(r, &req); err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
mode := strings.TrimSpace(req.Mode)
|
|
if mode == "" {
|
|
mode = "compensating"
|
|
}
|
|
if (req.TargetVersion == nil) == (normalizeOptionalString(req.TargetSnapshotID) == nil) {
|
|
writeError(w, http.StatusBadRequest, "exactly one of target_version or target_snapshot_id is required")
|
|
return
|
|
}
|
|
job, err := h.deps.Service.QueueRollback(r.Context(), entityType, entityID, req.TargetVersion, normalizeOptionalString(req.TargetSnapshotID), mode, normalizeOptionalString(req.Reason), normalizeOptionalString(req.RequestedBy))
|
|
if err != nil {
|
|
switch err {
|
|
case historysvc.ErrNotFound:
|
|
writeError(w, http.StatusNotFound, "entity not found")
|
|
case historysvc.ErrConflict:
|
|
writeError(w, http.StatusConflict, "history conflict")
|
|
default:
|
|
if strings.Contains(err.Error(), "invalid patch") {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "queue rollback failed")
|
|
}
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusAccepted, map[string]any{"job_id": job.ID, "status": job.Status, "mode": mode})
|
|
}
|