package api import ( "crypto/sha1" "encoding/hex" "errors" "net/http" "strings" "time" "reanimator/internal/domain" "reanimator/internal/history" "reanimator/internal/repository/failures" "reanimator/internal/repository/registry" ) type FailureDependencies struct { Failures *failures.FailureRepository Assets *registry.AssetRepository Components *registry.ComponentRepository Installations *registry.InstallationRepository History *history.Service } type failureHandlers struct { deps FailureDependencies } func RegisterFailureRoutes(mux *http.ServeMux, deps FailureDependencies) { h := failureHandlers{deps: deps} mux.HandleFunc("/failures", h.handleFailures) } func (h failureHandlers) handleFailures(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: h.handleFailuresGet(w, r) case http.MethodPost: h.handleFailuresPost(w, r) default: w.WriteHeader(http.StatusMethodNotAllowed) } } func (h failureHandlers) handleFailuresGet(w http.ResponseWriter, r *http.Request) { if h.deps.Failures == nil { writeError(w, http.StatusInternalServerError, "failures unavailable") return } limit := 200 if raw := r.URL.Query().Get("limit"); raw != "" { parsed, err := parseIntParam(raw) if err != nil || parsed <= 0 { writeError(w, http.StatusBadRequest, "limit must be a positive integer") return } limit = parsed } items, err := h.deps.Failures.ListAll(r.Context(), limit) if err != nil { writeError(w, http.StatusInternalServerError, "list failures failed") return } writeJSON(w, http.StatusOK, map[string]any{"items": items}) } func (h failureHandlers) handleFailuresPost(w http.ResponseWriter, r *http.Request) { if h.deps.Failures == nil || h.deps.Components == nil || h.deps.History == nil { writeError(w, http.StatusInternalServerError, "failures unavailable") return } var req struct { ComponentSerial string `json:"component_serial"` ServerSerial *string `json:"server_serial"` FailureDate string `json:"failure_date"` Description *string `json:"description"` } if err := decodeJSON(r, &req); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } componentSerial := strings.TrimSpace(req.ComponentSerial) if componentSerial == "" { writeError(w, http.StatusBadRequest, "component_serial is required") return } failureDateRaw := strings.TrimSpace(req.FailureDate) failureDate, err := time.Parse("2006-01-02", failureDateRaw) if err != nil { writeError(w, http.StatusBadRequest, "failure_date must be YYYY-MM-DD") return } failureAt := time.Date(failureDate.Year(), failureDate.Month(), failureDate.Day(), 0, 0, 0, 0, time.UTC) components, err := h.deps.Components.List(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, "list components failed") return } component, ok := findComponentByVendorSerial(components, componentSerial) if !ok { writeError(w, http.StatusNotFound, "component serial not found") return } var machineByID = map[string]registryMachine{} var allAssets []domain.Asset if h.deps.Assets != nil { list, listErr := h.deps.Assets.List(r.Context()) if listErr != nil { writeError(w, http.StatusInternalServerError, "list assets failed") return } allAssets = list for _, item := range list { machineByID[item.ID] = registryMachine{ID: item.ID, Name: item.Name, VendorSerial: item.VendorSerial} } } var currentAssetID *string var currentSlot *string if h.deps.Installations != nil { refs, refsErr := h.deps.Installations.ListCurrentInstallationsByComponentIDs(r.Context(), []string{component.ID}) if refsErr != nil { writeError(w, http.StatusInternalServerError, "lookup component installation failed") return } if ref, ok := refs[component.ID]; ok { currentAssetID = &ref.AssetID currentSlot = ref.SlotName } } var machineID *string if currentAssetID != nil { machineID = currentAssetID } if reqServer := normalizeOptionalString(req.ServerSerial); reqServer != nil { if h.deps.Assets == nil { writeError(w, http.StatusInternalServerError, "assets unavailable") return } if len(allAssets) == 0 { var err error allAssets, err = h.deps.Assets.List(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, "list assets failed") return } } asset, found := findAssetByVendorSerial(allAssets, *reqServer) if !found { writeError(w, http.StatusNotFound, "server serial not found") return } if currentAssetID != nil && *currentAssetID != asset.ID { writeJSON(w, http.StatusConflict, map[string]any{ "error": "server serial does not match current component installation", "code": "server_mismatch", }) return } machineID = &asset.ID machineByID[asset.ID] = registryMachine{ID: asset.ID, Name: asset.Name, VendorSerial: asset.VendorSerial} } desc := normalizeOptionalString(req.Description) externalID := buildManualFailureExternalIDForAPI(component.ID, failureAt, desc) sourceRef := "ui_failures_manual:" + component.ID + ":" + failureAt.Format("2006-01-02") var idemPtr *string if idem := strings.TrimSpace(r.Header.Get("Idempotency-Key")); idem != "" { idemPtr = &idem } res, err := h.deps.History.RegisterManualComponentFailure(r.Context(), history.RegisterManualComponentFailureCommand{ ComponentID: component.ID, MachineID: machineID, FailureTime: failureAt, Description: desc, FailureType: "component_failed_manual", FailureSource: "manual_ui", FailureExternal: externalID, SourceRef: &sourceRef, IdempotencyKey: idemPtr, }) if err != nil { switch { case errors.Is(err, history.ErrInvalidPatch): writeError(w, http.StatusBadRequest, err.Error()) case errors.Is(err, history.ErrNotFound): writeError(w, http.StatusNotFound, "component not found") case errors.Is(err, history.ErrConflict): writeError(w, http.StatusConflict, "history conflict") default: writeError(w, http.StatusInternalServerError, "register failure failed") } return } var resolvedServer any if machineID != nil { if info, ok := machineByID[*machineID]; ok { resolvedServer = map[string]any{ "id": info.ID, "vendor_serial": info.VendorSerial, "label": strings.TrimSpace(info.Name), } } else { resolvedServer = map[string]any{"id": *machineID} } } componentLabel := component.VendorSerial if component.Model != nil && strings.TrimSpace(*component.Model) != "" { componentLabel += " ยท " + strings.TrimSpace(*component.Model) } writeJSON(w, http.StatusOK, map[string]any{ "status": "registered", "failure_event_id": res.FailureEventID, "history_event_id": res.HistoryApply.EventID, "resolved": map[string]any{ "component": map[string]any{ "id": component.ID, "vendor_serial": component.VendorSerial, "label": componentLabel, }, "server": resolvedServer, "slot": currentSlot, }, }) } type registryMachine struct { ID string Name string VendorSerial string } func findComponentByVendorSerial(items []domain.Component, serial string) (domain.Component, bool) { needle := strings.ToLower(strings.TrimSpace(serial)) for _, item := range items { if strings.ToLower(strings.TrimSpace(item.VendorSerial)) == needle { return item, true } } return domain.Component{}, false } func findAssetByVendorSerial(items []domain.Asset, serial string) (domain.Asset, bool) { needle := strings.ToLower(strings.TrimSpace(serial)) for _, item := range items { if strings.ToLower(strings.TrimSpace(item.VendorSerial)) == needle { return item, true } } return domain.Asset{}, false } func buildManualFailureExternalIDForAPI(componentID string, failureAt time.Time, description *string) string { normDesc := "" if description != nil { normDesc = strings.TrimSpace(*description) } sum := sha1.Sum([]byte(strings.ToLower(strings.TrimSpace(componentID)) + "|" + failureAt.UTC().Format(time.RFC3339Nano) + "|" + strings.ToLower(normDesc))) return "manual_ui:" + strings.TrimSpace(componentID) + ":" + failureAt.UTC().Format(time.RFC3339Nano) + ":" + hex.EncodeToString(sum[:4]) }