Files
core/internal/api/history.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})
}