package api import ( "net/http" "net/url" "strings" "reanimator/internal/domain" "reanimator/internal/repository/registry" ) type RegistryDependencies struct { Customers *registry.CustomerRepository Projects *registry.ProjectRepository Assets *registry.AssetRepository Components *registry.ComponentRepository Lots *registry.LotRepository LotMappings *registry.LotModelMappingRepository } type registryHandlers struct { deps RegistryDependencies } func RegisterRegistryRoutes(mux *http.ServeMux, deps RegistryDependencies) { h := registryHandlers{deps: deps} mux.HandleFunc("/customers", h.handleCustomers) mux.HandleFunc("/customers/", h.handleCustomerByID) mux.HandleFunc("/projects", h.handleProjects) mux.HandleFunc("/projects/", h.handleProjectByID) mux.HandleFunc("/assets", h.handleAssets) mux.HandleFunc("/components", h.handleComponents) mux.HandleFunc("/lots", h.handleLots) mux.HandleFunc("/lot-mappings", h.handleLotMappings) mux.HandleFunc("/lot-mappings/", h.handleLotMappingByModel) } func (h registryHandlers) handleCustomers(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: items, err := h.deps.Customers.List(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, "list customers failed") return } writeJSON(w, http.StatusOK, items) case http.MethodPost: var req struct { Name string `json:"name"` } if err := decodeJSON(r, &req); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } if strings.TrimSpace(req.Name) == "" { writeError(w, http.StatusBadRequest, "name is required") return } item, err := h.deps.Customers.Create(r.Context(), strings.TrimSpace(req.Name)) if err != nil { switch err { case registry.ErrConflict: writeError(w, http.StatusConflict, "customer conflict") default: writeError(w, http.StatusInternalServerError, "create customer failed") } return } writeJSON(w, http.StatusCreated, item) default: w.WriteHeader(http.StatusMethodNotAllowed) } } func (h registryHandlers) handleCustomerByID(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { w.WriteHeader(http.StatusMethodNotAllowed) return } id, ok := parseID(r.URL.Path, "/customers/") if !ok { writeError(w, http.StatusNotFound, "customer not found") return } item, err := h.deps.Customers.Get(r.Context(), id) if err != nil { switch err { case registry.ErrNotFound: writeError(w, http.StatusNotFound, "customer not found") default: writeError(w, http.StatusInternalServerError, "get customer failed") } return } writeJSON(w, http.StatusOK, item) } func (h registryHandlers) handleProjects(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: items, err := h.deps.Projects.List(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, "list projects failed") return } writeJSON(w, http.StatusOK, items) case http.MethodPost: var req struct { CustomerID string `json:"customer_id"` Name string `json:"name"` } if err := decodeJSON(r, &req); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } if req.CustomerID == "" { writeError(w, http.StatusBadRequest, "customer_id is required") return } if strings.TrimSpace(req.Name) == "" { writeError(w, http.StatusBadRequest, "name is required") return } item, err := h.deps.Projects.Create(r.Context(), req.CustomerID, strings.TrimSpace(req.Name)) if err != nil { switch err { case registry.ErrConflict: writeError(w, http.StatusConflict, "project conflict") default: writeError(w, http.StatusInternalServerError, "create project failed") } return } writeJSON(w, http.StatusCreated, item) default: w.WriteHeader(http.StatusMethodNotAllowed) } } func (h registryHandlers) handleProjectByID(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { w.WriteHeader(http.StatusMethodNotAllowed) return } id, ok := parseID(r.URL.Path, "/projects/") if !ok { writeError(w, http.StatusNotFound, "project not found") return } item, err := h.deps.Projects.Get(r.Context(), id) if err != nil { switch err { case registry.ErrNotFound: writeError(w, http.StatusNotFound, "project not found") default: writeError(w, http.StatusInternalServerError, "get project failed") } return } writeJSON(w, http.StatusOK, item) } func (h registryHandlers) handleAssets(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: items, err := h.deps.Assets.List(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, "list assets failed") return } writeJSON(w, http.StatusOK, items) case http.MethodPost: var req struct { ProjectID string `json:"project_id"` LocationID *string `json:"location_id"` Name string `json:"name"` Vendor *string `json:"vendor"` Model *string `json:"model"` VendorSerial string `json:"vendor_serial"` AssetTag *string `json:"asset_tag"` } if err := decodeJSON(r, &req); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } if req.ProjectID == "" { writeError(w, http.StatusBadRequest, "project_id is required") return } if strings.TrimSpace(req.Name) == "" { writeError(w, http.StatusBadRequest, "name is required") return } if strings.TrimSpace(req.VendorSerial) == "" { writeError(w, http.StatusBadRequest, "vendor_serial is required") return } asset := domain.Asset{ ProjectID: req.ProjectID, LocationID: req.LocationID, Name: strings.TrimSpace(req.Name), Vendor: req.Vendor, Model: req.Model, VendorSerial: strings.TrimSpace(req.VendorSerial), MachineTag: req.AssetTag, } item, err := h.deps.Assets.Create(r.Context(), asset) if err != nil { switch err { case registry.ErrConflict: writeError(w, http.StatusConflict, "asset conflict") default: writeError(w, http.StatusInternalServerError, "create asset failed") } return } writeJSON(w, http.StatusCreated, item) default: w.WriteHeader(http.StatusMethodNotAllowed) } } func (h registryHandlers) handleComponents(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: items, err := h.deps.Components.List(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, "list components failed") return } writeJSON(w, http.StatusOK, items) case http.MethodPost: var req struct { LotID *string `json:"lot_id"` Vendor *string `json:"vendor"` Model *string `json:"model"` VendorSerial string `json:"vendor_serial"` } if err := decodeJSON(r, &req); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } if strings.TrimSpace(req.VendorSerial) == "" { writeError(w, http.StatusBadRequest, "vendor_serial is required") return } component := domain.Component{ LotID: req.LotID, Vendor: req.Vendor, Model: req.Model, VendorSerial: strings.TrimSpace(req.VendorSerial), } item, err := h.deps.Components.Create(r.Context(), component) if err != nil { switch err { case registry.ErrConflict: writeError(w, http.StatusConflict, "component conflict") default: writeError(w, http.StatusInternalServerError, "create component failed") } return } writeJSON(w, http.StatusCreated, item) default: w.WriteHeader(http.StatusMethodNotAllowed) } } func (h registryHandlers) handleLots(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { w.WriteHeader(http.StatusMethodNotAllowed) return } if h.deps.Lots == nil { writeError(w, http.StatusInternalServerError, "lots unavailable") return } items, err := h.deps.Lots.List(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, "list lots failed") return } writeJSON(w, http.StatusOK, items) } func (h registryHandlers) handleLotMappings(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { w.WriteHeader(http.StatusMethodNotAllowed) return } if h.deps.LotMappings == nil { writeError(w, http.StatusInternalServerError, "lot mappings unavailable") return } items, err := h.deps.LotMappings.List(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, "list lot mappings failed") return } writeJSON(w, http.StatusOK, items) } func (h registryHandlers) handleLotMappingByModel(w http.ResponseWriter, r *http.Request) { if h.deps.Lots == nil || h.deps.LotMappings == nil { writeError(w, http.StatusInternalServerError, "lot mapping dependencies unavailable") return } model, ok := parseModelPathParam(r.URL.Path, "/lot-mappings/") if !ok { writeError(w, http.StatusBadRequest, "model is required") return } switch r.Method { case http.MethodPut: var req struct { LotCode string `json:"lot_code"` } if err := decodeJSON(r, &req); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } lotCode := normalizeMappingValue(req.LotCode) if lotCode == "" { writeError(w, http.StatusBadRequest, "lot_code is required") return } lot, err := h.deps.Lots.EnsureByCode(r.Context(), lotCode) if err != nil { if err == registry.ErrConflict { writeError(w, http.StatusConflict, "lot conflict") return } writeError(w, http.StatusInternalServerError, "ensure lot failed") return } item, created, affectedParts, err := h.deps.LotMappings.UpsertByModelWithBackfill(r.Context(), model, lot.ID) if err != nil { if err == registry.ErrConflict { writeError(w, http.StatusConflict, "lot mapping conflict") return } writeError(w, http.StatusInternalServerError, "upsert lot mapping failed") return } status := http.StatusOK if created { status = http.StatusCreated } writeJSON(w, status, map[string]any{ "mapping": item, "affected_parts_count": affectedParts, }) case http.MethodDelete: affectedParts, err := h.deps.LotMappings.DeleteByModelWithReset(r.Context(), model) if err != nil { switch err { case registry.ErrNotFound: writeError(w, http.StatusNotFound, "lot mapping not found") default: writeError(w, http.StatusInternalServerError, "delete lot mapping failed") } return } writeJSON(w, http.StatusOK, map[string]any{ "deleted": true, "affected_parts_count": affectedParts, }) default: w.WriteHeader(http.StatusMethodNotAllowed) } } func parseModelPathParam(path, prefix string) (string, bool) { if !strings.HasPrefix(path, prefix) { return "", false } raw := strings.TrimPrefix(path, prefix) if raw == "" || strings.Contains(raw, "/") { return "", false } decoded, err := url.PathUnescape(raw) if err != nil { return "", false } model := normalizeMappingValue(decoded) if model == "" { return "", false } return model, true } func normalizeMappingValue(value string) string { trimmed := strings.TrimSpace(value) if len(trimmed) >= 2 { if (strings.HasPrefix(trimmed, "\"") && strings.HasSuffix(trimmed, "\"")) || (strings.HasPrefix(trimmed, "'") && strings.HasSuffix(trimmed, "'")) { trimmed = strings.TrimSpace(trimmed[1 : len(trimmed)-1]) } } return trimmed }