From c88e3073d27744e678af42a100b6c12f92f0c8f0 Mon Sep 17 00:00:00 2001 From: shark Date: Tue, 24 Mar 2026 19:05:11 +0800 Subject: [PATCH 01/20] feat(api): complete CRUD endpoints for entities and add new resource handlers --- internal/api/handlers/data_collector.go | 267 ++++++++++- internal/api/handlers/factory.go | 280 +++++++++++ internal/api/handlers/inspector.go | 572 +++++++++++++++++++++++ internal/api/handlers/organization.go | 564 ++++++++++++++++++++++ internal/api/handlers/robot.go | 245 ++++++++++ internal/api/handlers/robot_type.go | 241 ++++++++++ internal/api/handlers/scene.go | 598 ++++++++++++++++++++++++ internal/api/handlers/skill.go | 526 +++++++++++++++++++++ internal/api/handlers/sop.go | 527 +++++++++++++++++++++ internal/api/handlers/station.go | 138 ++++++ internal/api/handlers/subscene.go | 586 +++++++++++++++++++++++ internal/server/server.go | 42 ++ 12 files changed, 4585 insertions(+), 1 deletion(-) create mode 100644 internal/api/handlers/inspector.go create mode 100644 internal/api/handlers/organization.go create mode 100644 internal/api/handlers/scene.go create mode 100644 internal/api/handlers/skill.go create mode 100644 internal/api/handlers/sop.go create mode 100644 internal/api/handlers/subscene.go diff --git a/internal/api/handlers/data_collector.go b/internal/api/handlers/data_collector.go index 739a0fd..a570fc8 100644 --- a/internal/api/handlers/data_collector.go +++ b/internal/api/handlers/data_collector.go @@ -64,7 +64,9 @@ type CreateDataCollectorResponse struct { func (h *DataCollectorHandler) RegisterRoutes(apiV1 *gin.RouterGroup) { apiV1.GET("/data_collectors", h.ListDataCollectors) apiV1.POST("/data_collectors", h.CreateDataCollector) - apiV1.PATCH("/data_collectors/:id/status", h.UpdateDataCollectorStatus) + apiV1.GET("/data_collectors/:id", h.GetDataCollector) + apiV1.PATCH("/data_collectors/:id", h.UpdateDataCollector) + apiV1.DELETE("/data_collectors/:id", h.DeleteDataCollector) } // dataCollectorRow represents a data collector in the database @@ -326,3 +328,266 @@ func (h *DataCollectorHandler) UpdateDataCollectorStatus(c *gin.Context) { Status: req.Status, }) } + +// GetDataCollector handles getting a single data collector by ID. +// +// @Summary Get data collector +// @Description Gets a data collector by ID +// @Tags data_collectors +// @Accept json +// @Produce json +// @Param id path string true "Data Collector ID" +// @Success 200 {object} DataCollectorResponse +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /data_collectors/{id} [get] +func (h *DataCollectorHandler) GetDataCollector(c *gin.Context) { + idParam := c.Param("id") + id, err := strconv.ParseInt(idParam, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid data collector id"}) + return + } + + query := ` + SELECT + dc.id, + dc.name, + dc.operator_id, + dc.email, + dc.certification, + dc.status, + dc.created_at + FROM data_collectors dc + WHERE dc.id = ? AND dc.deleted_at IS NULL + ` + + var dc dataCollectorRow + if err := h.db.Get(&dc, query, id); err != nil { + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "data collector not found"}) + return + } + logger.Printf("[DC] Failed to query data collector: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get data collector"}) + return + } + + email := "" + if dc.Email.Valid { + email = dc.Email.String + } + + certification := "" + if dc.Certification.Valid { + certification = dc.Certification.String + } + + createdAt := "" + if dc.CreatedAt.Valid { + createdAt = dc.CreatedAt.String + } + + c.JSON(http.StatusOK, DataCollectorResponse{ + ID: fmt.Sprintf("%d", dc.ID), + Name: dc.Name, + OperatorID: dc.OperatorID, + Email: email, + Certification: certification, + Status: dc.Status, + CreatedAt: createdAt, + }) +} + +// UpdateDataCollectorRequest represents the request body for updating a data collector. +type UpdateDataCollectorRequest struct { + Name *string `json:"name,omitempty"` + Email *string `json:"email,omitempty"` + Status *string `json:"status,omitempty"` +} + +// UpdateDataCollector handles updating a data collector. +// +// @Summary Update data collector +// @Description Updates an existing data collector +// @Tags data_collectors +// @Accept json +// @Produce json +// @Param id path string true "Data Collector ID" +// @Param body body UpdateDataCollectorRequest true "Data Collector payload" +// @Success 200 {object} DataCollectorResponse +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /data_collectors/{id} [patch] +func (h *DataCollectorHandler) UpdateDataCollector(c *gin.Context) { + idParam := c.Param("id") + id, err := strconv.ParseInt(idParam, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid data collector id"}) + return + } + + var req UpdateDataCollectorRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + + // Build update query dynamically + updates := []string{} + args := []interface{}{} + + validStatuses := map[string]bool{ + "active": true, + "inactive": true, + "on_leave": true, + } + + if req.Name != nil { + name := strings.TrimSpace(*req.Name) + if name != "" { + updates = append(updates, "name = ?") + args = append(args, name) + } + } + + if req.Email != nil { + email := strings.TrimSpace(*req.Email) + var emailStr sql.NullString + if email != "" { + emailStr = sql.NullString{String: email, Valid: true} + } + updates = append(updates, "email = ?") + args = append(args, emailStr) + } + + if req.Status != nil { + status := strings.TrimSpace(*req.Status) + if !validStatuses[status] { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid status, must be one of: active, inactive, on_leave"}) + return + } + updates = append(updates, "status = ?") + args = append(args, status) + } + + if len(updates) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) + return + } + + updatedAt := time.Now().UTC().Format("2006-01-02 15:04:05") + updates = append(updates, "updated_at = ?") + args = append(args, updatedAt) + args = append(args, id) + + query := fmt.Sprintf("UPDATE data_collectors SET %s WHERE id = ? AND deleted_at IS NULL", strings.Join(updates, ", ")) + + result, err := h.db.Exec(query, args...) + if err != nil { + logger.Printf("[DC] Failed to update data collector: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update data collector"}) + return + } + + rowsAffected, _ := result.RowsAffected() + if rowsAffected == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "data collector not found"}) + return + } + + // Fetch the updated data collector + var dc dataCollectorRow + err = h.db.Get(&dc, ` + SELECT + dc.id, + dc.name, + dc.operator_id, + dc.email, + dc.certification, + dc.status, + dc.created_at + FROM data_collectors dc + WHERE dc.id = ? + `, id) + if err != nil { + logger.Printf("[DC] Failed to fetch updated data collector: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get updated data collector"}) + return + } + + email := "" + if dc.Email.Valid { + email = dc.Email.String + } + + certification := "" + if dc.Certification.Valid { + certification = dc.Certification.String + } + + createdAt := "" + if dc.CreatedAt.Valid { + createdAt = dc.CreatedAt.String + } + + c.JSON(http.StatusOK, DataCollectorResponse{ + ID: fmt.Sprintf("%d", dc.ID), + Name: dc.Name, + OperatorID: dc.OperatorID, + Email: email, + Certification: certification, + Status: dc.Status, + CreatedAt: createdAt, + }) +} + +// DeleteDataCollector handles data collector deletion requests (soft delete). +// +// @Summary Delete data collector +// @Description Soft deletes a data collector by ID +// @Tags data_collectors +// @Accept json +// @Produce json +// @Param id path string true "Data Collector ID" +// @Success 204 +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /data_collectors/{id} [delete] +func (h *DataCollectorHandler) DeleteDataCollector(c *gin.Context) { + idParam := c.Param("id") + id, err := strconv.ParseInt(idParam, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid data collector id"}) + return + } + + // Check if data collector exists + var exists bool + err = h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM data_collectors WHERE id = ? AND deleted_at IS NULL)", id) + if err != nil { + logger.Printf("[DC] Failed to check data collector existence: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete data collector"}) + return + } + + if !exists { + c.JSON(http.StatusNotFound, gin.H{"error": "data collector not found"}) + return + } + + updatedAt := time.Now().UTC().Format("2006-01-02 15:04:05") + + // Perform soft delete by setting deleted_at + _, err = h.db.Exec("UPDATE data_collectors SET deleted_at = NOW(), updated_at = ? WHERE id = ?", updatedAt, id) + if err != nil { + logger.Printf("[DC] Failed to delete data collector: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete data collector"}) + return + } + + c.Status(http.StatusNoContent) +} diff --git a/internal/api/handlers/factory.go b/internal/api/handlers/factory.go index 0220a11..4ce6382 100644 --- a/internal/api/handlers/factory.go +++ b/internal/api/handlers/factory.go @@ -66,6 +66,9 @@ type CreateFactoryResponse struct { func (h *FactoryHandler) RegisterRoutes(apiV1 *gin.RouterGroup) { apiV1.GET("/factories", h.ListFactories) apiV1.POST("/factories", h.CreateFactory) + apiV1.GET("/factories/:id", h.GetFactory) + apiV1.PATCH("/factories/:id", h.UpdateFactory) + apiV1.DELETE("/factories/:id", h.DeleteFactory) } // factoryRow represents a factory in the database @@ -262,3 +265,280 @@ func (h *FactoryHandler) CreateFactory(c *gin.Context) { CreatedAt: now.Format(time.RFC3339), }) } + +// GetFactory handles getting a single factory by ID. +// +// @Summary Get factory +// @Description Gets a factory by ID +// @Tags factories +// @Accept json +// @Produce json +// @Param id path string true "Factory ID" +// @Success 200 {object} FactoryResponse +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /factories/{id} [get] +func (h *FactoryHandler) GetFactory(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid factory id"}) + return + } + + query := ` + SELECT + id, + organization_id, + name, + slug, + location, + timezone, + settings, + created_at, + updated_at + FROM factories + WHERE id = ? AND deleted_at IS NULL + ` + + var f factoryRow + if err := h.db.Get(&f, query, id); err != nil { + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "factory not found"}) + return + } + logger.Printf("[FACTORY] Failed to query factory: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get factory"}) + return + } + + location := "" + if f.Location.Valid { + location = f.Location.String + } + timezone := "UTC" + if f.Timezone.Valid { + timezone = f.Timezone.String + } + createdAt := "" + if f.CreatedAt.Valid { + createdAt = f.CreatedAt.String + } + updatedAt := "" + if f.UpdatedAt.Valid { + updatedAt = f.UpdatedAt.String + } + + c.JSON(http.StatusOK, FactoryResponse{ + ID: fmt.Sprintf("%d", f.ID), + OrganizationID: fmt.Sprintf("%d", f.OrganizationID), + Name: f.Name, + Slug: f.Slug, + Location: location, + Timezone: timezone, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + }) +} + +// UpdateFactoryRequest represents the request body for updating a factory. +type UpdateFactoryRequest struct { + Name *string `json:"name,omitempty"` + Slug *string `json:"slug,omitempty"` + Location *string `json:"location,omitempty"` + Timezone *string `json:"timezone,omitempty"` +} + +// UpdateFactory handles updating a factory. +// +// @Summary Update factory +// @Description Updates an existing factory +// @Tags factories +// @Accept json +// @Produce json +// @Param id path string true "Factory ID" +// @Param body body UpdateFactoryRequest true "Factory payload" +// @Success 200 {object} FactoryResponse +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /factories/{id} [patch] +func (h *FactoryHandler) UpdateFactory(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid factory id"}) + return + } + + var req UpdateFactoryRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + + // Check if factory exists + var existing factoryRow + err = h.db.Get(&existing, "SELECT id, organization_id, name, slug, location, timezone, settings, created_at, updated_at FROM factories WHERE id = ? AND deleted_at IS NULL", id) + if err != nil { + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "factory not found"}) + return + } + logger.Printf("[FACTORY] Failed to query factory: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update factory"}) + return + } + + // Build update query dynamically + updates := []string{} + args := []interface{}{} + + if req.Name != nil { + name := strings.TrimSpace(*req.Name) + if name != "" { + updates = append(updates, "name = ?") + args = append(args, name) + } + } + + if req.Slug != nil { + slug := strings.TrimSpace(*req.Slug) + if slug != "" { + // Check if slug already exists for this organization + var exists bool + err := h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM factories WHERE organization_id = ? AND slug = ? AND id != ? AND deleted_at IS NULL)", existing.OrganizationID, slug, id) + if err == nil && exists { + c.JSON(http.StatusBadRequest, gin.H{"error": "slug already exists for this organization"}) + return + } + updates = append(updates, "slug = ?") + args = append(args, slug) + } + } + + if req.Location != nil { + location := strings.TrimSpace(*req.Location) + var locStr sql.NullString + if location != "" { + locStr = sql.NullString{String: location, Valid: true} + } + updates = append(updates, "location = ?") + args = append(args, locStr) + } + + if req.Timezone != nil { + tz := strings.TrimSpace(*req.Timezone) + var tzStr sql.NullString + if tz != "" { + tzStr = sql.NullString{String: tz, Valid: true} + } + updates = append(updates, "timezone = ?") + args = append(args, tzStr) + } + + if len(updates) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) + return + } + + now := time.Now().UTC() + updates = append(updates, "updated_at = ?") + args = append(args, now) + args = append(args, id) + + query := fmt.Sprintf("UPDATE factories SET %s WHERE id = ?", strings.Join(updates, ", ")) + + _, err = h.db.Exec(query, args...) + if err != nil { + logger.Printf("[FACTORY] Failed to update factory: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update factory"}) + return + } + + // Fetch the updated factory + var f factoryRow + err = h.db.Get(&f, "SELECT id, organization_id, name, slug, location, timezone, settings, created_at, updated_at FROM factories WHERE id = ?", id) + if err != nil { + logger.Printf("[FACTORY] Failed to fetch updated factory: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get updated factory"}) + return + } + + location := "" + if f.Location.Valid { + location = f.Location.String + } + timezone := "UTC" + if f.Timezone.Valid { + timezone = f.Timezone.String + } + createdAt := "" + if f.CreatedAt.Valid { + createdAt = f.CreatedAt.String + } + updatedAt := "" + if f.UpdatedAt.Valid { + updatedAt = f.UpdatedAt.String + } + + c.JSON(http.StatusOK, FactoryResponse{ + ID: fmt.Sprintf("%d", f.ID), + OrganizationID: fmt.Sprintf("%d", f.OrganizationID), + Name: f.Name, + Slug: f.Slug, + Location: location, + Timezone: timezone, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + }) +} + +// DeleteFactory handles factory deletion requests (soft delete). +// +// @Summary Delete factory +// @Description Soft deletes a factory by ID +// @Tags factories +// @Accept json +// @Produce json +// @Param id path string true "Factory ID" +// @Success 204 +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /factories/{id} [delete] +func (h *FactoryHandler) DeleteFactory(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid factory id"}) + return + } + + // Check if factory exists + var exists bool + err = h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM factories WHERE id = ? AND deleted_at IS NULL)", id) + if err != nil { + logger.Printf("[FACTORY] Failed to check factory existence: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete factory"}) + return + } + + if !exists { + c.JSON(http.StatusNotFound, gin.H{"error": "factory not found"}) + return + } + + now := time.Now().UTC() + + // Perform soft delete by setting deleted_at + _, err = h.db.Exec("UPDATE factories SET deleted_at = ?, updated_at = ? WHERE id = ?", now, now, id) + if err != nil { + logger.Printf("[FACTORY] Failed to delete factory: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete factory"}) + return + } + + c.Status(http.StatusNoContent) +} diff --git a/internal/api/handlers/inspector.go b/internal/api/handlers/inspector.go new file mode 100644 index 0000000..6a65641 --- /dev/null +++ b/internal/api/handlers/inspector.go @@ -0,0 +1,572 @@ +// SPDX-FileCopyrightText: 2026 ArcheBase +// +// SPDX-License-Identifier: MulanPSL-2.0 + +// Package handlers provides HTTP request handlers for Keystone Edge API +package handlers + +import ( + "database/sql" + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "archebase.com/keystone-edge/internal/logger" + "github.com/gin-gonic/gin" + "github.com/jmoiron/sqlx" +) + +// InspectorHandler handles inspector related HTTP requests. +type InspectorHandler struct { + db *sqlx.DB +} + +// NewInspectorHandler creates a new InspectorHandler. +func NewInspectorHandler(db *sqlx.DB) *InspectorHandler { + return &InspectorHandler{db: db} +} + +// InspectorResponse represents an inspector in the response. +type InspectorResponse struct { + ID string `json:"id"` + Name string `json:"name"` + InspectorID string `json:"inspector_id"` + Email string `json:"email,omitempty"` + CertificationLevel string `json:"certification_level"` + Status string `json:"status"` + Metadata interface{} `json:"metadata,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +// InspectorListResponse represents the response for listing inspectors. +type InspectorListResponse struct { + Inspectors []InspectorResponse `json:"inspectors"` +} + +// CreateInspectorRequest represents the request body for creating an inspector. +type CreateInspectorRequest struct { + Name string `json:"name"` + InspectorID string `json:"inspector_id"` + Email string `json:"email,omitempty"` + CertificationLevel string `json:"certification_level,omitempty"` +} + +// CreateInspectorResponse represents the response for creating an inspector. +type CreateInspectorResponse struct { + ID string `json:"id"` + Name string `json:"name"` + InspectorID string `json:"inspector_id"` + CertificationLevel string `json:"certification_level"` + Status string `json:"status"` + CreatedAt string `json:"created_at"` +} + +// UpdateInspectorRequest represents the request body for updating an inspector. +type UpdateInspectorRequest struct { + Name *string `json:"name,omitempty"` + Email *string `json:"email,omitempty"` + CertificationLevel *string `json:"certification_level,omitempty"` + Status *string `json:"status,omitempty"` + Metadata interface{} `json:"metadata,omitempty"` +} + +// RegisterRoutes registers inspector related routes. +func (h *InspectorHandler) RegisterRoutes(apiV1 *gin.RouterGroup) { + apiV1.GET("/inspectors", h.ListInspectors) + apiV1.POST("/inspectors", h.CreateInspector) + apiV1.GET("/inspectors/:id", h.GetInspector) + apiV1.PATCH("/inspectors/:id", h.UpdateInspector) + apiV1.DELETE("/inspectors/:id", h.DeleteInspector) +} + +// inspectorRow represents an inspector in the database +type inspectorRow struct { + ID int64 `db:"id"` + Name string `db:"name"` + InspectorID string `db:"inspector_id"` + Email sql.NullString `db:"email"` + CertificationLevel string `db:"certification_level"` + Status string `db:"status"` + Metadata sql.NullString `db:"metadata"` + CreatedAt sql.NullString `db:"created_at"` + UpdatedAt sql.NullString `db:"updated_at"` +} + +// ListInspectors handles inspector listing requests. +// +// @Summary List inspectors +// @Description Lists all inspectors +// @Tags inspectors +// @Accept json +// @Produce json +// @Param status query string false "Filter by status (active, inactive)" +// @Success 200 {object} InspectorListResponse +// @Failure 500 {object} map[string]string +// @Router /inspectors [get] +func (h *InspectorHandler) ListInspectors(c *gin.Context) { + status := c.Query("status") + + query := ` + SELECT + id, + name, + inspector_id, + email, + certification_level, + status, + metadata, + created_at, + updated_at + FROM inspectors + WHERE deleted_at IS NULL + ` + args := []interface{}{} + + if status != "" { + query += " AND status = ?" + args = append(args, status) + } + + query += " ORDER BY id DESC" + + var dbRows []inspectorRow + if err := h.db.Select(&dbRows, query, args...); err != nil { + logger.Printf("[INSPECTOR] Failed to query inspectors: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list inspectors"}) + return + } + + inspectors := []InspectorResponse{} + for _, i := range dbRows { + email := "" + if i.Email.Valid { + email = i.Email.String + } + certLevel := "level_1" + if i.CertificationLevel != "" { + certLevel = i.CertificationLevel + } + var metadata interface{} + if i.Metadata.Valid && i.Metadata.String != "" && i.Metadata.String != "null" { + metadata = parseJSONRaw(i.Metadata.String) + } + createdAt := "" + if i.CreatedAt.Valid { + createdAt = i.CreatedAt.String + } + updatedAt := "" + if i.UpdatedAt.Valid { + updatedAt = i.UpdatedAt.String + } + + inspectors = append(inspectors, InspectorResponse{ + ID: fmt.Sprintf("%d", i.ID), + Name: i.Name, + InspectorID: i.InspectorID, + Email: email, + CertificationLevel: certLevel, + Status: i.Status, + Metadata: metadata, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + }) + } + + c.JSON(http.StatusOK, InspectorListResponse{ + Inspectors: inspectors, + }) +} + +// GetInspector handles getting a single inspector by ID. +// +// @Summary Get inspector +// @Description Gets an inspector by ID +// @Tags inspectors +// @Accept json +// @Produce json +// @Param id path string true "Inspector ID" +// @Success 200 {object} InspectorResponse +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /inspectors/{id} [get] +func (h *InspectorHandler) GetInspector(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid inspector id"}) + return + } + + query := ` + SELECT + id, + name, + inspector_id, + email, + certification_level, + status, + metadata, + created_at, + updated_at + FROM inspectors + WHERE id = ? AND deleted_at IS NULL + ` + + var i inspectorRow + if err := h.db.Get(&i, query, id); err != nil { + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "inspector not found"}) + return + } + logger.Printf("[INSPECTOR] Failed to query inspector: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get inspector"}) + return + } + + email := "" + if i.Email.Valid { + email = i.Email.String + } + certLevel := "level_1" + if i.CertificationLevel != "" { + certLevel = i.CertificationLevel + } + var metadata interface{} + if i.Metadata.Valid && i.Metadata.String != "" && i.Metadata.String != "null" { + metadata = parseJSONRaw(i.Metadata.String) + } + createdAt := "" + if i.CreatedAt.Valid { + createdAt = i.CreatedAt.String + } + updatedAt := "" + if i.UpdatedAt.Valid { + updatedAt = i.UpdatedAt.String + } + + c.JSON(http.StatusOK, InspectorResponse{ + ID: fmt.Sprintf("%d", i.ID), + Name: i.Name, + InspectorID: i.InspectorID, + Email: email, + CertificationLevel: certLevel, + Status: i.Status, + Metadata: metadata, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + }) +} + +// CreateInspector handles inspector creation requests. +// +// @Summary Create inspector +// @Description Creates a new inspector +// @Tags inspectors +// @Accept json +// @Produce json +// @Param body body CreateInspectorRequest true "Inspector payload" +// @Success 201 {object} CreateInspectorResponse +// @Failure 400 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /inspectors [post] +func (h *InspectorHandler) CreateInspector(c *gin.Context) { + var req CreateInspectorRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + + req.Name = strings.TrimSpace(req.Name) + req.InspectorID = strings.TrimSpace(req.InspectorID) + req.Email = strings.TrimSpace(req.Email) + + if req.Name == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"}) + return + } + + if req.InspectorID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "inspector_id is required"}) + return + } + + // Check if inspector_id already exists + var exists bool + err := h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM inspectors WHERE inspector_id = ? AND deleted_at IS NULL)", req.InspectorID) + if err == nil && exists { + c.JSON(http.StatusBadRequest, gin.H{"error": "inspector_id already exists"}) + return + } + + // Validate certification level + validCertLevels := map[string]bool{ + "level_1": true, + "level_2": true, + "senior": true, + } + certLevel := "level_1" + if req.CertificationLevel != "" { + if !validCertLevels[req.CertificationLevel] { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid certification_level, must be one of: level_1, level_2, senior"}) + return + } + certLevel = req.CertificationLevel + } + + var emailStr sql.NullString + if req.Email != "" { + emailStr = sql.NullString{String: req.Email, Valid: true} + } + + now := time.Now().UTC() + + result, err := h.db.Exec( + `INSERT INTO inspectors ( + name, + inspector_id, + email, + certification_level, + status, + created_at, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?)`, + req.Name, + req.InspectorID, + emailStr, + certLevel, + "active", + now, + now, + ) + if err != nil { + logger.Printf("[INSPECTOR] Failed to insert inspector: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create inspector"}) + return + } + + id, err := result.LastInsertId() + if err != nil { + logger.Printf("[INSPECTOR] Failed to fetch inserted id: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create inspector"}) + return + } + + c.JSON(http.StatusCreated, CreateInspectorResponse{ + ID: fmt.Sprintf("%d", id), + Name: req.Name, + InspectorID: req.InspectorID, + CertificationLevel: certLevel, + Status: "active", + CreatedAt: now.Format(time.RFC3339), + }) +} + +// UpdateInspector handles updating an inspector. +// +// @Summary Update inspector +// @Description Updates an existing inspector +// @Tags inspectors +// @Accept json +// @Produce json +// @Param id path string true "Inspector ID" +// @Param body body UpdateInspectorRequest true "Inspector payload" +// @Success 200 {object} InspectorResponse +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /inspectors/{id} [patch] +func (h *InspectorHandler) UpdateInspector(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid inspector id"}) + return + } + + var req UpdateInspectorRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + + // Validate certification level and status if provided + validCertLevels := map[string]bool{ + "level_1": true, + "level_2": true, + "senior": true, + } + validStatuses := map[string]bool{ + "active": true, + "inactive": true, + } + + // Build update query dynamically + updates := []string{} + args := []interface{}{} + + if req.Name != nil { + name := strings.TrimSpace(*req.Name) + if name != "" { + updates = append(updates, "name = ?") + args = append(args, name) + } + } + + if req.Email != nil { + email := strings.TrimSpace(*req.Email) + var emailStr sql.NullString + if email != "" { + emailStr = sql.NullString{String: email, Valid: true} + } + updates = append(updates, "email = ?") + args = append(args, emailStr) + } + + if req.CertificationLevel != nil { + certLevel := strings.TrimSpace(*req.CertificationLevel) + if !validCertLevels[certLevel] { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid certification_level, must be one of: level_1, level_2, senior"}) + return + } + updates = append(updates, "certification_level = ?") + args = append(args, certLevel) + } + + if req.Status != nil { + status := strings.TrimSpace(*req.Status) + if !validStatuses[status] { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid status, must be one of: active, inactive"}) + return + } + updates = append(updates, "status = ?") + args = append(args, status) + } + + if req.Metadata != nil { + metadataJSON, err := json.Marshal(req.Metadata) + if err == nil { + updates = append(updates, "metadata = ?") + args = append(args, sql.NullString{String: string(metadataJSON), Valid: true}) + } + } + + if len(updates) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) + return + } + + now := time.Now().UTC() + updates = append(updates, "updated_at = ?") + args = append(args, now) + args = append(args, id) + + query := fmt.Sprintf("UPDATE inspectors SET %s WHERE id = ? AND deleted_at IS NULL", strings.Join(updates, ", ")) + + result, err := h.db.Exec(query, args...) + if err != nil { + logger.Printf("[INSPECTOR] Failed to update inspector: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update inspector"}) + return + } + + rowsAffected, _ := result.RowsAffected() + if rowsAffected == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "inspector not found"}) + return + } + + // Fetch the updated inspector + var i inspectorRow + err = h.db.Get(&i, "SELECT id, name, inspector_id, email, certification_level, status, metadata, created_at, updated_at FROM inspectors WHERE id = ?", id) + if err != nil { + logger.Printf("[INSPECTOR] Failed to fetch updated inspector: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get updated inspector"}) + return + } + + email := "" + if i.Email.Valid { + email = i.Email.String + } + certLevel := "level_1" + if i.CertificationLevel != "" { + certLevel = i.CertificationLevel + } + var metadata interface{} + if i.Metadata.Valid && i.Metadata.String != "" && i.Metadata.String != "null" { + metadata = parseJSONRaw(i.Metadata.String) + } + createdAt := "" + if i.CreatedAt.Valid { + createdAt = i.CreatedAt.String + } + updatedAt := "" + if i.UpdatedAt.Valid { + updatedAt = i.UpdatedAt.String + } + + c.JSON(http.StatusOK, InspectorResponse{ + ID: fmt.Sprintf("%d", i.ID), + Name: i.Name, + InspectorID: i.InspectorID, + Email: email, + CertificationLevel: certLevel, + Status: i.Status, + Metadata: metadata, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + }) +} + +// DeleteInspector handles inspector deletion requests (soft delete). +// +// @Summary Delete inspector +// @Description Soft deletes an inspector by ID +// @Tags inspectors +// @Accept json +// @Produce json +// @Param id path string true "Inspector ID" +// @Success 204 +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /inspectors/{id} [delete] +func (h *InspectorHandler) DeleteInspector(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid inspector id"}) + return + } + + // Check if inspector exists + var exists bool + err = h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM inspectors WHERE id = ? AND deleted_at IS NULL)", id) + if err != nil { + logger.Printf("[INSPECTOR] Failed to check inspector existence: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete inspector"}) + return + } + + if !exists { + c.JSON(http.StatusNotFound, gin.H{"error": "inspector not found"}) + return + } + + now := time.Now().UTC() + + // Perform soft delete by setting deleted_at + _, err = h.db.Exec("UPDATE inspectors SET deleted_at = ?, updated_at = ? WHERE id = ?", now, now, id) + if err != nil { + logger.Printf("[INSPECTOR] Failed to delete inspector: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete inspector"}) + return + } + + c.Status(http.StatusNoContent) +} diff --git a/internal/api/handlers/organization.go b/internal/api/handlers/organization.go new file mode 100644 index 0000000..d9adce8 --- /dev/null +++ b/internal/api/handlers/organization.go @@ -0,0 +1,564 @@ +// SPDX-FileCopyrightText: 2026 ArcheBase +// +// SPDX-License-Identifier: MulanPSL-2.0 + +// Package handlers provides HTTP request handlers for Keystone Edge API +package handlers + +import ( + "database/sql" + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "archebase.com/keystone-edge/internal/logger" + "github.com/gin-gonic/gin" + "github.com/jmoiron/sqlx" +) + +// OrganizationHandler handles organization related HTTP requests. +type OrganizationHandler struct { + db *sqlx.DB +} + +// NewOrganizationHandler creates a new OrganizationHandler. +func NewOrganizationHandler(db *sqlx.DB) *OrganizationHandler { + return &OrganizationHandler{db: db} +} + +// OrganizationResponse represents an organization in the response. +type OrganizationResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Description string `json:"description,omitempty"` + Settings interface{} `json:"settings,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +// OrganizationListResponse represents the response for listing organizations. +type OrganizationListResponse struct { + Organizations []OrganizationResponse `json:"organizations"` +} + +// CreateOrganizationRequest represents the request body for creating an organization. +type CreateOrganizationRequest struct { + Name string `json:"name"` + Slug string `json:"slug"` + Description string `json:"description,omitempty"` + Settings interface{} `json:"settings,omitempty"` +} + +// CreateOrganizationResponse represents the response for creating an organization. +type CreateOrganizationResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Description string `json:"description,omitempty"` + CreatedAt string `json:"created_at"` +} + +// UpdateOrganizationRequest represents the request body for updating an organization. +type UpdateOrganizationRequest struct { + Name string `json:"name,omitempty"` + Slug string `json:"slug,omitempty"` + Description *string `json:"description,omitempty"` + Settings interface{} `json:"settings,omitempty"` +} + +// RegisterRoutes registers organization related routes. +func (h *OrganizationHandler) RegisterRoutes(apiV1 *gin.RouterGroup) { + apiV1.GET("/organizations", h.ListOrganizations) + apiV1.POST("/organizations", h.CreateOrganization) + apiV1.GET("/organizations/:id", h.GetOrganization) + apiV1.PATCH("/organizations/:id", h.UpdateOrganization) + apiV1.DELETE("/organizations/:id", h.DeleteOrganization) +} + +// organizationRow represents an organization in the database +type organizationRow struct { + ID int64 `db:"id"` + Name string `db:"name"` + Slug string `db:"slug"` + Description sql.NullString `db:"description"` + Settings sql.NullString `db:"settings"` + CreatedAt sql.NullString `db:"created_at"` + UpdatedAt sql.NullString `db:"updated_at"` +} + +// ListOrganizations handles organization listing requests. +// +// @Summary List organizations +// @Description Lists all organizations +// @Tags organizations +// @Accept json +// @Produce json +// @Success 200 {object} OrganizationListResponse +// @Failure 500 {object} map[string]string +// @Router /organizations [get] +func (h *OrganizationHandler) ListOrganizations(c *gin.Context) { + query := ` + SELECT + id, + name, + slug, + description, + settings, + created_at, + updated_at + FROM organizations + WHERE deleted_at IS NULL + ORDER BY id DESC + ` + + var dbRows []organizationRow + if err := h.db.Select(&dbRows, query); err != nil { + logger.Printf("[ORGANIZATION] Failed to query organizations: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list organizations"}) + return + } + + organizations := []OrganizationResponse{} + for _, org := range dbRows { + description := "" + if org.Description.Valid { + description = org.Description.String + } + + var settings interface{} + if org.Settings.Valid && org.Settings.String != "" && org.Settings.String != "null" { + settings = parseJSONRaw(org.Settings.String) + } + + createdAt := "" + if org.CreatedAt.Valid { + createdAt = org.CreatedAt.String + } + + updatedAt := "" + if org.UpdatedAt.Valid { + updatedAt = org.UpdatedAt.String + } + + organizations = append(organizations, OrganizationResponse{ + ID: fmt.Sprintf("%d", org.ID), + Name: org.Name, + Slug: org.Slug, + Description: description, + Settings: settings, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + }) + } + + c.JSON(http.StatusOK, OrganizationListResponse{ + Organizations: organizations, + }) +} + +// GetOrganization handles getting a single organization by ID. +// +// @Summary Get organization +// @Description Gets an organization by ID +// @Tags organizations +// @Accept json +// @Produce json +// @Param id path string true "Organization ID" +// @Success 200 {object} OrganizationResponse +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /organizations/{id} [get] +func (h *OrganizationHandler) GetOrganization(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid organization id"}) + return + } + + query := ` + SELECT + id, + name, + slug, + description, + settings, + created_at, + updated_at + FROM organizations + WHERE id = ? AND deleted_at IS NULL + ` + + var org organizationRow + if err := h.db.Get(&org, query, id); err != nil { + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "organization not found"}) + return + } + logger.Printf("[ORGANIZATION] Failed to query organization: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get organization"}) + return + } + + description := "" + if org.Description.Valid { + description = org.Description.String + } + + var settings interface{} + if org.Settings.Valid && org.Settings.String != "" && org.Settings.String != "null" { + settings = parseJSONRaw(org.Settings.String) + } + + createdAt := "" + if org.CreatedAt.Valid { + createdAt = org.CreatedAt.String + } + + updatedAt := "" + if org.UpdatedAt.Valid { + updatedAt = org.UpdatedAt.String + } + + c.JSON(http.StatusOK, OrganizationResponse{ + ID: fmt.Sprintf("%d", org.ID), + Name: org.Name, + Slug: org.Slug, + Description: description, + Settings: settings, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + }) +} + +// CreateOrganization handles organization creation requests. +// +// @Summary Create organization +// @Description Creates a new organization +// @Tags organizations +// @Accept json +// @Produce json +// @Param body body CreateOrganizationRequest true "Organization payload" +// @Success 201 {object} CreateOrganizationResponse +// @Failure 400 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /organizations [post] +func (h *OrganizationHandler) CreateOrganization(c *gin.Context) { + var req CreateOrganizationRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + + req.Name = strings.TrimSpace(req.Name) + req.Slug = strings.TrimSpace(req.Slug) + req.Description = strings.TrimSpace(req.Description) + + if req.Name == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"}) + return + } + + if req.Slug == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "slug is required"}) + return + } + + // Validate slug format (alphanumeric and hyphens only) + if !isValidSlug(req.Slug) { + c.JSON(http.StatusBadRequest, gin.H{"error": "slug must contain only alphanumeric characters and hyphens"}) + return + } + + // Check if slug already exists + var exists bool + err := h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM organizations WHERE slug = ? AND deleted_at IS NULL)", req.Slug) + if err == nil && exists { + c.JSON(http.StatusBadRequest, gin.H{"error": "slug already exists"}) + return + } + + // Convert settings to JSON string if provided + var settingsStr sql.NullString + if req.Settings != nil { + settingsJSON, err := json.Marshal(req.Settings) + if err == nil { + settingsStr = sql.NullString{String: string(settingsJSON), Valid: true} + } + } + + // Convert description to nullable string + var descriptionStr sql.NullString + if req.Description != "" { + descriptionStr = sql.NullString{String: req.Description, Valid: true} + } + + now := time.Now().UTC() + + result, err := h.db.Exec( + `INSERT INTO organizations ( + name, + slug, + description, + settings, + created_at, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?)`, + req.Name, + req.Slug, + descriptionStr, + settingsStr, + now, + now, + ) + if err != nil { + logger.Printf("[ORGANIZATION] Failed to insert organization: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create organization"}) + return + } + + id, err := result.LastInsertId() + if err != nil { + logger.Printf("[ORGANIZATION] Failed to fetch inserted id: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create organization"}) + return + } + + c.JSON(http.StatusCreated, CreateOrganizationResponse{ + ID: fmt.Sprintf("%d", id), + Name: req.Name, + Slug: req.Slug, + Description: req.Description, + CreatedAt: now.Format(time.RFC3339), + }) +} + +// UpdateOrganization handles organization update requests. +// +// @Summary Update organization +// @Description Updates an existing organization +// @Tags organizations +// @Accept json +// @Produce json +// @Param id path string true "Organization ID" +// @Param body body UpdateOrganizationRequest true "Organization payload" +// @Success 200 {object} OrganizationResponse +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /organizations/{id} [put] +func (h *OrganizationHandler) UpdateOrganization(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid organization id"}) + return + } + + var req UpdateOrganizationRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + + // Check if organization exists + var existingOrg organizationRow + err = h.db.Get(&existingOrg, + "SELECT id, name, slug, description, settings, created_at, updated_at FROM organizations WHERE id = ? AND deleted_at IS NULL", + id) + if err != nil { + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "organization not found"}) + return + } + logger.Printf("[ORGANIZATION] Failed to query organization: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update organization"}) + return + } + + // Build update query dynamically + updates := []string{} + args := []interface{}{} + + req.Name = strings.TrimSpace(req.Name) + req.Slug = strings.TrimSpace(req.Slug) + + if req.Name != "" { + updates = append(updates, "name = ?") + args = append(args, req.Name) + } + + if req.Slug != "" { + // Validate slug format + if !isValidSlug(req.Slug) { + c.JSON(http.StatusBadRequest, gin.H{"error": "slug must contain only alphanumeric characters and hyphens"}) + return + } + // Check if new slug already exists for another organization + var exists bool + err := h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM organizations WHERE slug = ? AND id != ? AND deleted_at IS NULL)", req.Slug, id) + if err == nil && exists { + c.JSON(http.StatusBadRequest, gin.H{"error": "slug already exists"}) + return + } + updates = append(updates, "slug = ?") + args = append(args, req.Slug) + } + + if req.Description != nil { + desc := strings.TrimSpace(*req.Description) + var descStr sql.NullString + if desc != "" { + descStr = sql.NullString{String: desc, Valid: true} + } + updates = append(updates, "description = ?") + args = append(args, descStr) + } + + if req.Settings != nil { + settingsJSON, err := json.Marshal(req.Settings) + if err == nil { + updates = append(updates, "settings = ?") + args = append(args, sql.NullString{String: string(settingsJSON), Valid: true}) + } + } + + if len(updates) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) + return + } + + now := time.Now().UTC() + updates = append(updates, "updated_at = ?") + args = append(args, now) + args = append(args, id) + + query := fmt.Sprintf("UPDATE organizations SET %s WHERE id = ?", strings.Join(updates, ", ")) + + _, err = h.db.Exec(query, args...) + if err != nil { + logger.Printf("[ORGANIZATION] Failed to update organization: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update organization"}) + return + } + + // Fetch the updated organization + var org organizationRow + err = h.db.Get(&org, + "SELECT id, name, slug, description, settings, created_at, updated_at FROM organizations WHERE id = ?", + id) + if err != nil { + logger.Printf("[ORGANIZATION] Failed to fetch updated organization: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get updated organization"}) + return + } + + description := "" + if org.Description.Valid { + description = org.Description.String + } + + var settings interface{} + if org.Settings.Valid && org.Settings.String != "" && org.Settings.String != "null" { + settings = parseJSONRaw(org.Settings.String) + } + + createdAt := "" + if org.CreatedAt.Valid { + createdAt = org.CreatedAt.String + } + + updatedAt := "" + if org.UpdatedAt.Valid { + updatedAt = org.UpdatedAt.String + } + + c.JSON(http.StatusOK, OrganizationResponse{ + ID: fmt.Sprintf("%d", org.ID), + Name: org.Name, + Slug: org.Slug, + Description: description, + Settings: settings, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + }) +} + +// DeleteOrganization handles organization deletion requests (soft delete). +// +// @Summary Delete organization +// @Description Soft deletes an organization by ID +// @Tags organizations +// @Accept json +// @Produce json +// @Param id path string true "Organization ID" +// @Success 204 +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /organizations/{id} [delete] +func (h *OrganizationHandler) DeleteOrganization(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid organization id"}) + return + } + + // Check if organization exists + var exists bool + err = h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM organizations WHERE id = ? AND deleted_at IS NULL)", id) + if err != nil { + logger.Printf("[ORGANIZATION] Failed to check organization existence: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete organization"}) + return + } + + if !exists { + c.JSON(http.StatusNotFound, gin.H{"error": "organization not found"}) + return + } + + now := time.Now().UTC() + + // Perform soft delete by setting deleted_at + _, err = h.db.Exec("UPDATE organizations SET deleted_at = ?, updated_at = ? WHERE id = ?", now, now, id) + if err != nil { + logger.Printf("[ORGANIZATION] Failed to delete organization: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete organization"}) + return + } + + c.Status(http.StatusNoContent) +} + +// isValidSlug checks if the slug contains only alphanumeric characters and hyphens +func isValidSlug(s string) bool { + if len(s) == 0 { + return false + } + for _, c := range s { + if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-') { + return false + } + } + return true +} + +// parseJSONRaw parses a JSON string and returns it as a raw interface{} +func parseJSONRaw(s string) interface{} { + s = strings.TrimSpace(s) + if s == "" || s == "null" { + return nil + } + var result interface{} + if err := json.Unmarshal([]byte(s), &result); err != nil { + return s + } + return result +} diff --git a/internal/api/handlers/robot.go b/internal/api/handlers/robot.go index d401a5f..f4b78b0 100644 --- a/internal/api/handlers/robot.go +++ b/internal/api/handlers/robot.go @@ -73,6 +73,9 @@ type CreateRobotResponse struct { func (h *RobotHandler) RegisterRoutes(apiV1 *gin.RouterGroup) { apiV1.GET("/robots", h.ListRobots) apiV1.POST("/robots", h.CreateRobot) + apiV1.GET("/robots/:id", h.GetRobot) + apiV1.PATCH("/robots/:id", h.UpdateRobot) + apiV1.DELETE("/robots/:id", h.DeleteRobot) } // robotRow represents a robot in the database @@ -319,3 +322,245 @@ func (h *RobotHandler) CreateRobot(c *gin.Context) { CreatedAt: createdAt, }) } + +// GetRobot handles getting a single robot by ID. +// +// @Summary Get robot +// @Description Gets a robot by ID +// @Tags robots +// @Accept json +// @Produce json +// @Param id path string true "Robot ID" +// @Success 200 {object} RobotResponse +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /robots/{id} [get] +func (h *RobotHandler) GetRobot(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid robot id"}) + return + } + + query := ` + SELECT + r.id, + r.robot_type_id, + r.device_id, + r.factory_id, + r.status, + r.created_at + FROM robots r + WHERE r.id = ? AND r.deleted_at IS NULL + ` + + var r robotRow + if err := h.db.Get(&r, query, id); err != nil { + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "robot not found"}) + return + } + logger.Printf("[ROBOT] Failed to query robot: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get robot"}) + return + } + + createdAt := "" + if r.CreatedAt.Valid { + createdAt = r.CreatedAt.String + } + + connected := false + connectedAt := "" + if h.recorderHub != nil && h.transferHub != nil { + recConn := h.recorderHub.Get(r.DeviceID) + transConn := h.transferHub.Get(r.DeviceID) + connected = recConn != nil && transConn != nil + + if connected { + t := recConn.ConnectedAt + if transConn.ConnectedAt.After(t) { + t = transConn.ConnectedAt + } + connectedAt = t.UTC().Format(time.RFC3339) + } + } + + c.JSON(http.StatusOK, RobotResponse{ + ID: fmt.Sprintf("%d", r.ID), + RobotTypeID: fmt.Sprintf("%d", r.RobotTypeID), + DeviceID: r.DeviceID, + FactoryID: fmt.Sprintf("%d", r.FactoryID), + Status: r.Status, + CreatedAt: createdAt, + Connected: connected, + ConnectedAt: connectedAt, + }) +} + +// UpdateRobotRequest represents the request body for updating a robot. +type UpdateRobotRequest struct { + Status *string `json:"status,omitempty"` +} + +// UpdateRobot handles updating a robot. +// +// @Summary Update robot +// @Description Updates an existing robot +// @Tags robots +// @Accept json +// @Produce json +// @Param id path string true "Robot ID" +// @Param body body UpdateRobotRequest true "Robot payload" +// @Success 200 {object} RobotResponse +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /robots/{id} [patch] +func (h *RobotHandler) UpdateRobot(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid robot id"}) + return + } + + var req UpdateRobotRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + + // Validate status if provided + validStatuses := map[string]bool{ + "active": true, + "maintenance": true, + "retired": true, + } + + if req.Status != nil { + status := strings.TrimSpace(*req.Status) + if !validStatuses[status] { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid status, must be one of: active, maintenance, retired"}) + return + } + + // Check if robot exists + var exists bool + err := h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM robots WHERE id = ? AND deleted_at IS NULL)", id) + if err != nil || !exists { + c.JSON(http.StatusNotFound, gin.H{"error": "robot not found"}) + return + } + + updatedAt := time.Now().UTC().Format("2006-01-02 15:04:05") + + _, err = h.db.Exec("UPDATE robots SET status = ?, updated_at = ? WHERE id = ?", status, updatedAt, id) + if err != nil { + logger.Printf("[ROBOT] Failed to update robot: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update robot"}) + return + } + } + + // Fetch the updated robot + var r robotRow + err = h.db.Get(&r, ` + SELECT + r.id, + r.robot_type_id, + r.device_id, + r.factory_id, + r.status, + r.created_at + FROM robots r + WHERE r.id = ? + `, id) + if err != nil { + logger.Printf("[ROBOT] Failed to fetch updated robot: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get updated robot"}) + return + } + + createdAt := "" + if r.CreatedAt.Valid { + createdAt = r.CreatedAt.String + } + + connected := false + connectedAt := "" + if h.recorderHub != nil && h.transferHub != nil { + recConn := h.recorderHub.Get(r.DeviceID) + transConn := h.transferHub.Get(r.DeviceID) + connected = recConn != nil && transConn != nil + + if connected { + t := recConn.ConnectedAt + if transConn.ConnectedAt.After(t) { + t = transConn.ConnectedAt + } + connectedAt = t.UTC().Format(time.RFC3339) + } + } + + c.JSON(http.StatusOK, RobotResponse{ + ID: fmt.Sprintf("%d", r.ID), + RobotTypeID: fmt.Sprintf("%d", r.RobotTypeID), + DeviceID: r.DeviceID, + FactoryID: fmt.Sprintf("%d", r.FactoryID), + Status: r.Status, + CreatedAt: createdAt, + Connected: connected, + ConnectedAt: connectedAt, + }) +} + +// DeleteRobot handles robot deletion requests (soft delete). +// +// @Summary Delete robot +// @Description Soft deletes a robot by ID +// @Tags robots +// @Accept json +// @Produce json +// @Param id path string true "Robot ID" +// @Success 204 +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /robots/{id} [delete] +func (h *RobotHandler) DeleteRobot(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid robot id"}) + return + } + + // Check if robot exists + var exists bool + err = h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM robots WHERE id = ? AND deleted_at IS NULL)", id) + if err != nil { + logger.Printf("[ROBOT] Failed to check robot existence: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete robot"}) + return + } + + if !exists { + c.JSON(http.StatusNotFound, gin.H{"error": "robot not found"}) + return + } + + updatedAt := time.Now().UTC().Format("2006-01-02 15:04:05") + + // Perform soft delete by setting deleted_at + _, err = h.db.Exec("UPDATE robots SET deleted_at = NOW(), updated_at = ? WHERE id = ?", updatedAt, id) + if err != nil { + logger.Printf("[ROBOT] Failed to delete robot: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete robot"}) + return + } + + c.Status(http.StatusNoContent) +} diff --git a/internal/api/handlers/robot_type.go b/internal/api/handlers/robot_type.go index ad9f685..b307c1a 100644 --- a/internal/api/handlers/robot_type.go +++ b/internal/api/handlers/robot_type.go @@ -8,7 +8,9 @@ package handlers import ( "database/sql" "encoding/json" + "fmt" "net/http" + "strconv" "strings" "time" @@ -62,6 +64,9 @@ type RobotTypeListResponse struct { func (h *RobotTypeHandler) RegisterRoutes(apiV1 *gin.RouterGroup) { apiV1.GET("/robot_types", h.ListRobotTypes) apiV1.POST("/robot_types", h.CreateRobotType) + apiV1.GET("/robot_types/:id", h.GetRobotType) + apiV1.PATCH("/robot_types/:id", h.UpdateRobotType) + apiV1.DELETE("/robot_types/:id", h.DeleteRobotType) } // robotTypeRow represents a robot type in the database @@ -236,3 +241,239 @@ func toNullableJSONArray(values []string) sql.NullString { } return sql.NullString{String: string(data), Valid: true} } + +// GetRobotType handles getting a single robot type by ID. +// +// @Summary Get robot type +// @Description Gets a robot type by ID +// @Tags robot_types +// @Accept json +// @Produce json +// @Param id path string true "Robot Type ID" +// @Success 200 {object} RobotTypeResponse +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /robot_types/{id} [get] +func (h *RobotTypeHandler) GetRobotType(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid robot type id"}) + return + } + + query := ` + SELECT + id, + name, + model, + ros_topics, + created_at, + updated_at + FROM robot_types + WHERE id = ? AND deleted_at IS NULL + ` + + var rt robotTypeRow + if err := h.db.Get(&rt, query, id); err != nil { + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "robot type not found"}) + return + } + logger.Printf("[ROBOT] Failed to query robot type: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get robot type"}) + return + } + + var topics []string + if rt.ROSTopics.Valid && rt.ROSTopics.String != "" { + topics = parseJSONArray(rt.ROSTopics.String) + } + + createdAt := "" + if rt.CreatedAt.Valid { + createdAt = rt.CreatedAt.String + } + + updatedAt := "" + if rt.UpdatedAt.Valid { + updatedAt = rt.UpdatedAt.String + } + + c.JSON(http.StatusOK, RobotTypeResponse{ + ID: rt.ID, + Name: rt.Name, + Model: rt.Model, + ROSTopics: topics, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + }) +} + +// UpdateRobotTypeRequest represents the request body for updating a robot type. +type UpdateRobotTypeRequest struct { + Name *string `json:"name,omitempty"` + Model *string `json:"model,omitempty"` + ROSTopics *[]string `json:"ros_topics,omitempty"` +} + +// UpdateRobotType handles updating a robot type. +// +// @Summary Update robot type +// @Description Updates an existing robot type +// @Tags robot_types +// @Accept json +// @Produce json +// @Param id path string true "Robot Type ID" +// @Param body body UpdateRobotTypeRequest true "Robot Type payload" +// @Success 200 {object} RobotTypeResponse +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /robot_types/{id} [patch] +func (h *RobotTypeHandler) UpdateRobotType(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid robot type id"}) + return + } + + var req UpdateRobotTypeRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + + // Build update query dynamically + updates := []string{} + args := []interface{}{} + + if req.Name != nil { + name := strings.TrimSpace(*req.Name) + if name != "" { + updates = append(updates, "name = ?") + args = append(args, name) + } + } + + if req.Model != nil { + model := strings.TrimSpace(*req.Model) + if model != "" { + updates = append(updates, "model = ?") + args = append(args, model) + } + } + + if req.ROSTopics != nil { + updates = append(updates, "ros_topics = ?") + args = append(args, toNullableJSONArray(*req.ROSTopics)) + } + + if len(updates) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) + return + } + + now := time.Now().UTC() + updates = append(updates, "updated_at = ?") + args = append(args, now) + args = append(args, id) + + query := fmt.Sprintf("UPDATE robot_types SET %s WHERE id = ? AND deleted_at IS NULL", strings.Join(updates, ", ")) + + result, err := h.db.Exec(query, args...) + if err != nil { + logger.Printf("[ROBOT] Failed to update robot type: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update robot type"}) + return + } + + rowsAffected, _ := result.RowsAffected() + if rowsAffected == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "robot type not found"}) + return + } + + // Fetch the updated robot type + var rt robotTypeRow + err = h.db.Get(&rt, "SELECT id, name, model, ros_topics, created_at, updated_at FROM robot_types WHERE id = ?", id) + if err != nil { + logger.Printf("[ROBOT] Failed to fetch updated robot type: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get updated robot type"}) + return + } + + var topics []string + if rt.ROSTopics.Valid && rt.ROSTopics.String != "" { + topics = parseJSONArray(rt.ROSTopics.String) + } + + createdAt := "" + if rt.CreatedAt.Valid { + createdAt = rt.CreatedAt.String + } + + updatedAt := "" + if rt.UpdatedAt.Valid { + updatedAt = rt.UpdatedAt.String + } + + c.JSON(http.StatusOK, RobotTypeResponse{ + ID: rt.ID, + Name: rt.Name, + Model: rt.Model, + ROSTopics: topics, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + }) +} + +// DeleteRobotType handles robot type deletion requests (soft delete). +// +// @Summary Delete robot type +// @Description Soft deletes a robot type by ID +// @Tags robot_types +// @Accept json +// @Produce json +// @Param id path string true "Robot Type ID" +// @Success 204 +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /robot_types/{id} [delete] +func (h *RobotTypeHandler) DeleteRobotType(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid robot type id"}) + return + } + + // Check if robot type exists + var exists bool + err = h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM robot_types WHERE id = ? AND deleted_at IS NULL)", id) + if err != nil { + logger.Printf("[ROBOT] Failed to check robot type existence: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete robot type"}) + return + } + + if !exists { + c.JSON(http.StatusNotFound, gin.H{"error": "robot type not found"}) + return + } + + now := time.Now().UTC() + + // Perform soft delete by setting deleted_at + _, err = h.db.Exec("UPDATE robot_types SET deleted_at = ?, updated_at = ? WHERE id = ?", now, now, id) + if err != nil { + logger.Printf("[ROBOT] Failed to delete robot type: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete robot type"}) + return + } + + c.Status(http.StatusNoContent) +} diff --git a/internal/api/handlers/scene.go b/internal/api/handlers/scene.go new file mode 100644 index 0000000..264286a --- /dev/null +++ b/internal/api/handlers/scene.go @@ -0,0 +1,598 @@ +// SPDX-FileCopyrightText: 2026 ArcheBase +// +// SPDX-License-Identifier: MulanPSL-2.0 + +// Package handlers provides HTTP request handlers for Keystone Edge API +package handlers + +import ( + "database/sql" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "archebase.com/keystone-edge/internal/logger" + "github.com/gin-gonic/gin" + "github.com/jmoiron/sqlx" +) + +// SceneHandler handles scene related HTTP requests. +type SceneHandler struct { + db *sqlx.DB +} + +// NewSceneHandler creates a new SceneHandler. +func NewSceneHandler(db *sqlx.DB) *SceneHandler { + return &SceneHandler{db: db} +} + +// SceneResponse represents a scene in the response. +type SceneResponse struct { + ID string `json:"id"` + OrganizationID string `json:"organization_id"` + FactoryID string `json:"factory_id"` + Name string `json:"name"` + Slug string `json:"slug"` + Description string `json:"description,omitempty"` + InitialSceneLayoutTemplate string `json:"initial_scene_layout_template,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +// SceneListResponse represents the response for listing scenes. +type SceneListResponse struct { + Scenes []SceneResponse `json:"scenes"` +} + +// CreateSceneRequest represents the request body for creating a scene. +type CreateSceneRequest struct { + OrganizationID string `json:"organization_id"` + FactoryID string `json:"factory_id"` + Name string `json:"name"` + Slug string `json:"slug"` + Description string `json:"description,omitempty"` + InitialSceneLayoutTemplate string `json:"initial_scene_layout_template,omitempty"` +} + +// CreateSceneResponse represents the response for creating a scene. +type CreateSceneResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + CreatedAt string `json:"created_at"` +} + +// UpdateSceneRequest represents the request body for updating a scene. +type UpdateSceneRequest struct { + Name *string `json:"name,omitempty"` + Slug *string `json:"slug,omitempty"` + Description *string `json:"description,omitempty"` + InitialSceneLayoutTemplate *string `json:"initial_scene_layout_template,omitempty"` +} + +// RegisterRoutes registers scene related routes. +func (h *SceneHandler) RegisterRoutes(apiV1 *gin.RouterGroup) { + apiV1.GET("/scenes", h.ListScenes) + apiV1.POST("/scenes", h.CreateScene) + apiV1.GET("/scenes/:id", h.GetScene) + apiV1.PATCH("/scenes/:id", h.UpdateScene) + apiV1.DELETE("/scenes/:id", h.DeleteScene) +} + +// sceneRow represents a scene in the database +type sceneRow struct { + ID int64 `db:"id"` + OrganizationID int64 `db:"organization_id"` + FactoryID int64 `db:"factory_id"` + Name string `db:"name"` + Slug string `db:"slug"` + Description sql.NullString `db:"description"` + InitialSceneLayoutTemplate sql.NullString `db:"initial_scene_layout_template"` + CreatedAt sql.NullString `db:"created_at"` + UpdatedAt sql.NullString `db:"updated_at"` +} + +// ListScenes handles scene listing requests with filtering. +// +// @Summary List scenes +// @Description Lists scenes with optional filtering by organization_id and factory_id +// @Tags scenes +// @Accept json +// @Produce json +// @Param organization_id query string false "Filter by organization ID" +// @Param factory_id query string false "Filter by factory ID" +// @Success 200 {object} SceneListResponse +// @Failure 500 {object} map[string]string +// @Router /scenes [get] +func (h *SceneHandler) ListScenes(c *gin.Context) { + orgID := c.Query("organization_id") + factoryID := c.Query("factory_id") + + query := ` + SELECT + id, + organization_id, + factory_id, + name, + slug, + description, + initial_scene_layout_template, + created_at, + updated_at + FROM scenes + WHERE deleted_at IS NULL + ` + args := []interface{}{} + + if orgID != "" { + parsedOrgID, err := strconv.ParseInt(orgID, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid organization_id format"}) + return + } + query += " AND organization_id = ?" + args = append(args, parsedOrgID) + } + + if factoryID != "" { + parsedFactoryID, err := strconv.ParseInt(factoryID, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid factory_id format"}) + return + } + query += " AND factory_id = ?" + args = append(args, parsedFactoryID) + } + + query += " ORDER BY id DESC" + + var dbRows []sceneRow + if err := h.db.Select(&dbRows, query, args...); err != nil { + logger.Printf("[SCENE] Failed to query scenes: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list scenes"}) + return + } + + scenes := []SceneResponse{} + for _, s := range dbRows { + description := "" + if s.Description.Valid { + description = s.Description.String + } + layoutTemplate := "" + if s.InitialSceneLayoutTemplate.Valid { + layoutTemplate = s.InitialSceneLayoutTemplate.String + } + createdAt := "" + if s.CreatedAt.Valid { + createdAt = s.CreatedAt.String + } + updatedAt := "" + if s.UpdatedAt.Valid { + updatedAt = s.UpdatedAt.String + } + + scenes = append(scenes, SceneResponse{ + ID: fmt.Sprintf("%d", s.ID), + OrganizationID: fmt.Sprintf("%d", s.OrganizationID), + FactoryID: fmt.Sprintf("%d", s.FactoryID), + Name: s.Name, + Slug: s.Slug, + Description: description, + InitialSceneLayoutTemplate: layoutTemplate, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + }) + } + + c.JSON(http.StatusOK, SceneListResponse{ + Scenes: scenes, + }) +} + +// GetScene handles getting a single scene by ID. +// +// @Summary Get scene +// @Description Gets a scene by ID +// @Tags scenes +// @Accept json +// @Produce json +// @Param id path string true "Scene ID" +// @Success 200 {object} SceneResponse +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /scenes/{id} [get] +func (h *SceneHandler) GetScene(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid scene id"}) + return + } + + query := ` + SELECT + id, + organization_id, + factory_id, + name, + slug, + description, + initial_scene_layout_template, + created_at, + updated_at + FROM scenes + WHERE id = ? AND deleted_at IS NULL + ` + + var s sceneRow + if err := h.db.Get(&s, query, id); err != nil { + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "scene not found"}) + return + } + logger.Printf("[SCENE] Failed to query scene: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get scene"}) + return + } + + description := "" + if s.Description.Valid { + description = s.Description.String + } + layoutTemplate := "" + if s.InitialSceneLayoutTemplate.Valid { + layoutTemplate = s.InitialSceneLayoutTemplate.String + } + createdAt := "" + if s.CreatedAt.Valid { + createdAt = s.CreatedAt.String + } + updatedAt := "" + if s.UpdatedAt.Valid { + updatedAt = s.UpdatedAt.String + } + + c.JSON(http.StatusOK, SceneResponse{ + ID: fmt.Sprintf("%d", s.ID), + OrganizationID: fmt.Sprintf("%d", s.OrganizationID), + FactoryID: fmt.Sprintf("%d", s.FactoryID), + Name: s.Name, + Slug: s.Slug, + Description: description, + InitialSceneLayoutTemplate: layoutTemplate, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + }) +} + +// CreateScene handles scene creation requests. +// +// @Summary Create scene +// @Description Creates a new scene +// @Tags scenes +// @Accept json +// @Produce json +// @Param body body CreateSceneRequest true "Scene payload" +// @Success 201 {object} CreateSceneResponse +// @Failure 400 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /scenes [post] +func (h *SceneHandler) CreateScene(c *gin.Context) { + var req CreateSceneRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + + req.OrganizationID = strings.TrimSpace(req.OrganizationID) + req.FactoryID = strings.TrimSpace(req.FactoryID) + req.Name = strings.TrimSpace(req.Name) + req.Slug = strings.TrimSpace(req.Slug) + req.Description = strings.TrimSpace(req.Description) + + if req.OrganizationID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "organization_id is required"}) + return + } + + if req.FactoryID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "factory_id is required"}) + return + } + + if req.Name == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"}) + return + } + + if req.Slug == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "slug is required"}) + return + } + + // Parse organization_id + orgID, err := strconv.ParseInt(req.OrganizationID, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid organization_id format"}) + return + } + + // Parse factory_id + factoryID, err := strconv.ParseInt(req.FactoryID, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid factory_id format"}) + return + } + + // Verify organization exists + var exists bool + err = h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM organizations WHERE id = ? AND deleted_at IS NULL)", orgID) + if err != nil || !exists { + c.JSON(http.StatusBadRequest, gin.H{"error": "organization not found"}) + return + } + + // Verify factory exists + err = h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM factories WHERE id = ? AND deleted_at IS NULL)", factoryID) + if err != nil || !exists { + c.JSON(http.StatusBadRequest, gin.H{"error": "factory not found"}) + return + } + + // Check if slug already exists for this organization + err = h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM scenes WHERE organization_id = ? AND slug = ? AND deleted_at IS NULL)", orgID, req.Slug) + if err == nil && exists { + c.JSON(http.StatusBadRequest, gin.H{"error": "slug already exists for this organization"}) + return + } + + var descriptionStr sql.NullString + if req.Description != "" { + descriptionStr = sql.NullString{String: req.Description, Valid: true} + } + + var layoutTemplateStr sql.NullString + if req.InitialSceneLayoutTemplate != "" { + layoutTemplateStr = sql.NullString{String: req.InitialSceneLayoutTemplate, Valid: true} + } + + now := time.Now().UTC() + + result, err := h.db.Exec( + `INSERT INTO scenes ( + organization_id, + factory_id, + name, + slug, + description, + initial_scene_layout_template, + created_at, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + orgID, + factoryID, + req.Name, + req.Slug, + descriptionStr, + layoutTemplateStr, + now, + now, + ) + if err != nil { + logger.Printf("[SCENE] Failed to insert scene: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create scene"}) + return + } + + id, err := result.LastInsertId() + if err != nil { + logger.Printf("[SCENE] Failed to fetch inserted id: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create scene"}) + return + } + + c.JSON(http.StatusCreated, CreateSceneResponse{ + ID: fmt.Sprintf("%d", id), + Name: req.Name, + Slug: req.Slug, + CreatedAt: now.Format(time.RFC3339), + }) +} + +// UpdateScene handles updating a scene. +// +// @Summary Update scene +// @Description Updates an existing scene +// @Tags scenes +// @Accept json +// @Produce json +// @Param id path string true "Scene ID" +// @Param body body UpdateSceneRequest true "Scene payload" +// @Success 200 {object} SceneResponse +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /scenes/{id} [patch] +func (h *SceneHandler) UpdateScene(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid scene id"}) + return + } + + var req UpdateSceneRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + + // Check if scene exists + var existing sceneRow + err = h.db.Get(&existing, "SELECT id, organization_id, factory_id, name, slug, description, initial_scene_layout_template, created_at, updated_at FROM scenes WHERE id = ? AND deleted_at IS NULL", id) + if err != nil { + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "scene not found"}) + return + } + logger.Printf("[SCENE] Failed to query scene: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update scene"}) + return + } + + // Build update query dynamically + updates := []string{} + args := []interface{}{} + + if req.Name != nil { + name := strings.TrimSpace(*req.Name) + if name != "" { + updates = append(updates, "name = ?") + args = append(args, name) + } + } + + if req.Slug != nil { + slug := strings.TrimSpace(*req.Slug) + if slug != "" { + // Check if slug already exists for this organization + var exists bool + err := h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM scenes WHERE organization_id = ? AND slug = ? AND id != ? AND deleted_at IS NULL)", existing.OrganizationID, slug, id) + if err == nil && exists { + c.JSON(http.StatusBadRequest, gin.H{"error": "slug already exists for this organization"}) + return + } + updates = append(updates, "slug = ?") + args = append(args, slug) + } + } + + if req.Description != nil { + description := strings.TrimSpace(*req.Description) + var descStr sql.NullString + if description != "" { + descStr = sql.NullString{String: description, Valid: true} + } + updates = append(updates, "description = ?") + args = append(args, descStr) + } + + if req.InitialSceneLayoutTemplate != nil { + layout := strings.TrimSpace(*req.InitialSceneLayoutTemplate) + var layoutStr sql.NullString + if layout != "" { + layoutStr = sql.NullString{String: layout, Valid: true} + } + updates = append(updates, "initial_scene_layout_template = ?") + args = append(args, layoutStr) + } + + if len(updates) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) + return + } + + now := time.Now().UTC() + updates = append(updates, "updated_at = ?") + args = append(args, now) + args = append(args, id) + + query := fmt.Sprintf("UPDATE scenes SET %s WHERE id = ?", strings.Join(updates, ", ")) + + _, err = h.db.Exec(query, args...) + if err != nil { + logger.Printf("[SCENE] Failed to update scene: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update scene"}) + return + } + + // Fetch the updated scene + var s sceneRow + err = h.db.Get(&s, "SELECT id, organization_id, factory_id, name, slug, description, initial_scene_layout_template, created_at, updated_at FROM scenes WHERE id = ?", id) + if err != nil { + logger.Printf("[SCENE] Failed to fetch updated scene: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get updated scene"}) + return + } + + description := "" + if s.Description.Valid { + description = s.Description.String + } + layoutTemplate := "" + if s.InitialSceneLayoutTemplate.Valid { + layoutTemplate = s.InitialSceneLayoutTemplate.String + } + createdAt := "" + if s.CreatedAt.Valid { + createdAt = s.CreatedAt.String + } + updatedAt := "" + if s.UpdatedAt.Valid { + updatedAt = s.UpdatedAt.String + } + + c.JSON(http.StatusOK, SceneResponse{ + ID: fmt.Sprintf("%d", s.ID), + OrganizationID: fmt.Sprintf("%d", s.OrganizationID), + FactoryID: fmt.Sprintf("%d", s.FactoryID), + Name: s.Name, + Slug: s.Slug, + Description: description, + InitialSceneLayoutTemplate: layoutTemplate, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + }) +} + +// DeleteScene handles scene deletion requests (soft delete). +// +// @Summary Delete scene +// @Description Soft deletes a scene by ID +// @Tags scenes +// @Accept json +// @Produce json +// @Param id path string true "Scene ID" +// @Success 204 +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /scenes/{id} [delete] +func (h *SceneHandler) DeleteScene(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid scene id"}) + return + } + + // Check if scene exists + var exists bool + err = h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM scenes WHERE id = ? AND deleted_at IS NULL)", id) + if err != nil { + logger.Printf("[SCENE] Failed to check scene existence: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete scene"}) + return + } + + if !exists { + c.JSON(http.StatusNotFound, gin.H{"error": "scene not found"}) + return + } + + now := time.Now().UTC() + + // Perform soft delete by setting deleted_at + _, err = h.db.Exec("UPDATE scenes SET deleted_at = ?, updated_at = ? WHERE id = ?", now, now, id) + if err != nil { + logger.Printf("[SCENE] Failed to delete scene: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete scene"}) + return + } + + c.Status(http.StatusNoContent) +} diff --git a/internal/api/handlers/skill.go b/internal/api/handlers/skill.go new file mode 100644 index 0000000..b1548a5 --- /dev/null +++ b/internal/api/handlers/skill.go @@ -0,0 +1,526 @@ +// SPDX-FileCopyrightText: 2026 ArcheBase +// +// SPDX-License-Identifier: MulanPSL-2.0 + +// Package handlers provides HTTP request handlers for Keystone Edge API +package handlers + +import ( + "database/sql" + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "archebase.com/keystone-edge/internal/logger" + "github.com/gin-gonic/gin" + "github.com/jmoiron/sqlx" +) + +// SkillHandler handles skill related HTTP requests. +type SkillHandler struct { + db *sqlx.DB +} + +// NewSkillHandler creates a new SkillHandler. +func NewSkillHandler(db *sqlx.DB) *SkillHandler { + return &SkillHandler{db: db} +} + +// SkillResponse represents a skill in the response. +type SkillResponse struct { + ID string `json:"id"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + Description string `json:"description,omitempty"` + Version string `json:"version,omitempty"` + Metadata interface{} `json:"metadata,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +// SkillListResponse represents the response for listing skills. +type SkillListResponse struct { + Skills []SkillResponse `json:"skills"` +} + +// CreateSkillRequest represents the request body for creating a skill. +type CreateSkillRequest struct { + Name string `json:"name"` + DisplayName string `json:"display_name"` + Description string `json:"description,omitempty"` + Version string `json:"version,omitempty"` + Metadata interface{} `json:"metadata,omitempty"` +} + +// CreateSkillResponse represents the response for creating a skill. +type CreateSkillResponse struct { + ID string `json:"id"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + CreatedAt string `json:"created_at"` +} + +// UpdateSkillRequest represents the request body for updating a skill. +type UpdateSkillRequest struct { + DisplayName *string `json:"display_name,omitempty"` + Description *string `json:"description,omitempty"` + Version *string `json:"version,omitempty"` + Metadata interface{} `json:"metadata,omitempty"` +} + +// RegisterRoutes registers skill related routes. +func (h *SkillHandler) RegisterRoutes(apiV1 *gin.RouterGroup) { + apiV1.GET("/skills", h.ListSkills) + apiV1.POST("/skills", h.CreateSkill) + apiV1.GET("/skills/:id", h.GetSkill) + apiV1.PATCH("/skills/:id", h.UpdateSkill) + apiV1.DELETE("/skills/:id", h.DeleteSkill) +} + +// skillRow represents a skill in the database +type skillRow struct { + ID int64 `db:"id"` + Name string `db:"name"` + DisplayName string `db:"display_name"` + Description sql.NullString `db:"description"` + Version sql.NullString `db:"version"` + Metadata sql.NullString `db:"metadata"` + CreatedAt sql.NullString `db:"created_at"` + UpdatedAt sql.NullString `db:"updated_at"` +} + +// ListSkills handles skill listing requests. +// +// @Summary List skills +// @Description Lists all skills +// @Tags skills +// @Accept json +// @Produce json +// @Success 200 {object} SkillListResponse +// @Failure 500 {object} map[string]string +// @Router /skills [get] +func (h *SkillHandler) ListSkills(c *gin.Context) { + query := ` + SELECT + id, + name, + display_name, + description, + version, + metadata, + created_at, + updated_at + FROM skills + WHERE deleted_at IS NULL + ORDER BY id DESC + ` + + var dbRows []skillRow + if err := h.db.Select(&dbRows, query); err != nil { + logger.Printf("[SKILL] Failed to query skills: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list skills"}) + return + } + + skills := []SkillResponse{} + for _, s := range dbRows { + description := "" + if s.Description.Valid { + description = s.Description.String + } + version := "1.0" + if s.Version.Valid { + version = s.Version.String + } + var metadata interface{} + if s.Metadata.Valid && s.Metadata.String != "" && s.Metadata.String != "null" { + metadata = parseJSONRaw(s.Metadata.String) + } + createdAt := "" + if s.CreatedAt.Valid { + createdAt = s.CreatedAt.String + } + updatedAt := "" + if s.UpdatedAt.Valid { + updatedAt = s.UpdatedAt.String + } + + skills = append(skills, SkillResponse{ + ID: fmt.Sprintf("%d", s.ID), + Name: s.Name, + DisplayName: s.DisplayName, + Description: description, + Version: version, + Metadata: metadata, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + }) + } + + c.JSON(http.StatusOK, SkillListResponse{ + Skills: skills, + }) +} + +// GetSkill handles getting a single skill by ID. +// +// @Summary Get skill +// @Description Gets a skill by ID +// @Tags skills +// @Accept json +// @Produce json +// @Param id path string true "Skill ID" +// @Success 200 {object} SkillResponse +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /skills/{id} [get] +func (h *SkillHandler) GetSkill(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid skill id"}) + return + } + + query := ` + SELECT + id, + name, + display_name, + description, + version, + metadata, + created_at, + updated_at + FROM skills + WHERE id = ? AND deleted_at IS NULL + ` + + var s skillRow + if err := h.db.Get(&s, query, id); err != nil { + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "skill not found"}) + return + } + logger.Printf("[SKILL] Failed to query skill: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get skill"}) + return + } + + description := "" + if s.Description.Valid { + description = s.Description.String + } + version := "1.0" + if s.Version.Valid { + version = s.Version.String + } + var metadata interface{} + if s.Metadata.Valid && s.Metadata.String != "" && s.Metadata.String != "null" { + metadata = parseJSONRaw(s.Metadata.String) + } + createdAt := "" + if s.CreatedAt.Valid { + createdAt = s.CreatedAt.String + } + updatedAt := "" + if s.UpdatedAt.Valid { + updatedAt = s.UpdatedAt.String + } + + c.JSON(http.StatusOK, SkillResponse{ + ID: fmt.Sprintf("%d", s.ID), + Name: s.Name, + DisplayName: s.DisplayName, + Description: description, + Version: version, + Metadata: metadata, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + }) +} + +// CreateSkill handles skill creation requests. +// +// @Summary Create skill +// @Description Creates a new skill +// @Tags skills +// @Accept json +// @Produce json +// @Param body body CreateSkillRequest true "Skill payload" +// @Success 201 {object} CreateSkillResponse +// @Failure 400 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /skills [post] +func (h *SkillHandler) CreateSkill(c *gin.Context) { + var req CreateSkillRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + + req.Name = strings.TrimSpace(req.Name) + req.DisplayName = strings.TrimSpace(req.DisplayName) + req.Description = strings.TrimSpace(req.Description) + + if req.Name == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"}) + return + } + + if req.DisplayName == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "display_name is required"}) + return + } + + // Check if name already exists + var exists bool + err := h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM skills WHERE name = ? AND deleted_at IS NULL)", req.Name) + if err == nil && exists { + c.JSON(http.StatusBadRequest, gin.H{"error": "skill name already exists"}) + return + } + + version := "1.0" + if req.Version != "" { + version = req.Version + } + + var metadataStr sql.NullString + if req.Metadata != nil { + metadataJSON, err := json.Marshal(req.Metadata) + if err == nil { + metadataStr = sql.NullString{String: string(metadataJSON), Valid: true} + } + } + + var descriptionStr sql.NullString + if req.Description != "" { + descriptionStr = sql.NullString{String: req.Description, Valid: true} + } + + now := time.Now().UTC() + + result, err := h.db.Exec( + `INSERT INTO skills ( + name, + display_name, + description, + version, + metadata, + created_at, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?)`, + req.Name, + req.DisplayName, + descriptionStr, + version, + metadataStr, + now, + now, + ) + if err != nil { + logger.Printf("[SKILL] Failed to insert skill: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create skill"}) + return + } + + id, err := result.LastInsertId() + if err != nil { + logger.Printf("[SKILL] Failed to fetch inserted id: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create skill"}) + return + } + + c.JSON(http.StatusCreated, CreateSkillResponse{ + ID: fmt.Sprintf("%d", id), + Name: req.Name, + DisplayName: req.DisplayName, + CreatedAt: now.Format(time.RFC3339), + }) +} + +// UpdateSkill handles updating a skill. +// +// @Summary Update skill +// @Description Updates an existing skill +// @Tags skills +// @Accept json +// @Produce json +// @Param id path string true "Skill ID" +// @Param body body UpdateSkillRequest true "Skill payload" +// @Success 200 {object} SkillResponse +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /skills/{id} [patch] +func (h *SkillHandler) UpdateSkill(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid skill id"}) + return + } + + var req UpdateSkillRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + + // Build update query dynamically + updates := []string{} + args := []interface{}{} + + if req.DisplayName != nil { + displayName := strings.TrimSpace(*req.DisplayName) + if displayName != "" { + updates = append(updates, "display_name = ?") + args = append(args, displayName) + } + } + + if req.Description != nil { + description := strings.TrimSpace(*req.Description) + var descStr sql.NullString + if description != "" { + descStr = sql.NullString{String: description, Valid: true} + } + updates = append(updates, "description = ?") + args = append(args, descStr) + } + + if req.Version != nil { + version := strings.TrimSpace(*req.Version) + if version != "" { + updates = append(updates, "version = ?") + args = append(args, version) + } + } + + if req.Metadata != nil { + metadataJSON, err := json.Marshal(req.Metadata) + if err == nil { + updates = append(updates, "metadata = ?") + args = append(args, sql.NullString{String: string(metadataJSON), Valid: true}) + } + } + + if len(updates) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) + return + } + + now := time.Now().UTC() + updates = append(updates, "updated_at = ?") + args = append(args, now) + args = append(args, id) + + query := fmt.Sprintf("UPDATE skills SET %s WHERE id = ? AND deleted_at IS NULL", strings.Join(updates, ", ")) + + result, err := h.db.Exec(query, args...) + if err != nil { + logger.Printf("[SKILL] Failed to update skill: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update skill"}) + return + } + + rowsAffected, _ := result.RowsAffected() + if rowsAffected == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "skill not found"}) + return + } + + // Fetch the updated skill + var s skillRow + err = h.db.Get(&s, "SELECT id, name, display_name, description, version, metadata, created_at, updated_at FROM skills WHERE id = ?", id) + if err != nil { + logger.Printf("[SKILL] Failed to fetch updated skill: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get updated skill"}) + return + } + + description := "" + if s.Description.Valid { + description = s.Description.String + } + version := "1.0" + if s.Version.Valid { + version = s.Version.String + } + var metadata interface{} + if s.Metadata.Valid && s.Metadata.String != "" && s.Metadata.String != "null" { + metadata = parseJSONRaw(s.Metadata.String) + } + createdAt := "" + if s.CreatedAt.Valid { + createdAt = s.CreatedAt.String + } + updatedAt := "" + if s.UpdatedAt.Valid { + updatedAt = s.UpdatedAt.String + } + + c.JSON(http.StatusOK, SkillResponse{ + ID: fmt.Sprintf("%d", s.ID), + Name: s.Name, + DisplayName: s.DisplayName, + Description: description, + Version: version, + Metadata: metadata, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + }) +} + +// DeleteSkill handles skill deletion requests (soft delete). +// +// @Summary Delete skill +// @Description Soft deletes a skill by ID +// @Tags skills +// @Accept json +// @Produce json +// @Param id path string true "Skill ID" +// @Success 204 +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /skills/{id} [delete] +func (h *SkillHandler) DeleteSkill(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid skill id"}) + return + } + + // Check if skill exists + var exists bool + err = h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM skills WHERE id = ? AND deleted_at IS NULL)", id) + if err != nil { + logger.Printf("[SKILL] Failed to check skill existence: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete skill"}) + return + } + + if !exists { + c.JSON(http.StatusNotFound, gin.H{"error": "skill not found"}) + return + } + + now := time.Now().UTC() + + // Perform soft delete by setting deleted_at + _, err = h.db.Exec("UPDATE skills SET deleted_at = ?, updated_at = ? WHERE id = ?", now, now, id) + if err != nil { + logger.Printf("[SKILL] Failed to delete skill: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete skill"}) + return + } + + c.Status(http.StatusNoContent) +} diff --git a/internal/api/handlers/sop.go b/internal/api/handlers/sop.go new file mode 100644 index 0000000..6967349 --- /dev/null +++ b/internal/api/handlers/sop.go @@ -0,0 +1,527 @@ +// SPDX-FileCopyrightText: 2026 ArcheBase +// +// SPDX-License-Identifier: MulanPSL-2.0 + +// Package handlers provides HTTP request handlers for Keystone Edge API +package handlers + +import ( + "database/sql" + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "archebase.com/keystone-edge/internal/logger" + "github.com/gin-gonic/gin" + "github.com/jmoiron/sqlx" +) + +// SOPHandler handles SOP (Standard Operating Procedure) related HTTP requests. +type SOPHandler struct { + db *sqlx.DB +} + +// NewSOPHandler creates a new SOPHandler. +func NewSOPHandler(db *sqlx.DB) *SOPHandler { + return &SOPHandler{db: db} +} + +// SOPResponse represents an SOP in the response. +type SOPResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Description string `json:"description,omitempty"` + SkillSequence []string `json:"skill_sequence"` + Version int `json:"version"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +// SOPListResponse represents the response for listing SOPs. +type SOPListResponse struct { + SOPs []SOPResponse `json:"sops"` +} + +// CreateSOPRequest represents the request body for creating an SOP. +type CreateSOPRequest struct { + Name string `json:"name"` + Slug string `json:"slug"` + Description string `json:"description,omitempty"` + SkillSequence []string `json:"skill_sequence"` +} + +// CreateSOPResponse represents the response for creating an SOP. +type CreateSOPResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + SkillSequence []string `json:"skill_sequence"` + Version int `json:"version"` + CreatedAt string `json:"created_at"` +} + +// UpdateSOPRequest represents the request body for updating an SOP. +type UpdateSOPRequest struct { + Name *string `json:"name,omitempty"` + Slug *string `json:"slug,omitempty"` + Description *string `json:"description,omitempty"` + SkillSequence *[]string `json:"skill_sequence,omitempty"` +} + +// RegisterRoutes registers SOP related routes. +func (h *SOPHandler) RegisterRoutes(apiV1 *gin.RouterGroup) { + apiV1.GET("/sops", h.ListSOPs) + apiV1.POST("/sops", h.CreateSOP) + apiV1.GET("/sops/:id", h.GetSOP) + apiV1.PATCH("/sops/:id", h.UpdateSOP) + apiV1.DELETE("/sops/:id", h.DeleteSOP) +} + +// sopRow represents an SOP in the database +type sopRow struct { + ID int64 `db:"id"` + Name string `db:"name"` + Slug string `db:"slug"` + Description sql.NullString `db:"description"` + SkillSequence string `db:"skill_sequence"` + Version int `db:"version"` + CreatedAt sql.NullString `db:"created_at"` + UpdatedAt sql.NullString `db:"updated_at"` +} + +// ListSOPs handles SOP listing requests. +// +// @Summary List SOPs +// @Description Lists all SOPs +// @Tags sops +// @Accept json +// @Produce json +// @Success 200 {object} SOPListResponse +// @Failure 500 {object} map[string]string +// @Router /sops [get] +func (h *SOPHandler) ListSOPs(c *gin.Context) { + query := ` + SELECT + id, + name, + slug, + description, + skill_sequence, + version, + created_at, + updated_at + FROM sops + WHERE deleted_at IS NULL + ORDER BY id DESC + ` + + var dbRows []sopRow + if err := h.db.Select(&dbRows, query); err != nil { + logger.Printf("[SOP] Failed to query SOPs: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list SOPs"}) + return + } + + sops := []SOPResponse{} + for _, s := range dbRows { + description := "" + if s.Description.Valid { + description = s.Description.String + } + skillSequence := parseJSONArray(s.SkillSequence) + createdAt := "" + if s.CreatedAt.Valid { + createdAt = s.CreatedAt.String + } + updatedAt := "" + if s.UpdatedAt.Valid { + updatedAt = s.UpdatedAt.String + } + + sops = append(sops, SOPResponse{ + ID: fmt.Sprintf("%d", s.ID), + Name: s.Name, + Slug: s.Slug, + Description: description, + SkillSequence: skillSequence, + Version: s.Version, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + }) + } + + c.JSON(http.StatusOK, SOPListResponse{ + SOPs: sops, + }) +} + +// GetSOP handles getting a single SOP by ID. +// +// @Summary Get SOP +// @Description Gets an SOP by ID +// @Tags sops +// @Accept json +// @Produce json +// @Param id path string true "SOP ID" +// @Success 200 {object} SOPResponse +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /sops/{id} [get] +func (h *SOPHandler) GetSOP(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid SOP id"}) + return + } + + query := ` + SELECT + id, + name, + slug, + description, + skill_sequence, + version, + created_at, + updated_at + FROM sops + WHERE id = ? AND deleted_at IS NULL + ` + + var s sopRow + if err := h.db.Get(&s, query, id); err != nil { + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "SOP not found"}) + return + } + logger.Printf("[SOP] Failed to query SOP: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get SOP"}) + return + } + + description := "" + if s.Description.Valid { + description = s.Description.String + } + skillSequence := parseJSONArray(s.SkillSequence) + createdAt := "" + if s.CreatedAt.Valid { + createdAt = s.CreatedAt.String + } + updatedAt := "" + if s.UpdatedAt.Valid { + updatedAt = s.UpdatedAt.String + } + + c.JSON(http.StatusOK, SOPResponse{ + ID: fmt.Sprintf("%d", s.ID), + Name: s.Name, + Slug: s.Slug, + Description: description, + SkillSequence: skillSequence, + Version: s.Version, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + }) +} + +// CreateSOP handles SOP creation requests. +// +// @Summary Create SOP +// @Description Creates a new SOP +// @Tags sops +// @Accept json +// @Produce json +// @Param body body CreateSOPRequest true "SOP payload" +// @Success 201 {object} CreateSOPResponse +// @Failure 400 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /sops [post] +func (h *SOPHandler) CreateSOP(c *gin.Context) { + var req CreateSOPRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + + req.Name = strings.TrimSpace(req.Name) + req.Slug = strings.TrimSpace(req.Slug) + req.Description = strings.TrimSpace(req.Description) + + if req.Name == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"}) + return + } + + if req.Slug == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "slug is required"}) + return + } + + // Validate slug format + if !isValidSlug(req.Slug) { + c.JSON(http.StatusBadRequest, gin.H{"error": "slug must contain only alphanumeric characters and hyphens"}) + return + } + + // Check if slug already exists + var exists bool + err := h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM sops WHERE slug = ? AND deleted_at IS NULL)", req.Slug) + if err == nil && exists { + c.JSON(http.StatusBadRequest, gin.H{"error": "slug already exists"}) + return + } + + // Validate skill_sequence + if len(req.SkillSequence) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "skill_sequence is required and must not be empty"}) + return + } + + // Convert skill_sequence to JSON string + skillSeqJSON, err := json.Marshal(req.SkillSequence) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to process skill_sequence"}) + return + } + + var descriptionStr sql.NullString + if req.Description != "" { + descriptionStr = sql.NullString{String: req.Description, Valid: true} + } + + now := time.Now().UTC() + + result, err := h.db.Exec( + `INSERT INTO sops ( + name, + slug, + description, + skill_sequence, + version, + created_at, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?)`, + req.Name, + req.Slug, + descriptionStr, + string(skillSeqJSON), + 1, + now, + now, + ) + if err != nil { + logger.Printf("[SOP] Failed to insert SOP: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create SOP"}) + return + } + + id, err := result.LastInsertId() + if err != nil { + logger.Printf("[SOP] Failed to fetch inserted id: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create SOP"}) + return + } + + c.JSON(http.StatusCreated, CreateSOPResponse{ + ID: fmt.Sprintf("%d", id), + Name: req.Name, + Slug: req.Slug, + SkillSequence: req.SkillSequence, + Version: 1, + CreatedAt: now.Format(time.RFC3339), + }) +} + +// UpdateSOP handles updating an SOP. +// +// @Summary Update SOP +// @Description Updates an existing SOP +// @Tags sops +// @Accept json +// @Produce json +// @Param id path string true "SOP ID" +// @Param body body UpdateSOPRequest true "SOP payload" +// @Success 200 {object} SOPResponse +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /sops/{id} [patch] +func (h *SOPHandler) UpdateSOP(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid SOP id"}) + return + } + + var req UpdateSOPRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + + // Build update query dynamically + updates := []string{} + args := []interface{}{} + + if req.Name != nil { + name := strings.TrimSpace(*req.Name) + if name != "" { + updates = append(updates, "name = ?") + args = append(args, name) + } + } + + if req.Slug != nil { + slug := strings.TrimSpace(*req.Slug) + if slug != "" { + if !isValidSlug(slug) { + c.JSON(http.StatusBadRequest, gin.H{"error": "slug must contain only alphanumeric characters and hyphens"}) + return + } + // Check if slug already exists for another SOP + var exists bool + err := h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM sops WHERE slug = ? AND id != ? AND deleted_at IS NULL)", slug, id) + if err == nil && exists { + c.JSON(http.StatusBadRequest, gin.H{"error": "slug already exists"}) + return + } + updates = append(updates, "slug = ?") + args = append(args, slug) + } + } + + if req.Description != nil { + description := strings.TrimSpace(*req.Description) + var descStr sql.NullString + if description != "" { + descStr = sql.NullString{String: description, Valid: true} + } + updates = append(updates, "description = ?") + args = append(args, descStr) + } + + if req.SkillSequence != nil { + skillSeqJSON, err := json.Marshal(*req.SkillSequence) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "failed to process skill_sequence"}) + return + } + updates = append(updates, "skill_sequence = ?") + args = append(args, string(skillSeqJSON)) + } + + if len(updates) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) + return + } + + now := time.Now().UTC() + updates = append(updates, "updated_at = ?") + args = append(args, now) + args = append(args, id) + + query := fmt.Sprintf("UPDATE sops SET %s WHERE id = ? AND deleted_at IS NULL", strings.Join(updates, ", ")) + + result, err := h.db.Exec(query, args...) + if err != nil { + logger.Printf("[SOP] Failed to update SOP: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update SOP"}) + return + } + + rowsAffected, _ := result.RowsAffected() + if rowsAffected == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "SOP not found"}) + return + } + + // Fetch the updated SOP + var s sopRow + err = h.db.Get(&s, "SELECT id, name, slug, description, skill_sequence, version, created_at, updated_at FROM sops WHERE id = ?", id) + if err != nil { + logger.Printf("[SOP] Failed to fetch updated SOP: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get updated SOP"}) + return + } + + description := "" + if s.Description.Valid { + description = s.Description.String + } + skillSequence := parseJSONArray(s.SkillSequence) + createdAt := "" + if s.CreatedAt.Valid { + createdAt = s.CreatedAt.String + } + updatedAt := "" + if s.UpdatedAt.Valid { + updatedAt = s.UpdatedAt.String + } + + c.JSON(http.StatusOK, SOPResponse{ + ID: fmt.Sprintf("%d", s.ID), + Name: s.Name, + Slug: s.Slug, + Description: description, + SkillSequence: skillSequence, + Version: s.Version, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + }) +} + +// DeleteSOP handles SOP deletion requests (soft delete). +// +// @Summary Delete SOP +// @Description Soft deletes an SOP by ID +// @Tags sops +// @Accept json +// @Produce json +// @Param id path string true "SOP ID" +// @Success 204 +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /sops/{id} [delete] +func (h *SOPHandler) DeleteSOP(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid SOP id"}) + return + } + + // Check if SOP exists + var exists bool + err = h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM sops WHERE id = ? AND deleted_at IS NULL)", id) + if err != nil { + logger.Printf("[SOP] Failed to check SOP existence: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete SOP"}) + return + } + + if !exists { + c.JSON(http.StatusNotFound, gin.H{"error": "SOP not found"}) + return + } + + now := time.Now().UTC() + + // Perform soft delete by setting deleted_at + _, err = h.db.Exec("UPDATE sops SET deleted_at = ?, updated_at = ? WHERE id = ?", now, now, id) + if err != nil { + logger.Printf("[SOP] Failed to delete SOP: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete SOP"}) + return + } + + c.Status(http.StatusNoContent) +} diff --git a/internal/api/handlers/station.go b/internal/api/handlers/station.go index be2c593..4d56193 100644 --- a/internal/api/handlers/station.go +++ b/internal/api/handlers/station.go @@ -58,7 +58,9 @@ type StationResponse struct { func (h *StationHandler) RegisterRoutes(apiV1 *gin.RouterGroup) { apiV1.POST("/stations", h.CreateStation) apiV1.GET("/stations", h.ListStations) + apiV1.GET("/stations/:id", h.GetStation) apiV1.PATCH("/stations/:id", h.UpdateStation) + apiV1.DELETE("/stations/:id", h.DeleteStation) } // robotInfoRow represents robot info retrieved from DB @@ -531,3 +533,139 @@ func (h *StationHandler) UpdateStation(c *gin.Context) { CreatedAt: createdAtStr, }) } + +// GetStation handles getting a single station by ID. +// +// @Summary Get station +// @Description Gets a station by ID +// @Tags stations +// @Accept json +// @Produce json +// @Param id path string true "Station ID (e.g., ws_001)" +// @Success 200 {object} StationResponse +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /stations/{id} [get] +func (h *StationHandler) GetStation(c *gin.Context) { + stationIDStr := c.Param("id") + + // Parse station ID (format: ws_XXX) + if !strings.HasPrefix(stationIDStr, "ws_") { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid station ID format, expected ws_XXX"}) + return + } + + idStr := strings.TrimPrefix(stationIDStr, "ws_") + var stationID int64 + _, err := fmt.Sscanf(idStr, "%d", &stationID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid station ID format, expected ws_XXX"}) + return + } + + var station stationListRow + err = h.db.Get(&station, ` + SELECT + id, robot_id, robot_name, robot_serial, + data_collector_id, collector_name, collector_operator_id, + factory_id, name, status, created_at + FROM workstations + WHERE id = ? AND deleted_at IS NULL + `, stationID) + if err != nil { + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "station not found"}) + return + } + logger.Printf("[STATION] Failed to query station: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get station"}) + return + } + + // Get factory slug + var factory factoryInfoRow + err = h.db.Get(&factory, "SELECT id, slug FROM factories WHERE id = ?", station.FactoryID) + if err != nil { + logger.Printf("[STATION] Failed to get factory: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get station"}) + return + } + + var createdAtStr string + if station.CreatedAt.Valid { + createdAt, _ := time.Parse("2006-01-02 15:04:05", station.CreatedAt.String) + createdAtStr = createdAt.Format(time.RFC3339) + } + + c.JSON(http.StatusOK, StationResponse{ + ID: fmt.Sprintf("ws_%d", station.ID), + RobotID: fmt.Sprintf("robot_%d", station.RobotID), + RobotName: station.RobotName, + RobotSerial: station.RobotSerial, + DataCollectorID: fmt.Sprintf("dc_%d", station.DataCollectorID), + CollectorName: station.CollectorName, + CollectorOperatorID: station.CollectorOperatorID, + FactoryID: factory.Slug, + Status: station.Status, + Name: station.Name.String, + CreatedAt: createdAtStr, + }) +} + +// DeleteStation handles station deletion requests (soft delete). +// +// @Summary Delete station +// @Description Soft deletes a station by ID +// @Tags stations +// @Accept json +// @Produce json +// @Param id path string true "Station ID (e.g., ws_001)" +// @Success 204 +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /stations/{id} [delete] +func (h *StationHandler) DeleteStation(c *gin.Context) { + stationIDStr := c.Param("id") + + // Parse station ID (format: ws_XXX) + if !strings.HasPrefix(stationIDStr, "ws_") { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid station ID format, expected ws_XXX"}) + return + } + + idStr := strings.TrimPrefix(stationIDStr, "ws_") + var stationID int64 + _, err := fmt.Sscanf(idStr, "%d", &stationID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid station ID format, expected ws_XXX"}) + return + } + + // Check if station exists + var exists bool + err = h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM workstations WHERE id = ? AND deleted_at IS NULL)", stationID) + if err != nil { + logger.Printf("[STATION] Failed to check station existence: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete station"}) + return + } + + if !exists { + c.JSON(http.StatusNotFound, gin.H{"error": "station not found"}) + return + } + + now := time.Now().UTC() + + // Perform soft delete by setting deleted_at + _, err = h.db.Exec("UPDATE workstations SET deleted_at = ?, updated_at = ? WHERE id = ?", now, now, stationID) + if err != nil { + logger.Printf("[STATION] Failed to delete station: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete station"}) + return + } + + c.Status(http.StatusNoContent) +} diff --git a/internal/api/handlers/subscene.go b/internal/api/handlers/subscene.go new file mode 100644 index 0000000..c7f174b --- /dev/null +++ b/internal/api/handlers/subscene.go @@ -0,0 +1,586 @@ +// SPDX-FileCopyrightText: 2026 ArcheBase +// +// SPDX-License-Identifier: MulanPSL-2.0 + +// Package handlers provides HTTP request handlers for Keystone Edge API +package handlers + +import ( + "database/sql" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "archebase.com/keystone-edge/internal/logger" + "github.com/gin-gonic/gin" + "github.com/jmoiron/sqlx" +) + +// SubsceneHandler handles subscene related HTTP requests. +type SubsceneHandler struct { + db *sqlx.DB +} + +// NewSubsceneHandler creates a new SubsceneHandler. +func NewSubsceneHandler(db *sqlx.DB) *SubsceneHandler { + return &SubsceneHandler{db: db} +} + +// SubsceneResponse represents a subscene in the response. +type SubsceneResponse struct { + ID string `json:"id"` + SceneID string `json:"scene_id"` + Name string `json:"name"` + Slug string `json:"slug"` + Description string `json:"description,omitempty"` + InitialSceneLayout string `json:"initial_scene_layout,omitempty"` + RobotTypeID string `json:"robot_type_id"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +// SubsceneListResponse represents the response for listing subscenes. +type SubsceneListResponse struct { + Subscenes []SubsceneResponse `json:"subscenes"` +} + +// CreateSubsceneRequest represents the request body for creating a subscene. +type CreateSubsceneRequest struct { + SceneID string `json:"scene_id"` + Name string `json:"name"` + Slug string `json:"slug"` + Description string `json:"description,omitempty"` + InitialSceneLayout string `json:"initial_scene_layout,omitempty"` + RobotTypeID string `json:"robot_type_id"` +} + +// CreateSubsceneResponse represents the response for creating a subscene. +type CreateSubsceneResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + CreatedAt string `json:"created_at"` +} + +// UpdateSubsceneRequest represents the request body for updating a subscene. +type UpdateSubsceneRequest struct { + Name *string `json:"name,omitempty"` + Slug *string `json:"slug,omitempty"` + Description *string `json:"description,omitempty"` + InitialSceneLayout *string `json:"initial_scene_layout,omitempty"` +} + +// RegisterRoutes registers subscene related routes. +func (h *SubsceneHandler) RegisterRoutes(apiV1 *gin.RouterGroup) { + apiV1.GET("/subscenes", h.ListSubscenes) + apiV1.POST("/subscenes", h.CreateSubscene) + apiV1.GET("/subscenes/:id", h.GetSubscene) + apiV1.PATCH("/subscenes/:id", h.UpdateSubscene) + apiV1.DELETE("/subscenes/:id", h.DeleteSubscene) +} + +// subsceneRow represents a subscene in the database +type subsceneRow struct { + ID int64 `db:"id"` + SceneID int64 `db:"scene_id"` + Name string `db:"name"` + Slug string `db:"slug"` + Description sql.NullString `db:"description"` + InitialSceneLayout sql.NullString `db:"initial_scene_layout"` + RobotTypeID int64 `db:"robot_type_id"` + CreatedAt sql.NullString `db:"created_at"` + UpdatedAt sql.NullString `db:"updated_at"` +} + +// ListSubscenes handles subscene listing requests with filtering. +// +// @Summary List subscenes +// @Description Lists subscenes with optional filtering by scene_id +// @Tags subscenes +// @Accept json +// @Produce json +// @Param scene_id query string false "Filter by scene ID" +// @Success 200 {object} SubsceneListResponse +// @Failure 500 {object} map[string]string +// @Router /subscenes [get] +func (h *SubsceneHandler) ListSubscenes(c *gin.Context) { + sceneID := c.Query("scene_id") + + query := ` + SELECT + id, + scene_id, + name, + slug, + description, + initial_scene_layout, + robot_type_id, + created_at, + updated_at + FROM subscenes + WHERE deleted_at IS NULL + ` + args := []interface{}{} + + if sceneID != "" { + parsedSceneID, err := strconv.ParseInt(sceneID, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid scene_id format"}) + return + } + query += " AND scene_id = ?" + args = append(args, parsedSceneID) + } + + query += " ORDER BY id DESC" + + var dbRows []subsceneRow + if err := h.db.Select(&dbRows, query, args...); err != nil { + logger.Printf("[SUBSCENE] Failed to query subscenes: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list subscenes"}) + return + } + + subscenes := []SubsceneResponse{} + for _, s := range dbRows { + description := "" + if s.Description.Valid { + description = s.Description.String + } + layout := "" + if s.InitialSceneLayout.Valid { + layout = s.InitialSceneLayout.String + } + createdAt := "" + if s.CreatedAt.Valid { + createdAt = s.CreatedAt.String + } + updatedAt := "" + if s.UpdatedAt.Valid { + updatedAt = s.UpdatedAt.String + } + + subscenes = append(subscenes, SubsceneResponse{ + ID: fmt.Sprintf("%d", s.ID), + SceneID: fmt.Sprintf("%d", s.SceneID), + Name: s.Name, + Slug: s.Slug, + Description: description, + InitialSceneLayout: layout, + RobotTypeID: fmt.Sprintf("%d", s.RobotTypeID), + CreatedAt: createdAt, + UpdatedAt: updatedAt, + }) + } + + c.JSON(http.StatusOK, SubsceneListResponse{ + Subscenes: subscenes, + }) +} + +// GetSubscene handles getting a single subscene by ID. +// +// @Summary Get subscene +// @Description Gets a subscene by ID +// @Tags subscenes +// @Accept json +// @Produce json +// @Param id path string true "Subscene ID" +// @Success 200 {object} SubsceneResponse +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /subscenes/{id} [get] +func (h *SubsceneHandler) GetSubscene(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid subscene id"}) + return + } + + query := ` + SELECT + id, + scene_id, + name, + slug, + description, + initial_scene_layout, + robot_type_id, + created_at, + updated_at + FROM subscenes + WHERE id = ? AND deleted_at IS NULL + ` + + var s subsceneRow + if err := h.db.Get(&s, query, id); err != nil { + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "subscene not found"}) + return + } + logger.Printf("[SUBSCENE] Failed to query subscene: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get subscene"}) + return + } + + description := "" + if s.Description.Valid { + description = s.Description.String + } + layout := "" + if s.InitialSceneLayout.Valid { + layout = s.InitialSceneLayout.String + } + createdAt := "" + if s.CreatedAt.Valid { + createdAt = s.CreatedAt.String + } + updatedAt := "" + if s.UpdatedAt.Valid { + updatedAt = s.UpdatedAt.String + } + + c.JSON(http.StatusOK, SubsceneResponse{ + ID: fmt.Sprintf("%d", s.ID), + SceneID: fmt.Sprintf("%d", s.SceneID), + Name: s.Name, + Slug: s.Slug, + Description: description, + InitialSceneLayout: layout, + RobotTypeID: fmt.Sprintf("%d", s.RobotTypeID), + CreatedAt: createdAt, + UpdatedAt: updatedAt, + }) +} + +// CreateSubscene handles subscene creation requests. +// +// @Summary Create subscene +// @Description Creates a new subscene +// @Tags subscenes +// @Accept json +// @Produce json +// @Param body body CreateSubsceneRequest true "Subscene payload" +// @Success 201 {object} CreateSubsceneResponse +// @Failure 400 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /subscenes [post] +func (h *SubsceneHandler) CreateSubscene(c *gin.Context) { + var req CreateSubsceneRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + + req.SceneID = strings.TrimSpace(req.SceneID) + req.Name = strings.TrimSpace(req.Name) + req.Slug = strings.TrimSpace(req.Slug) + req.Description = strings.TrimSpace(req.Description) + req.RobotTypeID = strings.TrimSpace(req.RobotTypeID) + + if req.SceneID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "scene_id is required"}) + return + } + + if req.Name == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"}) + return + } + + if req.Slug == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "slug is required"}) + return + } + + if req.RobotTypeID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "robot_type_id is required"}) + return + } + + // Parse scene_id + sceneID, err := strconv.ParseInt(req.SceneID, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid scene_id format"}) + return + } + + // Parse robot_type_id + robotTypeID, err := strconv.ParseInt(req.RobotTypeID, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid robot_type_id format"}) + return + } + + // Verify scene exists + var exists bool + err = h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM scenes WHERE id = ? AND deleted_at IS NULL)", sceneID) + if err != nil || !exists { + c.JSON(http.StatusBadRequest, gin.H{"error": "scene not found"}) + return + } + + // Verify robot_type exists + err = h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM robot_types WHERE id = ? AND deleted_at IS NULL)", robotTypeID) + if err != nil || !exists { + c.JSON(http.StatusBadRequest, gin.H{"error": "robot_type not found"}) + return + } + + // Check if slug already exists for this scene + err = h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM subscenes WHERE scene_id = ? AND slug = ? AND deleted_at IS NULL)", sceneID, req.Slug) + if err == nil && exists { + c.JSON(http.StatusBadRequest, gin.H{"error": "slug already exists for this scene"}) + return + } + + var descriptionStr sql.NullString + if req.Description != "" { + descriptionStr = sql.NullString{String: req.Description, Valid: true} + } + + var layoutStr sql.NullString + if req.InitialSceneLayout != "" { + layoutStr = sql.NullString{String: req.InitialSceneLayout, Valid: true} + } + + now := time.Now().UTC() + + result, err := h.db.Exec( + `INSERT INTO subscenes ( + scene_id, + name, + slug, + description, + initial_scene_layout, + robot_type_id, + created_at, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + sceneID, + req.Name, + req.Slug, + descriptionStr, + layoutStr, + robotTypeID, + now, + now, + ) + if err != nil { + logger.Printf("[SUBSCENE] Failed to insert subscene: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create subscene"}) + return + } + + id, err := result.LastInsertId() + if err != nil { + logger.Printf("[SUBSCENE] Failed to fetch inserted id: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create subscene"}) + return + } + + c.JSON(http.StatusCreated, CreateSubsceneResponse{ + ID: fmt.Sprintf("%d", id), + Name: req.Name, + Slug: req.Slug, + CreatedAt: now.Format(time.RFC3339), + }) +} + +// UpdateSubscene handles updating a subscene. +// +// @Summary Update subscene +// @Description Updates an existing subscene +// @Tags subscenes +// @Accept json +// @Produce json +// @Param id path string true "Subscene ID" +// @Param body body UpdateSubsceneRequest true "Subscene payload" +// @Success 200 {object} SubsceneResponse +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /subscenes/{id} [patch] +func (h *SubsceneHandler) UpdateSubscene(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid subscene id"}) + return + } + + var req UpdateSubsceneRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + + // Check if subscene exists + var existing subsceneRow + err = h.db.Get(&existing, "SELECT id, scene_id, name, slug, description, initial_scene_layout, robot_type_id, created_at, updated_at FROM subscenes WHERE id = ? AND deleted_at IS NULL", id) + if err != nil { + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "subscene not found"}) + return + } + logger.Printf("[SUBSCENE] Failed to query subscene: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update subscene"}) + return + } + + // Build update query dynamically + updates := []string{} + args := []interface{}{} + + if req.Name != nil { + name := strings.TrimSpace(*req.Name) + if name != "" { + updates = append(updates, "name = ?") + args = append(args, name) + } + } + + if req.Slug != nil { + slug := strings.TrimSpace(*req.Slug) + if slug != "" { + // Check if slug already exists for this scene + var exists bool + err := h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM subscenes WHERE scene_id = ? AND slug = ? AND id != ? AND deleted_at IS NULL)", existing.SceneID, slug, id) + if err == nil && exists { + c.JSON(http.StatusBadRequest, gin.H{"error": "slug already exists for this scene"}) + return + } + updates = append(updates, "slug = ?") + args = append(args, slug) + } + } + + if req.Description != nil { + description := strings.TrimSpace(*req.Description) + var descStr sql.NullString + if description != "" { + descStr = sql.NullString{String: description, Valid: true} + } + updates = append(updates, "description = ?") + args = append(args, descStr) + } + + if req.InitialSceneLayout != nil { + layout := strings.TrimSpace(*req.InitialSceneLayout) + var layoutStr sql.NullString + if layout != "" { + layoutStr = sql.NullString{String: layout, Valid: true} + } + updates = append(updates, "initial_scene_layout = ?") + args = append(args, layoutStr) + } + + if len(updates) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) + return + } + + now := time.Now().UTC() + updates = append(updates, "updated_at = ?") + args = append(args, now) + args = append(args, id) + + query := fmt.Sprintf("UPDATE subscenes SET %s WHERE id = ?", strings.Join(updates, ", ")) + + _, err = h.db.Exec(query, args...) + if err != nil { + logger.Printf("[SUBSCENE] Failed to update subscene: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update subscene"}) + return + } + + // Fetch the updated subscene + var s subsceneRow + err = h.db.Get(&s, "SELECT id, scene_id, name, slug, description, initial_scene_layout, robot_type_id, created_at, updated_at FROM subscenes WHERE id = ?", id) + if err != nil { + logger.Printf("[SUBSCENE] Failed to fetch updated subscene: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get updated subscene"}) + return + } + + description := "" + if s.Description.Valid { + description = s.Description.String + } + layout := "" + if s.InitialSceneLayout.Valid { + layout = s.InitialSceneLayout.String + } + createdAt := "" + if s.CreatedAt.Valid { + createdAt = s.CreatedAt.String + } + updatedAt := "" + if s.UpdatedAt.Valid { + updatedAt = s.UpdatedAt.String + } + + c.JSON(http.StatusOK, SubsceneResponse{ + ID: fmt.Sprintf("%d", s.ID), + SceneID: fmt.Sprintf("%d", s.SceneID), + Name: s.Name, + Slug: s.Slug, + Description: description, + InitialSceneLayout: layout, + RobotTypeID: fmt.Sprintf("%d", s.RobotTypeID), + CreatedAt: createdAt, + UpdatedAt: updatedAt, + }) +} + +// DeleteSubscene handles subscene deletion requests (soft delete). +// +// @Summary Delete subscene +// @Description Soft deletes a subscene by ID +// @Tags subscenes +// @Accept json +// @Produce json +// @Param id path string true "Subscene ID" +// @Success 204 +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /subscenes/{id} [delete] +func (h *SubsceneHandler) DeleteSubscene(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid subscene id"}) + return + } + + // Check if subscene exists + var exists bool + err = h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM subscenes WHERE id = ? AND deleted_at IS NULL)", id) + if err != nil { + logger.Printf("[SUBSCENE] Failed to check subscene existence: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete subscene"}) + return + } + + if !exists { + c.JSON(http.StatusNotFound, gin.H{"error": "subscene not found"}) + return + } + + now := time.Now().UTC() + + // Perform soft delete by setting deleted_at + _, err = h.db.Exec("UPDATE subscenes SET deleted_at = ?, updated_at = ? WHERE id = ?", now, now, id) + if err != nil { + logger.Printf("[SUBSCENE] Failed to delete subscene: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete subscene"}) + return + } + + c.Status(http.StatusNoContent) +} diff --git a/internal/server/server.go b/internal/server/server.go index d6f6b45..2849062 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -41,6 +41,12 @@ type Server struct { factory *handlers.FactoryHandler dataCollector *handlers.DataCollectorHandler station *handlers.StationHandler + organization *handlers.OrganizationHandler + skill *handlers.SkillHandler + inspector *handlers.InspectorHandler + sop *handlers.SOPHandler + scene *handlers.SceneHandler + subscene *handlers.SubsceneHandler httpServer *http.Server transferWSServer *http.Server recorderWSServer *http.Server @@ -82,6 +88,12 @@ func New(cfg *config.Config, db *sqlx.DB, s3Client *s3.Client) *Server { factoryHandler *handlers.FactoryHandler dataCollectorHandler *handlers.DataCollectorHandler stationHandler *handlers.StationHandler + organizationHandler *handlers.OrganizationHandler + skillHandler *handlers.SkillHandler + inspectorHandler *handlers.InspectorHandler + sopHandler *handlers.SOPHandler + sceneHandler *handlers.SceneHandler + subsceneHandler *handlers.SubsceneHandler ) if db != nil { robotTypeHandler = handlers.NewRobotTypeHandler(db) @@ -89,6 +101,12 @@ func New(cfg *config.Config, db *sqlx.DB, s3Client *s3.Client) *Server { factoryHandler = handlers.NewFactoryHandler(db) dataCollectorHandler = handlers.NewDataCollectorHandler(db) stationHandler = handlers.NewStationHandler(db) + organizationHandler = handlers.NewOrganizationHandler(db) + skillHandler = handlers.NewSkillHandler(db) + inspectorHandler = handlers.NewInspectorHandler(db) + sopHandler = handlers.NewSOPHandler(db) + sceneHandler = handlers.NewSceneHandler(db) + subsceneHandler = handlers.NewSubsceneHandler(db) } s := &Server{ @@ -103,6 +121,12 @@ func New(cfg *config.Config, db *sqlx.DB, s3Client *s3.Client) *Server { factory: factoryHandler, dataCollector: dataCollectorHandler, station: stationHandler, + organization: organizationHandler, + skill: skillHandler, + inspector: inspectorHandler, + sop: sopHandler, + scene: sceneHandler, + subscene: subsceneHandler, engine: engine, } @@ -176,6 +200,24 @@ func (s *Server) buildRoutes() http.Handler { if s.station != nil { s.station.RegisterRoutes(v1Tasks) } + if s.organization != nil { + s.organization.RegisterRoutes(v1Tasks) + } + if s.skill != nil { + s.skill.RegisterRoutes(v1Tasks) + } + if s.inspector != nil { + s.inspector.RegisterRoutes(v1Tasks) + } + if s.sop != nil { + s.sop.RegisterRoutes(v1Tasks) + } + if s.scene != nil { + s.scene.RegisterRoutes(v1Tasks) + } + if s.subscene != nil { + s.subscene.RegisterRoutes(v1Tasks) + } // Axon callbacks v1Callbacks := v1.Group("/callbacks") From 4f7dbd79b8b078c6560a3b05aeb1e2b11afd591f Mon Sep 17 00:00:00 2001 From: shark Date: Wed, 25 Mar 2026 00:36:41 +0800 Subject: [PATCH 02/20] feat(db): add index optimizations for soft-delete filtering and query performance --- .../000002_version_2_schema.down.sql | 66 ++++++++++++++++ .../migrations/000002_version_2_schema.up.sql | 77 +++++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 internal/storage/database/migrations/000002_version_2_schema.down.sql create mode 100644 internal/storage/database/migrations/000002_version_2_schema.up.sql diff --git a/internal/storage/database/migrations/000002_version_2_schema.down.sql b/internal/storage/database/migrations/000002_version_2_schema.down.sql new file mode 100644 index 0000000..3478b65 --- /dev/null +++ b/internal/storage/database/migrations/000002_version_2_schema.down.sql @@ -0,0 +1,66 @@ +-- SPDX-FileCopyrightText: 2026 ArcheBase +-- +-- SPDX-License-Identifier: MulanPSL-2.0 + +-- migrations/000002_version_2_schema.down.sql +-- Revert index optimizations from version 2 + +-- ============================================================ +-- Production Units (reverse order) +-- ============================================================ + +DROP INDEX idx_order_batch_task_episode_del ON episodes; +CREATE INDEX idx_episode_id ON episodes (episode_id); + +DROP INDEX idx_order_batch_task_del ON tasks; +CREATE INDEX idx_task_id ON tasks (task_id); + +DROP INDEX idx_order_name_del ON batches; +CREATE INDEX idx_batch_id ON batches (batch_id); + +DROP INDEX idx_org_name_del ON orders; + +-- ============================================================ +-- Operational Resources +-- ============================================================ + +DROP INDEX idx_inspector_del ON inspectors; + +DROP INDEX idx_robot_datacollector_del ON workstations; + +DROP INDEX idx_operator_del ON data_collectors; +CREATE UNIQUE INDEX idx_operator_id ON data_collectors (operator_id); + +DROP INDEX idx_robottype_device_del ON robots; +CREATE UNIQUE INDEX idx_device_id ON robots (device_id); + +-- robot_types has no original index to restore (none existed before) + +-- ============================================================ +-- Capability & Procedure +-- ============================================================ + +DROP INDEX idx_slug_del ON sops; +CREATE INDEX idx_slug ON sops (slug); + +DROP INDEX idx_name_del ON skills; +CREATE INDEX idx_name ON skills (name); + +-- ============================================================ +-- Environmental Hierarchy +-- ============================================================ + +DROP INDEX idx_org_factory_scene_slug_del ON subscenes; +ALTER TABLE subscenes + DROP COLUMN factory_id, + DROP COLUMN organization_id; +CREATE UNIQUE INDEX idx_scene_slug ON subscenes (scene_id, slug); + +DROP INDEX idx_org_factory_slug_del ON scenes; +CREATE UNIQUE INDEX idx_org_slug ON scenes (organization_id, slug); + +DROP INDEX idx_org_slug_del ON factories; +CREATE UNIQUE INDEX idx_org_slug ON factories (organization_id, slug); + +DROP INDEX idx_slug_del ON organizations; +CREATE INDEX idx_slug ON organizations (slug); diff --git a/internal/storage/database/migrations/000002_version_2_schema.up.sql b/internal/storage/database/migrations/000002_version_2_schema.up.sql new file mode 100644 index 0000000..3a3e4dd --- /dev/null +++ b/internal/storage/database/migrations/000002_version_2_schema.up.sql @@ -0,0 +1,77 @@ +-- SPDX-FileCopyrightText: 2026 ArcheBase +-- +-- SPDX-License-Identifier: MulanPSL-2.0 + +-- migrations/000002_version_2_schema.up.sql +-- Optimize indexes for better performance + +-- ============================================================ +-- Environmental Hierarchy +-- ============================================================ + +DROP INDEX slug ON organizations; +DROP INDEX idx_slug ON organizations; +CREATE UNIQUE INDEX idx_slug_del ON organizations (slug, deleted_at); + +DROP INDEX idx_org_slug ON factories; +CREATE UNIQUE INDEX idx_org_slug_del ON factories (organization_id, slug, deleted_at); + +DROP INDEX idx_org_slug ON scenes; +CREATE UNIQUE INDEX idx_org_factory_slug_del ON scenes (organization_id, factory_id, slug, deleted_at); + +DROP INDEX idx_scene_slug ON subscenes; +ALTER TABLE subscenes +ADD COLUMN organization_id BIGINT NOT NULL AFTER scene_id, +ADD COLUMN factory_id BIGINT NOT NULL AFTER organization_id; +CREATE UNIQUE INDEX idx_org_factory_scene_slug_del ON subscenes (organization_id, factory_id, scene_id, slug, deleted_at); + +-- ============================================================ +-- Capability & Procedure +-- ============================================================ + +DROP INDEX name ON skills; +CREATE UNIQUE INDEX idx_name_del ON skills (name, deleted_at); + +DROP INDEX slug ON sops; +DROP INDEX idx_slug ON sops; +CREATE UNIQUE INDEX idx_slug_del ON sops (slug, deleted_at); + +-- ============================================================ +-- Operational Resources +-- ============================================================ + +CREATE UNIQUE INDEX idx_model_del ON robot_types (model, deleted_at); + +DROP INDEX device_id ON robots; +DROP INDEX idx_device_id ON robots; +CREATE UNIQUE INDEX idx_robottype_device_del ON robots (robot_type_id, device_id, deleted_at); + +DROP INDEX operator_id ON data_collectors; +DROP INDEX idx_operator_id ON data_collectors; +CREATE UNIQUE INDEX idx_operator_del ON data_collectors (operator_id, deleted_at); + +CREATE UNIQUE INDEX idx_robot_datacollector_del ON workstations (robot_id, data_collector_id, deleted_at); + +DROP INDEX inspector_id ON inspectors; +CREATE UNIQUE INDEX idx_inspector_del ON inspectors (inspector_id, deleted_at); + +-- ============================================================ +-- Production Units +-- ============================================================ + +CREATE UNIQUE INDEX idx_org_name_del ON orders (organization_id, name, deleted_at); + +DROP INDEX batch_id ON batches; +CREATE UNIQUE INDEX idx_order_name_del ON batches (order_id, name, deleted_at); + +DROP INDEX task_id ON tasks; +CREATE UNIQUE INDEX idx_order_batch_task_del ON tasks (order_id, batch_id, task_id, deleted_at); + +DROP INDEX episode_id ON episodes; +CREATE UNIQUE INDEX idx_order_batch_task_episode_del ON episodes (order_id, batch_id, task_id, episode_id, deleted_at); + + + + + + From b763438f38d9cfc9710f660f3f506dc3e3e53216 Mon Sep 17 00:00:00 2001 From: shark Date: Wed, 25 Mar 2026 01:28:33 +0800 Subject: [PATCH 03/20] feat(api): add factory count to organization endpoints --- internal/api/handlers/organization.go | 145 ++++++++++++++++---------- 1 file changed, 90 insertions(+), 55 deletions(-) diff --git a/internal/api/handlers/organization.go b/internal/api/handlers/organization.go index d9adce8..b229426 100644 --- a/internal/api/handlers/organization.go +++ b/internal/api/handlers/organization.go @@ -31,13 +31,14 @@ func NewOrganizationHandler(db *sqlx.DB) *OrganizationHandler { // OrganizationResponse represents an organization in the response. type OrganizationResponse struct { - ID string `json:"id"` - Name string `json:"name"` - Slug string `json:"slug"` - Description string `json:"description,omitempty"` - Settings interface{} `json:"settings,omitempty"` - CreatedAt string `json:"created_at,omitempty"` - UpdatedAt string `json:"updated_at,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Description string `json:"description,omitempty"` + Settings interface{} `json:"settings,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + FactoryCount int `json:"factoryCount"` } // OrganizationListResponse represents the response for listing organizations. @@ -75,19 +76,21 @@ func (h *OrganizationHandler) RegisterRoutes(apiV1 *gin.RouterGroup) { apiV1.GET("/organizations", h.ListOrganizations) apiV1.POST("/organizations", h.CreateOrganization) apiV1.GET("/organizations/:id", h.GetOrganization) + apiV1.PUT("/organizations/:id", h.UpdateOrganization) apiV1.PATCH("/organizations/:id", h.UpdateOrganization) apiV1.DELETE("/organizations/:id", h.DeleteOrganization) } // organizationRow represents an organization in the database type organizationRow struct { - ID int64 `db:"id"` - Name string `db:"name"` - Slug string `db:"slug"` - Description sql.NullString `db:"description"` - Settings sql.NullString `db:"settings"` - CreatedAt sql.NullString `db:"created_at"` - UpdatedAt sql.NullString `db:"updated_at"` + ID int64 `db:"id"` + Name string `db:"name"` + Slug string `db:"slug"` + Description sql.NullString `db:"description"` + Settings sql.NullString `db:"settings"` + CreatedAt sql.NullString `db:"created_at"` + UpdatedAt sql.NullString `db:"updated_at"` + FactoryCount int `db:"factory_count"` } // ListOrganizations handles organization listing requests. @@ -103,16 +106,18 @@ type organizationRow struct { func (h *OrganizationHandler) ListOrganizations(c *gin.Context) { query := ` SELECT - id, - name, - slug, - description, - settings, - created_at, - updated_at - FROM organizations - WHERE deleted_at IS NULL - ORDER BY id DESC + o.id, + o.name, + o.slug, + o.description, + o.settings, + o.created_at, + o.updated_at, + (SELECT COUNT(*) FROM factories f + WHERE f.organization_id = o.id AND f.deleted_at IS NULL) as factory_count + FROM organizations o + WHERE o.deleted_at IS NULL + ORDER BY o.id DESC ` var dbRows []organizationRow @@ -145,13 +150,14 @@ func (h *OrganizationHandler) ListOrganizations(c *gin.Context) { } organizations = append(organizations, OrganizationResponse{ - ID: fmt.Sprintf("%d", org.ID), - Name: org.Name, - Slug: org.Slug, - Description: description, - Settings: settings, - CreatedAt: createdAt, - UpdatedAt: updatedAt, + ID: fmt.Sprintf("%d", org.ID), + Name: org.Name, + Slug: org.Slug, + Description: description, + Settings: settings, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + FactoryCount: org.FactoryCount, }) } @@ -183,15 +189,17 @@ func (h *OrganizationHandler) GetOrganization(c *gin.Context) { query := ` SELECT - id, - name, - slug, - description, - settings, - created_at, - updated_at - FROM organizations - WHERE id = ? AND deleted_at IS NULL + o.id, + o.name, + o.slug, + o.description, + o.settings, + o.created_at, + o.updated_at, + (SELECT COUNT(*) FROM factories f + WHERE f.organization_id = o.id AND f.deleted_at IS NULL) as factory_count + FROM organizations o + WHERE o.id = ? AND o.deleted_at IS NULL ` var org organizationRow @@ -226,13 +234,14 @@ func (h *OrganizationHandler) GetOrganization(c *gin.Context) { } c.JSON(http.StatusOK, OrganizationResponse{ - ID: fmt.Sprintf("%d", org.ID), - Name: org.Name, - Slug: org.Slug, - Description: description, - Settings: settings, - CreatedAt: createdAt, - UpdatedAt: updatedAt, + ID: fmt.Sprintf("%d", org.ID), + Name: org.Name, + Slug: org.Slug, + Description: description, + Settings: settings, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + FactoryCount: org.FactoryCount, }) } @@ -352,6 +361,7 @@ func (h *OrganizationHandler) CreateOrganization(c *gin.Context) { // @Failure 404 {object} map[string]string // @Failure 500 {object} map[string]string // @Router /organizations/{id} [put] +// @Router /organizations/{id} [patch] func (h *OrganizationHandler) UpdateOrganization(c *gin.Context) { idStr := c.Param("id") id, err := strconv.ParseInt(idStr, 10, 64) @@ -450,7 +460,17 @@ func (h *OrganizationHandler) UpdateOrganization(c *gin.Context) { // Fetch the updated organization var org organizationRow err = h.db.Get(&org, - "SELECT id, name, slug, description, settings, created_at, updated_at FROM organizations WHERE id = ?", + `SELECT + o.id, + o.name, + o.slug, + o.description, + o.settings, + o.created_at, + o.updated_at, + (SELECT COUNT(*) FROM factories f + WHERE f.organization_id = o.id AND f.deleted_at IS NULL) as factory_count + FROM organizations o WHERE o.id = ?`, id) if err != nil { logger.Printf("[ORGANIZATION] Failed to fetch updated organization: %v", err) @@ -479,13 +499,14 @@ func (h *OrganizationHandler) UpdateOrganization(c *gin.Context) { } c.JSON(http.StatusOK, OrganizationResponse{ - ID: fmt.Sprintf("%d", org.ID), - Name: org.Name, - Slug: org.Slug, - Description: description, - Settings: settings, - CreatedAt: createdAt, - UpdatedAt: updatedAt, + ID: fmt.Sprintf("%d", org.ID), + Name: org.Name, + Slug: org.Slug, + Description: description, + Settings: settings, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + FactoryCount: org.FactoryCount, }) } @@ -524,6 +545,20 @@ func (h *OrganizationHandler) DeleteOrganization(c *gin.Context) { return } + // Check if organization has associated factories + var factoryCount int + err = h.db.Get(&factoryCount, "SELECT COUNT(*) FROM factories WHERE organization_id = ? AND deleted_at IS NULL", id) + if err != nil { + logger.Printf("[ORGANIZATION] Failed to check factory count: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete organization"}) + return + } + + if factoryCount > 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("cannot delete organization with %d associated factories", factoryCount)}) + return + } + now := time.Now().UTC() // Perform soft delete by setting deleted_at From 4e784f3fbfdf49df5588a7be5a6b080d333910de Mon Sep 17 00:00:00 2001 From: shark Date: Wed, 25 Mar 2026 02:07:51 +0800 Subject: [PATCH 04/20] feat(api): add location, timezone, and settings fields to factory creation --- internal/api/handlers/factory.go | 49 ++++++++++++++++++++++++++++---- 1 file changed, 44 insertions(+), 5 deletions(-) diff --git a/internal/api/handlers/factory.go b/internal/api/handlers/factory.go index 4ce6382..fdac3cf 100644 --- a/internal/api/handlers/factory.go +++ b/internal/api/handlers/factory.go @@ -7,6 +7,7 @@ package handlers import ( "database/sql" + "encoding/json" "fmt" "net/http" "strconv" @@ -48,9 +49,12 @@ type FactoryListResponse struct { // CreateFactoryRequest represents the request body for creating a factory. type CreateFactoryRequest struct { - OrganizationID string `json:"organization_id"` - Name string `json:"name"` - Slug string `json:"slug"` + OrganizationID string `json:"organization_id"` + Name string `json:"name"` + Slug string `json:"slug"` + Location string `json:"location,omitempty"` + Timezone string `json:"timezone,omitempty"` + Settings interface{} `json:"settings,omitempty"` } // CreateFactoryResponse represents the response for creating a factory. @@ -59,6 +63,8 @@ type CreateFactoryResponse struct { OrganizationID string `json:"organization_id"` Name string `json:"name"` Slug string `json:"slug"` + Location string `json:"location,omitempty"` + Timezone string `json:"timezone,omitempty"` CreatedAt string `json:"created_at"` } @@ -188,6 +194,8 @@ func (h *FactoryHandler) CreateFactory(c *gin.Context) { req.OrganizationID = strings.TrimSpace(req.OrganizationID) req.Name = strings.TrimSpace(req.Name) req.Slug = strings.TrimSpace(req.Slug) + req.Location = strings.TrimSpace(req.Location) + req.Timezone = strings.TrimSpace(req.Timezone) if req.OrganizationID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "organization_id is required"}) @@ -228,19 +236,48 @@ func (h *FactoryHandler) CreateFactory(c *gin.Context) { now := time.Now().UTC() + // Set default timezone if not provided + timezone := req.Timezone + if timezone == "" { + timezone = "UTC" + } + + // Convert location to nullable string + var locationStr sql.NullString + if req.Location != "" { + locationStr = sql.NullString{String: req.Location, Valid: true} + } + + // Convert timezone to nullable string + var timezoneStr sql.NullString + timezoneStr = sql.NullString{String: timezone, Valid: true} + + // Convert settings to JSON string if provided + var settingsStr sql.NullString + if req.Settings != nil { + settingsJSON, err := json.Marshal(req.Settings) + if err == nil { + settingsStr = sql.NullString{String: string(settingsJSON), Valid: true} + } + } + result, err := h.db.Exec( `INSERT INTO factories ( organization_id, name, slug, + location, timezone, + settings, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?)`, + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, orgID, req.Name, req.Slug, - "UTC", + locationStr, + timezoneStr, + settingsStr, now, now, ) @@ -262,6 +299,8 @@ func (h *FactoryHandler) CreateFactory(c *gin.Context) { OrganizationID: req.OrganizationID, Name: req.Name, Slug: req.Slug, + Location: req.Location, + Timezone: timezone, CreatedAt: now.Format(time.RFC3339), }) } From 7bf6e3205895924e4abdf70a6eb6739bc2f4c2e3 Mon Sep 17 00:00:00 2001 From: shark Date: Wed, 25 Mar 2026 02:18:52 +0800 Subject: [PATCH 05/20] feat(api): add replace factory endpoint (PUT /factories/:id) --- internal/api/handlers/factory.go | 186 +++++++++++++++++++++++++++++++ 1 file changed, 186 insertions(+) diff --git a/internal/api/handlers/factory.go b/internal/api/handlers/factory.go index fdac3cf..feef885 100644 --- a/internal/api/handlers/factory.go +++ b/internal/api/handlers/factory.go @@ -73,6 +73,7 @@ func (h *FactoryHandler) RegisterRoutes(apiV1 *gin.RouterGroup) { apiV1.GET("/factories", h.ListFactories) apiV1.POST("/factories", h.CreateFactory) apiV1.GET("/factories/:id", h.GetFactory) + apiV1.PUT("/factories/:id", h.ReplaceFactory) apiV1.PATCH("/factories/:id", h.UpdateFactory) apiV1.DELETE("/factories/:id", h.DeleteFactory) } @@ -389,6 +390,191 @@ type UpdateFactoryRequest struct { Timezone *string `json:"timezone,omitempty"` } +// ReplaceFactoryRequest represents the request body for replacing a factory (PUT). +type ReplaceFactoryRequest struct { + OrganizationID string `json:"organization_id"` + Name string `json:"name"` + Slug string `json:"slug"` + Location string `json:"location,omitempty"` + Timezone string `json:"timezone,omitempty"` + Settings interface{} `json:"settings,omitempty"` +} + +// ReplaceFactory handles replacing a factory (full update). +// +// @Summary Replace factory +// @Description Replaces an existing factory with the provided data +// @Tags factories +// @Accept json +// @Produce json +// @Param id path string true "Factory ID" +// @Param body body ReplaceFactoryRequest true "Factory payload" +// @Success 200 {object} FactoryResponse +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /factories/{id} [put] +func (h *FactoryHandler) ReplaceFactory(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid factory id"}) + return + } + + var req ReplaceFactoryRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + + req.OrganizationID = strings.TrimSpace(req.OrganizationID) + req.Name = strings.TrimSpace(req.Name) + req.Slug = strings.TrimSpace(req.Slug) + req.Location = strings.TrimSpace(req.Location) + req.Timezone = strings.TrimSpace(req.Timezone) + + if req.OrganizationID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "organization_id is required"}) + return + } + + if req.Name == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"}) + return + } + + if req.Slug == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "slug is required"}) + return + } + + // Parse organization_id + orgID, err := strconv.ParseInt(req.OrganizationID, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid organization_id format"}) + return + } + + // Verify organization exists + var exists bool + err = h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM organizations WHERE id = ? AND deleted_at IS NULL)", orgID) + if err != nil || !exists { + c.JSON(http.StatusBadRequest, gin.H{"error": "organization not found"}) + return + } + + // Check if factory exists + var existing factoryRow + err = h.db.Get(&existing, "SELECT id, organization_id, name, slug, location, timezone, settings, created_at, updated_at FROM factories WHERE id = ? AND deleted_at IS NULL", id) + if err != nil { + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "factory not found"}) + return + } + logger.Printf("[FACTORY] Failed to query factory: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to replace factory"}) + return + } + + // Check if slug already exists for this organization (excluding current factory) + err = h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM factories WHERE organization_id = ? AND slug = ? AND id != ? AND deleted_at IS NULL)", orgID, req.Slug, id) + if err == nil && exists { + c.JSON(http.StatusBadRequest, gin.H{"error": "slug already exists for this organization"}) + return + } + + now := time.Now().UTC() + + // Set default timezone if not provided + timezone := req.Timezone + if timezone == "" { + timezone = "UTC" + } + + // Convert location to nullable string + var locationStr sql.NullString + if req.Location != "" { + locationStr = sql.NullString{String: req.Location, Valid: true} + } + + // Convert timezone to nullable string + var timezoneStr sql.NullString + timezoneStr = sql.NullString{String: timezone, Valid: true} + + // Convert settings to JSON string if provided + var settingsStr sql.NullString + if req.Settings != nil { + settingsJSON, err := json.Marshal(req.Settings) + if err == nil { + settingsStr = sql.NullString{String: string(settingsJSON), Valid: true} + } + } + + // Perform full update + _, err = h.db.Exec( + `UPDATE factories SET + organization_id = ?, + name = ?, + slug = ?, + location = ?, + timezone = ?, + settings = ?, + updated_at = ? + WHERE id = ?`, + orgID, + req.Name, + req.Slug, + locationStr, + timezoneStr, + settingsStr, + now, + id, + ) + if err != nil { + logger.Printf("[FACTORY] Failed to replace factory: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to replace factory"}) + return + } + + // Fetch the updated factory + var f factoryRow + err = h.db.Get(&f, "SELECT id, organization_id, name, slug, location, timezone, settings, created_at, updated_at FROM factories WHERE id = ?", id) + if err != nil { + logger.Printf("[FACTORY] Failed to fetch updated factory: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get updated factory"}) + return + } + + location := "" + if f.Location.Valid { + location = f.Location.String + } + factoryTimezone := "UTC" + if f.Timezone.Valid { + factoryTimezone = f.Timezone.String + } + createdAt := "" + if f.CreatedAt.Valid { + createdAt = f.CreatedAt.String + } + updatedAt := "" + if f.UpdatedAt.Valid { + updatedAt = f.UpdatedAt.String + } + + c.JSON(http.StatusOK, FactoryResponse{ + ID: fmt.Sprintf("%d", f.ID), + OrganizationID: fmt.Sprintf("%d", f.OrganizationID), + Name: f.Name, + Slug: f.Slug, + Location: location, + Timezone: factoryTimezone, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + }) +} + // UpdateFactory handles updating a factory. // // @Summary Update factory From 01dfd84f45fe43dc8a747e89ee083db9f0d54953 Mon Sep 17 00:00:00 2001 From: shark Date: Wed, 25 Mar 2026 02:46:20 +0800 Subject: [PATCH 06/20] refactor(api): standardize update endpoints to use PUT instead of PATCH --- internal/api/handlers/data_collector.go | 91 +----------- internal/api/handlers/factory.go | 190 +----------------------- internal/api/handlers/inspector.go | 4 +- internal/api/handlers/organization.go | 3 +- internal/api/handlers/robot.go | 4 +- internal/api/handlers/robot_type.go | 4 +- internal/api/handlers/scene.go | 4 +- internal/api/handlers/skill.go | 4 +- internal/api/handlers/sop.go | 4 +- internal/api/handlers/station.go | 4 +- internal/api/handlers/subscene.go | 4 +- internal/api/handlers/task.go | 4 +- 12 files changed, 23 insertions(+), 297 deletions(-) diff --git a/internal/api/handlers/data_collector.go b/internal/api/handlers/data_collector.go index a570fc8..ff3f7d8 100644 --- a/internal/api/handlers/data_collector.go +++ b/internal/api/handlers/data_collector.go @@ -65,7 +65,7 @@ func (h *DataCollectorHandler) RegisterRoutes(apiV1 *gin.RouterGroup) { apiV1.GET("/data_collectors", h.ListDataCollectors) apiV1.POST("/data_collectors", h.CreateDataCollector) apiV1.GET("/data_collectors/:id", h.GetDataCollector) - apiV1.PATCH("/data_collectors/:id", h.UpdateDataCollector) + apiV1.PUT("/data_collectors/:id", h.UpdateDataCollector) apiV1.DELETE("/data_collectors/:id", h.DeleteDataCollector) } @@ -242,93 +242,6 @@ func (h *DataCollectorHandler) CreateDataCollector(c *gin.Context) { }) } -// UpdateDataCollectorStatusRequest represents the request body for updating data collector status. -type UpdateDataCollectorStatusRequest struct { - Status string `json:"status"` -} - -// UpdateDataCollectorStatusResponse represents the response for updating data collector status. -type UpdateDataCollectorStatusResponse struct { - ID string `json:"id"` - Status string `json:"status"` -} - -// UpdateDataCollectorStatus handles status update requests for a data collector. -// -// @Summary Update data collector status -// @Description Updates the status of an existing data collector -// @Tags data_collectors -// @Accept json -// @Produce json -// @Param id path string true "Data collector ID" -// @Param body body UpdateDataCollectorStatusRequest true "Status payload" -// @Success 200 {object} UpdateDataCollectorStatusResponse -// @Failure 400 {object} map[string]string -// @Failure 404 {object} map[string]string -// @Failure 500 {object} map[string]string -// @Router /data_collectors/{id}/status [patch] -func (h *DataCollectorHandler) UpdateDataCollectorStatus(c *gin.Context) { - idParam := c.Param("id") - id, err := strconv.ParseInt(idParam, 10, 64) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid data collector id"}) - return - } - - var req UpdateDataCollectorStatusRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) - return - } - - req.Status = strings.TrimSpace(req.Status) - - // Validate status value - validStatuses := map[string]bool{ - "active": true, - "inactive": true, - "on_leave": true, - } - if !validStatuses[req.Status] { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid status, must be one of: active, inactive, on_leave"}) - return - } - - // Check if data collector exists - var exists bool - err = h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM data_collectors WHERE id = ? AND deleted_at IS NULL)", id) - if err != nil { - logger.Printf("[DC] Failed to check data collector: %v", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update status"}) - return - } - if !exists { - c.JSON(http.StatusNotFound, gin.H{"error": "data collector not found"}) - return - } - - // Generate updated_at timestamp - updatedAt := time.Now().UTC().Format("2006-01-02 15:04:05") - - // Update the status - _, err = h.db.Exec( - `UPDATE data_collectors SET status = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL`, - req.Status, - updatedAt, - id, - ) - if err != nil { - logger.Printf("[DC] Failed to update status: %v", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update status"}) - return - } - - c.JSON(http.StatusOK, UpdateDataCollectorStatusResponse{ - ID: idParam, - Status: req.Status, - }) -} - // GetDataCollector handles getting a single data collector by ID. // // @Summary Get data collector @@ -420,7 +333,7 @@ type UpdateDataCollectorRequest struct { // @Failure 400 {object} map[string]string // @Failure 404 {object} map[string]string // @Failure 500 {object} map[string]string -// @Router /data_collectors/{id} [patch] +// @Router /data_collectors/{id} [put] func (h *DataCollectorHandler) UpdateDataCollector(c *gin.Context) { idParam := c.Param("id") id, err := strconv.ParseInt(idParam, 10, 64) diff --git a/internal/api/handlers/factory.go b/internal/api/handlers/factory.go index feef885..24f6674 100644 --- a/internal/api/handlers/factory.go +++ b/internal/api/handlers/factory.go @@ -73,8 +73,7 @@ func (h *FactoryHandler) RegisterRoutes(apiV1 *gin.RouterGroup) { apiV1.GET("/factories", h.ListFactories) apiV1.POST("/factories", h.CreateFactory) apiV1.GET("/factories/:id", h.GetFactory) - apiV1.PUT("/factories/:id", h.ReplaceFactory) - apiV1.PATCH("/factories/:id", h.UpdateFactory) + apiV1.PUT("/factories/:id", h.UpdateFactory) apiV1.DELETE("/factories/:id", h.DeleteFactory) } @@ -390,191 +389,6 @@ type UpdateFactoryRequest struct { Timezone *string `json:"timezone,omitempty"` } -// ReplaceFactoryRequest represents the request body for replacing a factory (PUT). -type ReplaceFactoryRequest struct { - OrganizationID string `json:"organization_id"` - Name string `json:"name"` - Slug string `json:"slug"` - Location string `json:"location,omitempty"` - Timezone string `json:"timezone,omitempty"` - Settings interface{} `json:"settings,omitempty"` -} - -// ReplaceFactory handles replacing a factory (full update). -// -// @Summary Replace factory -// @Description Replaces an existing factory with the provided data -// @Tags factories -// @Accept json -// @Produce json -// @Param id path string true "Factory ID" -// @Param body body ReplaceFactoryRequest true "Factory payload" -// @Success 200 {object} FactoryResponse -// @Failure 400 {object} map[string]string -// @Failure 404 {object} map[string]string -// @Failure 500 {object} map[string]string -// @Router /factories/{id} [put] -func (h *FactoryHandler) ReplaceFactory(c *gin.Context) { - idStr := c.Param("id") - id, err := strconv.ParseInt(idStr, 10, 64) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid factory id"}) - return - } - - var req ReplaceFactoryRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) - return - } - - req.OrganizationID = strings.TrimSpace(req.OrganizationID) - req.Name = strings.TrimSpace(req.Name) - req.Slug = strings.TrimSpace(req.Slug) - req.Location = strings.TrimSpace(req.Location) - req.Timezone = strings.TrimSpace(req.Timezone) - - if req.OrganizationID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "organization_id is required"}) - return - } - - if req.Name == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"}) - return - } - - if req.Slug == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "slug is required"}) - return - } - - // Parse organization_id - orgID, err := strconv.ParseInt(req.OrganizationID, 10, 64) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid organization_id format"}) - return - } - - // Verify organization exists - var exists bool - err = h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM organizations WHERE id = ? AND deleted_at IS NULL)", orgID) - if err != nil || !exists { - c.JSON(http.StatusBadRequest, gin.H{"error": "organization not found"}) - return - } - - // Check if factory exists - var existing factoryRow - err = h.db.Get(&existing, "SELECT id, organization_id, name, slug, location, timezone, settings, created_at, updated_at FROM factories WHERE id = ? AND deleted_at IS NULL", id) - if err != nil { - if err == sql.ErrNoRows { - c.JSON(http.StatusNotFound, gin.H{"error": "factory not found"}) - return - } - logger.Printf("[FACTORY] Failed to query factory: %v", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to replace factory"}) - return - } - - // Check if slug already exists for this organization (excluding current factory) - err = h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM factories WHERE organization_id = ? AND slug = ? AND id != ? AND deleted_at IS NULL)", orgID, req.Slug, id) - if err == nil && exists { - c.JSON(http.StatusBadRequest, gin.H{"error": "slug already exists for this organization"}) - return - } - - now := time.Now().UTC() - - // Set default timezone if not provided - timezone := req.Timezone - if timezone == "" { - timezone = "UTC" - } - - // Convert location to nullable string - var locationStr sql.NullString - if req.Location != "" { - locationStr = sql.NullString{String: req.Location, Valid: true} - } - - // Convert timezone to nullable string - var timezoneStr sql.NullString - timezoneStr = sql.NullString{String: timezone, Valid: true} - - // Convert settings to JSON string if provided - var settingsStr sql.NullString - if req.Settings != nil { - settingsJSON, err := json.Marshal(req.Settings) - if err == nil { - settingsStr = sql.NullString{String: string(settingsJSON), Valid: true} - } - } - - // Perform full update - _, err = h.db.Exec( - `UPDATE factories SET - organization_id = ?, - name = ?, - slug = ?, - location = ?, - timezone = ?, - settings = ?, - updated_at = ? - WHERE id = ?`, - orgID, - req.Name, - req.Slug, - locationStr, - timezoneStr, - settingsStr, - now, - id, - ) - if err != nil { - logger.Printf("[FACTORY] Failed to replace factory: %v", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to replace factory"}) - return - } - - // Fetch the updated factory - var f factoryRow - err = h.db.Get(&f, "SELECT id, organization_id, name, slug, location, timezone, settings, created_at, updated_at FROM factories WHERE id = ?", id) - if err != nil { - logger.Printf("[FACTORY] Failed to fetch updated factory: %v", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get updated factory"}) - return - } - - location := "" - if f.Location.Valid { - location = f.Location.String - } - factoryTimezone := "UTC" - if f.Timezone.Valid { - factoryTimezone = f.Timezone.String - } - createdAt := "" - if f.CreatedAt.Valid { - createdAt = f.CreatedAt.String - } - updatedAt := "" - if f.UpdatedAt.Valid { - updatedAt = f.UpdatedAt.String - } - - c.JSON(http.StatusOK, FactoryResponse{ - ID: fmt.Sprintf("%d", f.ID), - OrganizationID: fmt.Sprintf("%d", f.OrganizationID), - Name: f.Name, - Slug: f.Slug, - Location: location, - Timezone: factoryTimezone, - CreatedAt: createdAt, - UpdatedAt: updatedAt, - }) -} - // UpdateFactory handles updating a factory. // // @Summary Update factory @@ -588,7 +402,7 @@ func (h *FactoryHandler) ReplaceFactory(c *gin.Context) { // @Failure 400 {object} map[string]string // @Failure 404 {object} map[string]string // @Failure 500 {object} map[string]string -// @Router /factories/{id} [patch] +// @Router /factories/{id} [put] func (h *FactoryHandler) UpdateFactory(c *gin.Context) { idStr := c.Param("id") id, err := strconv.ParseInt(idStr, 10, 64) diff --git a/internal/api/handlers/inspector.go b/internal/api/handlers/inspector.go index 6a65641..bb22b91 100644 --- a/internal/api/handlers/inspector.go +++ b/internal/api/handlers/inspector.go @@ -79,7 +79,7 @@ func (h *InspectorHandler) RegisterRoutes(apiV1 *gin.RouterGroup) { apiV1.GET("/inspectors", h.ListInspectors) apiV1.POST("/inspectors", h.CreateInspector) apiV1.GET("/inspectors/:id", h.GetInspector) - apiV1.PATCH("/inspectors/:id", h.UpdateInspector) + apiV1.PUT("/inspectors/:id", h.UpdateInspector) apiV1.DELETE("/inspectors/:id", h.DeleteInspector) } @@ -379,7 +379,7 @@ func (h *InspectorHandler) CreateInspector(c *gin.Context) { // @Failure 400 {object} map[string]string // @Failure 404 {object} map[string]string // @Failure 500 {object} map[string]string -// @Router /inspectors/{id} [patch] +// @Router /inspectors/{id} [put] func (h *InspectorHandler) UpdateInspector(c *gin.Context) { idStr := c.Param("id") id, err := strconv.ParseInt(idStr, 10, 64) diff --git a/internal/api/handlers/organization.go b/internal/api/handlers/organization.go index b229426..0f99e18 100644 --- a/internal/api/handlers/organization.go +++ b/internal/api/handlers/organization.go @@ -77,7 +77,6 @@ func (h *OrganizationHandler) RegisterRoutes(apiV1 *gin.RouterGroup) { apiV1.POST("/organizations", h.CreateOrganization) apiV1.GET("/organizations/:id", h.GetOrganization) apiV1.PUT("/organizations/:id", h.UpdateOrganization) - apiV1.PATCH("/organizations/:id", h.UpdateOrganization) apiV1.DELETE("/organizations/:id", h.DeleteOrganization) } @@ -361,7 +360,7 @@ func (h *OrganizationHandler) CreateOrganization(c *gin.Context) { // @Failure 404 {object} map[string]string // @Failure 500 {object} map[string]string // @Router /organizations/{id} [put] -// @Router /organizations/{id} [patch] +// @Router /organizations/{id} [put] func (h *OrganizationHandler) UpdateOrganization(c *gin.Context) { idStr := c.Param("id") id, err := strconv.ParseInt(idStr, 10, 64) diff --git a/internal/api/handlers/robot.go b/internal/api/handlers/robot.go index f4b78b0..b496409 100644 --- a/internal/api/handlers/robot.go +++ b/internal/api/handlers/robot.go @@ -74,7 +74,7 @@ func (h *RobotHandler) RegisterRoutes(apiV1 *gin.RouterGroup) { apiV1.GET("/robots", h.ListRobots) apiV1.POST("/robots", h.CreateRobot) apiV1.GET("/robots/:id", h.GetRobot) - apiV1.PATCH("/robots/:id", h.UpdateRobot) + apiV1.PUT("/robots/:id", h.UpdateRobot) apiV1.DELETE("/robots/:id", h.DeleteRobot) } @@ -418,7 +418,7 @@ type UpdateRobotRequest struct { // @Failure 400 {object} map[string]string // @Failure 404 {object} map[string]string // @Failure 500 {object} map[string]string -// @Router /robots/{id} [patch] +// @Router /robots/{id} [put] func (h *RobotHandler) UpdateRobot(c *gin.Context) { idStr := c.Param("id") id, err := strconv.ParseInt(idStr, 10, 64) diff --git a/internal/api/handlers/robot_type.go b/internal/api/handlers/robot_type.go index b307c1a..b23642e 100644 --- a/internal/api/handlers/robot_type.go +++ b/internal/api/handlers/robot_type.go @@ -65,7 +65,7 @@ func (h *RobotTypeHandler) RegisterRoutes(apiV1 *gin.RouterGroup) { apiV1.GET("/robot_types", h.ListRobotTypes) apiV1.POST("/robot_types", h.CreateRobotType) apiV1.GET("/robot_types/:id", h.GetRobotType) - apiV1.PATCH("/robot_types/:id", h.UpdateRobotType) + apiV1.PUT("/robot_types/:id", h.UpdateRobotType) apiV1.DELETE("/robot_types/:id", h.DeleteRobotType) } @@ -331,7 +331,7 @@ type UpdateRobotTypeRequest struct { // @Failure 400 {object} map[string]string // @Failure 404 {object} map[string]string // @Failure 500 {object} map[string]string -// @Router /robot_types/{id} [patch] +// @Router /robot_types/{id} [put] func (h *RobotTypeHandler) UpdateRobotType(c *gin.Context) { idStr := c.Param("id") id, err := strconv.ParseInt(idStr, 10, 64) diff --git a/internal/api/handlers/scene.go b/internal/api/handlers/scene.go index 264286a..e1925a6 100644 --- a/internal/api/handlers/scene.go +++ b/internal/api/handlers/scene.go @@ -77,7 +77,7 @@ func (h *SceneHandler) RegisterRoutes(apiV1 *gin.RouterGroup) { apiV1.GET("/scenes", h.ListScenes) apiV1.POST("/scenes", h.CreateScene) apiV1.GET("/scenes/:id", h.GetScene) - apiV1.PATCH("/scenes/:id", h.UpdateScene) + apiV1.PUT("/scenes/:id", h.UpdateScene) apiV1.DELETE("/scenes/:id", h.DeleteScene) } @@ -416,7 +416,7 @@ func (h *SceneHandler) CreateScene(c *gin.Context) { // @Failure 400 {object} map[string]string // @Failure 404 {object} map[string]string // @Failure 500 {object} map[string]string -// @Router /scenes/{id} [patch] +// @Router /scenes/{id} [put] func (h *SceneHandler) UpdateScene(c *gin.Context) { idStr := c.Param("id") id, err := strconv.ParseInt(idStr, 10, 64) diff --git a/internal/api/handlers/skill.go b/internal/api/handlers/skill.go index b1548a5..ec3f7fd 100644 --- a/internal/api/handlers/skill.go +++ b/internal/api/handlers/skill.go @@ -76,7 +76,7 @@ func (h *SkillHandler) RegisterRoutes(apiV1 *gin.RouterGroup) { apiV1.GET("/skills", h.ListSkills) apiV1.POST("/skills", h.CreateSkill) apiV1.GET("/skills/:id", h.GetSkill) - apiV1.PATCH("/skills/:id", h.UpdateSkill) + apiV1.PUT("/skills/:id", h.UpdateSkill) apiV1.DELETE("/skills/:id", h.DeleteSkill) } @@ -357,7 +357,7 @@ func (h *SkillHandler) CreateSkill(c *gin.Context) { // @Failure 400 {object} map[string]string // @Failure 404 {object} map[string]string // @Failure 500 {object} map[string]string -// @Router /skills/{id} [patch] +// @Router /skills/{id} [put] func (h *SkillHandler) UpdateSkill(c *gin.Context) { idStr := c.Param("id") id, err := strconv.ParseInt(idStr, 10, 64) diff --git a/internal/api/handlers/sop.go b/internal/api/handlers/sop.go index 6967349..4b40200 100644 --- a/internal/api/handlers/sop.go +++ b/internal/api/handlers/sop.go @@ -77,7 +77,7 @@ func (h *SOPHandler) RegisterRoutes(apiV1 *gin.RouterGroup) { apiV1.GET("/sops", h.ListSOPs) apiV1.POST("/sops", h.CreateSOP) apiV1.GET("/sops/:id", h.GetSOP) - apiV1.PATCH("/sops/:id", h.UpdateSOP) + apiV1.PUT("/sops/:id", h.UpdateSOP) apiV1.DELETE("/sops/:id", h.DeleteSOP) } @@ -352,7 +352,7 @@ func (h *SOPHandler) CreateSOP(c *gin.Context) { // @Failure 400 {object} map[string]string // @Failure 404 {object} map[string]string // @Failure 500 {object} map[string]string -// @Router /sops/{id} [patch] +// @Router /sops/{id} [put] func (h *SOPHandler) UpdateSOP(c *gin.Context) { idStr := c.Param("id") id, err := strconv.ParseInt(idStr, 10, 64) diff --git a/internal/api/handlers/station.go b/internal/api/handlers/station.go index 4d56193..aa787fa 100644 --- a/internal/api/handlers/station.go +++ b/internal/api/handlers/station.go @@ -59,7 +59,7 @@ func (h *StationHandler) RegisterRoutes(apiV1 *gin.RouterGroup) { apiV1.POST("/stations", h.CreateStation) apiV1.GET("/stations", h.ListStations) apiV1.GET("/stations/:id", h.GetStation) - apiV1.PATCH("/stations/:id", h.UpdateStation) + apiV1.PUT("/stations/:id", h.UpdateStation) apiV1.DELETE("/stations/:id", h.DeleteStation) } @@ -420,7 +420,7 @@ var validStationStatuses = map[string]bool{ // @Failure 400 {object} map[string]string // @Failure 404 {object} map[string]string // @Failure 500 {object} map[string]string -// @Router /stations/{id} [patch] +// @Router /stations/{id} [put] func (h *StationHandler) UpdateStation(c *gin.Context) { stationIDStr := c.Param("id") diff --git a/internal/api/handlers/subscene.go b/internal/api/handlers/subscene.go index c7f174b..0caebe8 100644 --- a/internal/api/handlers/subscene.go +++ b/internal/api/handlers/subscene.go @@ -77,7 +77,7 @@ func (h *SubsceneHandler) RegisterRoutes(apiV1 *gin.RouterGroup) { apiV1.GET("/subscenes", h.ListSubscenes) apiV1.POST("/subscenes", h.CreateSubscene) apiV1.GET("/subscenes/:id", h.GetSubscene) - apiV1.PATCH("/subscenes/:id", h.UpdateSubscene) + apiV1.PUT("/subscenes/:id", h.UpdateSubscene) apiV1.DELETE("/subscenes/:id", h.DeleteSubscene) } @@ -404,7 +404,7 @@ func (h *SubsceneHandler) CreateSubscene(c *gin.Context) { // @Failure 400 {object} map[string]string // @Failure 404 {object} map[string]string // @Failure 500 {object} map[string]string -// @Router /subscenes/{id} [patch] +// @Router /subscenes/{id} [put] func (h *SubsceneHandler) UpdateSubscene(c *gin.Context) { idStr := c.Param("id") id, err := strconv.ParseInt(idStr, 10, 64) diff --git a/internal/api/handlers/task.go b/internal/api/handlers/task.go index 4f07be1..c6695fa 100644 --- a/internal/api/handlers/task.go +++ b/internal/api/handlers/task.go @@ -61,7 +61,7 @@ func (h *TaskHandler) RegisterRoutes(apiV1 *gin.RouterGroup) { apiV1.POST("/tasks", h.CreateTask) apiV1.GET("/tasks", h.ListTasks) apiV1.GET("/tasks/:id", h.GetTask) - apiV1.PATCH("/tasks/:id", h.UpdateTask) + apiV1.PUT("/tasks/:id", h.UpdateTask) apiV1.GET("/tasks/:id/config", h.GetTaskConfig) } @@ -323,7 +323,7 @@ func (h *TaskHandler) GetTask(c *gin.Context) { // @Failure 404 {object} map[string]string // @Failure 409 {object} map[string]string // @Failure 500 {object} map[string]string -// @Router /tasks/{id} [patch] +// @Router /tasks/{id} [put] func (h *TaskHandler) UpdateTask(c *gin.Context) { taskID := strings.TrimSpace(c.Param("id")) if taskID == "" { From 316d1e0bd0c7a689abd92fac28e9c32ca632aee9 Mon Sep 17 00:00:00 2001 From: shark Date: Wed, 25 Mar 2026 09:55:32 +0800 Subject: [PATCH 07/20] fix(docs): remove duplicate @Router annotation --- internal/api/handlers/organization.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/api/handlers/organization.go b/internal/api/handlers/organization.go index 0f99e18..511193d 100644 --- a/internal/api/handlers/organization.go +++ b/internal/api/handlers/organization.go @@ -360,7 +360,6 @@ func (h *OrganizationHandler) CreateOrganization(c *gin.Context) { // @Failure 404 {object} map[string]string // @Failure 500 {object} map[string]string // @Router /organizations/{id} [put] -// @Router /organizations/{id} [put] func (h *OrganizationHandler) UpdateOrganization(c *gin.Context) { idStr := c.Param("id") id, err := strconv.ParseInt(idStr, 10, 64) From 55dbeb011f05028d42b0a6f70cf7f6efb00d4089 Mon Sep 17 00:00:00 2001 From: shark Date: Wed, 25 Mar 2026 11:04:07 +0800 Subject: [PATCH 08/20] feat(db): simplify index naming and structure --- .../000002_version_2_schema.down.sql | 46 ++++++++++--------- .../migrations/000002_version_2_schema.up.sql | 25 ++++------ 2 files changed, 34 insertions(+), 37 deletions(-) diff --git a/internal/storage/database/migrations/000002_version_2_schema.down.sql b/internal/storage/database/migrations/000002_version_2_schema.down.sql index 3478b65..c1721f4 100644 --- a/internal/storage/database/migrations/000002_version_2_schema.down.sql +++ b/internal/storage/database/migrations/000002_version_2_schema.down.sql @@ -6,61 +6,63 @@ -- Revert index optimizations from version 2 -- ============================================================ --- Production Units (reverse order) +-- Production Units (reverse order of creation) -- ============================================================ -DROP INDEX idx_order_batch_task_episode_del ON episodes; -CREATE INDEX idx_episode_id ON episodes (episode_id); +DROP INDEX idx_episode_del ON episodes; +CREATE INDEX episode_id ON episodes (episode_id); -DROP INDEX idx_order_batch_task_del ON tasks; -CREATE INDEX idx_task_id ON tasks (task_id); +DROP INDEX idx_task_del ON tasks; +CREATE INDEX task_id ON tasks (task_id); -DROP INDEX idx_order_name_del ON batches; -CREATE INDEX idx_batch_id ON batches (batch_id); +DROP INDEX idx_name_del ON batches; +CREATE INDEX batch_id ON batches (batch_id); -DROP INDEX idx_org_name_del ON orders; +DROP INDEX idx_name_del ON orders; -- ============================================================ -- Operational Resources -- ============================================================ DROP INDEX idx_inspector_del ON inspectors; +CREATE INDEX inspector_id ON inspectors (inspector_id); -DROP INDEX idx_robot_datacollector_del ON workstations; +DROP INDEX idx_datacollector_del ON workstations; DROP INDEX idx_operator_del ON data_collectors; -CREATE UNIQUE INDEX idx_operator_id ON data_collectors (operator_id); +CREATE INDEX operator_id ON data_collectors (operator_id); +CREATE INDEX idx_operator_id ON data_collectors (operator_id); -DROP INDEX idx_robottype_device_del ON robots; -CREATE UNIQUE INDEX idx_device_id ON robots (device_id); +DROP INDEX idx_device_del ON robots; +CREATE INDEX device_id ON robots (device_id); +CREATE INDEX idx_device_id ON robots (device_id); --- robot_types has no original index to restore (none existed before) +DROP INDEX idx_model_del ON robot_types; -- ============================================================ -- Capability & Procedure -- ============================================================ -DROP INDEX idx_slug_del ON sops; +DROP INDEX idx_name_del ON sops; +CREATE INDEX slug ON sops (slug); CREATE INDEX idx_slug ON sops (slug); DROP INDEX idx_name_del ON skills; -CREATE INDEX idx_name ON skills (name); +CREATE INDEX name ON skills (name); -- ============================================================ -- Environmental Hierarchy -- ============================================================ -DROP INDEX idx_org_factory_scene_slug_del ON subscenes; -ALTER TABLE subscenes - DROP COLUMN factory_id, - DROP COLUMN organization_id; -CREATE UNIQUE INDEX idx_scene_slug ON subscenes (scene_id, slug); +DROP INDEX idx_name_del ON subscenes; +CREATE INDEX idx_scene_slug ON subscenes (scene_id, slug); -DROP INDEX idx_org_factory_slug_del ON scenes; +DROP INDEX idx_name_del ON scenes; CREATE UNIQUE INDEX idx_org_slug ON scenes (organization_id, slug); -DROP INDEX idx_org_slug_del ON factories; +DROP INDEX idx_slug_del ON factories; CREATE UNIQUE INDEX idx_org_slug ON factories (organization_id, slug); DROP INDEX idx_slug_del ON organizations; +CREATE INDEX slug ON organizations (slug); CREATE INDEX idx_slug ON organizations (slug); diff --git a/internal/storage/database/migrations/000002_version_2_schema.up.sql b/internal/storage/database/migrations/000002_version_2_schema.up.sql index 3a3e4dd..13ed5e4 100644 --- a/internal/storage/database/migrations/000002_version_2_schema.up.sql +++ b/internal/storage/database/migrations/000002_version_2_schema.up.sql @@ -14,16 +14,13 @@ DROP INDEX idx_slug ON organizations; CREATE UNIQUE INDEX idx_slug_del ON organizations (slug, deleted_at); DROP INDEX idx_org_slug ON factories; -CREATE UNIQUE INDEX idx_org_slug_del ON factories (organization_id, slug, deleted_at); +CREATE UNIQUE INDEX idx_slug_del ON factories (slug, deleted_at); DROP INDEX idx_org_slug ON scenes; -CREATE UNIQUE INDEX idx_org_factory_slug_del ON scenes (organization_id, factory_id, slug, deleted_at); +CREATE UNIQUE INDEX idx_name_del ON scenes (name, deleted_at); DROP INDEX idx_scene_slug ON subscenes; -ALTER TABLE subscenes -ADD COLUMN organization_id BIGINT NOT NULL AFTER scene_id, -ADD COLUMN factory_id BIGINT NOT NULL AFTER organization_id; -CREATE UNIQUE INDEX idx_org_factory_scene_slug_del ON subscenes (organization_id, factory_id, scene_id, slug, deleted_at); +CREATE UNIQUE INDEX idx_name_del ON subscenes (name, deleted_at); -- ============================================================ -- Capability & Procedure @@ -34,7 +31,7 @@ CREATE UNIQUE INDEX idx_name_del ON skills (name, deleted_at); DROP INDEX slug ON sops; DROP INDEX idx_slug ON sops; -CREATE UNIQUE INDEX idx_slug_del ON sops (slug, deleted_at); +CREATE UNIQUE INDEX idx_name_del ON sops (name, deleted_at); -- ============================================================ -- Operational Resources @@ -44,13 +41,13 @@ CREATE UNIQUE INDEX idx_model_del ON robot_types (model, deleted_at); DROP INDEX device_id ON robots; DROP INDEX idx_device_id ON robots; -CREATE UNIQUE INDEX idx_robottype_device_del ON robots (robot_type_id, device_id, deleted_at); +CREATE UNIQUE INDEX idx_device_del ON robots (device_id, deleted_at); DROP INDEX operator_id ON data_collectors; DROP INDEX idx_operator_id ON data_collectors; CREATE UNIQUE INDEX idx_operator_del ON data_collectors (operator_id, deleted_at); -CREATE UNIQUE INDEX idx_robot_datacollector_del ON workstations (robot_id, data_collector_id, deleted_at); +CREATE UNIQUE INDEX idx_datacollector_del ON workstations (data_collector_id, deleted_at); DROP INDEX inspector_id ON inspectors; CREATE UNIQUE INDEX idx_inspector_del ON inspectors (inspector_id, deleted_at); @@ -59,18 +56,16 @@ CREATE UNIQUE INDEX idx_inspector_del ON inspectors (inspector_id, deleted_at); -- Production Units -- ============================================================ -CREATE UNIQUE INDEX idx_org_name_del ON orders (organization_id, name, deleted_at); +CREATE UNIQUE INDEX idx_name_del ON orders (name, deleted_at); DROP INDEX batch_id ON batches; -CREATE UNIQUE INDEX idx_order_name_del ON batches (order_id, name, deleted_at); +CREATE UNIQUE INDEX idx_name_del ON batches (name, deleted_at); DROP INDEX task_id ON tasks; -CREATE UNIQUE INDEX idx_order_batch_task_del ON tasks (order_id, batch_id, task_id, deleted_at); +CREATE UNIQUE INDEX idx_task_del ON tasks (task_id, deleted_at); DROP INDEX episode_id ON episodes; -CREATE UNIQUE INDEX idx_order_batch_task_episode_del ON episodes (order_id, batch_id, task_id, episode_id, deleted_at); - - +CREATE UNIQUE INDEX idx_episode_del ON episodes (episode_id, deleted_at); From 960c06bb6ae4803233ca26bc57752dcdc58a251a Mon Sep 17 00:00:00 2001 From: shark Date: Wed, 25 Mar 2026 22:02:44 +0800 Subject: [PATCH 09/20] feat(api): enhance field flexibility --- internal/api/handlers/robot.go | 99 ++++++-- internal/api/handlers/robot_type.go | 7 +- internal/api/handlers/scene.go | 226 +++++++++++++----- internal/api/handlers/subscene.go | 159 ++++++++---- .../000002_version_2_schema.down.sql | 45 +++- .../migrations/000002_version_2_schema.up.sql | 78 ++++-- 6 files changed, 451 insertions(+), 163 deletions(-) diff --git a/internal/api/handlers/robot.go b/internal/api/handlers/robot.go index b496409..b558f0a 100644 --- a/internal/api/handlers/robot.go +++ b/internal/api/handlers/robot.go @@ -402,7 +402,10 @@ func (h *RobotHandler) GetRobot(c *gin.Context) { // UpdateRobotRequest represents the request body for updating a robot. type UpdateRobotRequest struct { - Status *string `json:"status,omitempty"` + RobotTypeID *string `json:"robot_type_id,omitempty"` + DeviceID *string `json:"device_id,omitempty"` + FactoryID *string `json:"factory_id,omitempty"` + Status *string `json:"status,omitempty"` } // UpdateRobot handles updating a robot. @@ -433,6 +436,14 @@ func (h *RobotHandler) UpdateRobot(c *gin.Context) { return } + // Check if robot exists + var exists bool + err = h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM robots WHERE id = ? AND deleted_at IS NULL)", id) + if err != nil || !exists { + c.JSON(http.StatusNotFound, gin.H{"error": "robot not found"}) + return + } + // Validate status if provided validStatuses := map[string]bool{ "active": true, @@ -440,29 +451,87 @@ func (h *RobotHandler) UpdateRobot(c *gin.Context) { "retired": true, } - if req.Status != nil { - status := strings.TrimSpace(*req.Status) - if !validStatuses[status] { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid status, must be one of: active, maintenance, retired"}) + // Build update query dynamically + updates := []string{} + args := []interface{}{} + + if req.RobotTypeID != nil { + if *req.RobotTypeID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "robot_type_id cannot be empty"}) return } - - // Check if robot exists - var exists bool - err := h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM robots WHERE id = ? AND deleted_at IS NULL)", id) - if err != nil || !exists { - c.JSON(http.StatusNotFound, gin.H{"error": "robot not found"}) + parsedRobotTypeID, err := strconv.ParseInt(*req.RobotTypeID, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid robot_type_id format"}) return } + // Verify robot_type exists + var rtExists bool + err = h.db.Get(&rtExists, "SELECT EXISTS(SELECT 1 FROM robot_types WHERE id = ? AND deleted_at IS NULL)", parsedRobotTypeID) + if err != nil || !rtExists { + c.JSON(http.StatusBadRequest, gin.H{"error": "robot_type not found"}) + return + } + updates = append(updates, "robot_type_id = ?") + args = append(args, parsedRobotTypeID) + } - updatedAt := time.Now().UTC().Format("2006-01-02 15:04:05") + if req.DeviceID != nil { + deviceID := strings.TrimSpace(*req.DeviceID) + if deviceID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "device_id cannot be empty"}) + return + } + updates = append(updates, "device_id = ?") + args = append(args, deviceID) + } - _, err = h.db.Exec("UPDATE robots SET status = ?, updated_at = ? WHERE id = ?", status, updatedAt, id) + if req.FactoryID != nil { + if *req.FactoryID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "factory_id cannot be empty"}) + return + } + parsedFactoryID, err := strconv.ParseInt(*req.FactoryID, 10, 64) if err != nil { - logger.Printf("[ROBOT] Failed to update robot: %v", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update robot"}) + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid factory_id format"}) + return + } + // Verify factory exists + var fExists bool + err = h.db.Get(&fExists, "SELECT EXISTS(SELECT 1 FROM factories WHERE id = ? AND deleted_at IS NULL)", parsedFactoryID) + if err != nil || !fExists { + c.JSON(http.StatusBadRequest, gin.H{"error": "factory not found"}) + return + } + updates = append(updates, "factory_id = ?") + args = append(args, parsedFactoryID) + } + + if req.Status != nil { + status := strings.TrimSpace(*req.Status) + if !validStatuses[status] { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid status, must be one of: active, maintenance, retired"}) return } + updates = append(updates, "status = ?") + args = append(args, status) + } + + if len(updates) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) + return + } + + updates = append(updates, "updated_at = ?") + args = append(args, time.Now().UTC().Format("2006-01-02 15:04:05")) + args = append(args, id) + + query := fmt.Sprintf("UPDATE robots SET %s WHERE id = ?", strings.Join(updates, ", ")) + _, err = h.db.Exec(query, args...) + if err != nil { + logger.Printf("[ROBOT] Failed to update robot: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update robot"}) + return } // Fetch the updated robot diff --git a/internal/api/handlers/robot_type.go b/internal/api/handlers/robot_type.go index b23642e..4af6235 100644 --- a/internal/api/handlers/robot_type.go +++ b/internal/api/handlers/robot_type.go @@ -111,11 +111,6 @@ func (h *RobotTypeHandler) CreateRobotType(c *gin.Context) { return } - if len(req.ROSTopics) == 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "ros_topics is required"}) - return - } - now := time.Now().UTC() result, err := h.db.Exec( @@ -233,7 +228,7 @@ func parseJSONArray(s string) []string { func toNullableJSONArray(values []string) sql.NullString { if len(values) == 0 { - return sql.NullString{} + return sql.NullString{String: "[]", Valid: true} } data, err := json.Marshal(values) if err != nil { diff --git a/internal/api/handlers/scene.go b/internal/api/handlers/scene.go index e1925a6..a610984 100644 --- a/internal/api/handlers/scene.go +++ b/internal/api/handlers/scene.go @@ -37,6 +37,7 @@ type SceneResponse struct { Slug string `json:"slug"` Description string `json:"description,omitempty"` InitialSceneLayoutTemplate string `json:"initial_scene_layout_template,omitempty"` + SubsceneCount int `json:"subscene_count"` CreatedAt string `json:"created_at,omitempty"` UpdatedAt string `json:"updated_at,omitempty"` } @@ -48,12 +49,12 @@ type SceneListResponse struct { // CreateSceneRequest represents the request body for creating a scene. type CreateSceneRequest struct { - OrganizationID string `json:"organization_id"` - FactoryID string `json:"factory_id"` - Name string `json:"name"` - Slug string `json:"slug"` - Description string `json:"description,omitempty"` - InitialSceneLayoutTemplate string `json:"initial_scene_layout_template,omitempty"` + OrganizationID *string `json:"organization_id,omitempty"` + FactoryID string `json:"factory_id"` + Name string `json:"name"` + Slug *string `json:"slug,omitempty"` + Description string `json:"description,omitempty"` + InitialSceneLayoutTemplate string `json:"initial_scene_layout_template,omitempty"` } // CreateSceneResponse represents the response for creating a scene. @@ -66,6 +67,8 @@ type CreateSceneResponse struct { // UpdateSceneRequest represents the request body for updating a scene. type UpdateSceneRequest struct { + OrganizationID *string `json:"organization_id,omitempty"` + FactoryID *string `json:"factory_id,omitempty"` Name *string `json:"name,omitempty"` Slug *string `json:"slug,omitempty"` Description *string `json:"description,omitempty"` @@ -84,12 +87,13 @@ func (h *SceneHandler) RegisterRoutes(apiV1 *gin.RouterGroup) { // sceneRow represents a scene in the database type sceneRow struct { ID int64 `db:"id"` - OrganizationID int64 `db:"organization_id"` + OrganizationID sql.NullInt64 `db:"organization_id"` FactoryID int64 `db:"factory_id"` Name string `db:"name"` - Slug string `db:"slug"` + Slug sql.NullString `db:"slug"` Description sql.NullString `db:"description"` InitialSceneLayoutTemplate sql.NullString `db:"initial_scene_layout_template"` + SubsceneCount int `db:"subscene_count"` CreatedAt sql.NullString `db:"created_at"` UpdatedAt sql.NullString `db:"updated_at"` } @@ -112,17 +116,18 @@ func (h *SceneHandler) ListScenes(c *gin.Context) { query := ` SELECT - id, - organization_id, - factory_id, - name, - slug, - description, - initial_scene_layout_template, - created_at, - updated_at - FROM scenes - WHERE deleted_at IS NULL + s.id, + s.organization_id, + s.factory_id, + s.name, + s.slug, + s.description, + s.initial_scene_layout_template, + s.created_at, + s.updated_at, + (SELECT COUNT(*) FROM subscenes sub WHERE sub.scene_id = s.id AND sub.deleted_at IS NULL) as subscene_count + FROM scenes s + WHERE s.deleted_at IS NULL ` args := []interface{}{} @@ -173,15 +178,24 @@ func (h *SceneHandler) ListScenes(c *gin.Context) { if s.UpdatedAt.Valid { updatedAt = s.UpdatedAt.String } + orgID := "" + if s.OrganizationID.Valid { + orgID = fmt.Sprintf("%d", s.OrganizationID.Int64) + } + slug := "" + if s.Slug.Valid { + slug = s.Slug.String + } scenes = append(scenes, SceneResponse{ ID: fmt.Sprintf("%d", s.ID), - OrganizationID: fmt.Sprintf("%d", s.OrganizationID), + OrganizationID: orgID, FactoryID: fmt.Sprintf("%d", s.FactoryID), Name: s.Name, - Slug: s.Slug, + Slug: slug, Description: description, InitialSceneLayoutTemplate: layoutTemplate, + SubsceneCount: s.SubsceneCount, CreatedAt: createdAt, UpdatedAt: updatedAt, }) @@ -255,13 +269,21 @@ func (h *SceneHandler) GetScene(c *gin.Context) { if s.UpdatedAt.Valid { updatedAt = s.UpdatedAt.String } + orgID := "" + if s.OrganizationID.Valid { + orgID = fmt.Sprintf("%d", s.OrganizationID.Int64) + } + slug := "" + if s.Slug.Valid { + slug = s.Slug.String + } c.JSON(http.StatusOK, SceneResponse{ ID: fmt.Sprintf("%d", s.ID), - OrganizationID: fmt.Sprintf("%d", s.OrganizationID), + OrganizationID: orgID, FactoryID: fmt.Sprintf("%d", s.FactoryID), Name: s.Name, - Slug: s.Slug, + Slug: slug, Description: description, InitialSceneLayoutTemplate: layoutTemplate, CreatedAt: createdAt, @@ -288,16 +310,17 @@ func (h *SceneHandler) CreateScene(c *gin.Context) { return } - req.OrganizationID = strings.TrimSpace(req.OrganizationID) + if req.OrganizationID != nil { + trimmed := strings.TrimSpace(*req.OrganizationID) + req.OrganizationID = &trimmed + } req.FactoryID = strings.TrimSpace(req.FactoryID) req.Name = strings.TrimSpace(req.Name) - req.Slug = strings.TrimSpace(req.Slug) - req.Description = strings.TrimSpace(req.Description) - - if req.OrganizationID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "organization_id is required"}) - return + if req.Slug != nil { + trimmed := strings.TrimSpace(*req.Slug) + req.Slug = &trimmed } + req.Description = strings.TrimSpace(req.Description) if req.FactoryID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "factory_id is required"}) @@ -309,18 +332,6 @@ func (h *SceneHandler) CreateScene(c *gin.Context) { return } - if req.Slug == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "slug is required"}) - return - } - - // Parse organization_id - orgID, err := strconv.ParseInt(req.OrganizationID, 10, 64) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid organization_id format"}) - return - } - // Parse factory_id factoryID, err := strconv.ParseInt(req.FactoryID, 10, 64) if err != nil { @@ -328,26 +339,47 @@ func (h *SceneHandler) CreateScene(c *gin.Context) { return } - // Verify organization exists - var exists bool - err = h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM organizations WHERE id = ? AND deleted_at IS NULL)", orgID) - if err != nil || !exists { - c.JSON(http.StatusBadRequest, gin.H{"error": "organization not found"}) - return - } - // Verify factory exists + var exists bool err = h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM factories WHERE id = ? AND deleted_at IS NULL)", factoryID) if err != nil || !exists { c.JSON(http.StatusBadRequest, gin.H{"error": "factory not found"}) return } - // Check if slug already exists for this organization - err = h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM scenes WHERE organization_id = ? AND slug = ? AND deleted_at IS NULL)", orgID, req.Slug) - if err == nil && exists { - c.JSON(http.StatusBadRequest, gin.H{"error": "slug already exists for this organization"}) - return + // Parse organization_id (optional) + var orgID sql.NullInt64 + if req.OrganizationID != nil && *req.OrganizationID != "" { + parsedOrgID, err := strconv.ParseInt(*req.OrganizationID, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid organization_id format"}) + return + } + // Verify organization exists + err = h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM organizations WHERE id = ? AND deleted_at IS NULL)", parsedOrgID) + if err != nil || !exists { + c.JSON(http.StatusBadRequest, gin.H{"error": "organization not found"}) + return + } + orgID = sql.NullInt64{Int64: parsedOrgID, Valid: true} + + // Check if slug already exists for this organization (if slug is provided) + if req.Slug != nil && *req.Slug != "" { + err = h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM scenes WHERE organization_id = ? AND slug = ? AND deleted_at IS NULL)", parsedOrgID, *req.Slug) + if err == nil && exists { + c.JSON(http.StatusBadRequest, gin.H{"error": "slug already exists for this organization"}) + return + } + } + } else { + // Check if slug already exists globally (when organization_id is not specified) + if req.Slug != nil && *req.Slug != "" { + err = h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM scenes WHERE (organization_id IS NULL OR organization_id = 0) AND slug = ? AND deleted_at IS NULL)", *req.Slug) + if err == nil && exists { + c.JSON(http.StatusBadRequest, gin.H{"error": "slug already exists"}) + return + } + } } var descriptionStr sql.NullString @@ -355,6 +387,11 @@ func (h *SceneHandler) CreateScene(c *gin.Context) { descriptionStr = sql.NullString{String: req.Description, Valid: true} } + var slugStr sql.NullString + if req.Slug != nil && *req.Slug != "" { + slugStr = sql.NullString{String: *req.Slug, Valid: true} + } + var layoutTemplateStr sql.NullString if req.InitialSceneLayoutTemplate != "" { layoutTemplateStr = sql.NullString{String: req.InitialSceneLayoutTemplate, Valid: true} @@ -376,7 +413,7 @@ func (h *SceneHandler) CreateScene(c *gin.Context) { orgID, factoryID, req.Name, - req.Slug, + slugStr, descriptionStr, layoutTemplateStr, now, @@ -396,9 +433,14 @@ func (h *SceneHandler) CreateScene(c *gin.Context) { } c.JSON(http.StatusCreated, CreateSceneResponse{ - ID: fmt.Sprintf("%d", id), - Name: req.Name, - Slug: req.Slug, + ID: fmt.Sprintf("%d", id), + Name: req.Name, + Slug: func() string { + if req.Slug != nil { + return *req.Slug + } + return "" + }(), CreatedAt: now.Format(time.RFC3339), }) } @@ -448,6 +490,52 @@ func (h *SceneHandler) UpdateScene(c *gin.Context) { updates := []string{} args := []interface{}{} + if req.OrganizationID != nil { + orgIDStr := strings.TrimSpace(*req.OrganizationID) + if orgIDStr == "" { + // Set organization_id to NULL + updates = append(updates, "organization_id = ?") + args = append(args, sql.NullInt64{}) + } else { + orgID, err := strconv.ParseInt(orgIDStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid organization_id format"}) + return + } + // Verify organization exists + var exists bool + err = h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM organizations WHERE id = ? AND deleted_at IS NULL)", orgID) + if err != nil || !exists { + c.JSON(http.StatusBadRequest, gin.H{"error": "organization not found"}) + return + } + updates = append(updates, "organization_id = ?") + args = append(args, sql.NullInt64{Int64: orgID, Valid: true}) + } + } + + if req.FactoryID != nil { + factoryIDStr := strings.TrimSpace(*req.FactoryID) + if factoryIDStr == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "factory_id cannot be empty"}) + return + } + factoryID, err := strconv.ParseInt(factoryIDStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid factory_id format"}) + return + } + // Verify factory exists + var exists bool + err = h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM factories WHERE id = ? AND deleted_at IS NULL)", factoryID) + if err != nil || !exists { + c.JSON(http.StatusBadRequest, gin.H{"error": "factory not found"}) + return + } + updates = append(updates, "factory_id = ?") + args = append(args, factoryID) + } + if req.Name != nil { name := strings.TrimSpace(*req.Name) if name != "" { @@ -458,10 +546,14 @@ func (h *SceneHandler) UpdateScene(c *gin.Context) { if req.Slug != nil { slug := strings.TrimSpace(*req.Slug) - if slug != "" { + if slug == "" { + // Set slug to NULL + updates = append(updates, "slug = ?") + args = append(args, sql.NullString{}) + } else { // Check if slug already exists for this organization var exists bool - err := h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM scenes WHERE organization_id = ? AND slug = ? AND id != ? AND deleted_at IS NULL)", existing.OrganizationID, slug, id) + err := h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM scenes WHERE organization_id IS NOT DISTINCT FROM ? AND slug = ? AND id != ? AND deleted_at IS NULL)", existing.OrganizationID, slug, id) if err == nil && exists { c.JSON(http.StatusBadRequest, gin.H{"error": "slug already exists for this organization"}) return @@ -535,13 +627,21 @@ func (h *SceneHandler) UpdateScene(c *gin.Context) { if s.UpdatedAt.Valid { updatedAt = s.UpdatedAt.String } + orgID := "" + if s.OrganizationID.Valid { + orgID = fmt.Sprintf("%d", s.OrganizationID.Int64) + } + slug := "" + if s.Slug.Valid { + slug = s.Slug.String + } c.JSON(http.StatusOK, SceneResponse{ ID: fmt.Sprintf("%d", s.ID), - OrganizationID: fmt.Sprintf("%d", s.OrganizationID), + OrganizationID: orgID, FactoryID: fmt.Sprintf("%d", s.FactoryID), Name: s.Name, - Slug: s.Slug, + Slug: slug, Description: description, InitialSceneLayoutTemplate: layoutTemplate, CreatedAt: createdAt, diff --git a/internal/api/handlers/subscene.go b/internal/api/handlers/subscene.go index 0caebe8..04dc7d5 100644 --- a/internal/api/handlers/subscene.go +++ b/internal/api/handlers/subscene.go @@ -33,10 +33,10 @@ type SubsceneResponse struct { ID string `json:"id"` SceneID string `json:"scene_id"` Name string `json:"name"` - Slug string `json:"slug"` + Slug string `json:"slug,omitempty"` Description string `json:"description,omitempty"` InitialSceneLayout string `json:"initial_scene_layout,omitempty"` - RobotTypeID string `json:"robot_type_id"` + RobotTypeID string `json:"robot_type_id,omitempty"` CreatedAt string `json:"created_at,omitempty"` UpdatedAt string `json:"updated_at,omitempty"` } @@ -48,19 +48,19 @@ type SubsceneListResponse struct { // CreateSubsceneRequest represents the request body for creating a subscene. type CreateSubsceneRequest struct { - SceneID string `json:"scene_id"` - Name string `json:"name"` - Slug string `json:"slug"` - Description string `json:"description,omitempty"` - InitialSceneLayout string `json:"initial_scene_layout,omitempty"` - RobotTypeID string `json:"robot_type_id"` + SceneID string `json:"scene_id"` + Name string `json:"name"` + Slug *string `json:"slug,omitempty"` + Description string `json:"description,omitempty"` + InitialSceneLayout string `json:"initial_scene_layout,omitempty"` + RobotTypeID *string `json:"robot_type_id,omitempty"` } // CreateSubsceneResponse represents the response for creating a subscene. type CreateSubsceneResponse struct { ID string `json:"id"` Name string `json:"name"` - Slug string `json:"slug"` + Slug string `json:"slug,omitempty"` CreatedAt string `json:"created_at"` } @@ -70,6 +70,7 @@ type UpdateSubsceneRequest struct { Slug *string `json:"slug,omitempty"` Description *string `json:"description,omitempty"` InitialSceneLayout *string `json:"initial_scene_layout,omitempty"` + RobotTypeID *string `json:"robot_type_id,omitempty"` } // RegisterRoutes registers subscene related routes. @@ -86,10 +87,10 @@ type subsceneRow struct { ID int64 `db:"id"` SceneID int64 `db:"scene_id"` Name string `db:"name"` - Slug string `db:"slug"` + Slug sql.NullString `db:"slug"` Description sql.NullString `db:"description"` InitialSceneLayout sql.NullString `db:"initial_scene_layout"` - RobotTypeID int64 `db:"robot_type_id"` + RobotTypeID sql.NullInt64 `db:"robot_type_id"` CreatedAt sql.NullString `db:"created_at"` UpdatedAt sql.NullString `db:"updated_at"` } @@ -161,15 +162,23 @@ func (h *SubsceneHandler) ListSubscenes(c *gin.Context) { if s.UpdatedAt.Valid { updatedAt = s.UpdatedAt.String } + slug := "" + if s.Slug.Valid { + slug = s.Slug.String + } + robotTypeID := "" + if s.RobotTypeID.Valid { + robotTypeID = fmt.Sprintf("%d", s.RobotTypeID.Int64) + } subscenes = append(subscenes, SubsceneResponse{ ID: fmt.Sprintf("%d", s.ID), SceneID: fmt.Sprintf("%d", s.SceneID), Name: s.Name, - Slug: s.Slug, + Slug: slug, Description: description, InitialSceneLayout: layout, - RobotTypeID: fmt.Sprintf("%d", s.RobotTypeID), + RobotTypeID: robotTypeID, CreatedAt: createdAt, UpdatedAt: updatedAt, }) @@ -243,15 +252,23 @@ func (h *SubsceneHandler) GetSubscene(c *gin.Context) { if s.UpdatedAt.Valid { updatedAt = s.UpdatedAt.String } + slug := "" + if s.Slug.Valid { + slug = s.Slug.String + } + robotTypeID := "" + if s.RobotTypeID.Valid { + robotTypeID = fmt.Sprintf("%d", s.RobotTypeID.Int64) + } c.JSON(http.StatusOK, SubsceneResponse{ ID: fmt.Sprintf("%d", s.ID), SceneID: fmt.Sprintf("%d", s.SceneID), Name: s.Name, - Slug: s.Slug, + Slug: slug, Description: description, InitialSceneLayout: layout, - RobotTypeID: fmt.Sprintf("%d", s.RobotTypeID), + RobotTypeID: robotTypeID, CreatedAt: createdAt, UpdatedAt: updatedAt, }) @@ -278,9 +295,15 @@ func (h *SubsceneHandler) CreateSubscene(c *gin.Context) { req.SceneID = strings.TrimSpace(req.SceneID) req.Name = strings.TrimSpace(req.Name) - req.Slug = strings.TrimSpace(req.Slug) + if req.Slug != nil { + trimmed := strings.TrimSpace(*req.Slug) + req.Slug = &trimmed + } req.Description = strings.TrimSpace(req.Description) - req.RobotTypeID = strings.TrimSpace(req.RobotTypeID) + if req.RobotTypeID != nil { + trimmed := strings.TrimSpace(*req.RobotTypeID) + req.RobotTypeID = &trimmed + } if req.SceneID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "scene_id is required"}) @@ -292,16 +315,6 @@ func (h *SubsceneHandler) CreateSubscene(c *gin.Context) { return } - if req.Slug == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "slug is required"}) - return - } - - if req.RobotTypeID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "robot_type_id is required"}) - return - } - // Parse scene_id sceneID, err := strconv.ParseInt(req.SceneID, 10, 64) if err != nil { @@ -309,13 +322,6 @@ func (h *SubsceneHandler) CreateSubscene(c *gin.Context) { return } - // Parse robot_type_id - robotTypeID, err := strconv.ParseInt(req.RobotTypeID, 10, 64) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid robot_type_id format"}) - return - } - // Verify scene exists var exists bool err = h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM scenes WHERE id = ? AND deleted_at IS NULL)", sceneID) @@ -324,18 +330,29 @@ func (h *SubsceneHandler) CreateSubscene(c *gin.Context) { return } - // Verify robot_type exists - err = h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM robot_types WHERE id = ? AND deleted_at IS NULL)", robotTypeID) - if err != nil || !exists { - c.JSON(http.StatusBadRequest, gin.H{"error": "robot_type not found"}) - return + // Parse and verify robot_type_id (optional) + var robotTypeID sql.NullInt64 + if req.RobotTypeID != nil && *req.RobotTypeID != "" { + parsedRobotTypeID, err := strconv.ParseInt(*req.RobotTypeID, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid robot_type_id format"}) + return + } + err = h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM robot_types WHERE id = ? AND deleted_at IS NULL)", parsedRobotTypeID) + if err != nil || !exists { + c.JSON(http.StatusBadRequest, gin.H{"error": "robot_type not found"}) + return + } + robotTypeID = sql.NullInt64{Int64: parsedRobotTypeID, Valid: true} } - // Check if slug already exists for this scene - err = h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM subscenes WHERE scene_id = ? AND slug = ? AND deleted_at IS NULL)", sceneID, req.Slug) - if err == nil && exists { - c.JSON(http.StatusBadRequest, gin.H{"error": "slug already exists for this scene"}) - return + // Check if slug already exists for this scene (if slug is provided) + if req.Slug != nil && *req.Slug != "" { + err = h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM subscenes WHERE scene_id = ? AND slug = ? AND deleted_at IS NULL)", sceneID, *req.Slug) + if err == nil && exists { + c.JSON(http.StatusBadRequest, gin.H{"error": "slug already exists for this scene"}) + return + } } var descriptionStr sql.NullString @@ -348,6 +365,11 @@ func (h *SubsceneHandler) CreateSubscene(c *gin.Context) { layoutStr = sql.NullString{String: req.InitialSceneLayout, Valid: true} } + var slugStr sql.NullString + if req.Slug != nil && *req.Slug != "" { + slugStr = sql.NullString{String: *req.Slug, Valid: true} + } + now := time.Now().UTC() result, err := h.db.Exec( @@ -363,7 +385,7 @@ func (h *SubsceneHandler) CreateSubscene(c *gin.Context) { ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, sceneID, req.Name, - req.Slug, + slugStr, descriptionStr, layoutStr, robotTypeID, @@ -384,9 +406,14 @@ func (h *SubsceneHandler) CreateSubscene(c *gin.Context) { } c.JSON(http.StatusCreated, CreateSubsceneResponse{ - ID: fmt.Sprintf("%d", id), - Name: req.Name, - Slug: req.Slug, + ID: fmt.Sprintf("%d", id), + Name: req.Name, + Slug: func() string { + if req.Slug != nil { + return *req.Slug + } + return "" + }(), CreatedAt: now.Format(time.RFC3339), }) } @@ -479,6 +506,30 @@ func (h *SubsceneHandler) UpdateSubscene(c *gin.Context) { args = append(args, layoutStr) } + // Handle robot_type_id update + if req.RobotTypeID != nil { + if *req.RobotTypeID == "" { + // Set to NULL to remove association + updates = append(updates, "robot_type_id = ?") + args = append(args, sql.NullInt64{}) + } else { + parsedRobotTypeID, err := strconv.ParseInt(*req.RobotTypeID, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid robot_type_id format"}) + return + } + // Verify robot_type exists + var exists bool + err = h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM robot_types WHERE id = ? AND deleted_at IS NULL)", parsedRobotTypeID) + if err != nil || !exists { + c.JSON(http.StatusBadRequest, gin.H{"error": "robot_type not found"}) + return + } + updates = append(updates, "robot_type_id = ?") + args = append(args, sql.NullInt64{Int64: parsedRobotTypeID, Valid: true}) + } + } + if len(updates) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) return @@ -523,15 +574,23 @@ func (h *SubsceneHandler) UpdateSubscene(c *gin.Context) { if s.UpdatedAt.Valid { updatedAt = s.UpdatedAt.String } + slug := "" + if s.Slug.Valid { + slug = s.Slug.String + } + robotTypeID := "" + if s.RobotTypeID.Valid { + robotTypeID = fmt.Sprintf("%d", s.RobotTypeID.Int64) + } c.JSON(http.StatusOK, SubsceneResponse{ ID: fmt.Sprintf("%d", s.ID), SceneID: fmt.Sprintf("%d", s.SceneID), Name: s.Name, - Slug: s.Slug, + Slug: slug, Description: description, InitialSceneLayout: layout, - RobotTypeID: fmt.Sprintf("%d", s.RobotTypeID), + RobotTypeID: robotTypeID, CreatedAt: createdAt, UpdatedAt: updatedAt, }) diff --git a/internal/storage/database/migrations/000002_version_2_schema.down.sql b/internal/storage/database/migrations/000002_version_2_schema.down.sql index c1721f4..713bac7 100644 --- a/internal/storage/database/migrations/000002_version_2_schema.down.sql +++ b/internal/storage/database/migrations/000002_version_2_schema.down.sql @@ -3,66 +3,93 @@ -- SPDX-License-Identifier: MulanPSL-2.0 -- migrations/000002_version_2_schema.down.sql --- Revert index optimizations from version 2 +-- Revert index optimizations from version 2 (remove virtual columns and restore original indexes) -- ============================================================ -- Production Units (reverse order of creation) -- ============================================================ DROP INDEX idx_episode_del ON episodes; +ALTER TABLE episodes DROP COLUMN _episode_unique; CREATE INDEX episode_id ON episodes (episode_id); DROP INDEX idx_task_del ON tasks; -CREATE INDEX task_id ON tasks (task_id); +ALTER TABLE tasks DROP COLUMN _task_unique; +CREATE UNIQUE INDEX task_id ON tasks (task_id); DROP INDEX idx_name_del ON batches; -CREATE INDEX batch_id ON batches (batch_id); +ALTER TABLE batches DROP COLUMN _name_unique; +CREATE UNIQUE INDEX batch_id ON batches (batch_id); DROP INDEX idx_name_del ON orders; +ALTER TABLE orders DROP COLUMN _name_unique; -- ============================================================ -- Operational Resources -- ============================================================ DROP INDEX idx_inspector_del ON inspectors; -CREATE INDEX inspector_id ON inspectors (inspector_id); +ALTER TABLE inspectors DROP COLUMN _inspector_unique; +CREATE UNIQUE INDEX inspector_id ON inspectors (inspector_id); DROP INDEX idx_datacollector_del ON workstations; +ALTER TABLE workstations DROP COLUMN _collector_unique; DROP INDEX idx_operator_del ON data_collectors; -CREATE INDEX operator_id ON data_collectors (operator_id); +ALTER TABLE data_collectors DROP COLUMN _operator_unique; +CREATE UNIQUE INDEX operator_id ON data_collectors (operator_id); CREATE INDEX idx_operator_id ON data_collectors (operator_id); DROP INDEX idx_device_del ON robots; -CREATE INDEX device_id ON robots (device_id); +ALTER TABLE robots DROP COLUMN _device_unique; +CREATE UNIQUE INDEX device_id ON robots (device_id); CREATE INDEX idx_device_id ON robots (device_id); DROP INDEX idx_model_del ON robot_types; +ALTER TABLE robot_types DROP COLUMN _model_unique; -- ============================================================ -- Capability & Procedure -- ============================================================ DROP INDEX idx_name_del ON sops; +ALTER TABLE sops DROP COLUMN _name_unique; CREATE INDEX slug ON sops (slug); CREATE INDEX idx_slug ON sops (slug); DROP INDEX idx_name_del ON skills; -CREATE INDEX name ON skills (name); +ALTER TABLE skills DROP COLUMN _name_unique; +CREATE UNIQUE INDEX name ON skills (name); -- ============================================================ -- Environmental Hierarchy -- ============================================================ DROP INDEX idx_name_del ON subscenes; -CREATE INDEX idx_scene_slug ON subscenes (scene_id, slug); +ALTER TABLE subscenes DROP COLUMN _name_unique; +CREATE UNIQUE INDEX idx_scene_slug ON subscenes (scene_id, slug); + +-- Revert: Make subscenes.robot_type_id NOT NULL again +ALTER TABLE subscenes MODIFY COLUMN robot_type_id BIGINT NOT NULL; + +-- Revert: Make subscenes.slug NOT NULL again +ALTER TABLE subscenes MODIFY COLUMN slug VARCHAR(100) NOT NULL; DROP INDEX idx_name_del ON scenes; +ALTER TABLE scenes DROP COLUMN _name_unique; CREATE UNIQUE INDEX idx_org_slug ON scenes (organization_id, slug); +-- Revert: Make scenes.slug NOT NULL again +ALTER TABLE scenes MODIFY COLUMN slug VARCHAR(100) NOT NULL; + +-- Revert: Make scenes.organization_id NOT NULL again +ALTER TABLE scenes MODIFY COLUMN organization_id BIGINT NOT NULL; + DROP INDEX idx_slug_del ON factories; +ALTER TABLE factories DROP COLUMN _slug_unique; CREATE UNIQUE INDEX idx_org_slug ON factories (organization_id, slug); DROP INDEX idx_slug_del ON organizations; -CREATE INDEX slug ON organizations (slug); +ALTER TABLE organizations DROP COLUMN _slug_unique; +CREATE UNIQUE INDEX slug ON organizations (slug); CREATE INDEX idx_slug ON organizations (slug); diff --git a/internal/storage/database/migrations/000002_version_2_schema.up.sql b/internal/storage/database/migrations/000002_version_2_schema.up.sql index 13ed5e4..8ae2c53 100644 --- a/internal/storage/database/migrations/000002_version_2_schema.up.sql +++ b/internal/storage/database/migrations/000002_version_2_schema.up.sql @@ -3,70 +3,108 @@ -- SPDX-License-Identifier: MulanPSL-2.0 -- migrations/000002_version_2_schema.up.sql --- Optimize indexes for better performance +-- Fix unique indexes with NULL values by using STORED virtual columns -- ============================================================ -- Environmental Hierarchy -- ============================================================ +-- organizations: slug + deleted_at +ALTER TABLE organizations ADD COLUMN _slug_unique VARCHAR(200) GENERATED ALWAYS AS (CONCAT(IFNULL(slug, ''), '|', IFNULL(deleted_at, ''))) STORED; DROP INDEX slug ON organizations; DROP INDEX idx_slug ON organizations; -CREATE UNIQUE INDEX idx_slug_del ON organizations (slug, deleted_at); +CREATE UNIQUE INDEX idx_slug_del ON organizations (_slug_unique); +-- factories: slug + deleted_at +ALTER TABLE factories ADD COLUMN _slug_unique VARCHAR(200) GENERATED ALWAYS AS (CONCAT(IFNULL(slug, ''), '|', IFNULL(deleted_at, ''))) STORED; DROP INDEX idx_org_slug ON factories; -CREATE UNIQUE INDEX idx_slug_del ON factories (slug, deleted_at); +CREATE UNIQUE INDEX idx_slug_del ON factories (_slug_unique); +-- scenes: name + deleted_at (organization_id is now nullable, so we include it in the unique key) +ALTER TABLE scenes ADD COLUMN _name_unique VARCHAR(400) GENERATED ALWAYS AS (CONCAT(IFNULL(organization_id, ''), '|', IFNULL(name, ''), '|', IFNULL(deleted_at, ''))) STORED; DROP INDEX idx_org_slug ON scenes; -CREATE UNIQUE INDEX idx_name_del ON scenes (name, deleted_at); +CREATE UNIQUE INDEX idx_name_del ON scenes (_name_unique); +-- Allow scenes.organization_id to be NULL +ALTER TABLE scenes MODIFY COLUMN organization_id BIGINT NULL; + +-- Allow scenes.slug to be NULL +ALTER TABLE scenes MODIFY COLUMN slug VARCHAR(100) NULL; + +-- subscenes: name + deleted_at +ALTER TABLE subscenes ADD COLUMN _name_unique VARCHAR(400) GENERATED ALWAYS AS (CONCAT(IFNULL(scene_id, ''), '|', IFNULL(name, ''), '|', IFNULL(deleted_at, ''))) STORED; DROP INDEX idx_scene_slug ON subscenes; -CREATE UNIQUE INDEX idx_name_del ON subscenes (name, deleted_at); +CREATE UNIQUE INDEX idx_name_del ON subscenes (_name_unique); + +-- Allow subscenes.slug to be NULL +ALTER TABLE subscenes MODIFY COLUMN slug VARCHAR(100) NULL; + +-- Allow subscenes.robot_type_id to be NULL +ALTER TABLE subscenes MODIFY COLUMN robot_type_id BIGINT NULL; -- ============================================================ -- Capability & Procedure -- ============================================================ +-- skills: name + deleted_at +ALTER TABLE skills ADD COLUMN _name_unique VARCHAR(300) GENERATED ALWAYS AS (CONCAT(IFNULL(name, ''), '|', IFNULL(deleted_at, ''))) STORED; DROP INDEX name ON skills; -CREATE UNIQUE INDEX idx_name_del ON skills (name, deleted_at); +CREATE UNIQUE INDEX idx_name_del ON skills (_name_unique); +-- sops: name + deleted_at +ALTER TABLE sops ADD COLUMN _name_unique VARCHAR(300) GENERATED ALWAYS AS (CONCAT(IFNULL(name, ''), '|', IFNULL(deleted_at, ''))) STORED; DROP INDEX slug ON sops; DROP INDEX idx_slug ON sops; -CREATE UNIQUE INDEX idx_name_del ON sops (name, deleted_at); +CREATE UNIQUE INDEX idx_name_del ON sops (_name_unique); -- ============================================================ -- Operational Resources -- ============================================================ -CREATE UNIQUE INDEX idx_model_del ON robot_types (model, deleted_at); +-- robot_types: model + deleted_at +ALTER TABLE robot_types ADD COLUMN _model_unique VARCHAR(300) GENERATED ALWAYS AS (CONCAT(IFNULL(model, ''), '|', IFNULL(deleted_at, ''))) STORED; +CREATE UNIQUE INDEX idx_model_del ON robot_types (_model_unique); +-- robots: device_id + deleted_at +ALTER TABLE robots ADD COLUMN _device_unique VARCHAR(200) GENERATED ALWAYS AS (CONCAT(IFNULL(device_id, ''), '|', IFNULL(deleted_at, ''))) STORED; DROP INDEX device_id ON robots; DROP INDEX idx_device_id ON robots; -CREATE UNIQUE INDEX idx_device_del ON robots (device_id, deleted_at); +CREATE UNIQUE INDEX idx_device_del ON robots (_device_unique); +-- data_collectors: operator_id + deleted_at +ALTER TABLE data_collectors ADD COLUMN _operator_unique VARCHAR(200) GENERATED ALWAYS AS (CONCAT(IFNULL(operator_id, ''), '|', IFNULL(deleted_at, ''))) STORED; DROP INDEX operator_id ON data_collectors; DROP INDEX idx_operator_id ON data_collectors; -CREATE UNIQUE INDEX idx_operator_del ON data_collectors (operator_id, deleted_at); +CREATE UNIQUE INDEX idx_operator_del ON data_collectors (_operator_unique); -CREATE UNIQUE INDEX idx_datacollector_del ON workstations (data_collector_id, deleted_at); +-- workstations: data_collector_id + deleted_at +ALTER TABLE workstations ADD COLUMN _collector_unique VARCHAR(200) GENERATED ALWAYS AS (CONCAT(IFNULL(data_collector_id, ''), '|', IFNULL(deleted_at, ''))) STORED; +CREATE UNIQUE INDEX idx_datacollector_del ON workstations (_collector_unique); +-- inspectors: inspector_id + deleted_at +ALTER TABLE inspectors ADD COLUMN _inspector_unique VARCHAR(200) GENERATED ALWAYS AS (CONCAT(IFNULL(inspector_id, ''), '|', IFNULL(deleted_at, ''))) STORED; DROP INDEX inspector_id ON inspectors; -CREATE UNIQUE INDEX idx_inspector_del ON inspectors (inspector_id, deleted_at); +CREATE UNIQUE INDEX idx_inspector_del ON inspectors (_inspector_unique); -- ============================================================ -- Production Units -- ============================================================ -CREATE UNIQUE INDEX idx_name_del ON orders (name, deleted_at); +-- orders: name + deleted_at (include organization_id and scene_id for uniqueness) +ALTER TABLE orders ADD COLUMN _name_unique VARCHAR(600) GENERATED ALWAYS AS (CONCAT(IFNULL(organization_id, ''), '|', IFNULL(scene_id, ''), '|', IFNULL(name, ''), '|', IFNULL(deleted_at, ''))) STORED; +CREATE UNIQUE INDEX idx_name_del ON orders (_name_unique); +-- batches: name + deleted_at (include batch_id as unique identifier) +ALTER TABLE batches ADD COLUMN _name_unique VARCHAR(600) GENERATED ALWAYS AS (CONCAT(IFNULL(order_id, ''), '|', IFNULL(name, ''), '|', IFNULL(deleted_at, ''))) STORED; DROP INDEX batch_id ON batches; -CREATE UNIQUE INDEX idx_name_del ON batches (name, deleted_at); +CREATE UNIQUE INDEX idx_name_del ON batches (_name_unique); +-- tasks: task_id + deleted_at +ALTER TABLE tasks ADD COLUMN _task_unique VARCHAR(200) GENERATED ALWAYS AS (CONCAT(IFNULL(task_id, ''), '|', IFNULL(deleted_at, ''))) STORED; DROP INDEX task_id ON tasks; -CREATE UNIQUE INDEX idx_task_del ON tasks (task_id, deleted_at); +CREATE UNIQUE INDEX idx_task_del ON tasks (_task_unique); +-- episodes: episode_id + deleted_at +ALTER TABLE episodes ADD COLUMN _episode_unique VARCHAR(200) GENERATED ALWAYS AS (CONCAT(IFNULL(episode_id, ''), '|', IFNULL(deleted_at, ''))) STORED; DROP INDEX episode_id ON episodes; -CREATE UNIQUE INDEX idx_episode_del ON episodes (episode_id, deleted_at); - - - - +CREATE UNIQUE INDEX idx_episode_del ON episodes (_episode_unique); From 8fe717016426b6facc9470f468aacf135128348a Mon Sep 17 00:00:00 2001 From: shark Date: Thu, 26 Mar 2026 11:43:41 +0800 Subject: [PATCH 10/20] feat(api): add email field to data collector creation --- internal/api/handlers/data_collector.go | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/internal/api/handlers/data_collector.go b/internal/api/handlers/data_collector.go index ff3f7d8..ab29207 100644 --- a/internal/api/handlers/data_collector.go +++ b/internal/api/handlers/data_collector.go @@ -49,6 +49,7 @@ type DataCollectorListResponse struct { type CreateDataCollectorRequest struct { Name string `json:"name"` OperatorID string `json:"operator_id"` + Email string `json:"email,omitempty"` } // CreateDataCollectorResponse represents the response for creating a data collector. @@ -56,6 +57,7 @@ type CreateDataCollectorResponse struct { ID string `json:"id"` Name string `json:"name"` OperatorID string `json:"operator_id"` + Email string `json:"email,omitempty"` Status string `json:"status"` CreatedAt string `json:"created_at"` } @@ -178,6 +180,7 @@ func (h *DataCollectorHandler) CreateDataCollector(c *gin.Context) { req.Name = strings.TrimSpace(req.Name) req.OperatorID = strings.TrimSpace(req.OperatorID) + req.Email = strings.TrimSpace(req.Email) if req.Name == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"}) @@ -206,16 +209,23 @@ func (h *DataCollectorHandler) CreateDataCollector(c *gin.Context) { createdAt := time.Now().UTC().Format("2006-01-02 15:04:05") // Insert the data collector + var emailStr sql.NullString + if req.Email != "" { + emailStr = sql.NullString{String: req.Email, Valid: true} + } + result, err := h.db.Exec( `INSERT INTO data_collectors ( name, operator_id, + email, status, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?)`, + ) VALUES (?, ?, ?, ?, ?, ?)`, req.Name, req.OperatorID, + emailStr, "active", createdAt, createdAt, @@ -237,6 +247,7 @@ func (h *DataCollectorHandler) CreateDataCollector(c *gin.Context) { ID: fmt.Sprintf("%d", id), Name: req.Name, OperatorID: req.OperatorID, + Email: req.Email, Status: "active", CreatedAt: createdAt, }) From bddcb31c0efceddb610c46bbc065a754fda3274c Mon Sep 17 00:00:00 2001 From: shark Date: Thu, 26 Mar 2026 19:47:49 +0800 Subject: [PATCH 11/20] feat(api): add queue size to inspector responses and enhance skill and SOP request structures --- internal/api/handlers/inspector.go | 4 + internal/api/handlers/skill.go | 44 +++-- internal/api/handlers/sop.go | 93 +++++++--- internal/api/handlers/station.go | 173 +++++++----------- .../migrations/000001_initial_schema.up.sql | 5 - .../000002_version_2_schema.down.sql | 3 + .../migrations/000002_version_2_schema.up.sql | 7 +- 7 files changed, 172 insertions(+), 157 deletions(-) diff --git a/internal/api/handlers/inspector.go b/internal/api/handlers/inspector.go index bb22b91..cfead7d 100644 --- a/internal/api/handlers/inspector.go +++ b/internal/api/handlers/inspector.go @@ -37,6 +37,7 @@ type InspectorResponse struct { Email string `json:"email,omitempty"` CertificationLevel string `json:"certification_level"` Status string `json:"status"` + QueueSize int `json:"queueSize"` Metadata interface{} `json:"metadata,omitempty"` CreatedAt string `json:"created_at,omitempty"` UpdatedAt string `json:"updated_at,omitempty"` @@ -170,6 +171,7 @@ func (h *InspectorHandler) ListInspectors(c *gin.Context) { Email: email, CertificationLevel: certLevel, Status: i.Status, + QueueSize: 0, Metadata: metadata, CreatedAt: createdAt, UpdatedAt: updatedAt, @@ -256,6 +258,7 @@ func (h *InspectorHandler) GetInspector(c *gin.Context) { Email: email, CertificationLevel: certLevel, Status: i.Status, + QueueSize: 0, Metadata: metadata, CreatedAt: createdAt, UpdatedAt: updatedAt, @@ -517,6 +520,7 @@ func (h *InspectorHandler) UpdateInspector(c *gin.Context) { Email: email, CertificationLevel: certLevel, Status: i.Status, + QueueSize: 0, Metadata: metadata, CreatedAt: createdAt, UpdatedAt: updatedAt, diff --git a/internal/api/handlers/skill.go b/internal/api/handlers/skill.go index ec3f7fd..a3bc5d4 100644 --- a/internal/api/handlers/skill.go +++ b/internal/api/handlers/skill.go @@ -65,6 +65,7 @@ type CreateSkillResponse struct { // UpdateSkillRequest represents the request body for updating a skill. type UpdateSkillRequest struct { + Name *string `json:"name,omitempty"` DisplayName *string `json:"display_name,omitempty"` Description *string `json:"description,omitempty"` Version *string `json:"version,omitempty"` @@ -273,16 +274,7 @@ func (h *SkillHandler) CreateSkill(c *gin.Context) { } if req.DisplayName == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "display_name is required"}) - return - } - - // Check if name already exists - var exists bool - err := h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM skills WHERE name = ? AND deleted_at IS NULL)", req.Name) - if err == nil && exists { - c.JSON(http.StatusBadRequest, gin.H{"error": "skill name already exists"}) - return + req.DisplayName = req.Name } version := "1.0" @@ -371,11 +363,35 @@ func (h *SkillHandler) UpdateSkill(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) return } + // version is immutable after creation + if req.Version != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "version is immutable and cannot be updated"}) + return + } // Build update query dynamically updates := []string{} args := []interface{}{} + if req.Name != nil { + name := strings.TrimSpace(*req.Name) + if name == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "name cannot be empty"}) + return + } + + // Check if name already exists (excluding current skill) + var exists bool + err := h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM skills WHERE name = ? AND id != ? AND deleted_at IS NULL)", name, id) + if err == nil && exists { + c.JSON(http.StatusBadRequest, gin.H{"error": "skill name already exists"}) + return + } + + updates = append(updates, "name = ?") + args = append(args, name) + } + if req.DisplayName != nil { displayName := strings.TrimSpace(*req.DisplayName) if displayName != "" { @@ -394,14 +410,6 @@ func (h *SkillHandler) UpdateSkill(c *gin.Context) { args = append(args, descStr) } - if req.Version != nil { - version := strings.TrimSpace(*req.Version) - if version != "" { - updates = append(updates, "version = ?") - args = append(args, version) - } - } - if req.Metadata != nil { metadataJSON, err := json.Marshal(req.Metadata) if err == nil { diff --git a/internal/api/handlers/sop.go b/internal/api/handlers/sop.go index 4b40200..9f28606 100644 --- a/internal/api/handlers/sop.go +++ b/internal/api/handlers/sop.go @@ -33,7 +33,7 @@ func NewSOPHandler(db *sqlx.DB) *SOPHandler { type SOPResponse struct { ID string `json:"id"` Name string `json:"name"` - Slug string `json:"slug"` + Slug *string `json:"slug,omitempty"` Description string `json:"description,omitempty"` SkillSequence []string `json:"skill_sequence"` Version int `json:"version"` @@ -52,13 +52,14 @@ type CreateSOPRequest struct { Slug string `json:"slug"` Description string `json:"description,omitempty"` SkillSequence []string `json:"skill_sequence"` + Version *int `json:"version,omitempty"` } // CreateSOPResponse represents the response for creating an SOP. type CreateSOPResponse struct { ID string `json:"id"` Name string `json:"name"` - Slug string `json:"slug"` + Slug *string `json:"slug,omitempty"` SkillSequence []string `json:"skill_sequence"` Version int `json:"version"` CreatedAt string `json:"created_at"` @@ -70,6 +71,7 @@ type UpdateSOPRequest struct { Slug *string `json:"slug,omitempty"` Description *string `json:"description,omitempty"` SkillSequence *[]string `json:"skill_sequence,omitempty"` + Version *int `json:"version,omitempty"` } // RegisterRoutes registers SOP related routes. @@ -85,7 +87,7 @@ func (h *SOPHandler) RegisterRoutes(apiV1 *gin.RouterGroup) { type sopRow struct { ID int64 `db:"id"` Name string `db:"name"` - Slug string `db:"slug"` + Slug sql.NullString `db:"slug"` Description sql.NullString `db:"description"` SkillSequence string `db:"skill_sequence"` Version int `db:"version"` @@ -132,6 +134,11 @@ func (h *SOPHandler) ListSOPs(c *gin.Context) { if s.Description.Valid { description = s.Description.String } + var slug *string + if s.Slug.Valid && strings.TrimSpace(s.Slug.String) != "" { + v := s.Slug.String + slug = &v + } skillSequence := parseJSONArray(s.SkillSequence) createdAt := "" if s.CreatedAt.Valid { @@ -145,7 +152,7 @@ func (h *SOPHandler) ListSOPs(c *gin.Context) { sops = append(sops, SOPResponse{ ID: fmt.Sprintf("%d", s.ID), Name: s.Name, - Slug: s.Slug, + Slug: slug, Description: description, SkillSequence: skillSequence, Version: s.Version, @@ -209,6 +216,11 @@ func (h *SOPHandler) GetSOP(c *gin.Context) { if s.Description.Valid { description = s.Description.String } + var slug *string + if s.Slug.Valid && strings.TrimSpace(s.Slug.String) != "" { + v := s.Slug.String + slug = &v + } skillSequence := parseJSONArray(s.SkillSequence) createdAt := "" if s.CreatedAt.Valid { @@ -222,7 +234,7 @@ func (h *SOPHandler) GetSOP(c *gin.Context) { c.JSON(http.StatusOK, SOPResponse{ ID: fmt.Sprintf("%d", s.ID), Name: s.Name, - Slug: s.Slug, + Slug: slug, Description: description, SkillSequence: skillSequence, Version: s.Version, @@ -259,23 +271,26 @@ func (h *SOPHandler) CreateSOP(c *gin.Context) { return } - if req.Slug == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "slug is required"}) - return - } + var slugStr sql.NullString + var slugResp *string + if req.Slug != "" { + // Validate slug format + if !isValidSlug(req.Slug) { + c.JSON(http.StatusBadRequest, gin.H{"error": "slug must contain only alphanumeric characters and hyphens"}) + return + } - // Validate slug format - if !isValidSlug(req.Slug) { - c.JSON(http.StatusBadRequest, gin.H{"error": "slug must contain only alphanumeric characters and hyphens"}) - return - } + // Check if slug already exists + var exists bool + err := h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM sops WHERE slug = ? AND deleted_at IS NULL)", req.Slug) + if err == nil && exists { + c.JSON(http.StatusBadRequest, gin.H{"error": "slug already exists"}) + return + } - // Check if slug already exists - var exists bool - err := h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM sops WHERE slug = ? AND deleted_at IS NULL)", req.Slug) - if err == nil && exists { - c.JSON(http.StatusBadRequest, gin.H{"error": "slug already exists"}) - return + slugStr = sql.NullString{String: req.Slug, Valid: true} + v := req.Slug + slugResp = &v } // Validate skill_sequence @@ -284,6 +299,15 @@ func (h *SOPHandler) CreateSOP(c *gin.Context) { return } + version := 1 + if req.Version != nil { + if *req.Version < 1 { + c.JSON(http.StatusBadRequest, gin.H{"error": "version must be >= 1"}) + return + } + version = *req.Version + } + // Convert skill_sequence to JSON string skillSeqJSON, err := json.Marshal(req.SkillSequence) if err != nil { @@ -309,10 +333,10 @@ func (h *SOPHandler) CreateSOP(c *gin.Context) { updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?)`, req.Name, - req.Slug, + slugStr, descriptionStr, string(skillSeqJSON), - 1, + version, now, now, ) @@ -332,9 +356,9 @@ func (h *SOPHandler) CreateSOP(c *gin.Context) { c.JSON(http.StatusCreated, CreateSOPResponse{ ID: fmt.Sprintf("%d", id), Name: req.Name, - Slug: req.Slug, + Slug: slugResp, SkillSequence: req.SkillSequence, - Version: 1, + Version: version, CreatedAt: now.Format(time.RFC3339), }) } @@ -381,7 +405,10 @@ func (h *SOPHandler) UpdateSOP(c *gin.Context) { if req.Slug != nil { slug := strings.TrimSpace(*req.Slug) - if slug != "" { + if slug == "" { + updates = append(updates, "slug = ?") + args = append(args, sql.NullString{Valid: false}) + } else { if !isValidSlug(slug) { c.JSON(http.StatusBadRequest, gin.H{"error": "slug must contain only alphanumeric characters and hyphens"}) return @@ -418,6 +445,15 @@ func (h *SOPHandler) UpdateSOP(c *gin.Context) { args = append(args, string(skillSeqJSON)) } + if req.Version != nil { + if *req.Version < 1 { + c.JSON(http.StatusBadRequest, gin.H{"error": "version must be >= 1"}) + return + } + updates = append(updates, "version = ?") + args = append(args, *req.Version) + } + if len(updates) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) return @@ -456,6 +492,11 @@ func (h *SOPHandler) UpdateSOP(c *gin.Context) { if s.Description.Valid { description = s.Description.String } + var slug *string + if s.Slug.Valid && strings.TrimSpace(s.Slug.String) != "" { + v := s.Slug.String + slug = &v + } skillSequence := parseJSONArray(s.SkillSequence) createdAt := "" if s.CreatedAt.Valid { @@ -469,7 +510,7 @@ func (h *SOPHandler) UpdateSOP(c *gin.Context) { c.JSON(http.StatusOK, SOPResponse{ ID: fmt.Sprintf("%d", s.ID), Name: s.Name, - Slug: s.Slug, + Slug: slug, Description: description, SkillSequence: skillSequence, Version: s.Version, diff --git a/internal/api/handlers/station.go b/internal/api/handlers/station.go index aa787fa..6bde995 100644 --- a/internal/api/handlers/station.go +++ b/internal/api/handlers/station.go @@ -9,6 +9,7 @@ import ( "database/sql" "fmt" "net/http" + "strconv" "strings" "time" @@ -43,11 +44,7 @@ type UpdateStationRequest struct { type StationResponse struct { ID string `json:"id"` RobotID string `json:"robot_id"` - RobotName string `json:"robot_name"` - RobotSerial string `json:"robot_serial"` DataCollectorID string `json:"data_collector_id"` - CollectorName string `json:"collector_name"` - CollectorOperatorID string `json:"collector_operator_id"` FactoryID string `json:"factory_id"` Status string `json:"status"` Name string `json:"name"` @@ -127,13 +124,20 @@ func (h *StationHandler) CreateStation(c *gin.Context) { return } - // Parse robot_id (device_id from robots table) + // Parse robot_id (robots.id) + robotIDStr := strings.TrimPrefix(req.RobotID, "robot_") + robotID, err := strconv.ParseInt(robotIDStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid robot_id format"}) + return + } + var robotInfo robotInfoRow - err := h.db.Get(&robotInfo, ` + err = h.db.Get(&robotInfo, ` SELECT id, device_id, factory_id, status, robot_type_id FROM robots - WHERE device_id = ? AND deleted_at IS NULL - `, req.RobotID) + WHERE id = ? AND deleted_at IS NULL + `, robotID) if err == sql.ErrNoRows { c.JSON(http.StatusBadRequest, gin.H{"error": "robot not found"}) return @@ -150,13 +154,20 @@ func (h *StationHandler) CreateStation(c *gin.Context) { return } - // Parse data_collector_id (operator_id from data_collectors table) + // Parse data_collector_id (data_collectors.id) + dcIDStr := strings.TrimPrefix(req.DataCollectorID, "dc_") + dcID, err := strconv.ParseInt(dcIDStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid data_collector_id format"}) + return + } + var dcInfo dataCollectorInfoRow err = h.db.Get(&dcInfo, ` SELECT id, name, operator_id, status FROM data_collectors - WHERE operator_id = ? AND deleted_at IS NULL - `, req.DataCollectorID) + WHERE id = ? AND deleted_at IS NULL + `, dcID) if err == sql.ErrNoRows { c.JSON(http.StatusBadRequest, gin.H{"error": "data_collector not found"}) return @@ -187,7 +198,7 @@ func (h *StationHandler) CreateStation(c *gin.Context) { // Robot is already assigned c.JSON(http.StatusConflict, gin.H{ "error": "ROBOT_ALREADY_ASSIGNED", - "message": fmt.Sprintf("Robot %s is already assigned to station ws_%d", req.RobotID, existingStationID), + "message": fmt.Sprintf("Robot robot_%d is already assigned to station ws_%d", robotInfo.ID, existingStationID), }) return } @@ -206,7 +217,7 @@ func (h *StationHandler) CreateStation(c *gin.Context) { // Data collector is already assigned c.JSON(http.StatusConflict, gin.H{ "error": "DATA_COLLECTOR_ALREADY_ASSIGNED", - "message": fmt.Sprintf("Data collector %s is already assigned to station ws_%d", req.DataCollectorID, existingStationID), + "message": fmt.Sprintf("Data collector dc_%d is already assigned to station ws_%d", dcInfo.ID, existingStationID), }) return } @@ -225,15 +236,6 @@ func (h *StationHandler) CreateStation(c *gin.Context) { return } - // Get factory slug for response - var factory factoryInfoRow - err = h.db.Get(&factory, "SELECT id, slug FROM factories WHERE id = ?", robotInfo.FactoryID) - if err != nil { - logger.Printf("[STATION] Failed to get factory: %v", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create station"}) - return - } - // Generate created_at timestamp createdAt := time.Now().UTC().Format("2006-01-02 15:04:05") @@ -261,7 +263,7 @@ func (h *StationHandler) CreateStation(c *gin.Context) { dcInfo.OperatorID, // collector_operator_id robotInfo.FactoryID, req.Name, - "active", + "inactive", createdAt, createdAt, ) @@ -283,14 +285,10 @@ func (h *StationHandler) CreateStation(c *gin.Context) { c.JSON(http.StatusCreated, StationResponse{ ID: fmt.Sprintf("ws_%d", stationID), - RobotID: req.RobotID, - RobotName: robotType.Name, - RobotSerial: robotInfo.DeviceID, - DataCollectorID: req.DataCollectorID, - CollectorName: dcInfo.Name, - CollectorOperatorID: dcInfo.OperatorID, - FactoryID: factory.Slug, - Status: "active", + RobotID: fmt.Sprintf("%d", robotInfo.ID), + DataCollectorID: fmt.Sprintf("%d", dcInfo.ID), + FactoryID: fmt.Sprintf("%d", robotInfo.FactoryID), + Status: "inactive", Name: req.Name, CreatedAt: createdAtISO.Format(time.RFC3339), }) @@ -341,55 +339,19 @@ func (h *StationHandler) ListStations(c *gin.Context) { stations = []stationListRow{} } - // Get factory slugs for all unique factory IDs - factoryIDs := make([]int64, 0, len(stations)) - existingFactoryIDs := make(map[int64]bool) - for _, s := range stations { - if !existingFactoryIDs[s.FactoryID] { - factoryIDs = append(factoryIDs, s.FactoryID) - existingFactoryIDs[s.FactoryID] = true - } - } - - factorySlugs := make(map[int64]string) - if len(factoryIDs) > 0 { - // Use placeholder query for MySQL - query := "SELECT id, slug FROM factories WHERE id IN (" + strings.Repeat("?,", len(factoryIDs)-1) + "?)" - // Convert to []interface{} for sqlx - args := make([]interface{}, len(factoryIDs)) - for i, id := range factoryIDs { - args[i] = id - } - var factoryRows []factoryInfoRow - err = h.db.Select(&factoryRows, query, args...) - if err != nil && err != sql.ErrNoRows { - logger.Printf("[STATION] Failed to query factories: %v", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list stations"}) - return - } - for _, f := range factoryRows { - factorySlugs[f.ID] = f.Slug - } - } - // Build response response := make([]StationResponse, 0, len(stations)) for _, s := range stations { var createdAtStr string if s.CreatedAt.Valid { - createdAt, _ := time.Parse("2006-01-02 15:04:05", s.CreatedAt.String) - createdAtStr = createdAt.Format(time.RFC3339) + createdAtStr = formatDBTimeToRFC3339(s.CreatedAt.String) } response = append(response, StationResponse{ ID: fmt.Sprintf("ws_%d", s.ID), - RobotID: fmt.Sprintf("robot_%d", s.RobotID), - RobotName: s.RobotName, - RobotSerial: s.RobotSerial, - DataCollectorID: fmt.Sprintf("dc_%d", s.DataCollectorID), - CollectorName: s.CollectorName, - CollectorOperatorID: s.CollectorOperatorID, - FactoryID: factorySlugs[s.FactoryID], + RobotID: fmt.Sprintf("%d", s.RobotID), + DataCollectorID: fmt.Sprintf("%d", s.DataCollectorID), + FactoryID: fmt.Sprintf("%d", s.FactoryID), Status: s.Status, Name: s.Name.String, CreatedAt: createdAtStr, @@ -504,30 +466,17 @@ func (h *StationHandler) UpdateStation(c *gin.Context) { return } - // Get factory slug - var factory factoryInfoRow - var factorySlug string - err = h.db.Get(&factory, "SELECT id, slug FROM factories WHERE id = ?", station.FactoryID) - if err == nil { - factorySlug = factory.Slug - } - // Format response var createdAtStr string if station.CreatedAt.Valid { - createdAt, _ := time.Parse("2006-01-02 15:04:05", station.CreatedAt.String) - createdAtStr = createdAt.Format(time.RFC3339) + createdAtStr = formatDBTimeToRFC3339(station.CreatedAt.String) } c.JSON(http.StatusOK, StationResponse{ ID: fmt.Sprintf("ws_%d", station.ID), - RobotID: fmt.Sprintf("robot_%d", station.RobotID), - RobotName: station.RobotName, - RobotSerial: station.RobotSerial, - DataCollectorID: fmt.Sprintf("dc_%d", station.DataCollectorID), - CollectorName: station.CollectorName, - CollectorOperatorID: station.CollectorOperatorID, - FactoryID: factorySlug, + RobotID: fmt.Sprintf("%d", station.RobotID), + DataCollectorID: fmt.Sprintf("%d", station.DataCollectorID), + FactoryID: fmt.Sprintf("%d", station.FactoryID), Status: station.Status, Name: station.Name.String, CreatedAt: createdAtStr, @@ -583,30 +532,16 @@ func (h *StationHandler) GetStation(c *gin.Context) { return } - // Get factory slug - var factory factoryInfoRow - err = h.db.Get(&factory, "SELECT id, slug FROM factories WHERE id = ?", station.FactoryID) - if err != nil { - logger.Printf("[STATION] Failed to get factory: %v", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get station"}) - return - } - var createdAtStr string if station.CreatedAt.Valid { - createdAt, _ := time.Parse("2006-01-02 15:04:05", station.CreatedAt.String) - createdAtStr = createdAt.Format(time.RFC3339) + createdAtStr = formatDBTimeToRFC3339(station.CreatedAt.String) } c.JSON(http.StatusOK, StationResponse{ ID: fmt.Sprintf("ws_%d", station.ID), - RobotID: fmt.Sprintf("robot_%d", station.RobotID), - RobotName: station.RobotName, - RobotSerial: station.RobotSerial, - DataCollectorID: fmt.Sprintf("dc_%d", station.DataCollectorID), - CollectorName: station.CollectorName, - CollectorOperatorID: station.CollectorOperatorID, - FactoryID: factory.Slug, + RobotID: fmt.Sprintf("%d", station.RobotID), + DataCollectorID: fmt.Sprintf("%d", station.DataCollectorID), + FactoryID: fmt.Sprintf("%d", station.FactoryID), Status: station.Status, Name: station.Name.String, CreatedAt: createdAtStr, @@ -669,3 +604,29 @@ func (h *StationHandler) DeleteStation(c *gin.Context) { c.Status(http.StatusNoContent) } + +func formatDBTimeToRFC3339(raw string) string { + s := strings.TrimSpace(raw) + if s == "" { + return "" + } + + // MySQL commonly returns "YYYY-MM-DD HH:MM:SS" or with fractional seconds. + // Some drivers/configs may return RFC3339. + layouts := []string{ + "2006-01-02 15:04:05", + "2006-01-02 15:04:05.999999", + "2006-01-02 15:04:05.999999999", + time.RFC3339Nano, + time.RFC3339, + } + + for _, layout := range layouts { + if t, err := time.Parse(layout, s); err == nil { + return t.Format(time.RFC3339) + } + } + + // Fallback: return original string instead of a wrong timestamp. + return s +} diff --git a/internal/storage/database/migrations/000001_initial_schema.up.sql b/internal/storage/database/migrations/000001_initial_schema.up.sql index b16182c..a2baa2e 100644 --- a/internal/storage/database/migrations/000001_initial_schema.up.sql +++ b/internal/storage/database/migrations/000001_initial_schema.up.sql @@ -463,8 +463,3 @@ INSERT INTO skills (name, display_name, description) VALUES ('navigate', 'Navigate', 'Move from one location to another'), ('pour', 'Pour', 'Transfer liquid between containers') ON DUPLICATE KEY UPDATE display_name=VALUES(display_name); - -INSERT INTO sops (name, slug, skill_sequence) VALUES -('Dish Cleaning SOP', 'sop_dish_cleaning_v2', - '["navigate", "pick", "place", "wipe", "navigate"]') -ON DUPLICATE KEY UPDATE name=VALUES(name); diff --git a/internal/storage/database/migrations/000002_version_2_schema.down.sql b/internal/storage/database/migrations/000002_version_2_schema.down.sql index 713bac7..56b2c87 100644 --- a/internal/storage/database/migrations/000002_version_2_schema.down.sql +++ b/internal/storage/database/migrations/000002_version_2_schema.down.sql @@ -57,6 +57,9 @@ ALTER TABLE sops DROP COLUMN _name_unique; CREATE INDEX slug ON sops (slug); CREATE INDEX idx_slug ON sops (slug); +-- Revert: Make sops.slug NOT NULL again +ALTER TABLE sops MODIFY COLUMN slug VARCHAR(100) NOT NULL; + DROP INDEX idx_name_del ON skills; ALTER TABLE skills DROP COLUMN _name_unique; CREATE UNIQUE INDEX name ON skills (name); diff --git a/internal/storage/database/migrations/000002_version_2_schema.up.sql b/internal/storage/database/migrations/000002_version_2_schema.up.sql index 8ae2c53..1fb84b5 100644 --- a/internal/storage/database/migrations/000002_version_2_schema.up.sql +++ b/internal/storage/database/migrations/000002_version_2_schema.up.sql @@ -46,8 +46,8 @@ ALTER TABLE subscenes MODIFY COLUMN robot_type_id BIGINT NULL; -- Capability & Procedure -- ============================================================ --- skills: name + deleted_at -ALTER TABLE skills ADD COLUMN _name_unique VARCHAR(300) GENERATED ALWAYS AS (CONCAT(IFNULL(name, ''), '|', IFNULL(deleted_at, ''))) STORED; +-- skills: name + version + deleted_at +ALTER TABLE skills ADD COLUMN _name_unique VARCHAR(300) GENERATED ALWAYS AS (CONCAT(IFNULL(name, ''), '|', IFNULL(version, ''), '|', IFNULL(deleted_at, ''))) STORED; DROP INDEX name ON skills; CREATE UNIQUE INDEX idx_name_del ON skills (_name_unique); @@ -57,6 +57,9 @@ DROP INDEX slug ON sops; DROP INDEX idx_slug ON sops; CREATE UNIQUE INDEX idx_name_del ON sops (_name_unique); +-- Allow sops.slug to be NULL +ALTER TABLE sops MODIFY COLUMN slug VARCHAR(100) NULL; + -- ============================================================ -- Operational Resources -- ============================================================ From 33cbe0d409872a8d8721fb8ac9e2fa4c492d7e1c Mon Sep 17 00:00:00 2001 From: shark Date: Sun, 29 Mar 2026 11:11:16 +0800 Subject: [PATCH 12/20] feat(api): enhance factory, scene, skill, and SOP structures with new fields and validation --- internal/api/handlers/factory.go | 29 ++- internal/api/handlers/scene.go | 178 ++--------------- internal/api/handlers/skill.go | 150 ++++++++++----- internal/api/handlers/sop.go | 164 +++++++++------- internal/api/handlers/subscene.go | 181 ++++-------------- .../migrations/000001_initial_schema.up.sql | 85 ++++---- .../000002_version_2_schema.down.sql | 98 ---------- .../migrations/000002_version_2_schema.up.sql | 113 ----------- 8 files changed, 315 insertions(+), 683 deletions(-) delete mode 100644 internal/storage/database/migrations/000002_version_2_schema.down.sql delete mode 100644 internal/storage/database/migrations/000002_version_2_schema.up.sql diff --git a/internal/api/handlers/factory.go b/internal/api/handlers/factory.go index 24f6674..4eef7e2 100644 --- a/internal/api/handlers/factory.go +++ b/internal/api/handlers/factory.go @@ -256,9 +256,11 @@ func (h *FactoryHandler) CreateFactory(c *gin.Context) { var settingsStr sql.NullString if req.Settings != nil { settingsJSON, err := json.Marshal(req.Settings) - if err == nil { - settingsStr = sql.NullString{String: string(settingsJSON), Valid: true} + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid settings"}) + return } + settingsStr = sql.NullString{String: string(settingsJSON), Valid: true} } result, err := h.db.Exec( @@ -383,10 +385,11 @@ func (h *FactoryHandler) GetFactory(c *gin.Context) { // UpdateFactoryRequest represents the request body for updating a factory. type UpdateFactoryRequest struct { - Name *string `json:"name,omitempty"` - Slug *string `json:"slug,omitempty"` - Location *string `json:"location,omitempty"` - Timezone *string `json:"timezone,omitempty"` + Name *string `json:"name,omitempty"` + Slug *string `json:"slug,omitempty"` + Location *string `json:"location,omitempty"` + Timezone *string `json:"timezone,omitempty"` + Settings *json.RawMessage `json:"settings,omitempty"` } // UpdateFactory handles updating a factory. @@ -477,6 +480,20 @@ func (h *FactoryHandler) UpdateFactory(c *gin.Context) { args = append(args, tzStr) } + if req.Settings != nil { + var settingsStr sql.NullString + rawSettings := strings.TrimSpace(string(*req.Settings)) + if rawSettings != "" && rawSettings != "null" { + if !json.Valid([]byte(rawSettings)) { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid settings"}) + return + } + settingsStr = sql.NullString{String: rawSettings, Valid: true} + } + updates = append(updates, "settings = ?") + args = append(args, settingsStr) + } + if len(updates) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) return diff --git a/internal/api/handlers/scene.go b/internal/api/handlers/scene.go index a610984..df7d574 100644 --- a/internal/api/handlers/scene.go +++ b/internal/api/handlers/scene.go @@ -31,10 +31,8 @@ func NewSceneHandler(db *sqlx.DB) *SceneHandler { // SceneResponse represents a scene in the response. type SceneResponse struct { ID string `json:"id"` - OrganizationID string `json:"organization_id"` FactoryID string `json:"factory_id"` Name string `json:"name"` - Slug string `json:"slug"` Description string `json:"description,omitempty"` InitialSceneLayoutTemplate string `json:"initial_scene_layout_template,omitempty"` SubsceneCount int `json:"subscene_count"` @@ -49,10 +47,8 @@ type SceneListResponse struct { // CreateSceneRequest represents the request body for creating a scene. type CreateSceneRequest struct { - OrganizationID *string `json:"organization_id,omitempty"` FactoryID string `json:"factory_id"` Name string `json:"name"` - Slug *string `json:"slug,omitempty"` Description string `json:"description,omitempty"` InitialSceneLayoutTemplate string `json:"initial_scene_layout_template,omitempty"` } @@ -61,16 +57,13 @@ type CreateSceneRequest struct { type CreateSceneResponse struct { ID string `json:"id"` Name string `json:"name"` - Slug string `json:"slug"` CreatedAt string `json:"created_at"` } // UpdateSceneRequest represents the request body for updating a scene. type UpdateSceneRequest struct { - OrganizationID *string `json:"organization_id,omitempty"` FactoryID *string `json:"factory_id,omitempty"` Name *string `json:"name,omitempty"` - Slug *string `json:"slug,omitempty"` Description *string `json:"description,omitempty"` InitialSceneLayoutTemplate *string `json:"initial_scene_layout_template,omitempty"` } @@ -87,10 +80,8 @@ func (h *SceneHandler) RegisterRoutes(apiV1 *gin.RouterGroup) { // sceneRow represents a scene in the database type sceneRow struct { ID int64 `db:"id"` - OrganizationID sql.NullInt64 `db:"organization_id"` FactoryID int64 `db:"factory_id"` Name string `db:"name"` - Slug sql.NullString `db:"slug"` Description sql.NullString `db:"description"` InitialSceneLayoutTemplate sql.NullString `db:"initial_scene_layout_template"` SubsceneCount int `db:"subscene_count"` @@ -101,26 +92,22 @@ type sceneRow struct { // ListScenes handles scene listing requests with filtering. // // @Summary List scenes -// @Description Lists scenes with optional filtering by organization_id and factory_id +// @Description Lists scenes with optional filtering by factory_id // @Tags scenes // @Accept json // @Produce json -// @Param organization_id query string false "Filter by organization ID" // @Param factory_id query string false "Filter by factory ID" // @Success 200 {object} SceneListResponse // @Failure 500 {object} map[string]string // @Router /scenes [get] func (h *SceneHandler) ListScenes(c *gin.Context) { - orgID := c.Query("organization_id") factoryID := c.Query("factory_id") query := ` SELECT s.id, - s.organization_id, s.factory_id, s.name, - s.slug, s.description, s.initial_scene_layout_template, s.created_at, @@ -131,16 +118,6 @@ func (h *SceneHandler) ListScenes(c *gin.Context) { ` args := []interface{}{} - if orgID != "" { - parsedOrgID, err := strconv.ParseInt(orgID, 10, 64) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid organization_id format"}) - return - } - query += " AND organization_id = ?" - args = append(args, parsedOrgID) - } - if factoryID != "" { parsedFactoryID, err := strconv.ParseInt(factoryID, 10, 64) if err != nil { @@ -178,21 +155,10 @@ func (h *SceneHandler) ListScenes(c *gin.Context) { if s.UpdatedAt.Valid { updatedAt = s.UpdatedAt.String } - orgID := "" - if s.OrganizationID.Valid { - orgID = fmt.Sprintf("%d", s.OrganizationID.Int64) - } - slug := "" - if s.Slug.Valid { - slug = s.Slug.String - } - scenes = append(scenes, SceneResponse{ ID: fmt.Sprintf("%d", s.ID), - OrganizationID: orgID, FactoryID: fmt.Sprintf("%d", s.FactoryID), Name: s.Name, - Slug: slug, Description: description, InitialSceneLayoutTemplate: layoutTemplate, SubsceneCount: s.SubsceneCount, @@ -230,10 +196,8 @@ func (h *SceneHandler) GetScene(c *gin.Context) { query := ` SELECT id, - organization_id, factory_id, name, - slug, description, initial_scene_layout_template, created_at, @@ -269,21 +233,10 @@ func (h *SceneHandler) GetScene(c *gin.Context) { if s.UpdatedAt.Valid { updatedAt = s.UpdatedAt.String } - orgID := "" - if s.OrganizationID.Valid { - orgID = fmt.Sprintf("%d", s.OrganizationID.Int64) - } - slug := "" - if s.Slug.Valid { - slug = s.Slug.String - } - c.JSON(http.StatusOK, SceneResponse{ ID: fmt.Sprintf("%d", s.ID), - OrganizationID: orgID, FactoryID: fmt.Sprintf("%d", s.FactoryID), Name: s.Name, - Slug: slug, Description: description, InitialSceneLayoutTemplate: layoutTemplate, CreatedAt: createdAt, @@ -310,16 +263,8 @@ func (h *SceneHandler) CreateScene(c *gin.Context) { return } - if req.OrganizationID != nil { - trimmed := strings.TrimSpace(*req.OrganizationID) - req.OrganizationID = &trimmed - } req.FactoryID = strings.TrimSpace(req.FactoryID) req.Name = strings.TrimSpace(req.Name) - if req.Slug != nil { - trimmed := strings.TrimSpace(*req.Slug) - req.Slug = &trimmed - } req.Description = strings.TrimSpace(req.Description) if req.FactoryID == "" { @@ -342,44 +287,14 @@ func (h *SceneHandler) CreateScene(c *gin.Context) { // Verify factory exists var exists bool err = h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM factories WHERE id = ? AND deleted_at IS NULL)", factoryID) - if err != nil || !exists { - c.JSON(http.StatusBadRequest, gin.H{"error": "factory not found"}) + if err != nil { + logger.Printf("[SCENE] Failed to verify factory: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create scene"}) return } - - // Parse organization_id (optional) - var orgID sql.NullInt64 - if req.OrganizationID != nil && *req.OrganizationID != "" { - parsedOrgID, err := strconv.ParseInt(*req.OrganizationID, 10, 64) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid organization_id format"}) - return - } - // Verify organization exists - err = h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM organizations WHERE id = ? AND deleted_at IS NULL)", parsedOrgID) - if err != nil || !exists { - c.JSON(http.StatusBadRequest, gin.H{"error": "organization not found"}) - return - } - orgID = sql.NullInt64{Int64: parsedOrgID, Valid: true} - - // Check if slug already exists for this organization (if slug is provided) - if req.Slug != nil && *req.Slug != "" { - err = h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM scenes WHERE organization_id = ? AND slug = ? AND deleted_at IS NULL)", parsedOrgID, *req.Slug) - if err == nil && exists { - c.JSON(http.StatusBadRequest, gin.H{"error": "slug already exists for this organization"}) - return - } - } - } else { - // Check if slug already exists globally (when organization_id is not specified) - if req.Slug != nil && *req.Slug != "" { - err = h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM scenes WHERE (organization_id IS NULL OR organization_id = 0) AND slug = ? AND deleted_at IS NULL)", *req.Slug) - if err == nil && exists { - c.JSON(http.StatusBadRequest, gin.H{"error": "slug already exists"}) - return - } - } + if !exists { + c.JSON(http.StatusBadRequest, gin.H{"error": "factory not found"}) + return } var descriptionStr sql.NullString @@ -387,11 +302,6 @@ func (h *SceneHandler) CreateScene(c *gin.Context) { descriptionStr = sql.NullString{String: req.Description, Valid: true} } - var slugStr sql.NullString - if req.Slug != nil && *req.Slug != "" { - slugStr = sql.NullString{String: *req.Slug, Valid: true} - } - var layoutTemplateStr sql.NullString if req.InitialSceneLayoutTemplate != "" { layoutTemplateStr = sql.NullString{String: req.InitialSceneLayoutTemplate, Valid: true} @@ -401,19 +311,15 @@ func (h *SceneHandler) CreateScene(c *gin.Context) { result, err := h.db.Exec( `INSERT INTO scenes ( - organization_id, factory_id, name, - slug, description, initial_scene_layout_template, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - orgID, + ) VALUES (?, ?, ?, ?, ?, ?)`, factoryID, req.Name, - slugStr, descriptionStr, layoutTemplateStr, now, @@ -433,14 +339,8 @@ func (h *SceneHandler) CreateScene(c *gin.Context) { } c.JSON(http.StatusCreated, CreateSceneResponse{ - ID: fmt.Sprintf("%d", id), - Name: req.Name, - Slug: func() string { - if req.Slug != nil { - return *req.Slug - } - return "" - }(), + ID: fmt.Sprintf("%d", id), + Name: req.Name, CreatedAt: now.Format(time.RFC3339), }) } @@ -475,7 +375,7 @@ func (h *SceneHandler) UpdateScene(c *gin.Context) { // Check if scene exists var existing sceneRow - err = h.db.Get(&existing, "SELECT id, organization_id, factory_id, name, slug, description, initial_scene_layout_template, created_at, updated_at FROM scenes WHERE id = ? AND deleted_at IS NULL", id) + err = h.db.Get(&existing, "SELECT id, factory_id, name, description, initial_scene_layout_template, created_at, updated_at FROM scenes WHERE id = ? AND deleted_at IS NULL", id) if err != nil { if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "scene not found"}) @@ -490,30 +390,6 @@ func (h *SceneHandler) UpdateScene(c *gin.Context) { updates := []string{} args := []interface{}{} - if req.OrganizationID != nil { - orgIDStr := strings.TrimSpace(*req.OrganizationID) - if orgIDStr == "" { - // Set organization_id to NULL - updates = append(updates, "organization_id = ?") - args = append(args, sql.NullInt64{}) - } else { - orgID, err := strconv.ParseInt(orgIDStr, 10, 64) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid organization_id format"}) - return - } - // Verify organization exists - var exists bool - err = h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM organizations WHERE id = ? AND deleted_at IS NULL)", orgID) - if err != nil || !exists { - c.JSON(http.StatusBadRequest, gin.H{"error": "organization not found"}) - return - } - updates = append(updates, "organization_id = ?") - args = append(args, sql.NullInt64{Int64: orgID, Valid: true}) - } - } - if req.FactoryID != nil { factoryIDStr := strings.TrimSpace(*req.FactoryID) if factoryIDStr == "" { @@ -544,25 +420,6 @@ func (h *SceneHandler) UpdateScene(c *gin.Context) { } } - if req.Slug != nil { - slug := strings.TrimSpace(*req.Slug) - if slug == "" { - // Set slug to NULL - updates = append(updates, "slug = ?") - args = append(args, sql.NullString{}) - } else { - // Check if slug already exists for this organization - var exists bool - err := h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM scenes WHERE organization_id IS NOT DISTINCT FROM ? AND slug = ? AND id != ? AND deleted_at IS NULL)", existing.OrganizationID, slug, id) - if err == nil && exists { - c.JSON(http.StatusBadRequest, gin.H{"error": "slug already exists for this organization"}) - return - } - updates = append(updates, "slug = ?") - args = append(args, slug) - } - } - if req.Description != nil { description := strings.TrimSpace(*req.Description) var descStr sql.NullString @@ -604,7 +461,7 @@ func (h *SceneHandler) UpdateScene(c *gin.Context) { // Fetch the updated scene var s sceneRow - err = h.db.Get(&s, "SELECT id, organization_id, factory_id, name, slug, description, initial_scene_layout_template, created_at, updated_at FROM scenes WHERE id = ?", id) + err = h.db.Get(&s, "SELECT id, factory_id, name, description, initial_scene_layout_template, created_at, updated_at FROM scenes WHERE id = ?", id) if err != nil { logger.Printf("[SCENE] Failed to fetch updated scene: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get updated scene"}) @@ -627,21 +484,10 @@ func (h *SceneHandler) UpdateScene(c *gin.Context) { if s.UpdatedAt.Valid { updatedAt = s.UpdatedAt.String } - orgID := "" - if s.OrganizationID.Valid { - orgID = fmt.Sprintf("%d", s.OrganizationID.Int64) - } - slug := "" - if s.Slug.Valid { - slug = s.Slug.String - } - c.JSON(http.StatusOK, SceneResponse{ ID: fmt.Sprintf("%d", s.ID), - OrganizationID: orgID, FactoryID: fmt.Sprintf("%d", s.FactoryID), Name: s.Name, - Slug: slug, Description: description, InitialSceneLayoutTemplate: layoutTemplate, CreatedAt: createdAt, diff --git a/internal/api/handlers/skill.go b/internal/api/handlers/skill.go index a3bc5d4..b2e16e7 100644 --- a/internal/api/handlers/skill.go +++ b/internal/api/handlers/skill.go @@ -32,8 +32,8 @@ func NewSkillHandler(db *sqlx.DB) *SkillHandler { // SkillResponse represents a skill in the response. type SkillResponse struct { ID string `json:"id"` + Slug string `json:"slug"` Name string `json:"name"` - DisplayName string `json:"display_name"` Description string `json:"description,omitempty"` Version string `json:"version,omitempty"` Metadata interface{} `json:"metadata,omitempty"` @@ -48,8 +48,8 @@ type SkillListResponse struct { // CreateSkillRequest represents the request body for creating a skill. type CreateSkillRequest struct { + Slug string `json:"slug"` Name string `json:"name"` - DisplayName string `json:"display_name"` Description string `json:"description,omitempty"` Version string `json:"version,omitempty"` Metadata interface{} `json:"metadata,omitempty"` @@ -57,16 +57,17 @@ type CreateSkillRequest struct { // CreateSkillResponse represents the response for creating a skill. type CreateSkillResponse struct { - ID string `json:"id"` - Name string `json:"name"` - DisplayName string `json:"display_name"` - CreatedAt string `json:"created_at"` + ID string `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + Version string `json:"version"` + CreatedAt string `json:"created_at"` } // UpdateSkillRequest represents the request body for updating a skill. type UpdateSkillRequest struct { + Slug *string `json:"slug,omitempty"` Name *string `json:"name,omitempty"` - DisplayName *string `json:"display_name,omitempty"` Description *string `json:"description,omitempty"` Version *string `json:"version,omitempty"` Metadata interface{} `json:"metadata,omitempty"` @@ -84,8 +85,8 @@ func (h *SkillHandler) RegisterRoutes(apiV1 *gin.RouterGroup) { // skillRow represents a skill in the database type skillRow struct { ID int64 `db:"id"` + Slug string `db:"slug"` Name string `db:"name"` - DisplayName string `db:"display_name"` Description sql.NullString `db:"description"` Version sql.NullString `db:"version"` Metadata sql.NullString `db:"metadata"` @@ -107,8 +108,8 @@ func (h *SkillHandler) ListSkills(c *gin.Context) { query := ` SELECT id, + slug, name, - display_name, description, version, metadata, @@ -132,7 +133,7 @@ func (h *SkillHandler) ListSkills(c *gin.Context) { if s.Description.Valid { description = s.Description.String } - version := "1.0" + version := "1.0.0" if s.Version.Valid { version = s.Version.String } @@ -151,8 +152,8 @@ func (h *SkillHandler) ListSkills(c *gin.Context) { skills = append(skills, SkillResponse{ ID: fmt.Sprintf("%d", s.ID), + Slug: s.Slug, Name: s.Name, - DisplayName: s.DisplayName, Description: description, Version: version, Metadata: metadata, @@ -190,8 +191,8 @@ func (h *SkillHandler) GetSkill(c *gin.Context) { query := ` SELECT id, + slug, name, - display_name, description, version, metadata, @@ -216,7 +217,7 @@ func (h *SkillHandler) GetSkill(c *gin.Context) { if s.Description.Valid { description = s.Description.String } - version := "1.0" + version := "1.0.0" if s.Version.Valid { version = s.Version.String } @@ -235,8 +236,8 @@ func (h *SkillHandler) GetSkill(c *gin.Context) { c.JSON(http.StatusOK, SkillResponse{ ID: fmt.Sprintf("%d", s.ID), + Slug: s.Slug, Name: s.Name, - DisplayName: s.DisplayName, Description: description, Version: version, Metadata: metadata, @@ -264,24 +265,37 @@ func (h *SkillHandler) CreateSkill(c *gin.Context) { return } + req.Slug = strings.TrimSpace(req.Slug) req.Name = strings.TrimSpace(req.Name) - req.DisplayName = strings.TrimSpace(req.DisplayName) req.Description = strings.TrimSpace(req.Description) + req.Version = strings.TrimSpace(req.Version) + if req.Slug == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "slug is required"}) + return + } + if !isValidSlug(req.Slug) { + c.JSON(http.StatusBadRequest, gin.H{"error": "slug must contain only alphanumeric characters and hyphens"}) + return + } if req.Name == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"}) return } - if req.DisplayName == "" { - req.DisplayName = req.Name - } - - version := "1.0" + version := "1.0.0" if req.Version != "" { version = req.Version } + // Check if slug already exists for the same version + var exists bool + err := h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM skills WHERE slug = ? AND version = ? AND deleted_at IS NULL)", req.Slug, version) + if err == nil && exists { + c.JSON(http.StatusBadRequest, gin.H{"error": "slug already exists for this version"}) + return + } + var metadataStr sql.NullString if req.Metadata != nil { metadataJSON, err := json.Marshal(req.Metadata) @@ -299,16 +313,16 @@ func (h *SkillHandler) CreateSkill(c *gin.Context) { result, err := h.db.Exec( `INSERT INTO skills ( + slug, name, - display_name, description, version, metadata, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?)`, + req.Slug, req.Name, - req.DisplayName, descriptionStr, version, metadataStr, @@ -329,10 +343,11 @@ func (h *SkillHandler) CreateSkill(c *gin.Context) { } c.JSON(http.StatusCreated, CreateSkillResponse{ - ID: fmt.Sprintf("%d", id), - Name: req.Name, - DisplayName: req.DisplayName, - CreatedAt: now.Format(time.RFC3339), + ID: fmt.Sprintf("%d", id), + Slug: req.Slug, + Name: req.Name, + Version: version, + CreatedAt: now.Format(time.RFC3339), }) } @@ -363,9 +378,20 @@ func (h *SkillHandler) UpdateSkill(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) return } - // version is immutable after creation - if req.Version != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "version is immutable and cannot be updated"}) + + // Load current skill to support immutable/version-dependent checks. + var current struct { + Slug string `db:"slug"` + Version sql.NullString `db:"version"` + } + err = h.db.Get(¤t, "SELECT slug, version FROM skills WHERE id = ? AND deleted_at IS NULL", id) + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "skill not found"}) + return + } + if err != nil { + logger.Printf("[SKILL] Failed to query current skill: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update skill"}) return } @@ -373,30 +399,41 @@ func (h *SkillHandler) UpdateSkill(c *gin.Context) { updates := []string{} args := []interface{}{} - if req.Name != nil { - name := strings.TrimSpace(*req.Name) - if name == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "name cannot be empty"}) + if req.Slug != nil { + slug := strings.TrimSpace(*req.Slug) + if slug == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "slug cannot be empty"}) return } - - // Check if name already exists (excluding current skill) - var exists bool - err := h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM skills WHERE name = ? AND id != ? AND deleted_at IS NULL)", name, id) - if err == nil && exists { - c.JSON(http.StatusBadRequest, gin.H{"error": "skill name already exists"}) + if !isValidSlug(slug) { + c.JSON(http.StatusBadRequest, gin.H{"error": "slug must contain only alphanumeric characters and hyphens"}) return } - - updates = append(updates, "name = ?") - args = append(args, name) + if slug != current.Slug { + // New slug: reset version to 1.0.0. + targetVersion := "1.0.0" + + var exists bool + err := h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM skills WHERE slug = ? AND id != ? AND version = ? AND deleted_at IS NULL)", slug, id, targetVersion) + if err == nil && exists { + c.JSON(http.StatusBadRequest, gin.H{"error": "slug already exists for this version"}) + return + } + + updates = append(updates, "slug = ?", "version = ?") + args = append(args, slug, targetVersion) + } else { + // Same slug in payload: no version reset. + updates = append(updates, "slug = ?") + args = append(args, slug) + } } - if req.DisplayName != nil { - displayName := strings.TrimSpace(*req.DisplayName) - if displayName != "" { - updates = append(updates, "display_name = ?") - args = append(args, displayName) + if req.Name != nil { + name := strings.TrimSpace(*req.Name) + if name != "" { + updates = append(updates, "name = ?") + args = append(args, name) } } @@ -418,6 +455,19 @@ func (h *SkillHandler) UpdateSkill(c *gin.Context) { } } + // version is immutable after creation; allow no-op payloads that resend the same version + if req.Version != nil { + inputVersion := strings.TrimSpace(*req.Version) + currentVersionStr := "1.0.0" + if current.Version.Valid { + currentVersionStr = strings.TrimSpace(current.Version.String) + } + if inputVersion != currentVersionStr { + c.JSON(http.StatusBadRequest, gin.H{"error": "version is immutable and cannot be updated"}) + return + } + } + if len(updates) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) return @@ -445,7 +495,7 @@ func (h *SkillHandler) UpdateSkill(c *gin.Context) { // Fetch the updated skill var s skillRow - err = h.db.Get(&s, "SELECT id, name, display_name, description, version, metadata, created_at, updated_at FROM skills WHERE id = ?", id) + err = h.db.Get(&s, "SELECT id, slug, name, description, version, metadata, created_at, updated_at FROM skills WHERE id = ?", id) if err != nil { logger.Printf("[SKILL] Failed to fetch updated skill: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get updated skill"}) @@ -456,7 +506,7 @@ func (h *SkillHandler) UpdateSkill(c *gin.Context) { if s.Description.Valid { description = s.Description.String } - version := "1.0" + version := "1.0.0" if s.Version.Valid { version = s.Version.String } @@ -475,8 +525,8 @@ func (h *SkillHandler) UpdateSkill(c *gin.Context) { c.JSON(http.StatusOK, SkillResponse{ ID: fmt.Sprintf("%d", s.ID), + Slug: s.Slug, Name: s.Name, - DisplayName: s.DisplayName, Description: description, Version: version, Metadata: metadata, diff --git a/internal/api/handlers/sop.go b/internal/api/handlers/sop.go index 9f28606..12efda0 100644 --- a/internal/api/handlers/sop.go +++ b/internal/api/handlers/sop.go @@ -33,10 +33,10 @@ func NewSOPHandler(db *sqlx.DB) *SOPHandler { type SOPResponse struct { ID string `json:"id"` Name string `json:"name"` - Slug *string `json:"slug,omitempty"` + Slug string `json:"slug"` Description string `json:"description,omitempty"` SkillSequence []string `json:"skill_sequence"` - Version int `json:"version"` + Version string `json:"version,omitempty"` CreatedAt string `json:"created_at,omitempty"` UpdatedAt string `json:"updated_at,omitempty"` } @@ -52,16 +52,16 @@ type CreateSOPRequest struct { Slug string `json:"slug"` Description string `json:"description,omitempty"` SkillSequence []string `json:"skill_sequence"` - Version *int `json:"version,omitempty"` + Version string `json:"version,omitempty"` } // CreateSOPResponse represents the response for creating an SOP. type CreateSOPResponse struct { ID string `json:"id"` Name string `json:"name"` - Slug *string `json:"slug,omitempty"` + Slug string `json:"slug"` SkillSequence []string `json:"skill_sequence"` - Version int `json:"version"` + Version string `json:"version"` CreatedAt string `json:"created_at"` } @@ -71,7 +71,7 @@ type UpdateSOPRequest struct { Slug *string `json:"slug,omitempty"` Description *string `json:"description,omitempty"` SkillSequence *[]string `json:"skill_sequence,omitempty"` - Version *int `json:"version,omitempty"` + Version *string `json:"version,omitempty"` } // RegisterRoutes registers SOP related routes. @@ -87,10 +87,10 @@ func (h *SOPHandler) RegisterRoutes(apiV1 *gin.RouterGroup) { type sopRow struct { ID int64 `db:"id"` Name string `db:"name"` - Slug sql.NullString `db:"slug"` + Slug string `db:"slug"` Description sql.NullString `db:"description"` SkillSequence string `db:"skill_sequence"` - Version int `db:"version"` + Version sql.NullString `db:"version"` CreatedAt sql.NullString `db:"created_at"` UpdatedAt sql.NullString `db:"updated_at"` } @@ -134,12 +134,11 @@ func (h *SOPHandler) ListSOPs(c *gin.Context) { if s.Description.Valid { description = s.Description.String } - var slug *string - if s.Slug.Valid && strings.TrimSpace(s.Slug.String) != "" { - v := s.Slug.String - slug = &v - } skillSequence := parseJSONArray(s.SkillSequence) + version := "1.0.0" + if s.Version.Valid { + version = s.Version.String + } createdAt := "" if s.CreatedAt.Valid { createdAt = s.CreatedAt.String @@ -152,10 +151,10 @@ func (h *SOPHandler) ListSOPs(c *gin.Context) { sops = append(sops, SOPResponse{ ID: fmt.Sprintf("%d", s.ID), Name: s.Name, - Slug: slug, + Slug: s.Slug, Description: description, SkillSequence: skillSequence, - Version: s.Version, + Version: version, CreatedAt: createdAt, UpdatedAt: updatedAt, }) @@ -216,12 +215,11 @@ func (h *SOPHandler) GetSOP(c *gin.Context) { if s.Description.Valid { description = s.Description.String } - var slug *string - if s.Slug.Valid && strings.TrimSpace(s.Slug.String) != "" { - v := s.Slug.String - slug = &v - } skillSequence := parseJSONArray(s.SkillSequence) + version := "1.0.0" + if s.Version.Valid { + version = s.Version.String + } createdAt := "" if s.CreatedAt.Valid { createdAt = s.CreatedAt.String @@ -234,10 +232,10 @@ func (h *SOPHandler) GetSOP(c *gin.Context) { c.JSON(http.StatusOK, SOPResponse{ ID: fmt.Sprintf("%d", s.ID), Name: s.Name, - Slug: slug, + Slug: s.Slug, Description: description, SkillSequence: skillSequence, - Version: s.Version, + Version: version, CreatedAt: createdAt, UpdatedAt: updatedAt, }) @@ -265,32 +263,32 @@ func (h *SOPHandler) CreateSOP(c *gin.Context) { req.Name = strings.TrimSpace(req.Name) req.Slug = strings.TrimSpace(req.Slug) req.Description = strings.TrimSpace(req.Description) + req.Version = strings.TrimSpace(req.Version) if req.Name == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"}) return } - var slugStr sql.NullString - var slugResp *string - if req.Slug != "" { - // Validate slug format - if !isValidSlug(req.Slug) { - c.JSON(http.StatusBadRequest, gin.H{"error": "slug must contain only alphanumeric characters and hyphens"}) - return - } - - // Check if slug already exists - var exists bool - err := h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM sops WHERE slug = ? AND deleted_at IS NULL)", req.Slug) - if err == nil && exists { - c.JSON(http.StatusBadRequest, gin.H{"error": "slug already exists"}) - return - } - - slugStr = sql.NullString{String: req.Slug, Valid: true} - v := req.Slug - slugResp = &v + if req.Slug == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "slug is required"}) + return + } + // Validate slug format + if !isValidSlug(req.Slug) { + c.JSON(http.StatusBadRequest, gin.H{"error": "slug must contain only alphanumeric characters and hyphens"}) + return + } + version := "1.0.0" + if req.Version != "" { + version = req.Version + } + // Check if slug already exists for the same version + var exists bool + err := h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM sops WHERE slug = ? AND version = ? AND deleted_at IS NULL)", req.Slug, version) + if err == nil && exists { + c.JSON(http.StatusBadRequest, gin.H{"error": "slug already exists for this version"}) + return } // Validate skill_sequence @@ -299,15 +297,6 @@ func (h *SOPHandler) CreateSOP(c *gin.Context) { return } - version := 1 - if req.Version != nil { - if *req.Version < 1 { - c.JSON(http.StatusBadRequest, gin.H{"error": "version must be >= 1"}) - return - } - version = *req.Version - } - // Convert skill_sequence to JSON string skillSeqJSON, err := json.Marshal(req.SkillSequence) if err != nil { @@ -333,7 +322,7 @@ func (h *SOPHandler) CreateSOP(c *gin.Context) { updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?)`, req.Name, - slugStr, + req.Slug, descriptionStr, string(skillSeqJSON), version, @@ -356,7 +345,7 @@ func (h *SOPHandler) CreateSOP(c *gin.Context) { c.JSON(http.StatusCreated, CreateSOPResponse{ ID: fmt.Sprintf("%d", id), Name: req.Name, - Slug: slugResp, + Slug: req.Slug, SkillSequence: req.SkillSequence, Version: version, CreatedAt: now.Format(time.RFC3339), @@ -391,6 +380,22 @@ func (h *SOPHandler) UpdateSOP(c *gin.Context) { return } + // Load current SOP to support immutable/version-dependent checks. + var current struct { + Slug string `db:"slug"` + Version sql.NullString `db:"version"` + } + err = h.db.Get(¤t, "SELECT slug, version FROM sops WHERE id = ? AND deleted_at IS NULL", id) + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "SOP not found"}) + return + } + if err != nil { + logger.Printf("[SOP] Failed to query current SOP: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update SOP"}) + return + } + // Build update query dynamically updates := []string{} args := []interface{}{} @@ -406,20 +411,28 @@ func (h *SOPHandler) UpdateSOP(c *gin.Context) { if req.Slug != nil { slug := strings.TrimSpace(*req.Slug) if slug == "" { - updates = append(updates, "slug = ?") - args = append(args, sql.NullString{Valid: false}) - } else { - if !isValidSlug(slug) { - c.JSON(http.StatusBadRequest, gin.H{"error": "slug must contain only alphanumeric characters and hyphens"}) - return - } - // Check if slug already exists for another SOP + c.JSON(http.StatusBadRequest, gin.H{"error": "slug cannot be empty"}) + return + } + if !isValidSlug(slug) { + c.JSON(http.StatusBadRequest, gin.H{"error": "slug must contain only alphanumeric characters and hyphens"}) + return + } + if slug != current.Slug { + // New slug: reset version to 1.0.0. + targetVersion := "1.0.0" + var exists bool - err := h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM sops WHERE slug = ? AND id != ? AND deleted_at IS NULL)", slug, id) + err := h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM sops WHERE slug = ? AND id != ? AND version = ? AND deleted_at IS NULL)", slug, id, targetVersion) if err == nil && exists { - c.JSON(http.StatusBadRequest, gin.H{"error": "slug already exists"}) + c.JSON(http.StatusBadRequest, gin.H{"error": "slug already exists for this version"}) return } + + updates = append(updates, "slug = ?", "version = ?") + args = append(args, slug, targetVersion) + } else { + // Same slug in payload: no version reset. updates = append(updates, "slug = ?") args = append(args, slug) } @@ -445,13 +458,17 @@ func (h *SOPHandler) UpdateSOP(c *gin.Context) { args = append(args, string(skillSeqJSON)) } + // version is immutable after creation; allow no-op payloads that resend the same version if req.Version != nil { - if *req.Version < 1 { - c.JSON(http.StatusBadRequest, gin.H{"error": "version must be >= 1"}) + inputVersion := strings.TrimSpace(*req.Version) + currentVersionStr := "1.0.0" + if current.Version.Valid { + currentVersionStr = strings.TrimSpace(current.Version.String) + } + if inputVersion != currentVersionStr { + c.JSON(http.StatusBadRequest, gin.H{"error": "version is immutable and cannot be updated"}) return } - updates = append(updates, "version = ?") - args = append(args, *req.Version) } if len(updates) == 0 { @@ -492,12 +509,11 @@ func (h *SOPHandler) UpdateSOP(c *gin.Context) { if s.Description.Valid { description = s.Description.String } - var slug *string - if s.Slug.Valid && strings.TrimSpace(s.Slug.String) != "" { - v := s.Slug.String - slug = &v - } skillSequence := parseJSONArray(s.SkillSequence) + version := "1.0.0" + if s.Version.Valid { + version = s.Version.String + } createdAt := "" if s.CreatedAt.Valid { createdAt = s.CreatedAt.String @@ -510,10 +526,10 @@ func (h *SOPHandler) UpdateSOP(c *gin.Context) { c.JSON(http.StatusOK, SOPResponse{ ID: fmt.Sprintf("%d", s.ID), Name: s.Name, - Slug: slug, + Slug: s.Slug, Description: description, SkillSequence: skillSequence, - Version: s.Version, + Version: version, CreatedAt: createdAt, UpdatedAt: updatedAt, }) diff --git a/internal/api/handlers/subscene.go b/internal/api/handlers/subscene.go index 04dc7d5..a4409bb 100644 --- a/internal/api/handlers/subscene.go +++ b/internal/api/handlers/subscene.go @@ -33,10 +33,8 @@ type SubsceneResponse struct { ID string `json:"id"` SceneID string `json:"scene_id"` Name string `json:"name"` - Slug string `json:"slug,omitempty"` Description string `json:"description,omitempty"` InitialSceneLayout string `json:"initial_scene_layout,omitempty"` - RobotTypeID string `json:"robot_type_id,omitempty"` CreatedAt string `json:"created_at,omitempty"` UpdatedAt string `json:"updated_at,omitempty"` } @@ -50,27 +48,22 @@ type SubsceneListResponse struct { type CreateSubsceneRequest struct { SceneID string `json:"scene_id"` Name string `json:"name"` - Slug *string `json:"slug,omitempty"` Description string `json:"description,omitempty"` InitialSceneLayout string `json:"initial_scene_layout,omitempty"` - RobotTypeID *string `json:"robot_type_id,omitempty"` } // CreateSubsceneResponse represents the response for creating a subscene. type CreateSubsceneResponse struct { ID string `json:"id"` Name string `json:"name"` - Slug string `json:"slug,omitempty"` CreatedAt string `json:"created_at"` } // UpdateSubsceneRequest represents the request body for updating a subscene. type UpdateSubsceneRequest struct { Name *string `json:"name,omitempty"` - Slug *string `json:"slug,omitempty"` Description *string `json:"description,omitempty"` InitialSceneLayout *string `json:"initial_scene_layout,omitempty"` - RobotTypeID *string `json:"robot_type_id,omitempty"` } // RegisterRoutes registers subscene related routes. @@ -87,14 +80,22 @@ type subsceneRow struct { ID int64 `db:"id"` SceneID int64 `db:"scene_id"` Name string `db:"name"` - Slug sql.NullString `db:"slug"` Description sql.NullString `db:"description"` InitialSceneLayout sql.NullString `db:"initial_scene_layout"` - RobotTypeID sql.NullInt64 `db:"robot_type_id"` CreatedAt sql.NullString `db:"created_at"` UpdatedAt sql.NullString `db:"updated_at"` } +func (h *SubsceneHandler) getSceneInitialLayoutTemplate(sceneID int64) (sql.NullString, error) { + var layoutTemplate sql.NullString + err := h.db.Get( + &layoutTemplate, + "SELECT initial_scene_layout_template FROM scenes WHERE id = ? AND deleted_at IS NULL", + sceneID, + ) + return layoutTemplate, err +} + // ListSubscenes handles subscene listing requests with filtering. // // @Summary List subscenes @@ -114,10 +115,8 @@ func (h *SubsceneHandler) ListSubscenes(c *gin.Context) { id, scene_id, name, - slug, description, initial_scene_layout, - robot_type_id, created_at, updated_at FROM subscenes @@ -162,23 +161,12 @@ func (h *SubsceneHandler) ListSubscenes(c *gin.Context) { if s.UpdatedAt.Valid { updatedAt = s.UpdatedAt.String } - slug := "" - if s.Slug.Valid { - slug = s.Slug.String - } - robotTypeID := "" - if s.RobotTypeID.Valid { - robotTypeID = fmt.Sprintf("%d", s.RobotTypeID.Int64) - } - subscenes = append(subscenes, SubsceneResponse{ ID: fmt.Sprintf("%d", s.ID), SceneID: fmt.Sprintf("%d", s.SceneID), Name: s.Name, - Slug: slug, Description: description, InitialSceneLayout: layout, - RobotTypeID: robotTypeID, CreatedAt: createdAt, UpdatedAt: updatedAt, }) @@ -215,10 +203,8 @@ func (h *SubsceneHandler) GetSubscene(c *gin.Context) { id, scene_id, name, - slug, description, initial_scene_layout, - robot_type_id, created_at, updated_at FROM subscenes @@ -252,23 +238,12 @@ func (h *SubsceneHandler) GetSubscene(c *gin.Context) { if s.UpdatedAt.Valid { updatedAt = s.UpdatedAt.String } - slug := "" - if s.Slug.Valid { - slug = s.Slug.String - } - robotTypeID := "" - if s.RobotTypeID.Valid { - robotTypeID = fmt.Sprintf("%d", s.RobotTypeID.Int64) - } - c.JSON(http.StatusOK, SubsceneResponse{ ID: fmt.Sprintf("%d", s.ID), SceneID: fmt.Sprintf("%d", s.SceneID), Name: s.Name, - Slug: slug, Description: description, InitialSceneLayout: layout, - RobotTypeID: robotTypeID, CreatedAt: createdAt, UpdatedAt: updatedAt, }) @@ -295,15 +270,8 @@ func (h *SubsceneHandler) CreateSubscene(c *gin.Context) { req.SceneID = strings.TrimSpace(req.SceneID) req.Name = strings.TrimSpace(req.Name) - if req.Slug != nil { - trimmed := strings.TrimSpace(*req.Slug) - req.Slug = &trimmed - } req.Description = strings.TrimSpace(req.Description) - if req.RobotTypeID != nil { - trimmed := strings.TrimSpace(*req.RobotTypeID) - req.RobotTypeID = &trimmed - } + req.InitialSceneLayout = strings.TrimSpace(req.InitialSceneLayout) if req.SceneID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "scene_id is required"}) @@ -322,37 +290,15 @@ func (h *SubsceneHandler) CreateSubscene(c *gin.Context) { return } - // Verify scene exists - var exists bool - err = h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM scenes WHERE id = ? AND deleted_at IS NULL)", sceneID) - if err != nil || !exists { - c.JSON(http.StatusBadRequest, gin.H{"error": "scene not found"}) - return - } - - // Parse and verify robot_type_id (optional) - var robotTypeID sql.NullInt64 - if req.RobotTypeID != nil && *req.RobotTypeID != "" { - parsedRobotTypeID, err := strconv.ParseInt(*req.RobotTypeID, 10, 64) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid robot_type_id format"}) - return - } - err = h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM robot_types WHERE id = ? AND deleted_at IS NULL)", parsedRobotTypeID) - if err != nil || !exists { - c.JSON(http.StatusBadRequest, gin.H{"error": "robot_type not found"}) - return - } - robotTypeID = sql.NullInt64{Int64: parsedRobotTypeID, Valid: true} - } - - // Check if slug already exists for this scene (if slug is provided) - if req.Slug != nil && *req.Slug != "" { - err = h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM subscenes WHERE scene_id = ? AND slug = ? AND deleted_at IS NULL)", sceneID, *req.Slug) - if err == nil && exists { - c.JSON(http.StatusBadRequest, gin.H{"error": "slug already exists for this scene"}) + sceneLayoutTemplate, err := h.getSceneInitialLayoutTemplate(sceneID) + if err != nil { + if err == sql.ErrNoRows { + c.JSON(http.StatusBadRequest, gin.H{"error": "scene not found"}) return } + logger.Printf("[SUBSCENE] Failed to query scene layout template: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create subscene"}) + return } var descriptionStr sql.NullString @@ -363,11 +309,8 @@ func (h *SubsceneHandler) CreateSubscene(c *gin.Context) { var layoutStr sql.NullString if req.InitialSceneLayout != "" { layoutStr = sql.NullString{String: req.InitialSceneLayout, Valid: true} - } - - var slugStr sql.NullString - if req.Slug != nil && *req.Slug != "" { - slugStr = sql.NullString{String: *req.Slug, Valid: true} + } else { + layoutStr = sceneLayoutTemplate } now := time.Now().UTC() @@ -376,19 +319,15 @@ func (h *SubsceneHandler) CreateSubscene(c *gin.Context) { `INSERT INTO subscenes ( scene_id, name, - slug, description, initial_scene_layout, - robot_type_id, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ) VALUES (?, ?, ?, ?, ?, ?)`, sceneID, req.Name, - slugStr, descriptionStr, layoutStr, - robotTypeID, now, now, ) @@ -408,12 +347,6 @@ func (h *SubsceneHandler) CreateSubscene(c *gin.Context) { c.JSON(http.StatusCreated, CreateSubsceneResponse{ ID: fmt.Sprintf("%d", id), Name: req.Name, - Slug: func() string { - if req.Slug != nil { - return *req.Slug - } - return "" - }(), CreatedAt: now.Format(time.RFC3339), }) } @@ -448,7 +381,7 @@ func (h *SubsceneHandler) UpdateSubscene(c *gin.Context) { // Check if subscene exists var existing subsceneRow - err = h.db.Get(&existing, "SELECT id, scene_id, name, slug, description, initial_scene_layout, robot_type_id, created_at, updated_at FROM subscenes WHERE id = ? AND deleted_at IS NULL", id) + err = h.db.Get(&existing, "SELECT id, scene_id, name, description, initial_scene_layout, created_at, updated_at FROM subscenes WHERE id = ? AND deleted_at IS NULL", id) if err != nil { if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "subscene not found"}) @@ -471,21 +404,6 @@ func (h *SubsceneHandler) UpdateSubscene(c *gin.Context) { } } - if req.Slug != nil { - slug := strings.TrimSpace(*req.Slug) - if slug != "" { - // Check if slug already exists for this scene - var exists bool - err := h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM subscenes WHERE scene_id = ? AND slug = ? AND id != ? AND deleted_at IS NULL)", existing.SceneID, slug, id) - if err == nil && exists { - c.JSON(http.StatusBadRequest, gin.H{"error": "slug already exists for this scene"}) - return - } - updates = append(updates, "slug = ?") - args = append(args, slug) - } - } - if req.Description != nil { description := strings.TrimSpace(*req.Description) var descStr sql.NullString @@ -496,39 +414,29 @@ func (h *SubsceneHandler) UpdateSubscene(c *gin.Context) { args = append(args, descStr) } + sceneLayoutTemplate, err := h.getSceneInitialLayoutTemplate(existing.SceneID) + if err != nil { + if err == sql.ErrNoRows { + c.JSON(http.StatusBadRequest, gin.H{"error": "scene not found"}) + return + } + logger.Printf("[SUBSCENE] Failed to query scene layout template: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update subscene"}) + return + } + var layoutStr sql.NullString if req.InitialSceneLayout != nil { layout := strings.TrimSpace(*req.InitialSceneLayout) - var layoutStr sql.NullString if layout != "" { layoutStr = sql.NullString{String: layout, Valid: true} - } - updates = append(updates, "initial_scene_layout = ?") - args = append(args, layoutStr) - } - - // Handle robot_type_id update - if req.RobotTypeID != nil { - if *req.RobotTypeID == "" { - // Set to NULL to remove association - updates = append(updates, "robot_type_id = ?") - args = append(args, sql.NullInt64{}) } else { - parsedRobotTypeID, err := strconv.ParseInt(*req.RobotTypeID, 10, 64) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid robot_type_id format"}) - return - } - // Verify robot_type exists - var exists bool - err = h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM robot_types WHERE id = ? AND deleted_at IS NULL)", parsedRobotTypeID) - if err != nil || !exists { - c.JSON(http.StatusBadRequest, gin.H{"error": "robot_type not found"}) - return - } - updates = append(updates, "robot_type_id = ?") - args = append(args, sql.NullInt64{Int64: parsedRobotTypeID, Valid: true}) + layoutStr = sceneLayoutTemplate } + } else { + layoutStr = sceneLayoutTemplate } + updates = append(updates, "initial_scene_layout = ?") + args = append(args, layoutStr) if len(updates) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) @@ -551,7 +459,7 @@ func (h *SubsceneHandler) UpdateSubscene(c *gin.Context) { // Fetch the updated subscene var s subsceneRow - err = h.db.Get(&s, "SELECT id, scene_id, name, slug, description, initial_scene_layout, robot_type_id, created_at, updated_at FROM subscenes WHERE id = ?", id) + err = h.db.Get(&s, "SELECT id, scene_id, name, description, initial_scene_layout, created_at, updated_at FROM subscenes WHERE id = ?", id) if err != nil { logger.Printf("[SUBSCENE] Failed to fetch updated subscene: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get updated subscene"}) @@ -574,23 +482,12 @@ func (h *SubsceneHandler) UpdateSubscene(c *gin.Context) { if s.UpdatedAt.Valid { updatedAt = s.UpdatedAt.String } - slug := "" - if s.Slug.Valid { - slug = s.Slug.String - } - robotTypeID := "" - if s.RobotTypeID.Valid { - robotTypeID = fmt.Sprintf("%d", s.RobotTypeID.Int64) - } - c.JSON(http.StatusOK, SubsceneResponse{ ID: fmt.Sprintf("%d", s.ID), SceneID: fmt.Sprintf("%d", s.SceneID), Name: s.Name, - Slug: slug, Description: description, InitialSceneLayout: layout, - RobotTypeID: robotTypeID, CreatedAt: createdAt, UpdatedAt: updatedAt, }) diff --git a/internal/storage/database/migrations/000001_initial_schema.up.sql b/internal/storage/database/migrations/000001_initial_schema.up.sql index a2baa2e..3c5e0ea 100644 --- a/internal/storage/database/migrations/000001_initial_schema.up.sql +++ b/internal/storage/database/migrations/000001_initial_schema.up.sql @@ -12,13 +12,14 @@ CREATE TABLE IF NOT EXISTS organizations ( id BIGINT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255) NOT NULL, - slug VARCHAR(100) NOT NULL UNIQUE, + slug VARCHAR(100) NOT NULL, description TEXT, settings JSON DEFAULT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, deleted_at TIMESTAMP NULL, - UNIQUE INDEX idx_slug (slug), + _slug_unique VARCHAR(200) GENERATED ALWAYS AS (CONCAT(IFNULL(slug, ''), '|', IFNULL(deleted_at, ''))) STORED, + UNIQUE INDEX idx_slug_del (_slug_unique), INDEX idx_deleted (deleted_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; @@ -33,23 +34,23 @@ CREATE TABLE IF NOT EXISTS factories ( created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, deleted_at TIMESTAMP NULL, - UNIQUE INDEX idx_org_slug (organization_id, slug), + _slug_unique VARCHAR(200) GENERATED ALWAYS AS (CONCAT(IFNULL(slug, ''), '|', IFNULL(deleted_at, ''))) STORED, + UNIQUE INDEX idx_slug_del (_slug_unique), INDEX idx_org (organization_id), INDEX idx_deleted (deleted_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS scenes ( id BIGINT AUTO_INCREMENT PRIMARY KEY, - organization_id BIGINT NOT NULL, factory_id BIGINT NOT NULL, name VARCHAR(255) NOT NULL, - slug VARCHAR(100) NOT NULL, description TEXT, initial_scene_layout_template TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, deleted_at TIMESTAMP NULL, - UNIQUE INDEX idx_org_slug (organization_id, slug), + _name_unique VARCHAR(400) GENERATED ALWAYS AS (CONCAT(IFNULL(name, ''), '|', IFNULL(deleted_at, ''))) STORED, + UNIQUE INDEX idx_name_del (_name_unique), INDEX idx_factory (factory_id), INDEX idx_deleted (deleted_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; @@ -58,16 +59,14 @@ CREATE TABLE IF NOT EXISTS subscenes ( id BIGINT AUTO_INCREMENT PRIMARY KEY, scene_id BIGINT NOT NULL, name VARCHAR(255) NOT NULL, - slug VARCHAR(100) NOT NULL, description TEXT, initial_scene_layout TEXT, - robot_type_id BIGINT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, deleted_at TIMESTAMP NULL, - UNIQUE INDEX idx_scene_slug (scene_id, slug), + _name_unique VARCHAR(400) GENERATED ALWAYS AS (CONCAT(IFNULL(scene_id, ''), '|', IFNULL(name, ''), '|', IFNULL(deleted_at, ''))) STORED, + UNIQUE INDEX idx_name_del (_name_unique), INDEX idx_scene (scene_id), - INDEX idx_robot_type (robot_type_id), INDEX idx_deleted (deleted_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; @@ -77,15 +76,16 @@ CREATE TABLE IF NOT EXISTS subscenes ( CREATE TABLE IF NOT EXISTS skills ( id BIGINT AUTO_INCREMENT PRIMARY KEY, - name VARCHAR(100) NOT NULL UNIQUE, - display_name VARCHAR(255) NOT NULL, + slug VARCHAR(100) NOT NULL, + name VARCHAR(255) NOT NULL, description TEXT, - version VARCHAR(20) DEFAULT '1.0', + version VARCHAR(20) DEFAULT '1.0.0', metadata JSON DEFAULT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, deleted_at TIMESTAMP NULL, - INDEX idx_name (name), + _slug_unique VARCHAR(300) GENERATED ALWAYS AS (CONCAT(IFNULL(slug, ''), '|', IFNULL(version, ''), '|', IFNULL(deleted_at, ''))) STORED, + UNIQUE INDEX idx_slug_ver_del (_slug_unique), INDEX idx_deleted (deleted_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; @@ -101,14 +101,15 @@ CREATE TABLE IF NOT EXISTS subscene_skills ( CREATE TABLE IF NOT EXISTS sops ( id BIGINT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255) NOT NULL, - slug VARCHAR(100) NOT NULL UNIQUE, + slug VARCHAR(100) NOT NULL, description TEXT, skill_sequence JSON NOT NULL, - version INT DEFAULT 1, + version VARCHAR(20) DEFAULT '1.0.0', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, deleted_at TIMESTAMP NULL, - INDEX idx_slug (slug), + _slug_unique VARCHAR(300) GENERATED ALWAYS AS (CONCAT(IFNULL(slug, ''), '|', IFNULL(version, ''), '|', IFNULL(deleted_at, ''))) STORED, + UNIQUE INDEX idx_slug_ver_del (_slug_unique), INDEX idx_deleted (deleted_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; @@ -128,13 +129,15 @@ CREATE TABLE IF NOT EXISTS robot_types ( created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, deleted_at TIMESTAMP NULL, + _model_unique VARCHAR(300) GENERATED ALWAYS AS (CONCAT(IFNULL(model, ''), '|', IFNULL(deleted_at, ''))) STORED, + UNIQUE INDEX idx_model_del (_model_unique), INDEX idx_deleted (deleted_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS robots ( id BIGINT AUTO_INCREMENT PRIMARY KEY, robot_type_id BIGINT NOT NULL, - device_id VARCHAR(100) NOT NULL UNIQUE, + device_id VARCHAR(100) NOT NULL, factory_id BIGINT NOT NULL, asset_id VARCHAR(100), status ENUM('active', 'maintenance', 'retired') DEFAULT 'active', @@ -142,7 +145,8 @@ CREATE TABLE IF NOT EXISTS robots ( created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, deleted_at TIMESTAMP NULL, - UNIQUE INDEX idx_device_id (device_id), + _device_unique VARCHAR(200) GENERATED ALWAYS AS (CONCAT(IFNULL(device_id, ''), '|', IFNULL(deleted_at, ''))) STORED, + UNIQUE INDEX idx_device_del (_device_unique), INDEX idx_type (robot_type_id), INDEX idx_factory (factory_id), INDEX idx_status (status), @@ -152,7 +156,7 @@ CREATE TABLE IF NOT EXISTS robots ( CREATE TABLE IF NOT EXISTS data_collectors ( id BIGINT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255) NOT NULL, - operator_id VARCHAR(100) NOT NULL UNIQUE, + operator_id VARCHAR(100) NOT NULL, email VARCHAR(255), certification VARCHAR(100), status ENUM('active', 'inactive', 'on_leave') DEFAULT 'active', @@ -160,7 +164,8 @@ CREATE TABLE IF NOT EXISTS data_collectors ( created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, deleted_at TIMESTAMP NULL, - UNIQUE INDEX idx_operator_id (operator_id), + _operator_unique VARCHAR(200) GENERATED ALWAYS AS (CONCAT(IFNULL(operator_id, ''), '|', IFNULL(deleted_at, ''))) STORED, + UNIQUE INDEX idx_operator_del (_operator_unique), INDEX idx_status (status), INDEX idx_deleted (deleted_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; @@ -180,6 +185,8 @@ CREATE TABLE IF NOT EXISTS workstations ( created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, deleted_at TIMESTAMP NULL, + _collector_unique VARCHAR(200) GENERATED ALWAYS AS (CONCAT(IFNULL(data_collector_id, ''), '|', IFNULL(deleted_at, ''))) STORED, + UNIQUE INDEX idx_datacollector_del (_collector_unique), INDEX idx_robot (robot_id), INDEX idx_collector (data_collector_id), INDEX idx_factory (factory_id), @@ -190,7 +197,7 @@ CREATE TABLE IF NOT EXISTS workstations ( CREATE TABLE IF NOT EXISTS inspectors ( id BIGINT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255) NOT NULL, - inspector_id VARCHAR(100) NOT NULL UNIQUE, + inspector_id VARCHAR(100) NOT NULL, email VARCHAR(255), certification_level ENUM('level_1', 'level_2', 'senior') DEFAULT 'level_1', status ENUM('active', 'inactive') DEFAULT 'active', @@ -198,6 +205,8 @@ CREATE TABLE IF NOT EXISTS inspectors ( created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, deleted_at TIMESTAMP NULL, + _inspector_unique VARCHAR(200) GENERATED ALWAYS AS (CONCAT(IFNULL(inspector_id, ''), '|', IFNULL(deleted_at, ''))) STORED, + UNIQUE INDEX idx_inspector_del (_inspector_unique), INDEX idx_status (status), INDEX idx_deleted (deleted_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; @@ -219,6 +228,8 @@ CREATE TABLE IF NOT EXISTS orders ( created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, deleted_at TIMESTAMP NULL, + _name_unique VARCHAR(600) GENERATED ALWAYS AS (CONCAT(IFNULL(organization_id, ''), '|', IFNULL(scene_id, ''), '|', IFNULL(name, ''), '|', IFNULL(deleted_at, ''))) STORED, + UNIQUE INDEX idx_name_del (_name_unique), INDEX idx_org (organization_id), INDEX idx_scene (scene_id), INDEX idx_status (status), @@ -229,7 +240,7 @@ CREATE TABLE IF NOT EXISTS orders ( CREATE TABLE IF NOT EXISTS batches ( id BIGINT AUTO_INCREMENT PRIMARY KEY, - batch_id VARCHAR(100) NOT NULL UNIQUE COMMENT 'Human-readable batch ID', + batch_id VARCHAR(100) NOT NULL COMMENT 'Human-readable batch ID', order_id BIGINT NOT NULL, workstation_id BIGINT NOT NULL, name VARCHAR(255) NOT NULL, @@ -242,6 +253,8 @@ CREATE TABLE IF NOT EXISTS batches ( created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, deleted_at TIMESTAMP NULL, + _name_unique VARCHAR(600) GENERATED ALWAYS AS (CONCAT(IFNULL(order_id, ''), '|', IFNULL(name, ''), '|', IFNULL(deleted_at, ''))) STORED, + UNIQUE INDEX idx_name_del (_name_unique), INDEX idx_batch_id (batch_id), INDEX idx_order (order_id), INDEX idx_workstation (workstation_id), @@ -252,7 +265,7 @@ CREATE TABLE IF NOT EXISTS batches ( CREATE TABLE IF NOT EXISTS tasks ( id BIGINT AUTO_INCREMENT PRIMARY KEY, - task_id VARCHAR(100) NOT NULL UNIQUE COMMENT 'Human-readable task ID', + task_id VARCHAR(100) NOT NULL COMMENT 'Human-readable task ID', batch_id BIGINT NOT NULL, order_id BIGINT NOT NULL, sop_id BIGINT NOT NULL, @@ -277,6 +290,8 @@ CREATE TABLE IF NOT EXISTS tasks ( created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, deleted_at TIMESTAMP NULL, + _task_unique VARCHAR(200) GENERATED ALWAYS AS (CONCAT(IFNULL(task_id, ''), '|', IFNULL(deleted_at, ''))) STORED, + UNIQUE INDEX idx_task_del (_task_unique), INDEX idx_task_id (task_id), INDEX idx_batch (batch_id), INDEX idx_order (order_id), @@ -292,7 +307,7 @@ CREATE TABLE IF NOT EXISTS tasks ( CREATE TABLE IF NOT EXISTS episodes ( id BIGINT AUTO_INCREMENT PRIMARY KEY, - episode_id VARCHAR(100) NOT NULL UNIQUE COMMENT 'Human-readable episode ID', + episode_id VARCHAR(100) NOT NULL COMMENT 'Human-readable episode ID', task_id BIGINT NOT NULL, batch_id BIGINT NOT NULL COMMENT 'Denormalized: from tasks.batch_id', order_id BIGINT NOT NULL COMMENT 'Denormalized: from tasks.order_id', @@ -327,6 +342,8 @@ CREATE TABLE IF NOT EXISTS episodes ( created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, deleted_at TIMESTAMP NULL, + _episode_unique VARCHAR(200) GENERATED ALWAYS AS (CONCAT(IFNULL(episode_id, ''), '|', IFNULL(deleted_at, ''))) STORED, + UNIQUE INDEX idx_episode_del (_episode_unique), INDEX idx_episode_id (episode_id), INDEX idx_task (task_id), INDEX idx_batch (batch_id), @@ -445,16 +462,16 @@ CREATE TABLE IF NOT EXISTS sync_logs ( -- Sample Data -- ============================================================ -INSERT INTO organizations (name, slug) VALUES - ('RoboticsLab Internal', 'roboticslab') -ON DUPLICATE KEY UPDATE name=VALUES(name); +INSERT INTO organizations (name, slug, settings) VALUES + ('RoboticsLab Internal', 'roboticslab', '{}') +ON DUPLICATE KEY UPDATE name=VALUES(name), settings=VALUES(settings); -INSERT INTO factories (organization_id, name, slug, location) VALUES -(1, 'Shanghai Factory', 'factory_shanghai', 'Shanghai, China'), -(1, 'San Francisco Warehouse', 'factory_sf', 'San Francisco, CA, USA') -ON DUPLICATE KEY UPDATE name=VALUES(name), location=VALUES(location); +INSERT INTO factories (organization_id, name, slug, location, settings) VALUES +(1, 'Shanghai Factory', 'factory-sh', 'Shanghai, China', '{}'), +(1, 'San Francisco Factory', 'factory-sf', 'San Francisco, USA', '{}') +ON DUPLICATE KEY UPDATE name=VALUES(name), location=VALUES(location), settings=VALUES(settings); -INSERT INTO skills (name, display_name, description) VALUES +INSERT INTO skills (slug, name, description) VALUES ('pick', 'Pick', 'Grasp and lift an object'), ('place', 'Place', 'Put an object at a target location'), ('drop', 'Drop', 'Release an object without precision'), @@ -462,4 +479,4 @@ INSERT INTO skills (name, display_name, description) VALUES ('wipe', 'Wipe', 'Clean a surface with wiping motion'), ('navigate', 'Navigate', 'Move from one location to another'), ('pour', 'Pour', 'Transfer liquid between containers') -ON DUPLICATE KEY UPDATE display_name=VALUES(display_name); +ON DUPLICATE KEY UPDATE name=VALUES(name); diff --git a/internal/storage/database/migrations/000002_version_2_schema.down.sql b/internal/storage/database/migrations/000002_version_2_schema.down.sql deleted file mode 100644 index 56b2c87..0000000 --- a/internal/storage/database/migrations/000002_version_2_schema.down.sql +++ /dev/null @@ -1,98 +0,0 @@ --- SPDX-FileCopyrightText: 2026 ArcheBase --- --- SPDX-License-Identifier: MulanPSL-2.0 - --- migrations/000002_version_2_schema.down.sql --- Revert index optimizations from version 2 (remove virtual columns and restore original indexes) - --- ============================================================ --- Production Units (reverse order of creation) --- ============================================================ - -DROP INDEX idx_episode_del ON episodes; -ALTER TABLE episodes DROP COLUMN _episode_unique; -CREATE INDEX episode_id ON episodes (episode_id); - -DROP INDEX idx_task_del ON tasks; -ALTER TABLE tasks DROP COLUMN _task_unique; -CREATE UNIQUE INDEX task_id ON tasks (task_id); - -DROP INDEX idx_name_del ON batches; -ALTER TABLE batches DROP COLUMN _name_unique; -CREATE UNIQUE INDEX batch_id ON batches (batch_id); - -DROP INDEX idx_name_del ON orders; -ALTER TABLE orders DROP COLUMN _name_unique; - --- ============================================================ --- Operational Resources --- ============================================================ - -DROP INDEX idx_inspector_del ON inspectors; -ALTER TABLE inspectors DROP COLUMN _inspector_unique; -CREATE UNIQUE INDEX inspector_id ON inspectors (inspector_id); - -DROP INDEX idx_datacollector_del ON workstations; -ALTER TABLE workstations DROP COLUMN _collector_unique; - -DROP INDEX idx_operator_del ON data_collectors; -ALTER TABLE data_collectors DROP COLUMN _operator_unique; -CREATE UNIQUE INDEX operator_id ON data_collectors (operator_id); -CREATE INDEX idx_operator_id ON data_collectors (operator_id); - -DROP INDEX idx_device_del ON robots; -ALTER TABLE robots DROP COLUMN _device_unique; -CREATE UNIQUE INDEX device_id ON robots (device_id); -CREATE INDEX idx_device_id ON robots (device_id); - -DROP INDEX idx_model_del ON robot_types; -ALTER TABLE robot_types DROP COLUMN _model_unique; - --- ============================================================ --- Capability & Procedure --- ============================================================ - -DROP INDEX idx_name_del ON sops; -ALTER TABLE sops DROP COLUMN _name_unique; -CREATE INDEX slug ON sops (slug); -CREATE INDEX idx_slug ON sops (slug); - --- Revert: Make sops.slug NOT NULL again -ALTER TABLE sops MODIFY COLUMN slug VARCHAR(100) NOT NULL; - -DROP INDEX idx_name_del ON skills; -ALTER TABLE skills DROP COLUMN _name_unique; -CREATE UNIQUE INDEX name ON skills (name); - --- ============================================================ --- Environmental Hierarchy --- ============================================================ - -DROP INDEX idx_name_del ON subscenes; -ALTER TABLE subscenes DROP COLUMN _name_unique; -CREATE UNIQUE INDEX idx_scene_slug ON subscenes (scene_id, slug); - --- Revert: Make subscenes.robot_type_id NOT NULL again -ALTER TABLE subscenes MODIFY COLUMN robot_type_id BIGINT NOT NULL; - --- Revert: Make subscenes.slug NOT NULL again -ALTER TABLE subscenes MODIFY COLUMN slug VARCHAR(100) NOT NULL; - -DROP INDEX idx_name_del ON scenes; -ALTER TABLE scenes DROP COLUMN _name_unique; -CREATE UNIQUE INDEX idx_org_slug ON scenes (organization_id, slug); - --- Revert: Make scenes.slug NOT NULL again -ALTER TABLE scenes MODIFY COLUMN slug VARCHAR(100) NOT NULL; - --- Revert: Make scenes.organization_id NOT NULL again -ALTER TABLE scenes MODIFY COLUMN organization_id BIGINT NOT NULL; - -DROP INDEX idx_slug_del ON factories; -ALTER TABLE factories DROP COLUMN _slug_unique; -CREATE UNIQUE INDEX idx_org_slug ON factories (organization_id, slug); - -DROP INDEX idx_slug_del ON organizations; -ALTER TABLE organizations DROP COLUMN _slug_unique; -CREATE UNIQUE INDEX slug ON organizations (slug); -CREATE INDEX idx_slug ON organizations (slug); diff --git a/internal/storage/database/migrations/000002_version_2_schema.up.sql b/internal/storage/database/migrations/000002_version_2_schema.up.sql deleted file mode 100644 index 1fb84b5..0000000 --- a/internal/storage/database/migrations/000002_version_2_schema.up.sql +++ /dev/null @@ -1,113 +0,0 @@ --- SPDX-FileCopyrightText: 2026 ArcheBase --- --- SPDX-License-Identifier: MulanPSL-2.0 - --- migrations/000002_version_2_schema.up.sql --- Fix unique indexes with NULL values by using STORED virtual columns - --- ============================================================ --- Environmental Hierarchy --- ============================================================ - --- organizations: slug + deleted_at -ALTER TABLE organizations ADD COLUMN _slug_unique VARCHAR(200) GENERATED ALWAYS AS (CONCAT(IFNULL(slug, ''), '|', IFNULL(deleted_at, ''))) STORED; -DROP INDEX slug ON organizations; -DROP INDEX idx_slug ON organizations; -CREATE UNIQUE INDEX idx_slug_del ON organizations (_slug_unique); - --- factories: slug + deleted_at -ALTER TABLE factories ADD COLUMN _slug_unique VARCHAR(200) GENERATED ALWAYS AS (CONCAT(IFNULL(slug, ''), '|', IFNULL(deleted_at, ''))) STORED; -DROP INDEX idx_org_slug ON factories; -CREATE UNIQUE INDEX idx_slug_del ON factories (_slug_unique); - --- scenes: name + deleted_at (organization_id is now nullable, so we include it in the unique key) -ALTER TABLE scenes ADD COLUMN _name_unique VARCHAR(400) GENERATED ALWAYS AS (CONCAT(IFNULL(organization_id, ''), '|', IFNULL(name, ''), '|', IFNULL(deleted_at, ''))) STORED; -DROP INDEX idx_org_slug ON scenes; -CREATE UNIQUE INDEX idx_name_del ON scenes (_name_unique); - --- Allow scenes.organization_id to be NULL -ALTER TABLE scenes MODIFY COLUMN organization_id BIGINT NULL; - --- Allow scenes.slug to be NULL -ALTER TABLE scenes MODIFY COLUMN slug VARCHAR(100) NULL; - --- subscenes: name + deleted_at -ALTER TABLE subscenes ADD COLUMN _name_unique VARCHAR(400) GENERATED ALWAYS AS (CONCAT(IFNULL(scene_id, ''), '|', IFNULL(name, ''), '|', IFNULL(deleted_at, ''))) STORED; -DROP INDEX idx_scene_slug ON subscenes; -CREATE UNIQUE INDEX idx_name_del ON subscenes (_name_unique); - --- Allow subscenes.slug to be NULL -ALTER TABLE subscenes MODIFY COLUMN slug VARCHAR(100) NULL; - --- Allow subscenes.robot_type_id to be NULL -ALTER TABLE subscenes MODIFY COLUMN robot_type_id BIGINT NULL; - --- ============================================================ --- Capability & Procedure --- ============================================================ - --- skills: name + version + deleted_at -ALTER TABLE skills ADD COLUMN _name_unique VARCHAR(300) GENERATED ALWAYS AS (CONCAT(IFNULL(name, ''), '|', IFNULL(version, ''), '|', IFNULL(deleted_at, ''))) STORED; -DROP INDEX name ON skills; -CREATE UNIQUE INDEX idx_name_del ON skills (_name_unique); - --- sops: name + deleted_at -ALTER TABLE sops ADD COLUMN _name_unique VARCHAR(300) GENERATED ALWAYS AS (CONCAT(IFNULL(name, ''), '|', IFNULL(deleted_at, ''))) STORED; -DROP INDEX slug ON sops; -DROP INDEX idx_slug ON sops; -CREATE UNIQUE INDEX idx_name_del ON sops (_name_unique); - --- Allow sops.slug to be NULL -ALTER TABLE sops MODIFY COLUMN slug VARCHAR(100) NULL; - --- ============================================================ --- Operational Resources --- ============================================================ - --- robot_types: model + deleted_at -ALTER TABLE robot_types ADD COLUMN _model_unique VARCHAR(300) GENERATED ALWAYS AS (CONCAT(IFNULL(model, ''), '|', IFNULL(deleted_at, ''))) STORED; -CREATE UNIQUE INDEX idx_model_del ON robot_types (_model_unique); - --- robots: device_id + deleted_at -ALTER TABLE robots ADD COLUMN _device_unique VARCHAR(200) GENERATED ALWAYS AS (CONCAT(IFNULL(device_id, ''), '|', IFNULL(deleted_at, ''))) STORED; -DROP INDEX device_id ON robots; -DROP INDEX idx_device_id ON robots; -CREATE UNIQUE INDEX idx_device_del ON robots (_device_unique); - --- data_collectors: operator_id + deleted_at -ALTER TABLE data_collectors ADD COLUMN _operator_unique VARCHAR(200) GENERATED ALWAYS AS (CONCAT(IFNULL(operator_id, ''), '|', IFNULL(deleted_at, ''))) STORED; -DROP INDEX operator_id ON data_collectors; -DROP INDEX idx_operator_id ON data_collectors; -CREATE UNIQUE INDEX idx_operator_del ON data_collectors (_operator_unique); - --- workstations: data_collector_id + deleted_at -ALTER TABLE workstations ADD COLUMN _collector_unique VARCHAR(200) GENERATED ALWAYS AS (CONCAT(IFNULL(data_collector_id, ''), '|', IFNULL(deleted_at, ''))) STORED; -CREATE UNIQUE INDEX idx_datacollector_del ON workstations (_collector_unique); - --- inspectors: inspector_id + deleted_at -ALTER TABLE inspectors ADD COLUMN _inspector_unique VARCHAR(200) GENERATED ALWAYS AS (CONCAT(IFNULL(inspector_id, ''), '|', IFNULL(deleted_at, ''))) STORED; -DROP INDEX inspector_id ON inspectors; -CREATE UNIQUE INDEX idx_inspector_del ON inspectors (_inspector_unique); - --- ============================================================ --- Production Units --- ============================================================ - --- orders: name + deleted_at (include organization_id and scene_id for uniqueness) -ALTER TABLE orders ADD COLUMN _name_unique VARCHAR(600) GENERATED ALWAYS AS (CONCAT(IFNULL(organization_id, ''), '|', IFNULL(scene_id, ''), '|', IFNULL(name, ''), '|', IFNULL(deleted_at, ''))) STORED; -CREATE UNIQUE INDEX idx_name_del ON orders (_name_unique); - --- batches: name + deleted_at (include batch_id as unique identifier) -ALTER TABLE batches ADD COLUMN _name_unique VARCHAR(600) GENERATED ALWAYS AS (CONCAT(IFNULL(order_id, ''), '|', IFNULL(name, ''), '|', IFNULL(deleted_at, ''))) STORED; -DROP INDEX batch_id ON batches; -CREATE UNIQUE INDEX idx_name_del ON batches (_name_unique); - --- tasks: task_id + deleted_at -ALTER TABLE tasks ADD COLUMN _task_unique VARCHAR(200) GENERATED ALWAYS AS (CONCAT(IFNULL(task_id, ''), '|', IFNULL(deleted_at, ''))) STORED; -DROP INDEX task_id ON tasks; -CREATE UNIQUE INDEX idx_task_del ON tasks (_task_unique); - --- episodes: episode_id + deleted_at -ALTER TABLE episodes ADD COLUMN _episode_unique VARCHAR(200) GENERATED ALWAYS AS (CONCAT(IFNULL(episode_id, ''), '|', IFNULL(deleted_at, ''))) STORED; -DROP INDEX episode_id ON episodes; -CREATE UNIQUE INDEX idx_episode_del ON episodes (_episode_unique); From 45205cf70568eba80c8a7e6b1e0332ebcc3b7aec Mon Sep 17 00:00:00 2001 From: shark Date: Sun, 29 Mar 2026 11:31:17 +0800 Subject: [PATCH 13/20] feat(api): add conflict response for skill deletion when referenced by SOPs --- internal/api/handlers/skill.go | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/internal/api/handlers/skill.go b/internal/api/handlers/skill.go index b2e16e7..da418fe 100644 --- a/internal/api/handlers/skill.go +++ b/internal/api/handlers/skill.go @@ -546,6 +546,7 @@ func (h *SkillHandler) UpdateSkill(c *gin.Context) { // @Success 204 // @Failure 400 {object} map[string]string // @Failure 404 {object} map[string]string +// @Failure 409 {object} map[string]string // @Failure 500 {object} map[string]string // @Router /skills/{id} [delete] func (h *SkillHandler) DeleteSkill(c *gin.Context) { @@ -556,7 +557,6 @@ func (h *SkillHandler) DeleteSkill(c *gin.Context) { return } - // Check if skill exists var exists bool err = h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM skills WHERE id = ? AND deleted_at IS NULL)", id) if err != nil { @@ -564,12 +564,30 @@ func (h *SkillHandler) DeleteSkill(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete skill"}) return } - if !exists { c.JSON(http.StatusNotFound, gin.H{"error": "skill not found"}) return } + // skill_sequence stores skill ids as JSON strings, e.g. ["1","2"]. + var referenced bool + err = h.db.Get(&referenced, ` + SELECT EXISTS( + SELECT 1 FROM sops + WHERE deleted_at IS NULL + AND JSON_CONTAINS(skill_sequence, JSON_QUOTE(CAST(? AS CHAR)), '$') + ) + `, id) + if err != nil { + logger.Printf("[SKILL] Failed to check SOP references: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete skill"}) + return + } + if referenced { + c.JSON(http.StatusConflict, gin.H{"error": "skill is referenced by one or more SOPs"}) + return + } + now := time.Now().UTC() // Perform soft delete by setting deleted_at From 90d7bc2adce820c6c0db451401d3f57b1221995f Mon Sep 17 00:00:00 2001 From: shark Date: Sun, 29 Mar 2026 14:10:49 +0800 Subject: [PATCH 14/20] feat(api): enforce deletion constraints for factories,scenes,subscenes --- internal/api/handlers/factory.go | 27 +++++++++++++++++++++++++-- internal/api/handlers/scene.go | 18 ++++++++++++++++-- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/internal/api/handlers/factory.go b/internal/api/handlers/factory.go index 4eef7e2..4c898c9 100644 --- a/internal/api/handlers/factory.go +++ b/internal/api/handlers/factory.go @@ -38,6 +38,7 @@ type FactoryResponse struct { Location string `json:"location,omitempty"` Timezone string `json:"timezone,omitempty"` Settings interface{} `json:"settings,omitempty"` + SceneCount int `json:"sceneCount"` CreatedAt string `json:"created_at,omitempty"` UpdatedAt string `json:"updated_at,omitempty"` } @@ -86,6 +87,7 @@ type factoryRow struct { Location sql.NullString `db:"location"` Timezone sql.NullString `db:"timezone"` Settings sql.NullString `db:"settings"` + SceneCount int `db:"scene_count"` CreatedAt sql.NullString `db:"created_at"` UpdatedAt sql.NullString `db:"updated_at"` } @@ -113,6 +115,7 @@ func (h *FactoryHandler) ListFactories(c *gin.Context) { location, timezone, settings, + (SELECT COUNT(*) FROM scenes s WHERE s.factory_id = factories.id AND s.deleted_at IS NULL) AS scene_count, created_at, updated_at FROM factories @@ -163,6 +166,7 @@ func (h *FactoryHandler) ListFactories(c *gin.Context) { Slug: f.Slug, Location: location, Timezone: timezone, + SceneCount: f.SceneCount, CreatedAt: createdAt, }) } @@ -337,6 +341,7 @@ func (h *FactoryHandler) GetFactory(c *gin.Context) { location, timezone, settings, + (SELECT COUNT(*) FROM scenes s WHERE s.factory_id = factories.id AND s.deleted_at IS NULL) AS scene_count, created_at, updated_at FROM factories @@ -378,6 +383,7 @@ func (h *FactoryHandler) GetFactory(c *gin.Context) { Slug: f.Slug, Location: location, Timezone: timezone, + SceneCount: f.SceneCount, CreatedAt: createdAt, UpdatedAt: updatedAt, }) @@ -515,7 +521,9 @@ func (h *FactoryHandler) UpdateFactory(c *gin.Context) { // Fetch the updated factory var f factoryRow - err = h.db.Get(&f, "SELECT id, organization_id, name, slug, location, timezone, settings, created_at, updated_at FROM factories WHERE id = ?", id) + err = h.db.Get(&f, `SELECT id, organization_id, name, slug, location, timezone, settings, + (SELECT COUNT(*) FROM scenes s WHERE s.factory_id = factories.id AND s.deleted_at IS NULL) AS scene_count, + created_at, updated_at FROM factories WHERE id = ?`, id) if err != nil { logger.Printf("[FACTORY] Failed to fetch updated factory: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get updated factory"}) @@ -546,6 +554,7 @@ func (h *FactoryHandler) UpdateFactory(c *gin.Context) { Slug: f.Slug, Location: location, Timezone: timezone, + SceneCount: f.SceneCount, CreatedAt: createdAt, UpdatedAt: updatedAt, }) @@ -554,7 +563,7 @@ func (h *FactoryHandler) UpdateFactory(c *gin.Context) { // DeleteFactory handles factory deletion requests (soft delete). // // @Summary Delete factory -// @Description Soft deletes a factory by ID +// @Description Soft deletes a factory by ID. Returns 400 if the factory has associated scenes. // @Tags factories // @Accept json // @Produce json @@ -586,6 +595,20 @@ func (h *FactoryHandler) DeleteFactory(c *gin.Context) { return } + // Check if factory has associated scenes + var sceneCount int + err = h.db.Get(&sceneCount, "SELECT COUNT(*) FROM scenes WHERE factory_id = ? AND deleted_at IS NULL", id) + if err != nil { + logger.Printf("[FACTORY] Failed to check scene count: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete factory"}) + return + } + + if sceneCount > 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("cannot delete factory with %d associated scenes", sceneCount)}) + return + } + now := time.Now().UTC() // Perform soft delete by setting deleted_at diff --git a/internal/api/handlers/scene.go b/internal/api/handlers/scene.go index df7d574..1583d6e 100644 --- a/internal/api/handlers/scene.go +++ b/internal/api/handlers/scene.go @@ -35,7 +35,7 @@ type SceneResponse struct { Name string `json:"name"` Description string `json:"description,omitempty"` InitialSceneLayoutTemplate string `json:"initial_scene_layout_template,omitempty"` - SubsceneCount int `json:"subscene_count"` + SubsceneCount int `json:"subsceneCount"` CreatedAt string `json:"created_at,omitempty"` UpdatedAt string `json:"updated_at,omitempty"` } @@ -498,7 +498,7 @@ func (h *SceneHandler) UpdateScene(c *gin.Context) { // DeleteScene handles scene deletion requests (soft delete). // // @Summary Delete scene -// @Description Soft deletes a scene by ID +// @Description Soft deletes a scene by ID. Returns 400 if the scene has associated subscenes. // @Tags scenes // @Accept json // @Produce json @@ -530,6 +530,20 @@ func (h *SceneHandler) DeleteScene(c *gin.Context) { return } + // Check if scene has associated subscenes + var subsceneCount int + err = h.db.Get(&subsceneCount, "SELECT COUNT(*) FROM subscenes WHERE scene_id = ? AND deleted_at IS NULL", id) + if err != nil { + logger.Printf("[SCENE] Failed to check subscene count: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete scene"}) + return + } + + if subsceneCount > 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("cannot delete scene with %d associated subscenes", subsceneCount)}) + return + } + now := time.Now().UTC() // Perform soft delete by setting deleted_at From a59b747326f5b260e41170b40b687cfb389dc6c8 Mon Sep 17 00:00:00 2001 From: shark Date: Sun, 29 Mar 2026 20:19:42 +0800 Subject: [PATCH 15/20] feat(api):enhance data collector, inspector, robot, and station structures with new metadata fields and update handling --- internal/api/handlers/data_collector.go | 300 ++++++++++----- internal/api/handlers/factory.go | 91 ++++- internal/api/handlers/inspector.go | 96 +++-- internal/api/handlers/organization.go | 39 +- internal/api/handlers/robot.go | 326 +++++++++------- internal/api/handlers/robot_type.go | 325 ++++++++++------ internal/api/handlers/station.go | 353 ++++++++++++++---- internal/api/handlers/subscene.go | 46 ++- .../migrations/000001_initial_schema.up.sql | 8 +- 9 files changed, 1108 insertions(+), 476 deletions(-) diff --git a/internal/api/handlers/data_collector.go b/internal/api/handlers/data_collector.go index ab29207..95d2c4c 100644 --- a/internal/api/handlers/data_collector.go +++ b/internal/api/handlers/data_collector.go @@ -6,7 +6,9 @@ package handlers import ( + "bytes" "database/sql" + "encoding/json" "fmt" "net/http" "strconv" @@ -31,13 +33,15 @@ func NewDataCollectorHandler(db *sqlx.DB) *DataCollectorHandler { // DataCollectorResponse represents a data collector in the response. type DataCollectorResponse struct { - ID string `json:"id"` - Name string `json:"name"` - OperatorID string `json:"operator_id"` - Email string `json:"email,omitempty"` - Certification string `json:"certification,omitempty"` - Status string `json:"status"` - CreatedAt string `json:"created_at,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + OperatorID string `json:"operator_id"` + Email string `json:"email,omitempty"` + Certification string `json:"certification,omitempty"` + Status string `json:"status"` + Metadata interface{} `json:"metadata,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` } // DataCollectorListResponse represents the response for listing data collectors. @@ -47,19 +51,24 @@ type DataCollectorListResponse struct { // CreateDataCollectorRequest represents the request body for creating a data collector. type CreateDataCollectorRequest struct { - Name string `json:"name"` - OperatorID string `json:"operator_id"` - Email string `json:"email,omitempty"` + Name string `json:"name"` + OperatorID string `json:"operator_id"` + Email string `json:"email,omitempty"` + Certification string `json:"certification,omitempty"` + Metadata interface{} `json:"metadata,omitempty"` } // CreateDataCollectorResponse represents the response for creating a data collector. type CreateDataCollectorResponse struct { - ID string `json:"id"` - Name string `json:"name"` - OperatorID string `json:"operator_id"` - Email string `json:"email,omitempty"` - Status string `json:"status"` - CreatedAt string `json:"created_at"` + ID string `json:"id"` + Name string `json:"name"` + OperatorID string `json:"operator_id"` + Email string `json:"email,omitempty"` + Certification string `json:"certification,omitempty"` + Status string `json:"status"` + Metadata interface{} `json:"metadata,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at,omitempty"` } // RegisterRoutes registers data collector related routes. @@ -79,7 +88,46 @@ type dataCollectorRow struct { Email sql.NullString `db:"email"` Certification sql.NullString `db:"certification"` Status string `db:"status"` + Metadata sql.NullString `db:"metadata"` CreatedAt sql.NullString `db:"created_at"` + UpdatedAt sql.NullString `db:"updated_at"` +} + +func dcMetadataFromDB(ns sql.NullString) interface{} { + if !ns.Valid || strings.TrimSpace(ns.String) == "" { + return nil + } + return parseJSONRaw(ns.String) +} + +func dataCollectorResponseFromRow(dc dataCollectorRow) DataCollectorResponse { + email := "" + if dc.Email.Valid { + email = dc.Email.String + } + certification := "" + if dc.Certification.Valid { + certification = dc.Certification.String + } + createdAt := "" + if dc.CreatedAt.Valid { + createdAt = dc.CreatedAt.String + } + updatedAt := "" + if dc.UpdatedAt.Valid { + updatedAt = dc.UpdatedAt.String + } + return DataCollectorResponse{ + ID: fmt.Sprintf("%d", dc.ID), + Name: dc.Name, + OperatorID: dc.OperatorID, + Email: email, + Certification: certification, + Status: dc.Status, + Metadata: dcMetadataFromDB(dc.Metadata), + CreatedAt: createdAt, + UpdatedAt: updatedAt, + } } // ListDataCollectors handles data collector listing requests with filtering. @@ -105,7 +153,9 @@ func (h *DataCollectorHandler) ListDataCollectors(c *gin.Context) { dc.email, dc.certification, dc.status, - dc.created_at + dc.metadata, + dc.created_at, + dc.updated_at FROM data_collectors dc WHERE dc.deleted_at IS NULL ` @@ -128,30 +178,7 @@ func (h *DataCollectorHandler) ListDataCollectors(c *gin.Context) { dataCollectors := []DataCollectorResponse{} for _, dc := range dbRows { - email := "" - if dc.Email.Valid { - email = dc.Email.String - } - - certification := "" - if dc.Certification.Valid { - certification = dc.Certification.String - } - - createdAt := "" - if dc.CreatedAt.Valid { - createdAt = dc.CreatedAt.String - } - - dataCollectors = append(dataCollectors, DataCollectorResponse{ - ID: fmt.Sprintf("%d", dc.ID), - Name: dc.Name, - OperatorID: dc.OperatorID, - Email: email, - Certification: certification, - Status: dc.Status, - CreatedAt: createdAt, - }) + dataCollectors = append(dataCollectors, dataCollectorResponseFromRow(dc)) } c.JSON(http.StatusOK, DataCollectorListResponse{ @@ -181,6 +208,7 @@ func (h *DataCollectorHandler) CreateDataCollector(c *gin.Context) { req.Name = strings.TrimSpace(req.Name) req.OperatorID = strings.TrimSpace(req.OperatorID) req.Email = strings.TrimSpace(req.Email) + req.Certification = strings.TrimSpace(req.Certification) if req.Name == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"}) @@ -214,19 +242,38 @@ func (h *DataCollectorHandler) CreateDataCollector(c *gin.Context) { emailStr = sql.NullString{String: req.Email, Valid: true} } + var certStr sql.NullString + if req.Certification != "" { + certStr = sql.NullString{String: req.Certification, Valid: true} + } + + metadataStr := sql.NullString{String: "{}", Valid: true} + if req.Metadata != nil { + metadataJSON, err := json.Marshal(req.Metadata) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid metadata JSON"}) + return + } + metadataStr = sql.NullString{String: string(metadataJSON), Valid: true} + } + result, err := h.db.Exec( `INSERT INTO data_collectors ( name, operator_id, email, + certification, status, + metadata, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?)`, + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, req.Name, req.OperatorID, emailStr, + certStr, "active", + metadataStr, createdAt, createdAt, ) @@ -243,13 +290,38 @@ func (h *DataCollectorHandler) CreateDataCollector(c *gin.Context) { return } + var row dataCollectorRow + err = h.db.Get(&row, ` + SELECT + dc.id, + dc.name, + dc.operator_id, + dc.email, + dc.certification, + dc.status, + dc.metadata, + dc.created_at, + dc.updated_at + FROM data_collectors dc + WHERE dc.id = ? AND dc.deleted_at IS NULL + `, id) + if err != nil { + logger.Printf("[DC] Failed to load created data collector: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create data collector"}) + return + } + + resp := dataCollectorResponseFromRow(row) c.JSON(http.StatusCreated, CreateDataCollectorResponse{ - ID: fmt.Sprintf("%d", id), - Name: req.Name, - OperatorID: req.OperatorID, - Email: req.Email, - Status: "active", - CreatedAt: createdAt, + ID: resp.ID, + Name: resp.Name, + OperatorID: resp.OperatorID, + Email: resp.Email, + Certification: resp.Certification, + Status: resp.Status, + Metadata: resp.Metadata, + CreatedAt: resp.CreatedAt, + UpdatedAt: resp.UpdatedAt, }) } @@ -282,7 +354,9 @@ func (h *DataCollectorHandler) GetDataCollector(c *gin.Context) { dc.email, dc.certification, dc.status, - dc.created_at + dc.metadata, + dc.created_at, + dc.updated_at FROM data_collectors dc WHERE dc.id = ? AND dc.deleted_at IS NULL ` @@ -298,37 +372,18 @@ func (h *DataCollectorHandler) GetDataCollector(c *gin.Context) { return } - email := "" - if dc.Email.Valid { - email = dc.Email.String - } - - certification := "" - if dc.Certification.Valid { - certification = dc.Certification.String - } - - createdAt := "" - if dc.CreatedAt.Valid { - createdAt = dc.CreatedAt.String - } - - c.JSON(http.StatusOK, DataCollectorResponse{ - ID: fmt.Sprintf("%d", dc.ID), - Name: dc.Name, - OperatorID: dc.OperatorID, - Email: email, - Certification: certification, - Status: dc.Status, - CreatedAt: createdAt, - }) + c.JSON(http.StatusOK, dataCollectorResponseFromRow(dc)) } // UpdateDataCollectorRequest represents the request body for updating a data collector. +// Metadata uses json.RawMessage so we can tell: key omitted (no change) vs explicit JSON null (store {}). type UpdateDataCollectorRequest struct { - Name *string `json:"name,omitempty"` - Email *string `json:"email,omitempty"` - Status *string `json:"status,omitempty"` + Name *string `json:"name,omitempty"` + OperatorID *string `json:"operator_id,omitempty"` + Email *string `json:"email,omitempty"` + Certification *string `json:"certification,omitempty"` + Status *string `json:"status,omitempty"` + Metadata json.RawMessage `json:"metadata,omitempty"` } // UpdateDataCollector handles updating a data collector. @@ -377,6 +432,29 @@ func (h *DataCollectorHandler) UpdateDataCollector(c *gin.Context) { } } + if req.OperatorID != nil { + op := strings.TrimSpace(*req.OperatorID) + if op == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "operator_id cannot be empty"}) + return + } + var taken bool + err := h.db.Get(&taken, `SELECT EXISTS( + SELECT 1 FROM data_collectors WHERE operator_id = ? AND deleted_at IS NULL AND id != ? + )`, op, id) + if err != nil { + logger.Printf("[DC] Failed to check operator_id: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update data collector"}) + return + } + if taken { + c.JSON(http.StatusBadRequest, gin.H{"error": "operator_id already exists"}) + return + } + updates = append(updates, "operator_id = ?") + args = append(args, op) + } + if req.Email != nil { email := strings.TrimSpace(*req.Email) var emailStr sql.NullString @@ -387,6 +465,16 @@ func (h *DataCollectorHandler) UpdateDataCollector(c *gin.Context) { args = append(args, emailStr) } + if req.Certification != nil { + cert := strings.TrimSpace(*req.Certification) + var certStr sql.NullString + if cert != "" { + certStr = sql.NullString{String: cert, Valid: true} + } + updates = append(updates, "certification = ?") + args = append(args, certStr) + } + if req.Status != nil { status := strings.TrimSpace(*req.Status) if !validStatuses[status] { @@ -397,6 +485,22 @@ func (h *DataCollectorHandler) UpdateDataCollector(c *gin.Context) { args = append(args, status) } + if len(req.Metadata) > 0 { + meta := bytes.TrimSpace(req.Metadata) + if bytes.Equal(meta, []byte("null")) { + updates = append(updates, "metadata = ?") + args = append(args, sql.NullString{String: "{}", Valid: true}) + } else { + var probe interface{} + if err := json.Unmarshal(req.Metadata, &probe); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid metadata JSON"}) + return + } + updates = append(updates, "metadata = ?") + args = append(args, sql.NullString{String: string(req.Metadata), Valid: true}) + } + } + if len(updates) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) return @@ -432,9 +536,11 @@ func (h *DataCollectorHandler) UpdateDataCollector(c *gin.Context) { dc.email, dc.certification, dc.status, - dc.created_at + dc.metadata, + dc.created_at, + dc.updated_at FROM data_collectors dc - WHERE dc.id = ? + WHERE dc.id = ? AND dc.deleted_at IS NULL `, id) if err != nil { logger.Printf("[DC] Failed to fetch updated data collector: %v", err) @@ -442,30 +548,7 @@ func (h *DataCollectorHandler) UpdateDataCollector(c *gin.Context) { return } - email := "" - if dc.Email.Valid { - email = dc.Email.String - } - - certification := "" - if dc.Certification.Valid { - certification = dc.Certification.String - } - - createdAt := "" - if dc.CreatedAt.Valid { - createdAt = dc.CreatedAt.String - } - - c.JSON(http.StatusOK, DataCollectorResponse{ - ID: fmt.Sprintf("%d", dc.ID), - Name: dc.Name, - OperatorID: dc.OperatorID, - Email: email, - Certification: certification, - Status: dc.Status, - CreatedAt: createdAt, - }) + c.JSON(http.StatusOK, dataCollectorResponseFromRow(dc)) } // DeleteDataCollector handles data collector deletion requests (soft delete). @@ -479,6 +562,7 @@ func (h *DataCollectorHandler) UpdateDataCollector(c *gin.Context) { // @Success 204 // @Failure 400 {object} map[string]string // @Failure 404 {object} map[string]string +// @Failure 409 {object} map[string]string // @Failure 500 {object} map[string]string // @Router /data_collectors/{id} [delete] func (h *DataCollectorHandler) DeleteDataCollector(c *gin.Context) { @@ -503,6 +587,18 @@ func (h *DataCollectorHandler) DeleteDataCollector(c *gin.Context) { return } + var usedByStation bool + err = h.db.Get(&usedByStation, "SELECT EXISTS(SELECT 1 FROM workstations WHERE data_collector_id = ? AND deleted_at IS NULL)", id) + if err != nil { + logger.Printf("[DC] Failed to check workstations referencing data collector: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete data collector"}) + return + } + if usedByStation { + c.JSON(http.StatusConflict, gin.H{"error": "data collector is assigned to one or more workstations"}) + return + } + updatedAt := time.Now().UTC().Format("2006-01-02 15:04:05") // Perform soft delete by setting deleted_at diff --git a/internal/api/handlers/factory.go b/internal/api/handlers/factory.go index 4c898c9..15d1f2b 100644 --- a/internal/api/handlers/factory.go +++ b/internal/api/handlers/factory.go @@ -37,7 +37,7 @@ type FactoryResponse struct { Slug string `json:"slug"` Location string `json:"location,omitempty"` Timezone string `json:"timezone,omitempty"` - Settings interface{} `json:"settings,omitempty"` + Settings interface{} `json:"settings"` SceneCount int `json:"sceneCount"` CreatedAt string `json:"created_at,omitempty"` UpdatedAt string `json:"updated_at,omitempty"` @@ -92,6 +92,13 @@ type factoryRow struct { UpdatedAt sql.NullString `db:"updated_at"` } +func factorySettingsFromDB(ns sql.NullString) interface{} { + if !ns.Valid || strings.TrimSpace(ns.String) == "" { + return nil + } + return json.RawMessage(ns.String) +} + // ListFactories handles factory listing requests with filtering. // // @Summary List factories @@ -166,6 +173,7 @@ func (h *FactoryHandler) ListFactories(c *gin.Context) { Slug: f.Slug, Location: location, Timezone: timezone, + Settings: factorySettingsFromDB(f.Settings), SceneCount: f.SceneCount, CreatedAt: createdAt, }) @@ -383,6 +391,7 @@ func (h *FactoryHandler) GetFactory(c *gin.Context) { Slug: f.Slug, Location: location, Timezone: timezone, + Settings: factorySettingsFromDB(f.Settings), SceneCount: f.SceneCount, CreatedAt: createdAt, UpdatedAt: updatedAt, @@ -391,11 +400,12 @@ func (h *FactoryHandler) GetFactory(c *gin.Context) { // UpdateFactoryRequest represents the request body for updating a factory. type UpdateFactoryRequest struct { - Name *string `json:"name,omitempty"` - Slug *string `json:"slug,omitempty"` - Location *string `json:"location,omitempty"` - Timezone *string `json:"timezone,omitempty"` - Settings *json.RawMessage `json:"settings,omitempty"` + OrganizationID *string `json:"organization_id,omitempty"` + Name *string `json:"name,omitempty"` + Slug *string `json:"slug,omitempty"` + Location *string `json:"location,omitempty"` + Timezone *string `json:"timezone,omitempty"` + Settings organizationSettingsPatch `json:"settings,omitempty"` } // UpdateFactory handles updating a factory. @@ -439,6 +449,25 @@ func (h *FactoryHandler) UpdateFactory(c *gin.Context) { return } + effectiveOrgID := existing.OrganizationID + if req.OrganizationID != nil { + s := strings.TrimSpace(*req.OrganizationID) + if s != "" { + orgID, err := strconv.ParseInt(s, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid organization_id format"}) + return + } + var exists bool + err = h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM organizations WHERE id = ? AND deleted_at IS NULL)", orgID) + if err != nil || !exists { + c.JSON(http.StatusBadRequest, gin.H{"error": "organization not found"}) + return + } + effectiveOrgID = orgID + } + } + // Build update query dynamically updates := []string{} args := []interface{}{} @@ -454,9 +483,9 @@ func (h *FactoryHandler) UpdateFactory(c *gin.Context) { if req.Slug != nil { slug := strings.TrimSpace(*req.Slug) if slug != "" { - // Check if slug already exists for this organization + // Check if slug already exists for the target organization var exists bool - err := h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM factories WHERE organization_id = ? AND slug = ? AND id != ? AND deleted_at IS NULL)", existing.OrganizationID, slug, id) + err := h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM factories WHERE organization_id = ? AND slug = ? AND id != ? AND deleted_at IS NULL)", effectiveOrgID, slug, id) if err == nil && exists { c.JSON(http.StatusBadRequest, gin.H{"error": "slug already exists for this organization"}) return @@ -486,18 +515,30 @@ func (h *FactoryHandler) UpdateFactory(c *gin.Context) { args = append(args, tzStr) } - if req.Settings != nil { - var settingsStr sql.NullString - rawSettings := strings.TrimSpace(string(*req.Settings)) - if rawSettings != "" && rawSettings != "null" { - if !json.Valid([]byte(rawSettings)) { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid settings"}) + if req.Settings.present { + var raw json.RawMessage + if req.Settings.isNull { + raw = nil + } else { + raw = req.Settings.raw + } + updates = append(updates, "settings = ?") + args = append(args, sql.NullString{String: jsonStringOrEmptyObject(raw), Valid: true}) + } + + if effectiveOrgID != existing.OrganizationID { + // Moving to another org: ensure current slug is unique there when slug is not updated in this request + slugChanging := req.Slug != nil && strings.TrimSpace(*req.Slug) != "" + if !slugChanging { + var exists bool + err = h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM factories WHERE organization_id = ? AND slug = ? AND id != ? AND deleted_at IS NULL)", effectiveOrgID, existing.Slug, id) + if err == nil && exists { + c.JSON(http.StatusBadRequest, gin.H{"error": "slug already exists for this organization"}) return } - settingsStr = sql.NullString{String: rawSettings, Valid: true} } - updates = append(updates, "settings = ?") - args = append(args, settingsStr) + updates = append(updates, "organization_id = ?") + args = append(args, effectiveOrgID) } if len(updates) == 0 { @@ -554,6 +595,7 @@ func (h *FactoryHandler) UpdateFactory(c *gin.Context) { Slug: f.Slug, Location: location, Timezone: timezone, + Settings: factorySettingsFromDB(f.Settings), SceneCount: f.SceneCount, CreatedAt: createdAt, UpdatedAt: updatedAt, @@ -563,7 +605,7 @@ func (h *FactoryHandler) UpdateFactory(c *gin.Context) { // DeleteFactory handles factory deletion requests (soft delete). // // @Summary Delete factory -// @Description Soft deletes a factory by ID. Returns 400 if the factory has associated scenes. +// @Description Soft deletes a factory by ID. Returns 400 if the factory has associated scenes or robots. // @Tags factories // @Accept json // @Produce json @@ -609,6 +651,19 @@ func (h *FactoryHandler) DeleteFactory(c *gin.Context) { return } + var robotCount int + err = h.db.Get(&robotCount, "SELECT COUNT(*) FROM robots WHERE factory_id = ? AND deleted_at IS NULL", id) + if err != nil { + logger.Printf("[FACTORY] Failed to check robot count: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete factory"}) + return + } + + if robotCount > 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("cannot delete factory with %d associated robots", robotCount)}) + return + } + now := time.Now().UTC() // Perform soft delete by setting deleted_at diff --git a/internal/api/handlers/inspector.go b/internal/api/handlers/inspector.go index cfead7d..3a95a1b 100644 --- a/internal/api/handlers/inspector.go +++ b/internal/api/handlers/inspector.go @@ -6,6 +6,7 @@ package handlers import ( + "bytes" "database/sql" "encoding/json" "fmt" @@ -37,7 +38,6 @@ type InspectorResponse struct { Email string `json:"email,omitempty"` CertificationLevel string `json:"certification_level"` Status string `json:"status"` - QueueSize int `json:"queueSize"` Metadata interface{} `json:"metadata,omitempty"` CreatedAt string `json:"created_at,omitempty"` UpdatedAt string `json:"updated_at,omitempty"` @@ -50,29 +50,33 @@ type InspectorListResponse struct { // CreateInspectorRequest represents the request body for creating an inspector. type CreateInspectorRequest struct { - Name string `json:"name"` - InspectorID string `json:"inspector_id"` - Email string `json:"email,omitempty"` - CertificationLevel string `json:"certification_level,omitempty"` + Name string `json:"name"` + InspectorID string `json:"inspector_id"` + Email string `json:"email,omitempty"` + CertificationLevel string `json:"certification_level,omitempty"` + Metadata interface{} `json:"metadata,omitempty"` } // CreateInspectorResponse represents the response for creating an inspector. type CreateInspectorResponse struct { - ID string `json:"id"` - Name string `json:"name"` - InspectorID string `json:"inspector_id"` - CertificationLevel string `json:"certification_level"` - Status string `json:"status"` - CreatedAt string `json:"created_at"` + ID string `json:"id"` + Name string `json:"name"` + InspectorID string `json:"inspector_id"` + CertificationLevel string `json:"certification_level"` + Status string `json:"status"` + Metadata interface{} `json:"metadata,omitempty"` + CreatedAt string `json:"created_at"` } // UpdateInspectorRequest represents the request body for updating an inspector. +// Metadata uses json.RawMessage so callers can distinguish omitted key vs explicit JSON null (stored as {}). type UpdateInspectorRequest struct { - Name *string `json:"name,omitempty"` - Email *string `json:"email,omitempty"` - CertificationLevel *string `json:"certification_level,omitempty"` - Status *string `json:"status,omitempty"` - Metadata interface{} `json:"metadata,omitempty"` + InspectorID *string `json:"inspector_id,omitempty"` + Name *string `json:"name,omitempty"` + Email *string `json:"email,omitempty"` + CertificationLevel *string `json:"certification_level,omitempty"` + Status *string `json:"status,omitempty"` + Metadata json.RawMessage `json:"metadata,omitempty"` } // RegisterRoutes registers inspector related routes. @@ -171,7 +175,6 @@ func (h *InspectorHandler) ListInspectors(c *gin.Context) { Email: email, CertificationLevel: certLevel, Status: i.Status, - QueueSize: 0, Metadata: metadata, CreatedAt: createdAt, UpdatedAt: updatedAt, @@ -258,7 +261,6 @@ func (h *InspectorHandler) GetInspector(c *gin.Context) { Email: email, CertificationLevel: certLevel, Status: i.Status, - QueueSize: 0, Metadata: metadata, CreatedAt: createdAt, UpdatedAt: updatedAt, @@ -326,6 +328,16 @@ func (h *InspectorHandler) CreateInspector(c *gin.Context) { emailStr = sql.NullString{String: req.Email, Valid: true} } + metadataStr := sql.NullString{String: "{}", Valid: true} + if req.Metadata != nil { + metadataJSON, err := json.Marshal(req.Metadata) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid metadata JSON"}) + return + } + metadataStr = sql.NullString{String: string(metadataJSON), Valid: true} + } + now := time.Now().UTC() result, err := h.db.Exec( @@ -335,14 +347,16 @@ func (h *InspectorHandler) CreateInspector(c *gin.Context) { email, certification_level, status, + metadata, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?)`, + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, req.Name, req.InspectorID, emailStr, certLevel, "active", + metadataStr, now, now, ) @@ -359,12 +373,18 @@ func (h *InspectorHandler) CreateInspector(c *gin.Context) { return } + var metaOut interface{} + if metadataStr.Valid { + metaOut = parseJSONRaw(metadataStr.String) + } + c.JSON(http.StatusCreated, CreateInspectorResponse{ ID: fmt.Sprintf("%d", id), Name: req.Name, InspectorID: req.InspectorID, CertificationLevel: certLevel, Status: "active", + Metadata: metaOut, CreatedAt: now.Format(time.RFC3339), }) } @@ -412,6 +432,27 @@ func (h *InspectorHandler) UpdateInspector(c *gin.Context) { updates := []string{} args := []interface{}{} + if req.InspectorID != nil { + inspectorID := strings.TrimSpace(*req.InspectorID) + if inspectorID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "inspector_id cannot be empty"}) + return + } + var taken bool + err = h.db.Get(&taken, "SELECT EXISTS(SELECT 1 FROM inspectors WHERE inspector_id = ? AND id != ? AND deleted_at IS NULL)", inspectorID, id) + if err != nil { + logger.Printf("[INSPECTOR] Failed to check inspector_id: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update inspector"}) + return + } + if taken { + c.JSON(http.StatusBadRequest, gin.H{"error": "inspector_id already exists"}) + return + } + updates = append(updates, "inspector_id = ?") + args = append(args, inspectorID) + } + if req.Name != nil { name := strings.TrimSpace(*req.Name) if name != "" { @@ -450,11 +491,19 @@ func (h *InspectorHandler) UpdateInspector(c *gin.Context) { args = append(args, status) } - if req.Metadata != nil { - metadataJSON, err := json.Marshal(req.Metadata) - if err == nil { + if len(req.Metadata) > 0 { + meta := bytes.TrimSpace(req.Metadata) + if bytes.Equal(meta, []byte("null")) { + updates = append(updates, "metadata = ?") + args = append(args, sql.NullString{String: "{}", Valid: true}) + } else { + var probe interface{} + if err := json.Unmarshal(req.Metadata, &probe); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid metadata JSON"}) + return + } updates = append(updates, "metadata = ?") - args = append(args, sql.NullString{String: string(metadataJSON), Valid: true}) + args = append(args, sql.NullString{String: string(req.Metadata), Valid: true}) } } @@ -520,7 +569,6 @@ func (h *InspectorHandler) UpdateInspector(c *gin.Context) { Email: email, CertificationLevel: certLevel, Status: i.Status, - QueueSize: 0, Metadata: metadata, CreatedAt: createdAt, UpdatedAt: updatedAt, diff --git a/internal/api/handlers/organization.go b/internal/api/handlers/organization.go index 511193d..3976266 100644 --- a/internal/api/handlers/organization.go +++ b/internal/api/handlers/organization.go @@ -63,12 +63,30 @@ type CreateOrganizationResponse struct { CreatedAt string `json:"created_at"` } +// organizationSettingsPatch distinguishes a missing "settings" key from JSON null. +// present: key appeared in the body; isNull: value was JSON null (stored as {}). +type organizationSettingsPatch struct { + present bool + isNull bool + raw json.RawMessage +} + +func (p *organizationSettingsPatch) UnmarshalJSON(data []byte) error { + p.present = true + if string(data) == "null" { + p.isNull = true + return nil + } + p.raw = append(json.RawMessage(nil), data...) + return nil +} + // UpdateOrganizationRequest represents the request body for updating an organization. type UpdateOrganizationRequest struct { - Name string `json:"name,omitempty"` - Slug string `json:"slug,omitempty"` - Description *string `json:"description,omitempty"` - Settings interface{} `json:"settings,omitempty"` + Name string `json:"name,omitempty"` + Slug string `json:"slug,omitempty"` + Description *string `json:"description,omitempty"` + Settings organizationSettingsPatch `json:"settings,omitempty"` } // RegisterRoutes registers organization related routes. @@ -428,12 +446,15 @@ func (h *OrganizationHandler) UpdateOrganization(c *gin.Context) { args = append(args, descStr) } - if req.Settings != nil { - settingsJSON, err := json.Marshal(req.Settings) - if err == nil { - updates = append(updates, "settings = ?") - args = append(args, sql.NullString{String: string(settingsJSON), Valid: true}) + if req.Settings.present { + var raw json.RawMessage + if req.Settings.isNull { + raw = nil + } else { + raw = req.Settings.raw } + updates = append(updates, "settings = ?") + args = append(args, sql.NullString{String: jsonStringOrEmptyObject(raw), Valid: true}) } if len(updates) == 0 { diff --git a/internal/api/handlers/robot.go b/internal/api/handlers/robot.go index b558f0a..8cb3ceb 100644 --- a/internal/api/handlers/robot.go +++ b/internal/api/handlers/robot.go @@ -6,7 +6,9 @@ package handlers import ( + "bytes" "database/sql" + "encoding/json" "fmt" "net/http" "strconv" @@ -37,14 +39,17 @@ func NewRobotHandler(db *sqlx.DB, recorderHub *services.RecorderHub, transferHub // RobotResponse represents a robot in the response. type RobotResponse struct { - ID string `json:"id"` - RobotTypeID string `json:"robot_type_id"` - DeviceID string `json:"device_id"` - FactoryID string `json:"factory_id"` - Status string `json:"status"` - CreatedAt string `json:"created_at,omitempty"` - Connected bool `json:"connected"` - ConnectedAt string `json:"connected_at,omitempty"` + ID string `json:"id"` + RobotTypeID string `json:"robot_type_id"` + DeviceID string `json:"device_id"` + FactoryID string `json:"factory_id"` + AssetID string `json:"asset_id,omitempty"` + Status string `json:"status"` + Metadata interface{} `json:"metadata,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + Connected bool `json:"connected"` + ConnectedAt string `json:"connected_at,omitempty"` } // RobotListResponse represents the response for listing robots. @@ -54,19 +59,24 @@ type RobotListResponse struct { // CreateRobotRequest represents the request body for creating a robot. type CreateRobotRequest struct { - RobotTypeID string `json:"robot_type_id"` - DeviceID string `json:"device_id"` - FactoryID string `json:"factory_id"` + RobotTypeID string `json:"robot_type_id"` + DeviceID string `json:"device_id"` + FactoryID string `json:"factory_id"` + AssetID string `json:"asset_id,omitempty"` + Metadata interface{} `json:"metadata,omitempty"` } // CreateRobotResponse represents the response for creating a robot. type CreateRobotResponse struct { - ID string `json:"id"` - RobotTypeID string `json:"robot_type_id"` - DeviceID string `json:"device_id"` - FactoryID string `json:"factory_id"` - Status string `json:"status"` - CreatedAt string `json:"created_at"` + ID string `json:"id"` + RobotTypeID string `json:"robot_type_id"` + DeviceID string `json:"device_id"` + FactoryID string `json:"factory_id"` + AssetID string `json:"asset_id,omitempty"` + Status string `json:"status"` + Metadata interface{} `json:"metadata,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at,omitempty"` } // RegisterRoutes registers robot related routes. @@ -84,8 +94,64 @@ type robotRow struct { RobotTypeID int64 `db:"robot_type_id"` DeviceID string `db:"device_id"` FactoryID int64 `db:"factory_id"` + AssetID sql.NullString `db:"asset_id"` Status string `db:"status"` + Metadata sql.NullString `db:"metadata"` CreatedAt sql.NullString `db:"created_at"` + UpdatedAt sql.NullString `db:"updated_at"` +} + +func robotMetadataFromDB(ns sql.NullString) interface{} { + if !ns.Valid || strings.TrimSpace(ns.String) == "" { + return nil + } + return parseJSONRaw(ns.String) +} + +func (h *RobotHandler) connectionState(deviceID string) (connected bool, connectedAt string) { + if h.recorderHub == nil || h.transferHub == nil { + return false, "" + } + recConn := h.recorderHub.Get(deviceID) + transConn := h.transferHub.Get(deviceID) + connected = recConn != nil && transConn != nil + if !connected { + return false, "" + } + t := recConn.ConnectedAt + if transConn.ConnectedAt.After(t) { + t = transConn.ConnectedAt + } + return true, t.UTC().Format(time.RFC3339) +} + +func (h *RobotHandler) responseFromRow(r robotRow) RobotResponse { + createdAt := "" + if r.CreatedAt.Valid { + createdAt = r.CreatedAt.String + } + updatedAt := "" + if r.UpdatedAt.Valid { + updatedAt = r.UpdatedAt.String + } + assetID := "" + if r.AssetID.Valid { + assetID = r.AssetID.String + } + connected, connectedAt := h.connectionState(r.DeviceID) + return RobotResponse{ + ID: fmt.Sprintf("%d", r.ID), + RobotTypeID: fmt.Sprintf("%d", r.RobotTypeID), + DeviceID: r.DeviceID, + FactoryID: fmt.Sprintf("%d", r.FactoryID), + AssetID: assetID, + Status: r.Status, + Metadata: robotMetadataFromDB(r.Metadata), + CreatedAt: createdAt, + UpdatedAt: updatedAt, + Connected: connected, + ConnectedAt: connectedAt, + } } // ListRobots handles robot listing requests with filtering. @@ -125,8 +191,11 @@ func (h *RobotHandler) ListRobots(c *gin.Context) { r.robot_type_id, r.device_id, r.factory_id, + r.asset_id, r.status, - r.created_at + r.metadata, + r.created_at, + r.updated_at FROM robots r WHERE r.deleted_at IS NULL ` @@ -170,42 +239,11 @@ func (h *RobotHandler) ListRobots(c *gin.Context) { robots := []RobotResponse{} for _, r := range dbRows { - createdAt := "" - if r.CreatedAt.Valid { - createdAt = r.CreatedAt.String - } - - connected := false - connectedAt := "" - if h.recorderHub != nil && h.transferHub != nil { - recConn := h.recorderHub.Get(r.DeviceID) - transConn := h.transferHub.Get(r.DeviceID) - connected = recConn != nil && transConn != nil - - // Only compute connectedAt when connected=true - if connected { - t := recConn.ConnectedAt - if transConn.ConnectedAt.After(t) { - t = transConn.ConnectedAt - } - connectedAt = t.UTC().Format(time.RFC3339) - } - } - - if connectedFilter != nil && connected != *connectedFilter { + resp := h.responseFromRow(r) + if connectedFilter != nil && resp.Connected != *connectedFilter { continue } - - robots = append(robots, RobotResponse{ - ID: fmt.Sprintf("%d", r.ID), - RobotTypeID: fmt.Sprintf("%d", r.RobotTypeID), - DeviceID: r.DeviceID, - FactoryID: fmt.Sprintf("%d", r.FactoryID), - Status: r.Status, - CreatedAt: createdAt, - Connected: connected, - ConnectedAt: connectedAt, - }) + robots = append(robots, resp) } c.JSON(http.StatusOK, RobotListResponse{ @@ -283,20 +321,39 @@ func (h *RobotHandler) CreateRobot(c *gin.Context) { // Generate created_at timestamp in application layer createdAt := time.Now().UTC().Format("2006-01-02 15:04:05") + var assetIDStr sql.NullString + if a := strings.TrimSpace(req.AssetID); a != "" { + assetIDStr = sql.NullString{String: a, Valid: true} + } + + metadataStr := sql.NullString{String: "{}", Valid: true} + if req.Metadata != nil { + metadataJSON, err := json.Marshal(req.Metadata) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid metadata JSON"}) + return + } + metadataStr = sql.NullString{String: string(metadataJSON), Valid: true} + } + // Insert the robot result, err := h.db.Exec( `INSERT INTO robots ( robot_type_id, device_id, factory_id, + asset_id, status, + metadata, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?)`, + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, robotTypeID, req.DeviceID, factoryID, + assetIDStr, "active", + metadataStr, createdAt, createdAt, ) @@ -313,13 +370,38 @@ func (h *RobotHandler) CreateRobot(c *gin.Context) { return } + var row robotRow + err = h.db.Get(&row, ` + SELECT + r.id, + r.robot_type_id, + r.device_id, + r.factory_id, + r.asset_id, + r.status, + r.metadata, + r.created_at, + r.updated_at + FROM robots r + WHERE r.id = ? AND r.deleted_at IS NULL + `, id) + if err != nil { + logger.Printf("[ROBOT] Failed to load created robot: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create robot"}) + return + } + + resp := h.responseFromRow(row) c.JSON(http.StatusCreated, CreateRobotResponse{ - ID: fmt.Sprintf("%d", id), - RobotTypeID: req.RobotTypeID, - DeviceID: req.DeviceID, - FactoryID: req.FactoryID, - Status: "active", - CreatedAt: createdAt, + ID: resp.ID, + RobotTypeID: resp.RobotTypeID, + DeviceID: resp.DeviceID, + FactoryID: resp.FactoryID, + AssetID: resp.AssetID, + Status: resp.Status, + Metadata: resp.Metadata, + CreatedAt: resp.CreatedAt, + UpdatedAt: resp.UpdatedAt, }) } @@ -350,8 +432,11 @@ func (h *RobotHandler) GetRobot(c *gin.Context) { r.robot_type_id, r.device_id, r.factory_id, + r.asset_id, r.status, - r.created_at + r.metadata, + r.created_at, + r.updated_at FROM robots r WHERE r.id = ? AND r.deleted_at IS NULL ` @@ -367,45 +452,18 @@ func (h *RobotHandler) GetRobot(c *gin.Context) { return } - createdAt := "" - if r.CreatedAt.Valid { - createdAt = r.CreatedAt.String - } - - connected := false - connectedAt := "" - if h.recorderHub != nil && h.transferHub != nil { - recConn := h.recorderHub.Get(r.DeviceID) - transConn := h.transferHub.Get(r.DeviceID) - connected = recConn != nil && transConn != nil - - if connected { - t := recConn.ConnectedAt - if transConn.ConnectedAt.After(t) { - t = transConn.ConnectedAt - } - connectedAt = t.UTC().Format(time.RFC3339) - } - } - - c.JSON(http.StatusOK, RobotResponse{ - ID: fmt.Sprintf("%d", r.ID), - RobotTypeID: fmt.Sprintf("%d", r.RobotTypeID), - DeviceID: r.DeviceID, - FactoryID: fmt.Sprintf("%d", r.FactoryID), - Status: r.Status, - CreatedAt: createdAt, - Connected: connected, - ConnectedAt: connectedAt, - }) + c.JSON(http.StatusOK, h.responseFromRow(r)) } // UpdateRobotRequest represents the request body for updating a robot. +// Metadata uses json.RawMessage so we can tell: key omitted (no change) vs explicit JSON null (store {}). type UpdateRobotRequest struct { - RobotTypeID *string `json:"robot_type_id,omitempty"` - DeviceID *string `json:"device_id,omitempty"` - FactoryID *string `json:"factory_id,omitempty"` - Status *string `json:"status,omitempty"` + RobotTypeID *string `json:"robot_type_id,omitempty"` + DeviceID *string `json:"device_id,omitempty"` + FactoryID *string `json:"factory_id,omitempty"` + AssetID *string `json:"asset_id,omitempty"` + Status *string `json:"status,omitempty"` + Metadata json.RawMessage `json:"metadata,omitempty"` } // UpdateRobot handles updating a robot. @@ -507,6 +565,16 @@ func (h *RobotHandler) UpdateRobot(c *gin.Context) { args = append(args, parsedFactoryID) } + if req.AssetID != nil { + trimmed := strings.TrimSpace(*req.AssetID) + var a sql.NullString + if trimmed != "" { + a = sql.NullString{String: trimmed, Valid: true} + } + updates = append(updates, "asset_id = ?") + args = append(args, a) + } + if req.Status != nil { status := strings.TrimSpace(*req.Status) if !validStatuses[status] { @@ -517,6 +585,22 @@ func (h *RobotHandler) UpdateRobot(c *gin.Context) { args = append(args, status) } + if len(req.Metadata) > 0 { + meta := bytes.TrimSpace(req.Metadata) + if bytes.Equal(meta, []byte("null")) { + updates = append(updates, "metadata = ?") + args = append(args, sql.NullString{String: "{}", Valid: true}) + } else { + var probe interface{} + if err := json.Unmarshal(req.Metadata, &probe); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid metadata JSON"}) + return + } + updates = append(updates, "metadata = ?") + args = append(args, sql.NullString{String: string(req.Metadata), Valid: true}) + } + } + if len(updates) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) return @@ -542,10 +626,13 @@ func (h *RobotHandler) UpdateRobot(c *gin.Context) { r.robot_type_id, r.device_id, r.factory_id, + r.asset_id, r.status, - r.created_at + r.metadata, + r.created_at, + r.updated_at FROM robots r - WHERE r.id = ? + WHERE r.id = ? AND r.deleted_at IS NULL `, id) if err != nil { logger.Printf("[ROBOT] Failed to fetch updated robot: %v", err) @@ -553,37 +640,7 @@ func (h *RobotHandler) UpdateRobot(c *gin.Context) { return } - createdAt := "" - if r.CreatedAt.Valid { - createdAt = r.CreatedAt.String - } - - connected := false - connectedAt := "" - if h.recorderHub != nil && h.transferHub != nil { - recConn := h.recorderHub.Get(r.DeviceID) - transConn := h.transferHub.Get(r.DeviceID) - connected = recConn != nil && transConn != nil - - if connected { - t := recConn.ConnectedAt - if transConn.ConnectedAt.After(t) { - t = transConn.ConnectedAt - } - connectedAt = t.UTC().Format(time.RFC3339) - } - } - - c.JSON(http.StatusOK, RobotResponse{ - ID: fmt.Sprintf("%d", r.ID), - RobotTypeID: fmt.Sprintf("%d", r.RobotTypeID), - DeviceID: r.DeviceID, - FactoryID: fmt.Sprintf("%d", r.FactoryID), - Status: r.Status, - CreatedAt: createdAt, - Connected: connected, - ConnectedAt: connectedAt, - }) + c.JSON(http.StatusOK, h.responseFromRow(r)) } // DeleteRobot handles robot deletion requests (soft delete). @@ -597,6 +654,7 @@ func (h *RobotHandler) UpdateRobot(c *gin.Context) { // @Success 204 // @Failure 400 {object} map[string]string // @Failure 404 {object} map[string]string +// @Failure 409 {object} map[string]string // @Failure 500 {object} map[string]string // @Router /robots/{id} [delete] func (h *RobotHandler) DeleteRobot(c *gin.Context) { @@ -621,6 +679,18 @@ func (h *RobotHandler) DeleteRobot(c *gin.Context) { return } + var usedByStation bool + err = h.db.Get(&usedByStation, "SELECT EXISTS(SELECT 1 FROM workstations WHERE robot_id = ? AND deleted_at IS NULL)", id) + if err != nil { + logger.Printf("[ROBOT] Failed to check workstations referencing robot: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete robot"}) + return + } + if usedByStation { + c.JSON(http.StatusConflict, gin.H{"error": "robot is assigned to one or more workstations"}) + return + } + updatedAt := time.Now().UTC().Format("2006-01-02 15:04:05") // Perform soft delete by setting deleted_at diff --git a/internal/api/handlers/robot_type.go b/internal/api/handlers/robot_type.go index 4af6235..07c19d6 100644 --- a/internal/api/handlers/robot_type.go +++ b/internal/api/handlers/robot_type.go @@ -31,28 +31,27 @@ func NewRobotTypeHandler(db *sqlx.DB) *RobotTypeHandler { // CreateRobotTypeRequest represents the request body for creating a robot type. type CreateRobotTypeRequest struct { - Name string `json:"name"` - Model string `json:"model"` - ROSTopics []string `json:"ros_topics"` -} - -// CreateRobotTypeResponse represents the response body for creating a robot type. -type CreateRobotTypeResponse struct { - ID int64 `json:"id"` - Name string `json:"name"` - Model string `json:"model"` - ROSTopics []string `json:"ros_topics"` - CreatedAt string `json:"created_at"` + Name string `json:"name"` + Model string `json:"model"` + Manufacturer *string `json:"manufacturer,omitempty"` + EndEffector *string `json:"end_effector,omitempty"` + SensorSuite json.RawMessage `json:"sensor_suite,omitempty"` + ROSTopics []string `json:"ros_topics"` + Capabilities json.RawMessage `json:"capabilities,omitempty"` } // RobotTypeResponse represents a robot type in the response. type RobotTypeResponse struct { - ID int64 `json:"id"` - Name string `json:"name"` - Model string `json:"model"` - ROSTopics []string `json:"ros_topics"` - CreatedAt string `json:"created_at,omitempty"` - UpdatedAt string `json:"updated_at,omitempty"` + ID int64 `json:"id"` + Name string `json:"name"` + Model string `json:"model"` + Manufacturer *string `json:"manufacturer,omitempty"` + EndEffector *string `json:"end_effector,omitempty"` + SensorSuite *json.RawMessage `json:"sensor_suite,omitempty"` + ROSTopics []string `json:"ros_topics"` + Capabilities *json.RawMessage `json:"capabilities,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` } // RobotTypeListResponse represents the response for listing robot types. @@ -71,14 +70,114 @@ func (h *RobotTypeHandler) RegisterRoutes(apiV1 *gin.RouterGroup) { // robotTypeRow represents a robot type in the database type robotTypeRow struct { - ID int64 `db:"id"` - Name string `db:"name"` - Model string `db:"model"` - ROSTopics sql.NullString `db:"ros_topics"` - CreatedAt sql.NullString `db:"created_at"` - UpdatedAt sql.NullString `db:"updated_at"` + ID int64 `db:"id"` + Name string `db:"name"` + Model string `db:"model"` + Manufacturer sql.NullString `db:"manufacturer"` + EndEffector sql.NullString `db:"end_effector"` + SensorSuite sql.NullString `db:"sensor_suite"` + ROSTopics sql.NullString `db:"ros_topics"` + Capabilities sql.NullString `db:"capabilities"` + CreatedAt sql.NullString `db:"created_at"` + UpdatedAt sql.NullString `db:"updated_at"` +} + +func sqlNullStringFromOptionalPtr(s *string) sql.NullString { + if s == nil { + return sql.NullString{Valid: false} + } + v := strings.TrimSpace(*s) + if v == "" { + return sql.NullString{Valid: false} + } + return sql.NullString{String: v, Valid: true} +} + +func sqlNullJSONFromRaw(raw json.RawMessage) sql.NullString { + if len(raw) == 0 { + return sql.NullString{Valid: false} + } + s := strings.TrimSpace(string(raw)) + if s == "" || s == "null" { + return sql.NullString{Valid: false} + } + return sql.NullString{String: s, Valid: true} +} + +// jsonStringOrEmptyObject returns JSON text for sensor_suite/capabilities. +// Empty or null raw defaults to {}. +func jsonStringOrEmptyObject(raw json.RawMessage) string { + ns := sqlNullJSONFromRaw(raw) + if !ns.Valid { + return "{}" + } + return ns.String +} + +// jsonColumnForCreate stores sensor_suite/capabilities on INSERT. +// If the client omits the field or sends empty/null, defaults to {}. +func jsonColumnForCreate(raw json.RawMessage) sql.NullString { + return sql.NullString{String: jsonStringOrEmptyObject(raw), Valid: true} } +func stringPtrFromNull(ns sql.NullString) *string { + if !ns.Valid { + return nil + } + s := ns.String + return &s +} + +func ptrRawJSONFromNull(ns sql.NullString) *json.RawMessage { + if !ns.Valid || strings.TrimSpace(ns.String) == "" { + return nil + } + r := json.RawMessage(ns.String) + return &r +} + +func robotTypeRowToResponse(rt robotTypeRow) RobotTypeResponse { + var topics []string + if rt.ROSTopics.Valid && rt.ROSTopics.String != "" { + topics = parseJSONArray(rt.ROSTopics.String) + } + + createdAt := "" + if rt.CreatedAt.Valid { + createdAt = rt.CreatedAt.String + } + + updatedAt := "" + if rt.UpdatedAt.Valid { + updatedAt = rt.UpdatedAt.String + } + + return RobotTypeResponse{ + ID: rt.ID, + Name: rt.Name, + Model: rt.Model, + Manufacturer: stringPtrFromNull(rt.Manufacturer), + EndEffector: stringPtrFromNull(rt.EndEffector), + SensorSuite: ptrRawJSONFromNull(rt.SensorSuite), + ROSTopics: topics, + Capabilities: ptrRawJSONFromNull(rt.Capabilities), + CreatedAt: createdAt, + UpdatedAt: updatedAt, + } +} + +const robotTypeSelectColumns = ` + id, + name, + model, + manufacturer, + end_effector, + sensor_suite, + ros_topics, + capabilities, + created_at, + updated_at` + // CreateRobotType handles robot type creation requests. // // @Summary Create robot type @@ -87,7 +186,7 @@ type robotTypeRow struct { // @Accept json // @Produce json // @Param body body CreateRobotTypeRequest true "Robot type payload" -// @Success 201 {object} CreateRobotTypeResponse +// @Success 201 {object} RobotTypeResponse // @Failure 400 {object} map[string]string // @Failure 500 {object} map[string]string // @Router /robot_types [post] @@ -117,13 +216,21 @@ func (h *RobotTypeHandler) CreateRobotType(c *gin.Context) { `INSERT INTO robot_types ( name, model, + manufacturer, + end_effector, + sensor_suite, ros_topics, + capabilities, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?)`, + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, req.Name, req.Model, + sqlNullStringFromOptionalPtr(req.Manufacturer), + sqlNullStringFromOptionalPtr(req.EndEffector), + jsonColumnForCreate(req.SensorSuite), toNullableJSONArray(req.ROSTopics), + jsonColumnForCreate(req.Capabilities), now, now, ) @@ -140,13 +247,15 @@ func (h *RobotTypeHandler) CreateRobotType(c *gin.Context) { return } - c.JSON(http.StatusCreated, CreateRobotTypeResponse{ - ID: id, - Name: req.Name, - Model: req.Model, - ROSTopics: req.ROSTopics, - CreatedAt: now.Format(time.RFC3339), - }) + var rt robotTypeRow + err = h.db.Get(&rt, `SELECT `+robotTypeSelectColumns+` FROM robot_types WHERE id = ?`, id) + if err != nil { + logger.Printf("[ROBOT] Failed to fetch created robot type: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create robot type"}) + return + } + + c.JSON(http.StatusCreated, robotTypeRowToResponse(rt)) } // ListRobotTypes handles robot type listing requests. @@ -161,19 +270,12 @@ func (h *RobotTypeHandler) CreateRobotType(c *gin.Context) { // @Router /robot_types [get] func (h *RobotTypeHandler) ListRobotTypes(c *gin.Context) { query := ` - SELECT - id, - name, - model, - ros_topics, - created_at, - updated_at + SELECT ` + robotTypeSelectColumns + ` FROM robot_types WHERE deleted_at IS NULL ORDER BY id DESC ` - // Use db.Select for cleaner code and automatic resource management var dbRows []robotTypeRow if err := h.db.Select(&dbRows, query); err != nil { logger.Printf("[ROBOT] Failed to query robot types: %v", err) @@ -181,32 +283,9 @@ func (h *RobotTypeHandler) ListRobotTypes(c *gin.Context) { return } - robotTypes := []RobotTypeResponse{} + robotTypes := make([]RobotTypeResponse, 0, len(dbRows)) for _, rt := range dbRows { - // Parse ROS topics from JSON array - var topics []string - if rt.ROSTopics.Valid && rt.ROSTopics.String != "" { - topics = parseJSONArray(rt.ROSTopics.String) - } - - createdAt := "" - if rt.CreatedAt.Valid { - createdAt = rt.CreatedAt.String - } - - updatedAt := "" - if rt.UpdatedAt.Valid { - updatedAt = rt.UpdatedAt.String - } - - robotTypes = append(robotTypes, RobotTypeResponse{ - ID: rt.ID, - Name: rt.Name, - Model: rt.Model, - ROSTopics: topics, - CreatedAt: createdAt, - UpdatedAt: updatedAt, - }) + robotTypes = append(robotTypes, robotTypeRowToResponse(rt)) } c.JSON(http.StatusOK, RobotTypeListResponse{ @@ -259,13 +338,7 @@ func (h *RobotTypeHandler) GetRobotType(c *gin.Context) { } query := ` - SELECT - id, - name, - model, - ros_topics, - created_at, - updated_at + SELECT ` + robotTypeSelectColumns + ` FROM robot_types WHERE id = ? AND deleted_at IS NULL ` @@ -281,36 +354,18 @@ func (h *RobotTypeHandler) GetRobotType(c *gin.Context) { return } - var topics []string - if rt.ROSTopics.Valid && rt.ROSTopics.String != "" { - topics = parseJSONArray(rt.ROSTopics.String) - } - - createdAt := "" - if rt.CreatedAt.Valid { - createdAt = rt.CreatedAt.String - } - - updatedAt := "" - if rt.UpdatedAt.Valid { - updatedAt = rt.UpdatedAt.String - } - - c.JSON(http.StatusOK, RobotTypeResponse{ - ID: rt.ID, - Name: rt.Name, - Model: rt.Model, - ROSTopics: topics, - CreatedAt: createdAt, - UpdatedAt: updatedAt, - }) + c.JSON(http.StatusOK, robotTypeRowToResponse(rt)) } // UpdateRobotTypeRequest represents the request body for updating a robot type. type UpdateRobotTypeRequest struct { - Name *string `json:"name,omitempty"` - Model *string `json:"model,omitempty"` - ROSTopics *[]string `json:"ros_topics,omitempty"` + Name *string `json:"name,omitempty"` + Model *string `json:"model,omitempty"` + Manufacturer *string `json:"manufacturer,omitempty"` + EndEffector *string `json:"end_effector,omitempty"` + SensorSuite *json.RawMessage `json:"sensor_suite,omitempty"` + ROSTopics *[]string `json:"ros_topics,omitempty"` + Capabilities *json.RawMessage `json:"capabilities,omitempty"` } // UpdateRobotType handles updating a robot type. @@ -361,11 +416,43 @@ func (h *RobotTypeHandler) UpdateRobotType(c *gin.Context) { } } + if req.Manufacturer != nil { + v := strings.TrimSpace(*req.Manufacturer) + if v == "" { + updates = append(updates, "manufacturer = NULL") + } else { + updates = append(updates, "manufacturer = ?") + args = append(args, v) + } + } + + if req.EndEffector != nil { + v := strings.TrimSpace(*req.EndEffector) + if v == "" { + updates = append(updates, "end_effector = NULL") + } else { + updates = append(updates, "end_effector = ?") + args = append(args, v) + } + } + + if req.SensorSuite != nil { + raw := *req.SensorSuite + updates = append(updates, "sensor_suite = ?") + args = append(args, jsonStringOrEmptyObject(raw)) + } + if req.ROSTopics != nil { updates = append(updates, "ros_topics = ?") args = append(args, toNullableJSONArray(*req.ROSTopics)) } + if req.Capabilities != nil { + raw := *req.Capabilities + updates = append(updates, "capabilities = ?") + args = append(args, jsonStringOrEmptyObject(raw)) + } + if len(updates) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) return @@ -391,38 +478,15 @@ func (h *RobotTypeHandler) UpdateRobotType(c *gin.Context) { return } - // Fetch the updated robot type var rt robotTypeRow - err = h.db.Get(&rt, "SELECT id, name, model, ros_topics, created_at, updated_at FROM robot_types WHERE id = ?", id) + err = h.db.Get(&rt, "SELECT "+robotTypeSelectColumns+" FROM robot_types WHERE id = ?", id) if err != nil { logger.Printf("[ROBOT] Failed to fetch updated robot type: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get updated robot type"}) return } - var topics []string - if rt.ROSTopics.Valid && rt.ROSTopics.String != "" { - topics = parseJSONArray(rt.ROSTopics.String) - } - - createdAt := "" - if rt.CreatedAt.Valid { - createdAt = rt.CreatedAt.String - } - - updatedAt := "" - if rt.UpdatedAt.Valid { - updatedAt = rt.UpdatedAt.String - } - - c.JSON(http.StatusOK, RobotTypeResponse{ - ID: rt.ID, - Name: rt.Name, - Model: rt.Model, - ROSTopics: topics, - CreatedAt: createdAt, - UpdatedAt: updatedAt, - }) + c.JSON(http.StatusOK, robotTypeRowToResponse(rt)) } // DeleteRobotType handles robot type deletion requests (soft delete). @@ -436,6 +500,7 @@ func (h *RobotTypeHandler) UpdateRobotType(c *gin.Context) { // @Success 204 // @Failure 400 {object} map[string]string // @Failure 404 {object} map[string]string +// @Failure 409 {object} map[string]string // @Failure 500 {object} map[string]string // @Router /robot_types/{id} [delete] func (h *RobotTypeHandler) DeleteRobotType(c *gin.Context) { @@ -460,6 +525,18 @@ func (h *RobotTypeHandler) DeleteRobotType(c *gin.Context) { return } + var robotsUseType bool + err = h.db.Get(&robotsUseType, "SELECT EXISTS(SELECT 1 FROM robots WHERE robot_type_id = ? AND deleted_at IS NULL)", id) + if err != nil { + logger.Printf("[ROBOT] Failed to check robots referencing robot type: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete robot type"}) + return + } + if robotsUseType { + c.JSON(http.StatusConflict, gin.H{"error": "robot type is in used by one or more robots"}) + return + } + now := time.Now().UTC() // Perform soft delete by setting deleted_at diff --git a/internal/api/handlers/station.go b/internal/api/handlers/station.go index 6bde995..aaf8902 100644 --- a/internal/api/handlers/station.go +++ b/internal/api/handlers/station.go @@ -6,7 +6,9 @@ package handlers import ( + "bytes" "database/sql" + "encoding/json" "fmt" "net/http" "strconv" @@ -30,25 +32,40 @@ func NewStationHandler(db *sqlx.DB) *StationHandler { // CreateStationRequest represents the request body for creating a station. type CreateStationRequest struct { - RobotID string `json:"robot_id"` - DataCollectorID string `json:"data_collector_id"` - Name string `json:"name"` + RobotID string `json:"robot_id"` + DataCollectorID string `json:"data_collector_id"` + Name string `json:"name"` + Metadata interface{} `json:"metadata,omitempty"` } // UpdateStationRequest represents the request body for updating a station. +// Optional fields use pointers / json.RawMessage so callers can omit keys. type UpdateStationRequest struct { - Status string `json:"status"` + Name *string `json:"name,omitempty"` + RobotID *string `json:"robot_id,omitempty"` + DataCollectorID *string `json:"data_collector_id,omitempty"` + Status *string `json:"status,omitempty"` + Metadata json.RawMessage `json:"metadata,omitempty"` } // StationResponse represents a station in the response. type StationResponse struct { - ID string `json:"id"` - RobotID string `json:"robot_id"` - DataCollectorID string `json:"data_collector_id"` - FactoryID string `json:"factory_id"` - Status string `json:"status"` - Name string `json:"name"` - CreatedAt string `json:"created_at"` + ID string `json:"id"` + RobotID string `json:"robot_id"` + DataCollectorID string `json:"data_collector_id"` + FactoryID string `json:"factory_id"` + Status string `json:"status"` + Name string `json:"name"` + Metadata interface{} `json:"metadata,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +func stationMetadataFromDB(ns sql.NullString) interface{} { + if !ns.Valid || strings.TrimSpace(ns.String) == "" { + return nil + } + return parseJSONRaw(ns.String) } // RegisterRoutes registers station related routes. @@ -239,6 +256,16 @@ func (h *StationHandler) CreateStation(c *gin.Context) { // Generate created_at timestamp createdAt := time.Now().UTC().Format("2006-01-02 15:04:05") + metadataStr := sql.NullString{String: "{}", Valid: true} + if req.Metadata != nil { + metadataJSON, err := json.Marshal(req.Metadata) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid metadata JSON"}) + return + } + metadataStr = sql.NullString{String: string(metadataJSON), Valid: true} + } + // Insert the workstation (station) result, err := h.db.Exec(` INSERT INTO workstations ( @@ -251,9 +278,10 @@ func (h *StationHandler) CreateStation(c *gin.Context) { factory_id, name, status, + metadata, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, robotInfo.ID, robotType.Name, // robot_name from robot_types.name @@ -264,6 +292,7 @@ func (h *StationHandler) CreateStation(c *gin.Context) { robotInfo.FactoryID, req.Name, "inactive", + metadataStr, createdAt, createdAt, ) @@ -283,14 +312,21 @@ func (h *StationHandler) CreateStation(c *gin.Context) { // Format created_at for response in ISO 8601 createdAtISO, _ := time.Parse("2006-01-02 15:04:05", createdAt) + var metaOut interface{} + if metadataStr.Valid { + metaOut = stationMetadataFromDB(metadataStr) + } + c.JSON(http.StatusCreated, StationResponse{ - ID: fmt.Sprintf("ws_%d", stationID), - RobotID: fmt.Sprintf("%d", robotInfo.ID), - DataCollectorID: fmt.Sprintf("%d", dcInfo.ID), - FactoryID: fmt.Sprintf("%d", robotInfo.FactoryID), - Status: "inactive", - Name: req.Name, - CreatedAt: createdAtISO.Format(time.RFC3339), + ID: fmt.Sprintf("ws_%d", stationID), + RobotID: fmt.Sprintf("%d", robotInfo.ID), + DataCollectorID: fmt.Sprintf("%d", dcInfo.ID), + FactoryID: fmt.Sprintf("%d", robotInfo.FactoryID), + Status: "inactive", + Name: req.Name, + Metadata: metaOut, + CreatedAt: createdAtISO.Format(time.RFC3339), + UpdatedAt: createdAtISO.Format(time.RFC3339), }) } @@ -306,7 +342,9 @@ type stationListRow struct { FactoryID int64 `db:"factory_id"` Name sql.NullString `db:"name"` Status string `db:"status"` + Metadata sql.NullString `db:"metadata"` CreatedAt sql.NullString `db:"created_at"` + UpdatedAt sql.NullString `db:"updated_at"` } // ListStations handles listing all stations. @@ -324,7 +362,7 @@ func (h *StationHandler) ListStations(c *gin.Context) { SELECT id, robot_id, robot_name, robot_serial, data_collector_id, collector_name, collector_operator_id, - factory_id, name, status, created_at + factory_id, name, status, metadata, created_at, updated_at FROM workstations WHERE deleted_at IS NULL ORDER BY id DESC @@ -346,15 +384,21 @@ func (h *StationHandler) ListStations(c *gin.Context) { if s.CreatedAt.Valid { createdAtStr = formatDBTimeToRFC3339(s.CreatedAt.String) } + var updatedAtStr string + if s.UpdatedAt.Valid { + updatedAtStr = formatDBTimeToRFC3339(s.UpdatedAt.String) + } response = append(response, StationResponse{ - ID: fmt.Sprintf("ws_%d", s.ID), - RobotID: fmt.Sprintf("%d", s.RobotID), - DataCollectorID: fmt.Sprintf("%d", s.DataCollectorID), - FactoryID: fmt.Sprintf("%d", s.FactoryID), - Status: s.Status, - Name: s.Name.String, - CreatedAt: createdAtStr, + ID: fmt.Sprintf("ws_%d", s.ID), + RobotID: fmt.Sprintf("%d", s.RobotID), + DataCollectorID: fmt.Sprintf("%d", s.DataCollectorID), + FactoryID: fmt.Sprintf("%d", s.FactoryID), + Status: s.Status, + Name: s.Name.String, + Metadata: stationMetadataFromDB(s.Metadata), + CreatedAt: createdAtStr, + UpdatedAt: updatedAtStr, }) } @@ -406,44 +450,209 @@ func (h *StationHandler) UpdateStation(c *gin.Context) { return } - // Validate status - req.Status = strings.TrimSpace(req.Status) - if req.Status == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "status is required"}) + hasName := req.Name != nil + hasRobot := req.RobotID != nil + hasDC := req.DataCollectorID != nil + hasStatus := req.Status != nil + hasMeta := len(req.Metadata) > 0 + + if hasRobot && strings.TrimSpace(*req.RobotID) == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "robot_id cannot be empty"}) return } - - if !validStationStatuses[req.Status] { - c.JSON(http.StatusBadRequest, gin.H{ - "error": "invalid status value", - "valid": []string{"active", "inactive", "break", "offline"}, - "actual": req.Status, - }) + if hasDC && strings.TrimSpace(*req.DataCollectorID) == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "data_collector_id cannot be empty"}) return } - // Check if station exists - var existingStatus string - err = h.db.Get(&existingStatus, ` - SELECT status FROM workstations - WHERE id = ? AND deleted_at IS NULL - `, stationID) - if err == sql.ErrNoRows { - c.JSON(http.StatusNotFound, gin.H{"error": "station not found"}) + if !hasName && !hasRobot && !hasDC && !hasStatus && !hasMeta { + c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) return } + + // Station must exist before pairing validations + var exists bool + err = h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM workstations WHERE id = ? AND deleted_at IS NULL)", stationID) if err != nil { logger.Printf("[STATION] Failed to query station: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update station"}) return } + if !exists { + c.JSON(http.StatusNotFound, gin.H{"error": "station not found"}) + return + } - // Update the station status - _, err = h.db.Exec(` + var robotInfo robotInfoRow + var robotType robotTypeInfoRow + if hasRobot { + ridStr := strings.TrimSpace(*req.RobotID) + ridStr = strings.TrimPrefix(ridStr, "robot_") + newRobotID, err := strconv.ParseInt(ridStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid robot_id format"}) + return + } + err = h.db.Get(&robotInfo, ` + SELECT id, device_id, factory_id, status, robot_type_id + FROM robots + WHERE id = ? AND deleted_at IS NULL + `, newRobotID) + if err == sql.ErrNoRows { + c.JSON(http.StatusBadRequest, gin.H{"error": "robot not found"}) + return + } + if err != nil { + logger.Printf("[STATION] Failed to query robot: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update station"}) + return + } + if robotInfo.Status != "active" { + c.JSON(http.StatusBadRequest, gin.H{"error": "robot status must be active to be paired"}) + return + } + var otherWS int64 + err = h.db.Get(&otherWS, ` + SELECT id FROM workstations + WHERE robot_id = ? AND deleted_at IS NULL AND id != ? + `, robotInfo.ID, stationID) + if err == nil { + c.JSON(http.StatusConflict, gin.H{ + "error": "ROBOT_ALREADY_ASSIGNED", + "message": fmt.Sprintf("Robot robot_%d is already assigned to station ws_%d", robotInfo.ID, otherWS), + }) + return + } + if err != sql.ErrNoRows { + logger.Printf("[STATION] Failed to check robot assignment: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update station"}) + return + } + err = h.db.Get(&robotType, "SELECT id, name FROM robot_types WHERE id = ?", robotInfo.RobotTypeID) + if err != nil { + logger.Printf("[STATION] Failed to get robot type: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update station"}) + return + } + } + + var dcInfo dataCollectorInfoRow + if hasDC { + dcStr := strings.TrimSpace(*req.DataCollectorID) + dcStr = strings.TrimPrefix(dcStr, "dc_") + newDCID, err := strconv.ParseInt(dcStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid data_collector_id format"}) + return + } + err = h.db.Get(&dcInfo, ` + SELECT id, name, operator_id, status + FROM data_collectors + WHERE id = ? AND deleted_at IS NULL + `, newDCID) + if err == sql.ErrNoRows { + c.JSON(http.StatusBadRequest, gin.H{"error": "data_collector not found"}) + return + } + if err != nil { + logger.Printf("[STATION] Failed to query data collector: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update station"}) + return + } + if dcInfo.Status != "active" { + c.JSON(http.StatusBadRequest, gin.H{"error": "data_collector status must be active to be paired"}) + return + } + var otherWS int64 + err = h.db.Get(&otherWS, ` + SELECT id FROM workstations + WHERE data_collector_id = ? AND deleted_at IS NULL AND id != ? + `, dcInfo.ID, stationID) + if err == nil { + c.JSON(http.StatusConflict, gin.H{ + "error": "DATA_COLLECTOR_ALREADY_ASSIGNED", + "message": fmt.Sprintf("Data collector dc_%d is already assigned to station ws_%d", dcInfo.ID, otherWS), + }) + return + } + if err != sql.ErrNoRows { + logger.Printf("[STATION] Failed to check data collector assignment: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update station"}) + return + } + } + + updates := []string{} + args := []interface{}{} + + if hasName { + name := strings.TrimSpace(*req.Name) + if name == "" { + updates = append(updates, "name = NULL") + } else { + updates = append(updates, "name = ?") + args = append(args, name) + } + } + + if hasRobot { + updates = append(updates, "robot_id = ?", "robot_name = ?", "robot_serial = ?", "factory_id = ?") + args = append(args, robotInfo.ID, robotType.Name, robotInfo.DeviceID, robotInfo.FactoryID) + } + + if hasDC { + updates = append(updates, "data_collector_id = ?", "collector_name = ?", "collector_operator_id = ?") + args = append(args, dcInfo.ID, dcInfo.Name, dcInfo.OperatorID) + } + + if hasStatus { + status := strings.TrimSpace(*req.Status) + if status == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "status cannot be empty"}) + return + } + if !validStationStatuses[status] { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "invalid status value", + "valid": []string{"active", "inactive", "break", "offline"}, + "actual": status, + }) + return + } + updates = append(updates, "status = ?") + args = append(args, status) + } + + if hasMeta { + meta := bytes.TrimSpace(req.Metadata) + if bytes.Equal(meta, []byte("null")) { + updates = append(updates, "metadata = ?") + args = append(args, sql.NullString{String: "{}", Valid: true}) + } else { + var probe interface{} + if err := json.Unmarshal(req.Metadata, &probe); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid metadata JSON"}) + return + } + updates = append(updates, "metadata = ?") + args = append(args, sql.NullString{String: string(req.Metadata), Valid: true}) + } + } + + if len(updates) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) + return + } + + updates = append(updates, "updated_at = NOW()") + args = append(args, stationID) + + query := fmt.Sprintf(` UPDATE workstations - SET status = ?, updated_at = NOW() + SET %s WHERE id = ? AND deleted_at IS NULL - `, req.Status, stationID) + `, strings.Join(updates, ", ")) + _, err = h.db.Exec(query, args...) if err != nil { logger.Printf("[STATION] Failed to update station: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update station"}) @@ -456,7 +665,7 @@ func (h *StationHandler) UpdateStation(c *gin.Context) { SELECT id, robot_id, robot_name, robot_serial, data_collector_id, collector_name, collector_operator_id, - factory_id, name, status, created_at + factory_id, name, status, metadata, created_at, updated_at FROM workstations WHERE id = ? AND deleted_at IS NULL `, stationID) @@ -471,15 +680,21 @@ func (h *StationHandler) UpdateStation(c *gin.Context) { if station.CreatedAt.Valid { createdAtStr = formatDBTimeToRFC3339(station.CreatedAt.String) } + var updatedAtStr string + if station.UpdatedAt.Valid { + updatedAtStr = formatDBTimeToRFC3339(station.UpdatedAt.String) + } c.JSON(http.StatusOK, StationResponse{ - ID: fmt.Sprintf("ws_%d", station.ID), - RobotID: fmt.Sprintf("%d", station.RobotID), - DataCollectorID: fmt.Sprintf("%d", station.DataCollectorID), - FactoryID: fmt.Sprintf("%d", station.FactoryID), - Status: station.Status, - Name: station.Name.String, - CreatedAt: createdAtStr, + ID: fmt.Sprintf("ws_%d", station.ID), + RobotID: fmt.Sprintf("%d", station.RobotID), + DataCollectorID: fmt.Sprintf("%d", station.DataCollectorID), + FactoryID: fmt.Sprintf("%d", station.FactoryID), + Status: station.Status, + Name: station.Name.String, + Metadata: stationMetadataFromDB(station.Metadata), + CreatedAt: createdAtStr, + UpdatedAt: updatedAtStr, }) } @@ -518,7 +733,7 @@ func (h *StationHandler) GetStation(c *gin.Context) { SELECT id, robot_id, robot_name, robot_serial, data_collector_id, collector_name, collector_operator_id, - factory_id, name, status, created_at + factory_id, name, status, metadata, created_at, updated_at FROM workstations WHERE id = ? AND deleted_at IS NULL `, stationID) @@ -536,15 +751,21 @@ func (h *StationHandler) GetStation(c *gin.Context) { if station.CreatedAt.Valid { createdAtStr = formatDBTimeToRFC3339(station.CreatedAt.String) } + var updatedAtStr string + if station.UpdatedAt.Valid { + updatedAtStr = formatDBTimeToRFC3339(station.UpdatedAt.String) + } c.JSON(http.StatusOK, StationResponse{ - ID: fmt.Sprintf("ws_%d", station.ID), - RobotID: fmt.Sprintf("%d", station.RobotID), - DataCollectorID: fmt.Sprintf("%d", station.DataCollectorID), - FactoryID: fmt.Sprintf("%d", station.FactoryID), - Status: station.Status, - Name: station.Name.String, - CreatedAt: createdAtStr, + ID: fmt.Sprintf("ws_%d", station.ID), + RobotID: fmt.Sprintf("%d", station.RobotID), + DataCollectorID: fmt.Sprintf("%d", station.DataCollectorID), + FactoryID: fmt.Sprintf("%d", station.FactoryID), + Status: station.Status, + Name: station.Name.String, + Metadata: stationMetadataFromDB(station.Metadata), + CreatedAt: createdAtStr, + UpdatedAt: updatedAtStr, }) } diff --git a/internal/api/handlers/subscene.go b/internal/api/handlers/subscene.go index a4409bb..7eb96e3 100644 --- a/internal/api/handlers/subscene.go +++ b/internal/api/handlers/subscene.go @@ -61,6 +61,7 @@ type CreateSubsceneResponse struct { // UpdateSubsceneRequest represents the request body for updating a subscene. type UpdateSubsceneRequest struct { + SceneID *string `json:"scene_id,omitempty"` Name *string `json:"name,omitempty"` Description *string `json:"description,omitempty"` InitialSceneLayout *string `json:"initial_scene_layout,omitempty"` @@ -392,6 +393,44 @@ func (h *SubsceneHandler) UpdateSubscene(c *gin.Context) { return } + effectiveSceneID := existing.SceneID + if req.SceneID != nil { + s := strings.TrimSpace(*req.SceneID) + if s != "" { + sid, err := strconv.ParseInt(s, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid scene_id format"}) + return + } + _, err = h.getSceneInitialLayoutTemplate(sid) + if err != nil { + if err == sql.ErrNoRows { + c.JSON(http.StatusBadRequest, gin.H{"error": "scene not found"}) + return + } + logger.Printf("[SUBSCENE] Failed to query scene layout template: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update subscene"}) + return + } + effectiveSceneID = sid + } + } + + finalName := existing.Name + if req.Name != nil { + if t := strings.TrimSpace(*req.Name); t != "" { + finalName = t + } + } + if effectiveSceneID != existing.SceneID || finalName != existing.Name { + var dup bool + err = h.db.Get(&dup, "SELECT EXISTS(SELECT 1 FROM subscenes WHERE scene_id = ? AND name = ? AND id != ? AND deleted_at IS NULL)", effectiveSceneID, finalName, id) + if err == nil && dup { + c.JSON(http.StatusBadRequest, gin.H{"error": "subscene name already exists in this scene"}) + return + } + } + // Build update query dynamically updates := []string{} args := []interface{}{} @@ -414,7 +453,7 @@ func (h *SubsceneHandler) UpdateSubscene(c *gin.Context) { args = append(args, descStr) } - sceneLayoutTemplate, err := h.getSceneInitialLayoutTemplate(existing.SceneID) + sceneLayoutTemplate, err := h.getSceneInitialLayoutTemplate(effectiveSceneID) if err != nil { if err == sql.ErrNoRows { c.JSON(http.StatusBadRequest, gin.H{"error": "scene not found"}) @@ -438,6 +477,11 @@ func (h *SubsceneHandler) UpdateSubscene(c *gin.Context) { updates = append(updates, "initial_scene_layout = ?") args = append(args, layoutStr) + if effectiveSceneID != existing.SceneID { + updates = append(updates, "scene_id = ?") + args = append(args, effectiveSceneID) + } + if len(updates) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) return diff --git a/internal/storage/database/migrations/000001_initial_schema.up.sql b/internal/storage/database/migrations/000001_initial_schema.up.sql index 3c5e0ea..fd3dd0a 100644 --- a/internal/storage/database/migrations/000001_initial_schema.up.sql +++ b/internal/storage/database/migrations/000001_initial_schema.up.sql @@ -466,10 +466,10 @@ INSERT INTO organizations (name, slug, settings) VALUES ('RoboticsLab Internal', 'roboticslab', '{}') ON DUPLICATE KEY UPDATE name=VALUES(name), settings=VALUES(settings); -INSERT INTO factories (organization_id, name, slug, location, settings) VALUES -(1, 'Shanghai Factory', 'factory-sh', 'Shanghai, China', '{}'), -(1, 'San Francisco Factory', 'factory-sf', 'San Francisco, USA', '{}') -ON DUPLICATE KEY UPDATE name=VALUES(name), location=VALUES(location), settings=VALUES(settings); +INSERT INTO factories (organization_id, name, slug, location, timezone, settings) VALUES +(1, 'Shanghai Factory', 'factory-sh', 'Shanghai, China', 'Asia/Shanghai', '{}'), +(1, 'San Francisco Factory', 'factory-sf', 'San Francisco, USA', 'America/Los_Angeles', '{}') +ON DUPLICATE KEY UPDATE name=VALUES(name), location=VALUES(location), timezone=VALUES(timezone), settings=VALUES(settings); INSERT INTO skills (slug, name, description) VALUES ('pick', 'Pick', 'Grasp and lift an object'), From d02afb777156a5bab4334adccf996c8ca45ad77b Mon Sep 17 00:00:00 2001 From: shark Date: Mon, 30 Mar 2026 09:21:00 +0800 Subject: [PATCH 16/20] fix(lint): fix golangci-lint issues --- internal/api/handlers/data_collector.go | 12 +----------- internal/api/handlers/factory.go | 3 +-- internal/api/handlers/organization.go | 2 +- internal/api/handlers/station.go | 6 ------ 4 files changed, 3 insertions(+), 20 deletions(-) diff --git a/internal/api/handlers/data_collector.go b/internal/api/handlers/data_collector.go index 95d2c4c..74835d5 100644 --- a/internal/api/handlers/data_collector.go +++ b/internal/api/handlers/data_collector.go @@ -312,17 +312,7 @@ func (h *DataCollectorHandler) CreateDataCollector(c *gin.Context) { } resp := dataCollectorResponseFromRow(row) - c.JSON(http.StatusCreated, CreateDataCollectorResponse{ - ID: resp.ID, - Name: resp.Name, - OperatorID: resp.OperatorID, - Email: resp.Email, - Certification: resp.Certification, - Status: resp.Status, - Metadata: resp.Metadata, - CreatedAt: resp.CreatedAt, - UpdatedAt: resp.UpdatedAt, - }) + c.JSON(http.StatusCreated, CreateDataCollectorResponse(resp)) } // GetDataCollector handles getting a single data collector by ID. diff --git a/internal/api/handlers/factory.go b/internal/api/handlers/factory.go index 15d1f2b..51571af 100644 --- a/internal/api/handlers/factory.go +++ b/internal/api/handlers/factory.go @@ -261,8 +261,7 @@ func (h *FactoryHandler) CreateFactory(c *gin.Context) { } // Convert timezone to nullable string - var timezoneStr sql.NullString - timezoneStr = sql.NullString{String: timezone, Valid: true} + timezoneStr := sql.NullString{String: timezone, Valid: true} // Convert settings to JSON string if provided var settingsStr sql.NullString diff --git a/internal/api/handlers/organization.go b/internal/api/handlers/organization.go index 3976266..dc22b12 100644 --- a/internal/api/handlers/organization.go +++ b/internal/api/handlers/organization.go @@ -597,7 +597,7 @@ func isValidSlug(s string) bool { return false } for _, c := range s { - if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-') { + if (c < 'a' || c > 'z') && (c < 'A' || c > 'Z') && (c < '0' || c > '9') && c != '-' { return false } } diff --git a/internal/api/handlers/station.go b/internal/api/handlers/station.go index aaf8902..db62fca 100644 --- a/internal/api/handlers/station.go +++ b/internal/api/handlers/station.go @@ -100,12 +100,6 @@ type dataCollectorInfoRow struct { Status string `db:"status"` } -// factoryRow represents factory info -type factoryInfoRow struct { - ID int64 `db:"id"` - Slug string `db:"slug"` -} - // CreateStation handles station creation requests. // // @Summary Create station From 7268cf6bd69f988d4e8219be514e2be740e789a2 Mon Sep 17 00:00:00 2001 From: shark Date: Mon, 30 Mar 2026 09:52:41 +0800 Subject: [PATCH 17/20] fix(api): enforce soft delete constraints across multiple handlers --- internal/api/handlers/data_collector.go | 2 +- internal/api/handlers/factory.go | 4 ++-- internal/api/handlers/inspector.go | 2 +- internal/api/handlers/organization.go | 4 ++-- internal/api/handlers/robot.go | 4 ++-- internal/api/handlers/scene.go | 4 ++-- internal/api/handlers/sop.go | 2 +- internal/api/handlers/station.go | 2 +- internal/api/handlers/subscene.go | 4 ++-- 9 files changed, 14 insertions(+), 14 deletions(-) diff --git a/internal/api/handlers/data_collector.go b/internal/api/handlers/data_collector.go index 74835d5..53c9703 100644 --- a/internal/api/handlers/data_collector.go +++ b/internal/api/handlers/data_collector.go @@ -592,7 +592,7 @@ func (h *DataCollectorHandler) DeleteDataCollector(c *gin.Context) { updatedAt := time.Now().UTC().Format("2006-01-02 15:04:05") // Perform soft delete by setting deleted_at - _, err = h.db.Exec("UPDATE data_collectors SET deleted_at = NOW(), updated_at = ? WHERE id = ?", updatedAt, id) + _, err = h.db.Exec("UPDATE data_collectors SET deleted_at = NOW(), updated_at = ? WHERE id = ? AND deleted_at IS NULL", updatedAt, id) if err != nil { logger.Printf("[DC] Failed to delete data collector: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete data collector"}) diff --git a/internal/api/handlers/factory.go b/internal/api/handlers/factory.go index 51571af..3c8d147 100644 --- a/internal/api/handlers/factory.go +++ b/internal/api/handlers/factory.go @@ -550,7 +550,7 @@ func (h *FactoryHandler) UpdateFactory(c *gin.Context) { args = append(args, now) args = append(args, id) - query := fmt.Sprintf("UPDATE factories SET %s WHERE id = ?", strings.Join(updates, ", ")) + query := fmt.Sprintf("UPDATE factories SET %s WHERE id = ? AND deleted_at IS NULL", strings.Join(updates, ", ")) _, err = h.db.Exec(query, args...) if err != nil { @@ -666,7 +666,7 @@ func (h *FactoryHandler) DeleteFactory(c *gin.Context) { now := time.Now().UTC() // Perform soft delete by setting deleted_at - _, err = h.db.Exec("UPDATE factories SET deleted_at = ?, updated_at = ? WHERE id = ?", now, now, id) + _, err = h.db.Exec("UPDATE factories SET deleted_at = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL", now, now, id) if err != nil { logger.Printf("[FACTORY] Failed to delete factory: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete factory"}) diff --git a/internal/api/handlers/inspector.go b/internal/api/handlers/inspector.go index 3a95a1b..f58df21 100644 --- a/internal/api/handlers/inspector.go +++ b/internal/api/handlers/inspector.go @@ -613,7 +613,7 @@ func (h *InspectorHandler) DeleteInspector(c *gin.Context) { now := time.Now().UTC() // Perform soft delete by setting deleted_at - _, err = h.db.Exec("UPDATE inspectors SET deleted_at = ?, updated_at = ? WHERE id = ?", now, now, id) + _, err = h.db.Exec("UPDATE inspectors SET deleted_at = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL", now, now, id) if err != nil { logger.Printf("[INSPECTOR] Failed to delete inspector: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete inspector"}) diff --git a/internal/api/handlers/organization.go b/internal/api/handlers/organization.go index dc22b12..c94df91 100644 --- a/internal/api/handlers/organization.go +++ b/internal/api/handlers/organization.go @@ -467,7 +467,7 @@ func (h *OrganizationHandler) UpdateOrganization(c *gin.Context) { args = append(args, now) args = append(args, id) - query := fmt.Sprintf("UPDATE organizations SET %s WHERE id = ?", strings.Join(updates, ", ")) + query := fmt.Sprintf("UPDATE organizations SET %s WHERE id = ? AND deleted_at IS NULL", strings.Join(updates, ", ")) _, err = h.db.Exec(query, args...) if err != nil { @@ -581,7 +581,7 @@ func (h *OrganizationHandler) DeleteOrganization(c *gin.Context) { now := time.Now().UTC() // Perform soft delete by setting deleted_at - _, err = h.db.Exec("UPDATE organizations SET deleted_at = ?, updated_at = ? WHERE id = ?", now, now, id) + _, err = h.db.Exec("UPDATE organizations SET deleted_at = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL", now, now, id) if err != nil { logger.Printf("[ORGANIZATION] Failed to delete organization: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete organization"}) diff --git a/internal/api/handlers/robot.go b/internal/api/handlers/robot.go index 8cb3ceb..8e26227 100644 --- a/internal/api/handlers/robot.go +++ b/internal/api/handlers/robot.go @@ -610,7 +610,7 @@ func (h *RobotHandler) UpdateRobot(c *gin.Context) { args = append(args, time.Now().UTC().Format("2006-01-02 15:04:05")) args = append(args, id) - query := fmt.Sprintf("UPDATE robots SET %s WHERE id = ?", strings.Join(updates, ", ")) + query := fmt.Sprintf("UPDATE robots SET %s WHERE id = ? AND deleted_at IS NULL", strings.Join(updates, ", ")) _, err = h.db.Exec(query, args...) if err != nil { logger.Printf("[ROBOT] Failed to update robot: %v", err) @@ -694,7 +694,7 @@ func (h *RobotHandler) DeleteRobot(c *gin.Context) { updatedAt := time.Now().UTC().Format("2006-01-02 15:04:05") // Perform soft delete by setting deleted_at - _, err = h.db.Exec("UPDATE robots SET deleted_at = NOW(), updated_at = ? WHERE id = ?", updatedAt, id) + _, err = h.db.Exec("UPDATE robots SET deleted_at = NOW(), updated_at = ? WHERE id = ? AND deleted_at IS NULL", updatedAt, id) if err != nil { logger.Printf("[ROBOT] Failed to delete robot: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete robot"}) diff --git a/internal/api/handlers/scene.go b/internal/api/handlers/scene.go index 1583d6e..9b3a11a 100644 --- a/internal/api/handlers/scene.go +++ b/internal/api/handlers/scene.go @@ -450,7 +450,7 @@ func (h *SceneHandler) UpdateScene(c *gin.Context) { args = append(args, now) args = append(args, id) - query := fmt.Sprintf("UPDATE scenes SET %s WHERE id = ?", strings.Join(updates, ", ")) + query := fmt.Sprintf("UPDATE scenes SET %s WHERE id = ? AND deleted_at IS NULL", strings.Join(updates, ", ")) _, err = h.db.Exec(query, args...) if err != nil { @@ -547,7 +547,7 @@ func (h *SceneHandler) DeleteScene(c *gin.Context) { now := time.Now().UTC() // Perform soft delete by setting deleted_at - _, err = h.db.Exec("UPDATE scenes SET deleted_at = ?, updated_at = ? WHERE id = ?", now, now, id) + _, err = h.db.Exec("UPDATE scenes SET deleted_at = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL", now, now, id) if err != nil { logger.Printf("[SCENE] Failed to delete scene: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete scene"}) diff --git a/internal/api/handlers/sop.go b/internal/api/handlers/sop.go index 12efda0..65e1906 100644 --- a/internal/api/handlers/sop.go +++ b/internal/api/handlers/sop.go @@ -573,7 +573,7 @@ func (h *SOPHandler) DeleteSOP(c *gin.Context) { now := time.Now().UTC() // Perform soft delete by setting deleted_at - _, err = h.db.Exec("UPDATE sops SET deleted_at = ?, updated_at = ? WHERE id = ?", now, now, id) + _, err = h.db.Exec("UPDATE sops SET deleted_at = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL", now, now, id) if err != nil { logger.Printf("[SOP] Failed to delete SOP: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete SOP"}) diff --git a/internal/api/handlers/station.go b/internal/api/handlers/station.go index db62fca..0d8a06f 100644 --- a/internal/api/handlers/station.go +++ b/internal/api/handlers/station.go @@ -810,7 +810,7 @@ func (h *StationHandler) DeleteStation(c *gin.Context) { now := time.Now().UTC() // Perform soft delete by setting deleted_at - _, err = h.db.Exec("UPDATE workstations SET deleted_at = ?, updated_at = ? WHERE id = ?", now, now, stationID) + _, err = h.db.Exec("UPDATE workstations SET deleted_at = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL", now, now, stationID) if err != nil { logger.Printf("[STATION] Failed to delete station: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete station"}) diff --git a/internal/api/handlers/subscene.go b/internal/api/handlers/subscene.go index 7eb96e3..bd98a6a 100644 --- a/internal/api/handlers/subscene.go +++ b/internal/api/handlers/subscene.go @@ -492,7 +492,7 @@ func (h *SubsceneHandler) UpdateSubscene(c *gin.Context) { args = append(args, now) args = append(args, id) - query := fmt.Sprintf("UPDATE subscenes SET %s WHERE id = ?", strings.Join(updates, ", ")) + query := fmt.Sprintf("UPDATE subscenes SET %s WHERE id = ? AND deleted_at IS NULL", strings.Join(updates, ", ")) _, err = h.db.Exec(query, args...) if err != nil { @@ -575,7 +575,7 @@ func (h *SubsceneHandler) DeleteSubscene(c *gin.Context) { now := time.Now().UTC() // Perform soft delete by setting deleted_at - _, err = h.db.Exec("UPDATE subscenes SET deleted_at = ?, updated_at = ? WHERE id = ?", now, now, id) + _, err = h.db.Exec("UPDATE subscenes SET deleted_at = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL", now, now, id) if err != nil { logger.Printf("[SUBSCENE] Failed to delete subscene: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete subscene"}) From 93aa2186baccff2ce76e27f25996c675f0590e9e Mon Sep 17 00:00:00 2001 From: shark Date: Mon, 30 Mar 2026 09:55:32 +0800 Subject: [PATCH 18/20] feat(api): extract common utility functions for api handlers --- internal/api/handlers/common.go | 97 +++++++++++++++++++++++++++ internal/api/handlers/organization.go | 26 ------- internal/api/handlers/robot_type.go | 33 --------- internal/api/handlers/station.go | 26 ------- 4 files changed, 97 insertions(+), 85 deletions(-) create mode 100644 internal/api/handlers/common.go diff --git a/internal/api/handlers/common.go b/internal/api/handlers/common.go new file mode 100644 index 0000000..122c5ac --- /dev/null +++ b/internal/api/handlers/common.go @@ -0,0 +1,97 @@ +// SPDX-FileCopyrightText: 2026 ArcheBase +// +// SPDX-License-Identifier: MulanPSL-2.0 + +package handlers + +import ( + "database/sql" + "encoding/json" + "strings" + "time" +) + +// isValidSlug checks if the slug contains only alphanumeric characters and hyphens. +func isValidSlug(s string) bool { + if len(s) == 0 { + return false + } + for _, c := range s { + if (c < 'a' || c > 'z') && (c < 'A' || c > 'Z') && (c < '0' || c > '9') && c != '-' { + return false + } + } + return true +} + +// parseJSONRaw parses a JSON string and returns it as a raw interface{}. +func parseJSONRaw(s string) interface{} { + s = strings.TrimSpace(s) + if s == "" || s == "null" { + return nil + } + var result interface{} + if err := json.Unmarshal([]byte(s), &result); err != nil { + return s + } + return result +} + +func parseJSONArray(s string) []string { + s = strings.TrimSpace(s) + if s == "" || s == "null" { + return nil + } + var result []string + if err := json.Unmarshal([]byte(s), &result); err != nil { + return nil + } + return result +} + +// jsonStringOrEmptyObject returns JSON text for sensor_suite/capabilities. +// Empty or null raw defaults to {}. +func jsonStringOrEmptyObject(raw json.RawMessage) string { + ns := sqlNullJSONFromRaw(raw) + if !ns.Valid { + return "{}" + } + return ns.String +} + +func sqlNullJSONFromRaw(raw json.RawMessage) sql.NullString { + if len(raw) == 0 { + return sql.NullString{Valid: false} + } + s := strings.TrimSpace(string(raw)) + if s == "" || s == "null" { + return sql.NullString{Valid: false} + } + return sql.NullString{String: s, Valid: true} +} + +func formatDBTimeToRFC3339(raw string) string { + s := strings.TrimSpace(raw) + if s == "" { + return "" + } + + // MySQL commonly returns "YYYY-MM-DD HH:MM:SS" or with fractional seconds. + // Some drivers/configs may return RFC3339. + layouts := []string{ + "2006-01-02 15:04:05", + "2006-01-02 15:04:05.999999", + "2006-01-02 15:04:05.999999999", + time.RFC3339Nano, + time.RFC3339, + } + + for _, layout := range layouts { + if t, err := time.Parse(layout, s); err == nil { + return t.Format(time.RFC3339) + } + } + + // Fallback: return original string instead of a wrong timestamp. + return s +} diff --git a/internal/api/handlers/organization.go b/internal/api/handlers/organization.go index c94df91..986a88d 100644 --- a/internal/api/handlers/organization.go +++ b/internal/api/handlers/organization.go @@ -590,29 +590,3 @@ func (h *OrganizationHandler) DeleteOrganization(c *gin.Context) { c.Status(http.StatusNoContent) } - -// isValidSlug checks if the slug contains only alphanumeric characters and hyphens -func isValidSlug(s string) bool { - if len(s) == 0 { - return false - } - for _, c := range s { - if (c < 'a' || c > 'z') && (c < 'A' || c > 'Z') && (c < '0' || c > '9') && c != '-' { - return false - } - } - return true -} - -// parseJSONRaw parses a JSON string and returns it as a raw interface{} -func parseJSONRaw(s string) interface{} { - s = strings.TrimSpace(s) - if s == "" || s == "null" { - return nil - } - var result interface{} - if err := json.Unmarshal([]byte(s), &result); err != nil { - return s - } - return result -} diff --git a/internal/api/handlers/robot_type.go b/internal/api/handlers/robot_type.go index 07c19d6..4b4d04f 100644 --- a/internal/api/handlers/robot_type.go +++ b/internal/api/handlers/robot_type.go @@ -93,27 +93,6 @@ func sqlNullStringFromOptionalPtr(s *string) sql.NullString { return sql.NullString{String: v, Valid: true} } -func sqlNullJSONFromRaw(raw json.RawMessage) sql.NullString { - if len(raw) == 0 { - return sql.NullString{Valid: false} - } - s := strings.TrimSpace(string(raw)) - if s == "" || s == "null" { - return sql.NullString{Valid: false} - } - return sql.NullString{String: s, Valid: true} -} - -// jsonStringOrEmptyObject returns JSON text for sensor_suite/capabilities. -// Empty or null raw defaults to {}. -func jsonStringOrEmptyObject(raw json.RawMessage) string { - ns := sqlNullJSONFromRaw(raw) - if !ns.Valid { - return "{}" - } - return ns.String -} - // jsonColumnForCreate stores sensor_suite/capabilities on INSERT. // If the client omits the field or sends empty/null, defaults to {}. func jsonColumnForCreate(raw json.RawMessage) sql.NullString { @@ -293,18 +272,6 @@ func (h *RobotTypeHandler) ListRobotTypes(c *gin.Context) { }) } -func parseJSONArray(s string) []string { - s = strings.TrimSpace(s) - if s == "" || s == "null" { - return nil - } - var result []string - if err := json.Unmarshal([]byte(s), &result); err != nil { - return nil - } - return result -} - func toNullableJSONArray(values []string) sql.NullString { if len(values) == 0 { return sql.NullString{String: "[]", Valid: true} diff --git a/internal/api/handlers/station.go b/internal/api/handlers/station.go index 0d8a06f..93286b0 100644 --- a/internal/api/handlers/station.go +++ b/internal/api/handlers/station.go @@ -819,29 +819,3 @@ func (h *StationHandler) DeleteStation(c *gin.Context) { c.Status(http.StatusNoContent) } - -func formatDBTimeToRFC3339(raw string) string { - s := strings.TrimSpace(raw) - if s == "" { - return "" - } - - // MySQL commonly returns "YYYY-MM-DD HH:MM:SS" or with fractional seconds. - // Some drivers/configs may return RFC3339. - layouts := []string{ - "2006-01-02 15:04:05", - "2006-01-02 15:04:05.999999", - "2006-01-02 15:04:05.999999999", - time.RFC3339Nano, - time.RFC3339, - } - - for _, layout := range layouts { - if t, err := time.Parse(layout, s); err == nil { - return t.Format(time.RFC3339) - } - } - - // Fallback: return original string instead of a wrong timestamp. - return s -} From 1665b29a6dc5f9098509882d5ff958a3d4b95250 Mon Sep 17 00:00:00 2001 From: shark Date: Mon, 30 Mar 2026 10:52:52 +0800 Subject: [PATCH 19/20] feat(api): improve slug validation and enhance skill metadata handling --- ROADMAP.md | 4 +-- internal/api/handlers/common.go | 10 ++++-- internal/api/handlers/organization.go | 4 +-- internal/api/handlers/skill.go | 46 ++++++++++++++++++++------- internal/api/handlers/sop.go | 4 +-- 5 files changed, 49 insertions(+), 19 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 1d17f64..1f8b2c4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -58,8 +58,8 @@ Second Release Implementation Order: - Role-based access control (RBAC) - API private key management 2. **Scene & Skill Management** - - scene and subscene CRUD - - skill and sop CRUD + - ✅ scene and subscene CRUD + - ✅ skill and sop CRUD 3. **Order & Task Management** - order CRUD - batch CRUD diff --git a/internal/api/handlers/common.go b/internal/api/handlers/common.go index 122c5ac..ae7ee6e 100644 --- a/internal/api/handlers/common.go +++ b/internal/api/handlers/common.go @@ -11,9 +11,15 @@ import ( "time" ) -// isValidSlug checks if the slug contains only alphanumeric characters and hyphens. +// maxSlugLength matches VARCHAR(100) for slug columns in the schema. +const maxSlugLength = 100 + +// invalidSlugUserMessage is returned when slug fails isValidSlug (length or charset). +const invalidSlugUserMessage = "slug must be at most 100 characters and contain only alphanumeric characters and hyphens" + +// isValidSlug checks non-empty slug, length <= maxSlugLength, and alphanumeric plus hyphen only. func isValidSlug(s string) bool { - if len(s) == 0 { + if len(s) == 0 || len(s) > maxSlugLength { return false } for _, c := range s { diff --git a/internal/api/handlers/organization.go b/internal/api/handlers/organization.go index 986a88d..1a74ffd 100644 --- a/internal/api/handlers/organization.go +++ b/internal/api/handlers/organization.go @@ -297,7 +297,7 @@ func (h *OrganizationHandler) CreateOrganization(c *gin.Context) { // Validate slug format (alphanumeric and hyphens only) if !isValidSlug(req.Slug) { - c.JSON(http.StatusBadRequest, gin.H{"error": "slug must contain only alphanumeric characters and hyphens"}) + c.JSON(http.StatusBadRequest, gin.H{"error": invalidSlugUserMessage}) return } @@ -422,7 +422,7 @@ func (h *OrganizationHandler) UpdateOrganization(c *gin.Context) { if req.Slug != "" { // Validate slug format if !isValidSlug(req.Slug) { - c.JSON(http.StatusBadRequest, gin.H{"error": "slug must contain only alphanumeric characters and hyphens"}) + c.JSON(http.StatusBadRequest, gin.H{"error": invalidSlugUserMessage}) return } // Check if new slug already exists for another organization diff --git a/internal/api/handlers/skill.go b/internal/api/handlers/skill.go index da418fe..94e84c6 100644 --- a/internal/api/handlers/skill.go +++ b/internal/api/handlers/skill.go @@ -6,6 +6,7 @@ package handlers import ( + "bytes" "database/sql" "encoding/json" "fmt" @@ -19,6 +20,23 @@ import ( "github.com/jmoiron/sqlx" ) +// optionalJSONPatch supports PATCH-style updates: omitted JSON key → Present false; +// key present with null or a value → Present true (Value nil means store metadata as "{}"). +// encoding/json does not distinguish null from omitted for *interface{}; this type does. +type optionalJSONPatch struct { + Present bool + Value interface{} +} + +func (o *optionalJSONPatch) UnmarshalJSON(data []byte) error { + o.Present = true + if len(data) == 0 || bytes.Equal(data, []byte("null")) { + o.Value = nil + return nil + } + return json.Unmarshal(data, &o.Value) +} + // SkillHandler handles skill related HTTP requests. type SkillHandler struct { db *sqlx.DB @@ -65,12 +83,13 @@ type CreateSkillResponse struct { } // UpdateSkillRequest represents the request body for updating a skill. +// Metadata uses optionalJSONPatch so JSON null (clear) is distinct from omitting the key (unchanged). type UpdateSkillRequest struct { - Slug *string `json:"slug,omitempty"` - Name *string `json:"name,omitempty"` - Description *string `json:"description,omitempty"` - Version *string `json:"version,omitempty"` - Metadata interface{} `json:"metadata,omitempty"` + Slug *string `json:"slug,omitempty"` + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + Version *string `json:"version,omitempty"` + Metadata optionalJSONPatch `json:"metadata,omitempty"` } // RegisterRoutes registers skill related routes. @@ -275,7 +294,7 @@ func (h *SkillHandler) CreateSkill(c *gin.Context) { return } if !isValidSlug(req.Slug) { - c.JSON(http.StatusBadRequest, gin.H{"error": "slug must contain only alphanumeric characters and hyphens"}) + c.JSON(http.StatusBadRequest, gin.H{"error": invalidSlugUserMessage}) return } if req.Name == "" { @@ -406,7 +425,7 @@ func (h *SkillHandler) UpdateSkill(c *gin.Context) { return } if !isValidSlug(slug) { - c.JSON(http.StatusBadRequest, gin.H{"error": "slug must contain only alphanumeric characters and hyphens"}) + c.JSON(http.StatusBadRequest, gin.H{"error": invalidSlugUserMessage}) return } if slug != current.Slug { @@ -447,11 +466,16 @@ func (h *SkillHandler) UpdateSkill(c *gin.Context) { args = append(args, descStr) } - if req.Metadata != nil { - metadataJSON, err := json.Marshal(req.Metadata) - if err == nil { + if req.Metadata.Present { + if req.Metadata.Value == nil { updates = append(updates, "metadata = ?") - args = append(args, sql.NullString{String: string(metadataJSON), Valid: true}) + args = append(args, sql.NullString{String: "{}", Valid: true}) + } else { + metadataJSON, err := json.Marshal(req.Metadata.Value) + if err == nil { + updates = append(updates, "metadata = ?") + args = append(args, sql.NullString{String: string(metadataJSON), Valid: true}) + } } } diff --git a/internal/api/handlers/sop.go b/internal/api/handlers/sop.go index 65e1906..0939e56 100644 --- a/internal/api/handlers/sop.go +++ b/internal/api/handlers/sop.go @@ -276,7 +276,7 @@ func (h *SOPHandler) CreateSOP(c *gin.Context) { } // Validate slug format if !isValidSlug(req.Slug) { - c.JSON(http.StatusBadRequest, gin.H{"error": "slug must contain only alphanumeric characters and hyphens"}) + c.JSON(http.StatusBadRequest, gin.H{"error": invalidSlugUserMessage}) return } version := "1.0.0" @@ -415,7 +415,7 @@ func (h *SOPHandler) UpdateSOP(c *gin.Context) { return } if !isValidSlug(slug) { - c.JSON(http.StatusBadRequest, gin.H{"error": "slug must contain only alphanumeric characters and hyphens"}) + c.JSON(http.StatusBadRequest, gin.H{"error": invalidSlugUserMessage}) return } if slug != current.Slug { From d01ac13cd2a7cc023824c645f464bbde78198bb1 Mon Sep 17 00:00:00 2001 From: shark Date: Mon, 30 Mar 2026 11:19:49 +0800 Subject: [PATCH 20/20] feat(api): add swaggertype annotations for metadata and other fields in various request structures --- internal/api/handlers/data_collector.go | 2 +- internal/api/handlers/inspector.go | 2 +- internal/api/handlers/robot.go | 2 +- internal/api/handlers/robot_type.go | 12 ++++++------ internal/api/handlers/scene.go | 8 ++++---- internal/api/handlers/station.go | 2 +- internal/api/handlers/subscene.go | 12 ++++++------ 7 files changed, 20 insertions(+), 20 deletions(-) diff --git a/internal/api/handlers/data_collector.go b/internal/api/handlers/data_collector.go index 53c9703..a1a1e2e 100644 --- a/internal/api/handlers/data_collector.go +++ b/internal/api/handlers/data_collector.go @@ -373,7 +373,7 @@ type UpdateDataCollectorRequest struct { Email *string `json:"email,omitempty"` Certification *string `json:"certification,omitempty"` Status *string `json:"status,omitempty"` - Metadata json.RawMessage `json:"metadata,omitempty"` + Metadata json.RawMessage `json:"metadata,omitempty" swaggertype:"object"` } // UpdateDataCollector handles updating a data collector. diff --git a/internal/api/handlers/inspector.go b/internal/api/handlers/inspector.go index f58df21..a70359a 100644 --- a/internal/api/handlers/inspector.go +++ b/internal/api/handlers/inspector.go @@ -76,7 +76,7 @@ type UpdateInspectorRequest struct { Email *string `json:"email,omitempty"` CertificationLevel *string `json:"certification_level,omitempty"` Status *string `json:"status,omitempty"` - Metadata json.RawMessage `json:"metadata,omitempty"` + Metadata json.RawMessage `json:"metadata,omitempty" swaggertype:"object"` } // RegisterRoutes registers inspector related routes. diff --git a/internal/api/handlers/robot.go b/internal/api/handlers/robot.go index 8e26227..6f88684 100644 --- a/internal/api/handlers/robot.go +++ b/internal/api/handlers/robot.go @@ -463,7 +463,7 @@ type UpdateRobotRequest struct { FactoryID *string `json:"factory_id,omitempty"` AssetID *string `json:"asset_id,omitempty"` Status *string `json:"status,omitempty"` - Metadata json.RawMessage `json:"metadata,omitempty"` + Metadata json.RawMessage `json:"metadata,omitempty" swaggertype:"object"` } // UpdateRobot handles updating a robot. diff --git a/internal/api/handlers/robot_type.go b/internal/api/handlers/robot_type.go index 4b4d04f..5ad2b99 100644 --- a/internal/api/handlers/robot_type.go +++ b/internal/api/handlers/robot_type.go @@ -35,9 +35,9 @@ type CreateRobotTypeRequest struct { Model string `json:"model"` Manufacturer *string `json:"manufacturer,omitempty"` EndEffector *string `json:"end_effector,omitempty"` - SensorSuite json.RawMessage `json:"sensor_suite,omitempty"` + SensorSuite json.RawMessage `json:"sensor_suite,omitempty" swaggertype:"object"` ROSTopics []string `json:"ros_topics"` - Capabilities json.RawMessage `json:"capabilities,omitempty"` + Capabilities json.RawMessage `json:"capabilities,omitempty" swaggertype:"object"` } // RobotTypeResponse represents a robot type in the response. @@ -47,9 +47,9 @@ type RobotTypeResponse struct { Model string `json:"model"` Manufacturer *string `json:"manufacturer,omitempty"` EndEffector *string `json:"end_effector,omitempty"` - SensorSuite *json.RawMessage `json:"sensor_suite,omitempty"` + SensorSuite *json.RawMessage `json:"sensor_suite,omitempty" swaggertype:"object"` ROSTopics []string `json:"ros_topics"` - Capabilities *json.RawMessage `json:"capabilities,omitempty"` + Capabilities *json.RawMessage `json:"capabilities,omitempty" swaggertype:"object"` CreatedAt string `json:"created_at,omitempty"` UpdatedAt string `json:"updated_at,omitempty"` } @@ -330,9 +330,9 @@ type UpdateRobotTypeRequest struct { Model *string `json:"model,omitempty"` Manufacturer *string `json:"manufacturer,omitempty"` EndEffector *string `json:"end_effector,omitempty"` - SensorSuite *json.RawMessage `json:"sensor_suite,omitempty"` + SensorSuite *json.RawMessage `json:"sensor_suite,omitempty" swaggertype:"object"` ROSTopics *[]string `json:"ros_topics,omitempty"` - Capabilities *json.RawMessage `json:"capabilities,omitempty"` + Capabilities *json.RawMessage `json:"capabilities,omitempty" swaggertype:"object"` } // UpdateRobotType handles updating a robot type. diff --git a/internal/api/handlers/scene.go b/internal/api/handlers/scene.go index 9b3a11a..60b4c62 100644 --- a/internal/api/handlers/scene.go +++ b/internal/api/handlers/scene.go @@ -47,10 +47,10 @@ type SceneListResponse struct { // CreateSceneRequest represents the request body for creating a scene. type CreateSceneRequest struct { - FactoryID string `json:"factory_id"` - Name string `json:"name"` - Description string `json:"description,omitempty"` - InitialSceneLayoutTemplate string `json:"initial_scene_layout_template,omitempty"` + FactoryID string `json:"factory_id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + InitialSceneLayoutTemplate string `json:"initial_scene_layout_template,omitempty"` } // CreateSceneResponse represents the response for creating a scene. diff --git a/internal/api/handlers/station.go b/internal/api/handlers/station.go index 93286b0..7cab4f3 100644 --- a/internal/api/handlers/station.go +++ b/internal/api/handlers/station.go @@ -45,7 +45,7 @@ type UpdateStationRequest struct { RobotID *string `json:"robot_id,omitempty"` DataCollectorID *string `json:"data_collector_id,omitempty"` Status *string `json:"status,omitempty"` - Metadata json.RawMessage `json:"metadata,omitempty"` + Metadata json.RawMessage `json:"metadata,omitempty" swaggertype:"object"` } // StationResponse represents a station in the response. diff --git a/internal/api/handlers/subscene.go b/internal/api/handlers/subscene.go index bd98a6a..0bfc5e2 100644 --- a/internal/api/handlers/subscene.go +++ b/internal/api/handlers/subscene.go @@ -46,10 +46,10 @@ type SubsceneListResponse struct { // CreateSubsceneRequest represents the request body for creating a subscene. type CreateSubsceneRequest struct { - SceneID string `json:"scene_id"` - Name string `json:"name"` - Description string `json:"description,omitempty"` - InitialSceneLayout string `json:"initial_scene_layout,omitempty"` + SceneID string `json:"scene_id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + InitialSceneLayout string `json:"initial_scene_layout,omitempty"` } // CreateSubsceneResponse represents the response for creating a subscene. @@ -346,8 +346,8 @@ func (h *SubsceneHandler) CreateSubscene(c *gin.Context) { } c.JSON(http.StatusCreated, CreateSubsceneResponse{ - ID: fmt.Sprintf("%d", id), - Name: req.Name, + ID: fmt.Sprintf("%d", id), + Name: req.Name, CreatedAt: now.Format(time.RFC3339), }) }