feat: location hierarchy CRUD API
Add LocationRepository with CRUD operations, hierarchy traversal
(children, subtree by path prefix), and inventory-safe deletion.
Endpoints:
GET /api/locations — list all or ?tree={path} for subtree
POST /api/locations — create (auto-resolves parent_id, depth)
GET /api/locations/{path..} — get by hierarchical path
PUT /api/locations/{path..} — update name, type, metadata
DELETE /api/locations/{path..} — delete (rejects if inventory exists)
Uses chi wildcard routes to support multi-segment paths like
/api/locations/lab/shelf-a/bin-3.
Includes 10 handler integration tests covering CRUD, nesting,
validation, duplicates, tree queries, and delete-not-found.
Closes #81
This commit is contained in:
@@ -47,6 +47,7 @@ type Server struct {
|
||||
serverState *ServerState
|
||||
dag *db.DAGRepository
|
||||
jobs *db.JobRepository
|
||||
locations *db.LocationRepository
|
||||
jobDefs map[string]*jobdef.Definition
|
||||
jobDefsDir string
|
||||
modules *modules.Registry
|
||||
@@ -79,6 +80,7 @@ func NewServer(
|
||||
dag := db.NewDAGRepository(database)
|
||||
jobs := db.NewJobRepository(database)
|
||||
settings := db.NewSettingsRepository(database)
|
||||
locations := db.NewLocationRepository(database)
|
||||
seqStore := &dbSequenceStore{db: database, schemas: schemas}
|
||||
partgen := partnum.NewGenerator(schemas, seqStore)
|
||||
|
||||
@@ -101,6 +103,7 @@ func NewServer(
|
||||
serverState: state,
|
||||
dag: dag,
|
||||
jobs: jobs,
|
||||
locations: locations,
|
||||
jobDefs: jobDefs,
|
||||
jobDefsDir: jobDefsDir,
|
||||
modules: registry,
|
||||
|
||||
234
internal/api/location_handlers.go
Normal file
234
internal/api/location_handlers.go
Normal file
@@ -0,0 +1,234 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/kindredsystems/silo/internal/db"
|
||||
)
|
||||
|
||||
// LocationResponse is the API representation of a location.
|
||||
type LocationResponse struct {
|
||||
ID string `json:"id"`
|
||||
Path string `json:"path"`
|
||||
Name string `json:"name"`
|
||||
ParentID *string `json:"parent_id,omitempty"`
|
||||
LocationType string `json:"location_type"`
|
||||
Depth int `json:"depth"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// CreateLocationRequest represents a request to create a location.
|
||||
type CreateLocationRequest struct {
|
||||
Path string `json:"path"`
|
||||
Name string `json:"name"`
|
||||
LocationType string `json:"location_type"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateLocationRequest represents a request to update a location.
|
||||
type UpdateLocationRequest struct {
|
||||
Name string `json:"name"`
|
||||
LocationType string `json:"location_type"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
func locationToResponse(loc *db.Location) LocationResponse {
|
||||
return LocationResponse{
|
||||
ID: loc.ID,
|
||||
Path: loc.Path,
|
||||
Name: loc.Name,
|
||||
ParentID: loc.ParentID,
|
||||
LocationType: loc.LocationType,
|
||||
Depth: loc.Depth,
|
||||
Metadata: loc.Metadata,
|
||||
CreatedAt: loc.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
}
|
||||
}
|
||||
|
||||
// HandleListLocations lists all locations. If ?tree={path} is set, returns that
|
||||
// subtree. If ?root=true, returns only root-level locations (depth 0).
|
||||
func (s *Server) HandleListLocations(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
treePath := r.URL.Query().Get("tree")
|
||||
if treePath != "" {
|
||||
locs, err := s.locations.GetTree(ctx, treePath)
|
||||
if err != nil {
|
||||
s.logger.Error().Err(err).Str("tree", treePath).Msg("failed to get location tree")
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get location tree")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, locationsToResponse(locs))
|
||||
return
|
||||
}
|
||||
|
||||
locs, err := s.locations.List(ctx)
|
||||
if err != nil {
|
||||
s.logger.Error().Err(err).Msg("failed to list locations")
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list locations")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, locationsToResponse(locs))
|
||||
}
|
||||
|
||||
// HandleCreateLocation creates a new location.
|
||||
func (s *Server) HandleCreateLocation(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
var req CreateLocationRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_json", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if req.Path == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "Path is required")
|
||||
return
|
||||
}
|
||||
if req.Name == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "Name is required")
|
||||
return
|
||||
}
|
||||
if req.LocationType == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "Location type is required")
|
||||
return
|
||||
}
|
||||
|
||||
// Normalize: trim slashes
|
||||
req.Path = strings.Trim(req.Path, "/")
|
||||
|
||||
loc := &db.Location{
|
||||
Path: req.Path,
|
||||
Name: req.Name,
|
||||
LocationType: req.LocationType,
|
||||
Metadata: req.Metadata,
|
||||
}
|
||||
if loc.Metadata == nil {
|
||||
loc.Metadata = map[string]any{}
|
||||
}
|
||||
|
||||
if err := s.locations.Create(ctx, loc); err != nil {
|
||||
if strings.Contains(err.Error(), "parent location") || strings.Contains(err.Error(), "does not exist") {
|
||||
writeError(w, http.StatusBadRequest, "invalid_parent", err.Error())
|
||||
return
|
||||
}
|
||||
if strings.Contains(err.Error(), "duplicate key") || strings.Contains(err.Error(), "unique") {
|
||||
writeError(w, http.StatusConflict, "already_exists", "Location path already exists")
|
||||
return
|
||||
}
|
||||
s.logger.Error().Err(err).Str("path", req.Path).Msg("failed to create location")
|
||||
writeError(w, http.StatusInternalServerError, "create_failed", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, locationToResponse(loc))
|
||||
}
|
||||
|
||||
// HandleGetLocation retrieves a location by path. The path is the rest of the
|
||||
// URL after /api/locations/, which chi captures as a wildcard.
|
||||
func (s *Server) HandleGetLocation(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
path := strings.Trim(chi.URLParam(r, "*"), "/")
|
||||
|
||||
if path == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "Location path is required")
|
||||
return
|
||||
}
|
||||
|
||||
loc, err := s.locations.GetByPath(ctx, path)
|
||||
if err != nil {
|
||||
s.logger.Error().Err(err).Str("path", path).Msg("failed to get location")
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get location")
|
||||
return
|
||||
}
|
||||
if loc == nil {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Location not found")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, locationToResponse(loc))
|
||||
}
|
||||
|
||||
// HandleUpdateLocation updates a location by path.
|
||||
func (s *Server) HandleUpdateLocation(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
path := strings.Trim(chi.URLParam(r, "*"), "/")
|
||||
|
||||
if path == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "Location path is required")
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdateLocationRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_json", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if req.Name == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "Name is required")
|
||||
return
|
||||
}
|
||||
if req.LocationType == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "Location type is required")
|
||||
return
|
||||
}
|
||||
|
||||
meta := req.Metadata
|
||||
if meta == nil {
|
||||
meta = map[string]any{}
|
||||
}
|
||||
|
||||
if err := s.locations.Update(ctx, path, req.Name, req.LocationType, meta); err != nil {
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Location not found")
|
||||
return
|
||||
}
|
||||
s.logger.Error().Err(err).Str("path", path).Msg("failed to update location")
|
||||
writeError(w, http.StatusInternalServerError, "update_failed", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
loc, _ := s.locations.GetByPath(ctx, path)
|
||||
writeJSON(w, http.StatusOK, locationToResponse(loc))
|
||||
}
|
||||
|
||||
// HandleDeleteLocation deletes a location by path. Rejects if inventory exists.
|
||||
func (s *Server) HandleDeleteLocation(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
path := strings.Trim(chi.URLParam(r, "*"), "/")
|
||||
|
||||
if path == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "Location path is required")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.locations.Delete(ctx, path); err != nil {
|
||||
if strings.Contains(err.Error(), "inventory record") {
|
||||
writeError(w, http.StatusConflict, "has_inventory", err.Error())
|
||||
return
|
||||
}
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Location not found")
|
||||
return
|
||||
}
|
||||
s.logger.Error().Err(err).Str("path", path).Msg("failed to delete location")
|
||||
writeError(w, http.StatusInternalServerError, "delete_failed", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func locationsToResponse(locs []*db.Location) []LocationResponse {
|
||||
result := make([]LocationResponse, len(locs))
|
||||
for i, l := range locs {
|
||||
result[i] = locationToResponse(l)
|
||||
}
|
||||
return result
|
||||
}
|
||||
323
internal/api/location_handlers_test.go
Normal file
323
internal/api/location_handlers_test.go
Normal file
@@ -0,0 +1,323 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func newLocationRouter(s *Server) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/api/locations", s.HandleListLocations)
|
||||
r.Post("/api/locations", s.HandleCreateLocation)
|
||||
r.Get("/api/locations/*", s.HandleGetLocation)
|
||||
r.Put("/api/locations/*", s.HandleUpdateLocation)
|
||||
r.Delete("/api/locations/*", s.HandleDeleteLocation)
|
||||
return r
|
||||
}
|
||||
|
||||
func TestHandleListLocationsEmpty(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
router := newLocationRouter(s)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/locations", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status: got %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
|
||||
}
|
||||
|
||||
var locs []LocationResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &locs); err != nil {
|
||||
t.Fatalf("decoding response: %v", err)
|
||||
}
|
||||
if len(locs) != 0 {
|
||||
t.Fatalf("expected 0 locations, got %d", len(locs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCreateAndGetLocation(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
router := newLocationRouter(s)
|
||||
|
||||
// Create root location
|
||||
body := `{"path": "lab", "name": "Lab", "location_type": "building"}`
|
||||
req := httptest.NewRequest("POST", "/api/locations", strings.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create status: got %d, want %d; body: %s", w.Code, http.StatusCreated, w.Body.String())
|
||||
}
|
||||
|
||||
var created LocationResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &created); err != nil {
|
||||
t.Fatalf("decoding create response: %v", err)
|
||||
}
|
||||
if created.Path != "lab" {
|
||||
t.Errorf("path: got %q, want %q", created.Path, "lab")
|
||||
}
|
||||
if created.Name != "Lab" {
|
||||
t.Errorf("name: got %q, want %q", created.Name, "Lab")
|
||||
}
|
||||
if created.Depth != 0 {
|
||||
t.Errorf("depth: got %d, want 0", created.Depth)
|
||||
}
|
||||
if created.ID == "" {
|
||||
t.Error("expected ID to be set")
|
||||
}
|
||||
|
||||
// Get by path
|
||||
req = httptest.NewRequest("GET", "/api/locations/lab", nil)
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("get status: got %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
|
||||
}
|
||||
|
||||
var got LocationResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decoding get response: %v", err)
|
||||
}
|
||||
if got.ID != created.ID {
|
||||
t.Errorf("ID mismatch: got %q, want %q", got.ID, created.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCreateNestedLocation(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
router := newLocationRouter(s)
|
||||
|
||||
// Create root
|
||||
body := `{"path": "warehouse", "name": "Warehouse", "location_type": "building"}`
|
||||
req := httptest.NewRequest("POST", "/api/locations", strings.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create root: got %d; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Create child
|
||||
body = `{"path": "warehouse/shelf-a", "name": "Shelf A", "location_type": "shelf"}`
|
||||
req = httptest.NewRequest("POST", "/api/locations", strings.NewReader(body))
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create child: got %d; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var child LocationResponse
|
||||
json.Unmarshal(w.Body.Bytes(), &child)
|
||||
if child.Depth != 1 {
|
||||
t.Errorf("child depth: got %d, want 1", child.Depth)
|
||||
}
|
||||
if child.ParentID == nil {
|
||||
t.Error("expected parent_id to be set")
|
||||
}
|
||||
|
||||
// Create grandchild
|
||||
body = `{"path": "warehouse/shelf-a/bin-3", "name": "Bin 3", "location_type": "bin"}`
|
||||
req = httptest.NewRequest("POST", "/api/locations", strings.NewReader(body))
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create grandchild: got %d; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var gc LocationResponse
|
||||
json.Unmarshal(w.Body.Bytes(), &gc)
|
||||
if gc.Depth != 2 {
|
||||
t.Errorf("grandchild depth: got %d, want 2", gc.Depth)
|
||||
}
|
||||
|
||||
// Get nested path
|
||||
req = httptest.NewRequest("GET", "/api/locations/warehouse/shelf-a/bin-3", nil)
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("get nested: got %d; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCreateLocationMissingParent(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
router := newLocationRouter(s)
|
||||
|
||||
body := `{"path": "nonexistent/child", "name": "Child", "location_type": "shelf"}`
|
||||
req := httptest.NewRequest("POST", "/api/locations", strings.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUpdateLocation(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
router := newLocationRouter(s)
|
||||
|
||||
// Create
|
||||
body := `{"path": "office", "name": "Office", "location_type": "room"}`
|
||||
req := httptest.NewRequest("POST", "/api/locations", strings.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create: got %d; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Update
|
||||
body = `{"name": "Main Office", "location_type": "building", "metadata": {"floor": 2}}`
|
||||
req = httptest.NewRequest("PUT", "/api/locations/office", strings.NewReader(body))
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("update: got %d; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var updated LocationResponse
|
||||
json.Unmarshal(w.Body.Bytes(), &updated)
|
||||
if updated.Name != "Main Office" {
|
||||
t.Errorf("name: got %q, want %q", updated.Name, "Main Office")
|
||||
}
|
||||
if updated.LocationType != "building" {
|
||||
t.Errorf("type: got %q, want %q", updated.LocationType, "building")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDeleteLocation(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
router := newLocationRouter(s)
|
||||
|
||||
// Create
|
||||
body := `{"path": "temp", "name": "Temp", "location_type": "area"}`
|
||||
req := httptest.NewRequest("POST", "/api/locations", strings.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create: got %d; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Delete
|
||||
req = httptest.NewRequest("DELETE", "/api/locations/temp", nil)
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Fatalf("delete: got %d, want %d; body: %s", w.Code, http.StatusNoContent, w.Body.String())
|
||||
}
|
||||
|
||||
// Verify gone
|
||||
req = httptest.NewRequest("GET", "/api/locations/temp", nil)
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("get after delete: got %d, want %d", w.Code, http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDeleteLocationNotFound(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
router := newLocationRouter(s)
|
||||
|
||||
req := httptest.NewRequest("DELETE", "/api/locations/doesnotexist", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("delete missing: got %d, want %d; body: %s", w.Code, http.StatusNotFound, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleListLocationsTree(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
router := newLocationRouter(s)
|
||||
|
||||
// Create hierarchy
|
||||
for _, loc := range []string{
|
||||
`{"path": "site", "name": "Site", "location_type": "site"}`,
|
||||
`{"path": "site/bldg", "name": "Building", "location_type": "building"}`,
|
||||
`{"path": "site/bldg/room1", "name": "Room 1", "location_type": "room"}`,
|
||||
`{"path": "other", "name": "Other", "location_type": "site"}`,
|
||||
} {
|
||||
req := httptest.NewRequest("POST", "/api/locations", strings.NewReader(loc))
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create: got %d; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// List tree under "site"
|
||||
req := httptest.NewRequest("GET", "/api/locations?tree=site", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("tree: got %d; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var locs []LocationResponse
|
||||
json.Unmarshal(w.Body.Bytes(), &locs)
|
||||
if len(locs) != 3 {
|
||||
t.Fatalf("tree count: got %d, want 3 (site + bldg + room1)", len(locs))
|
||||
}
|
||||
|
||||
// Full list should have 4
|
||||
req = httptest.NewRequest("GET", "/api/locations", nil)
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
json.Unmarshal(w.Body.Bytes(), &locs)
|
||||
if len(locs) != 4 {
|
||||
t.Fatalf("full list: got %d, want 4", len(locs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCreateLocationDuplicate(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
router := newLocationRouter(s)
|
||||
|
||||
body := `{"path": "dup", "name": "Dup", "location_type": "area"}`
|
||||
req := httptest.NewRequest("POST", "/api/locations", strings.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("first create: got %d; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Duplicate
|
||||
req = httptest.NewRequest("POST", "/api/locations", strings.NewReader(body))
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("duplicate: got %d, want %d; body: %s", w.Code, http.StatusConflict, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCreateLocationValidation(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
router := newLocationRouter(s)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{"missing path", `{"name": "X", "location_type": "area"}`},
|
||||
{"missing name", `{"path": "x", "location_type": "area"}`},
|
||||
{"missing type", `{"path": "x", "name": "X"}`},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest("POST", "/api/locations", strings.NewReader(tc.body))
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("got %d, want 400; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -117,6 +117,26 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler {
|
||||
})
|
||||
})
|
||||
|
||||
// Locations (read: viewer, write: editor)
|
||||
r.Route("/locations", func(r chi.Router) {
|
||||
r.Get("/", server.HandleListLocations)
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(server.RequireWritable)
|
||||
r.Use(server.RequireRole(auth.RoleEditor))
|
||||
r.Post("/", server.HandleCreateLocation)
|
||||
})
|
||||
|
||||
// Wildcard routes for path-based lookup (e.g., /api/locations/lab/shelf-a/bin-3)
|
||||
r.Get("/*", server.HandleGetLocation)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(server.RequireWritable)
|
||||
r.Use(server.RequireRole(auth.RoleEditor))
|
||||
r.Put("/*", server.HandleUpdateLocation)
|
||||
r.Delete("/*", server.HandleDeleteLocation)
|
||||
})
|
||||
})
|
||||
|
||||
// Items (read: viewer, write: editor)
|
||||
r.Route("/items", func(r chi.Router) {
|
||||
r.Get("/", server.HandleListItems)
|
||||
|
||||
230
internal/db/locations.go
Normal file
230
internal/db/locations.go
Normal file
@@ -0,0 +1,230 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// Location represents a location in the hierarchy.
|
||||
type Location struct {
|
||||
ID string
|
||||
Path string
|
||||
Name string
|
||||
ParentID *string
|
||||
LocationType string
|
||||
Depth int
|
||||
Metadata map[string]any
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// LocationRepository provides location database operations.
|
||||
type LocationRepository struct {
|
||||
db *DB
|
||||
}
|
||||
|
||||
// NewLocationRepository creates a new location repository.
|
||||
func NewLocationRepository(db *DB) *LocationRepository {
|
||||
return &LocationRepository{db: db}
|
||||
}
|
||||
|
||||
// List returns all locations ordered by path.
|
||||
func (r *LocationRepository) List(ctx context.Context) ([]*Location, error) {
|
||||
rows, err := r.db.pool.Query(ctx, `
|
||||
SELECT id, path, name, parent_id, location_type, depth, metadata, created_at
|
||||
FROM locations
|
||||
ORDER BY path
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanLocations(rows)
|
||||
}
|
||||
|
||||
// GetByPath returns a location by its path.
|
||||
func (r *LocationRepository) GetByPath(ctx context.Context, path string) (*Location, error) {
|
||||
loc := &Location{}
|
||||
var meta []byte
|
||||
err := r.db.pool.QueryRow(ctx, `
|
||||
SELECT id, path, name, parent_id, location_type, depth, metadata, created_at
|
||||
FROM locations
|
||||
WHERE path = $1
|
||||
`, path).Scan(&loc.ID, &loc.Path, &loc.Name, &loc.ParentID, &loc.LocationType, &loc.Depth, &meta, &loc.CreatedAt)
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if meta != nil {
|
||||
json.Unmarshal(meta, &loc.Metadata)
|
||||
}
|
||||
return loc, nil
|
||||
}
|
||||
|
||||
// GetByID returns a location by its ID.
|
||||
func (r *LocationRepository) GetByID(ctx context.Context, id string) (*Location, error) {
|
||||
loc := &Location{}
|
||||
var meta []byte
|
||||
err := r.db.pool.QueryRow(ctx, `
|
||||
SELECT id, path, name, parent_id, location_type, depth, metadata, created_at
|
||||
FROM locations
|
||||
WHERE id = $1
|
||||
`, id).Scan(&loc.ID, &loc.Path, &loc.Name, &loc.ParentID, &loc.LocationType, &loc.Depth, &meta, &loc.CreatedAt)
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if meta != nil {
|
||||
json.Unmarshal(meta, &loc.Metadata)
|
||||
}
|
||||
return loc, nil
|
||||
}
|
||||
|
||||
// GetChildren returns direct children of a location.
|
||||
func (r *LocationRepository) GetChildren(ctx context.Context, parentID string) ([]*Location, error) {
|
||||
rows, err := r.db.pool.Query(ctx, `
|
||||
SELECT id, path, name, parent_id, location_type, depth, metadata, created_at
|
||||
FROM locations
|
||||
WHERE parent_id = $1
|
||||
ORDER BY path
|
||||
`, parentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanLocations(rows)
|
||||
}
|
||||
|
||||
// GetTree returns a location and all its descendants (by path prefix).
|
||||
func (r *LocationRepository) GetTree(ctx context.Context, rootPath string) ([]*Location, error) {
|
||||
rows, err := r.db.pool.Query(ctx, `
|
||||
SELECT id, path, name, parent_id, location_type, depth, metadata, created_at
|
||||
FROM locations
|
||||
WHERE path = $1 OR path LIKE $2
|
||||
ORDER BY path
|
||||
`, rootPath, rootPath+"/%")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanLocations(rows)
|
||||
}
|
||||
|
||||
// Create inserts a new location. ParentID and Depth are resolved from the path.
|
||||
func (r *LocationRepository) Create(ctx context.Context, loc *Location) error {
|
||||
// Auto-calculate depth from path segments
|
||||
loc.Depth = strings.Count(loc.Path, "/")
|
||||
|
||||
// Resolve parent_id from path if not explicitly set
|
||||
if loc.ParentID == nil && loc.Depth > 0 {
|
||||
parentPath := loc.Path[:strings.LastIndex(loc.Path, "/")]
|
||||
parent, err := r.GetByPath(ctx, parentPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("looking up parent %q: %w", parentPath, err)
|
||||
}
|
||||
if parent == nil {
|
||||
return fmt.Errorf("parent location %q does not exist", parentPath)
|
||||
}
|
||||
loc.ParentID = &parent.ID
|
||||
}
|
||||
|
||||
meta, err := json.Marshal(loc.Metadata)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshaling metadata: %w", err)
|
||||
}
|
||||
|
||||
return r.db.pool.QueryRow(ctx, `
|
||||
INSERT INTO locations (path, name, parent_id, location_type, depth, metadata)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, created_at
|
||||
`, loc.Path, loc.Name, loc.ParentID, loc.LocationType, loc.Depth, meta).Scan(&loc.ID, &loc.CreatedAt)
|
||||
}
|
||||
|
||||
// Update updates a location's name, type, and metadata.
|
||||
func (r *LocationRepository) Update(ctx context.Context, path string, name, locationType string, metadata map[string]any) error {
|
||||
meta, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshaling metadata: %w", err)
|
||||
}
|
||||
tag, err := r.db.pool.Exec(ctx, `
|
||||
UPDATE locations
|
||||
SET name = $2, location_type = $3, metadata = $4
|
||||
WHERE path = $1
|
||||
`, path, name, locationType, meta)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return fmt.Errorf("location %q not found", path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes a location. Returns an error if inventory rows reference it.
|
||||
func (r *LocationRepository) Delete(ctx context.Context, path string) error {
|
||||
// Check for inventory references
|
||||
var count int
|
||||
err := r.db.pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM inventory
|
||||
WHERE location_id = (SELECT id FROM locations WHERE path = $1)
|
||||
`, path).Scan(&count)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return fmt.Errorf("cannot delete location %q: %d inventory record(s) exist", path, count)
|
||||
}
|
||||
|
||||
// Delete children first (cascade by path prefix), deepest first
|
||||
_, err = r.db.pool.Exec(ctx, `
|
||||
DELETE FROM locations
|
||||
WHERE path LIKE $1
|
||||
`, path+"/%")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tag, err := r.db.pool.Exec(ctx, `DELETE FROM locations WHERE path = $1`, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return fmt.Errorf("location %q not found", path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HasInventory checks if a location (or descendants) have inventory records.
|
||||
func (r *LocationRepository) HasInventory(ctx context.Context, path string) (bool, error) {
|
||||
var count int
|
||||
err := r.db.pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM inventory i
|
||||
JOIN locations l ON l.id = i.location_id
|
||||
WHERE l.path = $1 OR l.path LIKE $2
|
||||
`, path, path+"/%").Scan(&count)
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
func scanLocations(rows pgx.Rows) ([]*Location, error) {
|
||||
var locs []*Location
|
||||
for rows.Next() {
|
||||
loc := &Location{}
|
||||
var meta []byte
|
||||
if err := rows.Scan(&loc.ID, &loc.Path, &loc.Name, &loc.ParentID, &loc.LocationType, &loc.Depth, &meta, &loc.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if meta != nil {
|
||||
json.Unmarshal(meta, &loc.Metadata)
|
||||
}
|
||||
locs = append(locs, loc)
|
||||
}
|
||||
return locs, rows.Err()
|
||||
}
|
||||
@@ -84,7 +84,7 @@ func TruncateAll(t *testing.T, pool *pgxpool.Pool) {
|
||||
dag_cross_edges, dag_edges, dag_nodes,
|
||||
audit_log, sync_log, api_tokens, sessions, item_files,
|
||||
item_projects, relationships, revisions, inventory, items,
|
||||
projects, sequences_by_name, users, property_migrations
|
||||
locations, projects, sequences_by_name, users, property_migrations
|
||||
CASCADE
|
||||
`)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user