From 4ef912cf4bd18f64ca9c84eb424fb240c1b59882 Mon Sep 17 00:00:00 2001 From: Forbes Date: Sun, 15 Feb 2026 03:09:38 -0600 Subject: [PATCH] feat: location hierarchy CRUD API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/api/handlers.go | 3 + internal/api/location_handlers.go | 234 ++++++++++++++++++ internal/api/location_handlers_test.go | 323 +++++++++++++++++++++++++ internal/api/routes.go | 20 ++ internal/db/locations.go | 230 ++++++++++++++++++ internal/testutil/testutil.go | 2 +- 6 files changed, 811 insertions(+), 1 deletion(-) create mode 100644 internal/api/location_handlers.go create mode 100644 internal/api/location_handlers_test.go create mode 100644 internal/db/locations.go diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 8302d76..b914af8 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -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, diff --git a/internal/api/location_handlers.go b/internal/api/location_handlers.go new file mode 100644 index 0000000..f5fb3e0 --- /dev/null +++ b/internal/api/location_handlers.go @@ -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 +} diff --git a/internal/api/location_handlers_test.go b/internal/api/location_handlers_test.go new file mode 100644 index 0000000..c678743 --- /dev/null +++ b/internal/api/location_handlers_test.go @@ -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()) + } + }) + } +} diff --git a/internal/api/routes.go b/internal/api/routes.go index d099b0b..a7e7fc6 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -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) diff --git a/internal/db/locations.go b/internal/db/locations.go new file mode 100644 index 0000000..b5db2a5 --- /dev/null +++ b/internal/db/locations.go @@ -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() +} diff --git a/internal/testutil/testutil.go b/internal/testutil/testutil.go index 35b99ae..4029cdc 100644 --- a/internal/testutil/testutil.go +++ b/internal/testutil/testutil.go @@ -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 {