package api import ( "net/http" "net/url" "strings" "time" "reanimator/internal/domain" "reanimator/internal/repository/registry" ) type RegistryDependencies struct { Customers *registry.CustomerRepository Projects *registry.ProjectRepository Locations *registry.LocationRepository 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("/locations", h.handleLocations) mux.HandleFunc("/locations/", h.handleLocationByID) mux.HandleFunc("/assets", h.handleAssets) mux.HandleFunc("/registry/assets/", h.handleAssetByID) mux.HandleFunc("/machines/dispatch", h.handleMachineDispatch) mux.HandleFunc("/machines/return-to-stock", h.handleMachineReturnToStock) 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) handleLocations(w http.ResponseWriter, r *http.Request) { if h.deps.Locations == nil { writeError(w, http.StatusInternalServerError, "locations unavailable") return } switch r.Method { case http.MethodGet: items, err := h.deps.Locations.List(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, "list locations failed") return } writeJSON(w, http.StatusOK, items) case http.MethodPost: var req struct { Name string `json:"name"` Kind *string `json:"kind"` } if err := decodeJSON(r, &req); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } name := strings.TrimSpace(req.Name) if name == "" { writeError(w, http.StatusBadRequest, "name is required") return } item, err := h.deps.Locations.Create(r.Context(), name, normalizeOptionalString(req.Kind)) if err != nil { switch err { case registry.ErrConflict: writeError(w, http.StatusConflict, "location conflict") default: writeError(w, http.StatusInternalServerError, "create location failed") } return } writeJSON(w, http.StatusCreated, item) default: w.WriteHeader(http.StatusMethodNotAllowed) } } func (h registryHandlers) handleLocationByID(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { w.WriteHeader(http.StatusMethodNotAllowed) return } if h.deps.Locations == nil { writeError(w, http.StatusInternalServerError, "locations unavailable") return } id, ok := parseID(r.URL.Path, "/locations/") if !ok { writeError(w, http.StatusNotFound, "location not found") return } item, err := h.deps.Locations.Get(r.Context(), id) if err != nil { switch err { case registry.ErrNotFound: writeError(w, http.StatusNotFound, "location not found") default: writeError(w, http.StatusInternalServerError, "get location 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"` CustomerID *string `json:"customer_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 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 } projectID := "" if req.ProjectID != nil { projectID = strings.TrimSpace(*req.ProjectID) } asset := domain.Asset{ ProjectID: projectID, CustomerID: normalizeOptionalString(req.CustomerID), LocationID: normalizeOptionalString(req.LocationID), Name: strings.TrimSpace(req.Name), Vendor: normalizeOptionalString(req.Vendor), Model: normalizeOptionalString(req.Model), VendorSerial: strings.TrimSpace(req.VendorSerial), MachineTag: normalizeOptionalString(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) handleAssetByID(w http.ResponseWriter, r *http.Request) { id, ok := parseID(r.URL.Path, "/registry/assets/") if !ok { writeError(w, http.StatusNotFound, "asset not found") return } switch r.Method { case http.MethodGet: item, err := h.deps.Assets.Get(r.Context(), id) if err != nil { switch err { case registry.ErrNotFound: writeError(w, http.StatusNotFound, "asset not found") default: writeError(w, http.StatusInternalServerError, "get asset failed") } return } writeJSON(w, http.StatusOK, item) case http.MethodDelete: result, err := h.deps.Assets.DeleteWithDetails(r.Context(), id) if err != nil { switch err { case registry.ErrNotFound: writeError(w, http.StatusNotFound, "asset not found") default: writeError(w, http.StatusInternalServerError, "delete asset failed") } return } writeJSON(w, http.StatusOK, map[string]any{ "deleted": true, "deleted_parts": result.DeletedParts, }) default: w.WriteHeader(http.StatusMethodNotAllowed) } } func (h registryHandlers) handleMachineDispatch(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed) return } if h.deps.Assets == nil { writeError(w, http.StatusInternalServerError, "machines unavailable") return } var req struct { SerialNumbers []string `json:"serial_numbers"` CustomerID string `json:"customer_id"` LocationID *string `json:"location_id"` } if err := decodeJSON(r, &req); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } serials := normalizeSerialInput(req.SerialNumbers) if len(serials) == 0 { writeError(w, http.StatusBadRequest, "serial_numbers is required") return } customerID := strings.TrimSpace(req.CustomerID) if customerID == "" { writeError(w, http.StatusBadRequest, "customer_id is required") return } locationID := normalizeOptionalString(req.LocationID) result, err := h.deps.Assets.DispatchBySerials(r.Context(), serials, customerID, locationID, time.Now().UTC()) if err != nil { if err == registry.ErrConflict { writeError(w, http.StatusConflict, "invalid customer_id or location_id") return } writeError(w, http.StatusInternalServerError, "dispatch failed") return } if len(result.MissingSerials) > 0 { writeJSON(w, http.StatusNotFound, map[string]any{ "error": "machines not found", "missing_serials": result.MissingSerials, }) return } if len(result.AlreadyAssignedSerials) > 0 { writeJSON(w, http.StatusConflict, map[string]any{ "error": "machines already assigned", "already_assigned_serials": result.AlreadyAssignedSerials, }) return } writeJSON(w, http.StatusOK, map[string]any{ "updated": result.Updated, }) } func (h registryHandlers) handleMachineReturnToStock(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed) return } if h.deps.Assets == nil { writeError(w, http.StatusInternalServerError, "machines unavailable") return } var req struct { SerialNumbers []string `json:"serial_numbers"` } if err := decodeJSON(r, &req); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } serials := normalizeSerialInput(req.SerialNumbers) if len(serials) == 0 { writeError(w, http.StatusBadRequest, "serial_numbers is required") return } result, err := h.deps.Assets.ReturnToStockBySerials(r.Context(), serials, time.Now().UTC()) if err != nil { writeError(w, http.StatusInternalServerError, "return to stock failed") return } if len(result.MissingSerials) > 0 { writeJSON(w, http.StatusNotFound, map[string]any{ "error": "machines not found", "missing_serials": result.MissingSerials, }) return } writeJSON(w, http.StatusOK, map[string]any{ "updated": result.Updated, }) } 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 normalizeOptionalString(value *string) *string { if value == nil { return nil } trimmed := strings.TrimSpace(*value) if trimmed == "" { return nil } return &trimmed } func normalizeSerialInput(values []string) []string { seen := make(map[string]struct{}, len(values)) serials := make([]string, 0, len(values)) for _, value := range values { trimmed := strings.TrimSpace(value) if trimmed == "" { continue } if _, exists := seen[trimmed]; exists { continue } seen[trimmed] = struct{}{} serials = append(serials, trimmed) } return serials } 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 }