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 new file mode 100644 index 0000000..ae7ee6e --- /dev/null +++ b/internal/api/handlers/common.go @@ -0,0 +1,103 @@ +// SPDX-FileCopyrightText: 2026 ArcheBase +// +// SPDX-License-Identifier: MulanPSL-2.0 + +package handlers + +import ( + "database/sql" + "encoding/json" + "strings" + "time" +) + +// 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 || len(s) > maxSlugLength { + 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/data_collector.go b/internal/api/handlers/data_collector.go index 739a0fd..a1a1e2e 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,24 +51,33 @@ 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"` + 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"` - 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. 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.PUT("/data_collectors/:id", h.UpdateDataCollector) + apiV1.DELETE("/data_collectors/:id", h.DeleteDataCollector) } // dataCollectorRow represents a data collector in the database @@ -75,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. @@ -101,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 ` @@ -124,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{ @@ -176,6 +207,8 @@ 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"}) @@ -204,17 +237,43 @@ 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} + } + + 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, ) @@ -231,41 +290,107 @@ func (h *DataCollectorHandler) CreateDataCollector(c *gin.Context) { return } - c.JSON(http.StatusCreated, CreateDataCollectorResponse{ - ID: fmt.Sprintf("%d", id), - Name: req.Name, - OperatorID: req.OperatorID, - Status: "active", - CreatedAt: createdAt, - }) + 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(resp)) } -// UpdateDataCollectorStatusRequest represents the request body for updating data collector status. -type UpdateDataCollectorStatusRequest struct { - Status string `json:"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.metadata, + dc.created_at, + dc.updated_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 + } + + c.JSON(http.StatusOK, dataCollectorResponseFromRow(dc)) } -// UpdateDataCollectorStatusResponse represents the response for updating data collector status. -type UpdateDataCollectorStatusResponse struct { - ID string `json:"id"` - Status string `json:"status"` +// 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"` + 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" swaggertype:"object"` } -// UpdateDataCollectorStatus handles status update requests for a data collector. +// UpdateDataCollector handles updating a data collector. // -// @Summary Update data collector status -// @Description Updates the status of an existing 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 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) { +// @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} [put] +func (h *DataCollectorHandler) UpdateDataCollector(c *gin.Context) { idParam := c.Param("id") id, err := strconv.ParseInt(idParam, 10, 64) if err != nil { @@ -273,22 +398,168 @@ func (h *DataCollectorHandler) UpdateDataCollectorStatus(c *gin.Context) { return } - var req UpdateDataCollectorStatusRequest + var req UpdateDataCollectorRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) return } - req.Status = strings.TrimSpace(req.Status) + // Build update query dynamically + updates := []string{} + args := []interface{}{} - // 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"}) + + if req.Name != nil { + name := strings.TrimSpace(*req.Name) + if name != "" { + updates = append(updates, "name = ?") + args = append(args, name) + } + } + + 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 + if email != "" { + emailStr = sql.NullString{String: email, Valid: true} + } + updates = append(updates, "email = ?") + 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] { + 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(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 + } + + 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.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 fetch updated data collector: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get updated data collector"}) + return + } + + c.JSON(http.StatusOK, dataCollectorResponseFromRow(dc)) +} + +// 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 409 {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 } @@ -296,33 +567,37 @@ func (h *DataCollectorHandler) UpdateDataCollectorStatus(c *gin.Context) { 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"}) + 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 } - // Generate updated_at timestamp + 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") - // 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, - ) + // Perform soft delete by setting deleted_at + _, 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 update status: %v", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update status"}) + logger.Printf("[DC] Failed to delete data collector: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete data collector"}) return } - c.JSON(http.StatusOK, UpdateDataCollectorStatusResponse{ - ID: idParam, - Status: req.Status, - }) + c.Status(http.StatusNoContent) } diff --git a/internal/api/handlers/factory.go b/internal/api/handlers/factory.go index 0220a11..3c8d147 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" @@ -36,7 +37,8 @@ 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"` } @@ -48,9 +50,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 +64,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"` } @@ -66,6 +73,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.PUT("/factories/:id", h.UpdateFactory) + apiV1.DELETE("/factories/:id", h.DeleteFactory) } // factoryRow represents a factory in the database @@ -77,10 +87,18 @@ 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"` } +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 @@ -104,6 +122,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 @@ -154,6 +173,8 @@ func (h *FactoryHandler) ListFactories(c *gin.Context) { Slug: f.Slug, Location: location, Timezone: timezone, + Settings: factorySettingsFromDB(f.Settings), + SceneCount: f.SceneCount, CreatedAt: createdAt, }) } @@ -185,6 +206,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"}) @@ -225,19 +248,49 @@ 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 + 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 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid settings"}) + return + } + 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, ) @@ -259,6 +312,366 @@ 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), }) } + +// 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, + (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 = ? 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, + Settings: factorySettingsFromDB(f.Settings), + SceneCount: f.SceneCount, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + }) +} + +// UpdateFactoryRequest represents the request body for updating a factory. +type UpdateFactoryRequest struct { + 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. +// +// @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} [put] +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 + } + + 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{}{} + + 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 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)", effectiveOrgID, 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 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 + } + } + updates = append(updates, "organization_id = ?") + args = append(args, effectiveOrgID) + } + + 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 = ? AND deleted_at IS NULL", 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, + (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"}) + 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, + Settings: factorySettingsFromDB(f.Settings), + SceneCount: f.SceneCount, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + }) +} + +// 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 or robots. +// @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 + } + + // 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 + } + + 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 + _, 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"}) + 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..a70359a --- /dev/null +++ b/internal/api/handlers/inspector.go @@ -0,0 +1,624 @@ +// SPDX-FileCopyrightText: 2026 ArcheBase +// +// SPDX-License-Identifier: MulanPSL-2.0 + +// Package handlers provides HTTP request handlers for Keystone Edge API +package handlers + +import ( + "bytes" + "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"` + 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"` + 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 { + 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" swaggertype:"object"` +} + +// 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.PUT("/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} + } + + 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( + `INSERT INTO inspectors ( + name, + inspector_id, + email, + certification_level, + status, + metadata, + created_at, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + req.Name, + req.InspectorID, + emailStr, + certLevel, + "active", + metadataStr, + 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 + } + + 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), + }) +} + +// 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} [put] +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.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 != "" { + 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 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 + } + + 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 = ? 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"}) + 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..1a74ffd --- /dev/null +++ b/internal/api/handlers/organization.go @@ -0,0 +1,592 @@ +// 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"` + FactoryCount int `json:"factoryCount"` +} + +// 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"` +} + +// 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 organizationSettingsPatch `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.PUT("/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"` + FactoryCount int `db:"factory_count"` +} + +// 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 + 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 + 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, + FactoryCount: org.FactoryCount, + }) + } + + 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 + 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 + 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, + FactoryCount: org.FactoryCount, + }) +} + +// 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": invalidSlugUserMessage}) + 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": invalidSlugUserMessage}) + 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.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 { + 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 = ? AND deleted_at IS NULL", 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 + 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) + 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, + FactoryCount: org.FactoryCount, + }) +} + +// 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 + } + + // 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 + _, 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"}) + return + } + + c.Status(http.StatusNoContent) +} diff --git a/internal/api/handlers/robot.go b/internal/api/handlers/robot.go index d401a5f..6f88684 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,25 +59,33 @@ 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. func (h *RobotHandler) RegisterRoutes(apiV1 *gin.RouterGroup) { apiV1.GET("/robots", h.ListRobots) apiV1.POST("/robots", h.CreateRobot) + apiV1.GET("/robots/:id", h.GetRobot) + apiV1.PUT("/robots/:id", h.UpdateRobot) + apiV1.DELETE("/robots/:id", h.DeleteRobot) } // robotRow represents a robot in the database @@ -81,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. @@ -122,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 ` @@ -167,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{ @@ -280,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, ) @@ -310,12 +370,336 @@ 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, }) } + +// 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.asset_id, + r.status, + r.metadata, + r.created_at, + r.updated_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 + } + + 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"` + AssetID *string `json:"asset_id,omitempty"` + Status *string `json:"status,omitempty"` + Metadata json.RawMessage `json:"metadata,omitempty" swaggertype:"object"` +} + +// 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} [put] +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 + } + + // 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, + "maintenance": true, + "retired": true, + } + + // 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 + } + 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) + } + + 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) + } + + 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 { + 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.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] { + 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(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 + } + + 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 = ? 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) + 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.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 fetch updated robot: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get updated robot"}) + return + } + + c.JSON(http.StatusOK, h.responseFromRow(r)) +} + +// 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 409 {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 + } + + 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 + _, 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"}) + return + } + + c.Status(http.StatusNoContent) +} diff --git a/internal/api/handlers/robot_type.go b/internal/api/handlers/robot_type.go index ad9f685..5ad2b99 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" @@ -29,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" swaggertype:"object"` + ROSTopics []string `json:"ros_topics"` + Capabilities json.RawMessage `json:"capabilities,omitempty" swaggertype:"object"` } // 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" swaggertype:"object"` + ROSTopics []string `json:"ros_topics"` + Capabilities *json.RawMessage `json:"capabilities,omitempty" swaggertype:"object"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` } // RobotTypeListResponse represents the response for listing robot types. @@ -62,18 +63,100 @@ 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.PUT("/robot_types/:id", h.UpdateRobotType) + apiV1.DELETE("/robot_types/:id", h.DeleteRobotType) } // 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} +} + +// 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 @@ -82,7 +165,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] @@ -106,24 +189,27 @@ 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( `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 +226,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 +249,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,58 +262,257 @@ 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) + robotTypes = append(robotTypes, robotTypeRowToResponse(rt)) + } + + c.JSON(http.StatusOK, RobotTypeListResponse{ + RobotTypes: robotTypes, + }) +} + +func toNullableJSONArray(values []string) sql.NullString { + if len(values) == 0 { + return sql.NullString{String: "[]", Valid: true} + } + data, err := json.Marshal(values) + if err != nil { + return 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 ` + robotTypeSelectColumns + ` + 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 + } + + 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"` + Manufacturer *string `json:"manufacturer,omitempty"` + EndEffector *string `json:"end_effector,omitempty"` + SensorSuite *json.RawMessage `json:"sensor_suite,omitempty" swaggertype:"object"` + ROSTopics *[]string `json:"ros_topics,omitempty"` + Capabilities *json.RawMessage `json:"capabilities,omitempty" swaggertype:"object"` +} + +// 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} [put] +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) } + } - createdAt := "" - if rt.CreatedAt.Valid { - createdAt = rt.CreatedAt.String + 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) } + } - updatedAt := "" - if rt.UpdatedAt.Valid { - updatedAt = rt.UpdatedAt.String + 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) } + } - robotTypes = append(robotTypes, RobotTypeResponse{ - ID: rt.ID, - Name: rt.Name, - Model: rt.Model, - ROSTopics: topics, - CreatedAt: createdAt, - UpdatedAt: updatedAt, - }) + if req.SensorSuite != nil { + raw := *req.SensorSuite + updates = append(updates, "sensor_suite = ?") + args = append(args, jsonStringOrEmptyObject(raw)) } - c.JSON(http.StatusOK, RobotTypeListResponse{ - RobotTypes: robotTypes, - }) -} + if req.ROSTopics != nil { + updates = append(updates, "ros_topics = ?") + args = append(args, toNullableJSONArray(*req.ROSTopics)) + } -func parseJSONArray(s string) []string { - s = strings.TrimSpace(s) - if s == "" || s == "null" { - return nil + if req.Capabilities != nil { + raw := *req.Capabilities + updates = append(updates, "capabilities = ?") + args = append(args, jsonStringOrEmptyObject(raw)) } - var result []string - if err := json.Unmarshal([]byte(s), &result); err != nil { - return nil + + 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 } - return result + + rowsAffected, _ := result.RowsAffected() + if rowsAffected == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "robot type not found"}) + return + } + + 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 updated robot type: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get updated robot type"}) + return + } + + c.JSON(http.StatusOK, robotTypeRowToResponse(rt)) } -func toNullableJSONArray(values []string) sql.NullString { - if len(values) == 0 { - return sql.NullString{} +// 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 409 {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 } - data, err := json.Marshal(values) + + // 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 { - return sql.NullString{} + logger.Printf("[ROBOT] Failed to check robot type existence: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete robot type"}) + return } - return sql.NullString{String: string(data), Valid: true} + + if !exists { + c.JSON(http.StatusNotFound, gin.H{"error": "robot type not found"}) + 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 + _, 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..60b4c62 --- /dev/null +++ b/internal/api/handlers/scene.go @@ -0,0 +1,558 @@ +// 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"` + FactoryID string `json:"factory_id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + InitialSceneLayoutTemplate string `json:"initial_scene_layout_template,omitempty"` + SubsceneCount int `json:"subsceneCount"` + 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 { + 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. +type CreateSceneResponse struct { + ID string `json:"id"` + Name string `json:"name"` + CreatedAt string `json:"created_at"` +} + +// UpdateSceneRequest represents the request body for updating a scene. +type UpdateSceneRequest struct { + FactoryID *string `json:"factory_id,omitempty"` + Name *string `json:"name,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.PUT("/scenes/:id", h.UpdateScene) + apiV1.DELETE("/scenes/:id", h.DeleteScene) +} + +// sceneRow represents a scene in the database +type sceneRow struct { + ID int64 `db:"id"` + FactoryID int64 `db:"factory_id"` + Name string `db:"name"` + 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"` +} + +// ListScenes handles scene listing requests with filtering. +// +// @Summary List scenes +// @Description Lists scenes with optional filtering by factory_id +// @Tags scenes +// @Accept json +// @Produce json +// @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) { + factoryID := c.Query("factory_id") + + query := ` + SELECT + s.id, + s.factory_id, + s.name, + 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{}{} + + 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), + FactoryID: fmt.Sprintf("%d", s.FactoryID), + Name: s.Name, + Description: description, + InitialSceneLayoutTemplate: layoutTemplate, + SubsceneCount: s.SubsceneCount, + 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, + factory_id, + name, + 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), + FactoryID: fmt.Sprintf("%d", s.FactoryID), + Name: s.Name, + 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.FactoryID = strings.TrimSpace(req.FactoryID) + req.Name = strings.TrimSpace(req.Name) + req.Description = strings.TrimSpace(req.Description) + + 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 + } + + // 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 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 { + logger.Printf("[SCENE] Failed to verify factory: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create scene"}) + return + } + if !exists { + c.JSON(http.StatusBadRequest, gin.H{"error": "factory not found"}) + 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 ( + factory_id, + name, + description, + initial_scene_layout_template, + created_at, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?)`, + factoryID, + req.Name, + 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, + 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} [put] +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, 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"}) + 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.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 != "" { + updates = append(updates, "name = ?") + args = append(args, name) + } + } + + 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 = ? AND deleted_at IS NULL", 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, 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"}) + 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), + FactoryID: fmt.Sprintf("%d", s.FactoryID), + Name: s.Name, + 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. Returns 400 if the scene has associated subscenes. +// @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 + } + + // 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 + _, 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"}) + 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..94e84c6 --- /dev/null +++ b/internal/api/handlers/skill.go @@ -0,0 +1,626 @@ +// SPDX-FileCopyrightText: 2026 ArcheBase +// +// SPDX-License-Identifier: MulanPSL-2.0 + +// Package handlers provides HTTP request handlers for Keystone Edge API +package handlers + +import ( + "bytes" + "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" +) + +// 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 +} + +// 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"` + Slug string `json:"slug"` + Name string `json:"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 { + Slug string `json:"slug"` + Name string `json:"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"` + 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. +// 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 optionalJSONPatch `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.PUT("/skills/:id", h.UpdateSkill) + apiV1.DELETE("/skills/:id", h.DeleteSkill) +} + +// skillRow represents a skill in the database +type skillRow struct { + ID int64 `db:"id"` + Slug string `db:"slug"` + Name string `db:"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, + slug, + 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.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), + Slug: s.Slug, + Name: s.Name, + 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, + slug, + 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.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), + Slug: s.Slug, + Name: s.Name, + 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.Slug = strings.TrimSpace(req.Slug) + req.Name = strings.TrimSpace(req.Name) + 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": invalidSlugUserMessage}) + return + } + if req.Name == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"}) + 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 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) + 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 ( + slug, + name, + description, + version, + metadata, + created_at, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?)`, + req.Slug, + req.Name, + 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), + Slug: req.Slug, + Name: req.Name, + Version: version, + 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} [put] +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 + } + + // 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 + } + + // Build update query dynamically + updates := []string{} + args := []interface{}{} + + if req.Slug != nil { + slug := strings.TrimSpace(*req.Slug) + if slug == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "slug cannot be empty"}) + return + } + if !isValidSlug(slug) { + c.JSON(http.StatusBadRequest, gin.H{"error": invalidSlugUserMessage}) + 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 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.Name != nil { + name := strings.TrimSpace(*req.Name) + if name != "" { + updates = append(updates, "name = ?") + args = append(args, name) + } + } + + 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.Metadata.Present { + if req.Metadata.Value == nil { + updates = append(updates, "metadata = ?") + 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}) + } + } + } + + // 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 + } + + 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, 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"}) + return + } + + description := "" + if s.Description.Valid { + description = s.Description.String + } + version := "1.0.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), + Slug: s.Slug, + Name: s.Name, + 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 409 {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 + } + + 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 + } + + // 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 + _, 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..0939e56 --- /dev/null +++ b/internal/api/handlers/sop.go @@ -0,0 +1,584 @@ +// 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 string `json:"version,omitempty"` + 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"` + 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"` + SkillSequence []string `json:"skill_sequence"` + Version string `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"` + Version *string `json:"version,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.PUT("/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 sql.NullString `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) + version := "1.0.0" + if s.Version.Valid { + version = s.Version.String + } + 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: 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) + version := "1.0.0" + if s.Version.Valid { + version = s.Version.String + } + 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: 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) + req.Version = strings.TrimSpace(req.Version) + + 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": invalidSlugUserMessage}) + 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 + 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), + version, + 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: version, + 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} [put] +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 + } + + // 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{}{} + + 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 == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "slug cannot be empty"}) + return + } + if !isValidSlug(slug) { + c.JSON(http.StatusBadRequest, gin.H{"error": invalidSlugUserMessage}) + 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 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.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)) + } + + // 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 + } + + 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) + version := "1.0.0" + if s.Version.Valid { + version = s.Version.String + } + 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: 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 = ? 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"}) + return + } + + c.Status(http.StatusNoContent) +} diff --git a/internal/api/handlers/station.go b/internal/api/handlers/station.go index be2c593..7cab4f3 100644 --- a/internal/api/handlers/station.go +++ b/internal/api/handlers/station.go @@ -6,9 +6,12 @@ package handlers import ( + "bytes" "database/sql" + "encoding/json" "fmt" "net/http" + "strconv" "strings" "time" @@ -29,36 +32,49 @@ 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" swaggertype:"object"` } // StationResponse represents a station in the response. 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"` - 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. func (h *StationHandler) RegisterRoutes(apiV1 *gin.RouterGroup) { apiV1.POST("/stations", h.CreateStation) apiV1.GET("/stations", h.ListStations) - apiV1.PATCH("/stations/:id", h.UpdateStation) + apiV1.GET("/stations/:id", h.GetStation) + apiV1.PUT("/stations/:id", h.UpdateStation) + apiV1.DELETE("/stations/:id", h.DeleteStation) } // robotInfoRow represents robot info retrieved from DB @@ -84,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 @@ -125,13 +135,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 @@ -148,13 +165,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 @@ -185,7 +209,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 } @@ -204,7 +228,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 } @@ -223,18 +247,19 @@ 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") + 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 ( @@ -247,9 +272,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 @@ -259,7 +285,8 @@ func (h *StationHandler) CreateStation(c *gin.Context) { dcInfo.OperatorID, // collector_operator_id robotInfo.FactoryID, req.Name, - "active", + "inactive", + metadataStr, createdAt, createdAt, ) @@ -279,18 +306,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: req.RobotID, - RobotName: robotType.Name, - RobotSerial: robotInfo.DeviceID, - DataCollectorID: req.DataCollectorID, - CollectorName: dcInfo.Name, - CollectorOperatorID: dcInfo.OperatorID, - FactoryID: factory.Slug, - Status: "active", - 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 +336,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 +356,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 @@ -339,58 +371,28 @@ 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) + } + 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("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], - 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, }) } @@ -418,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") @@ -442,44 +444,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 + } + + 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) + } + } - // Update the station status - _, err = h.db.Exec(` + 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"}) @@ -492,7 +659,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) @@ -502,32 +669,153 @@ 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 { + 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, + Metadata: stationMetadataFromDB(station.Metadata), + CreatedAt: createdAtStr, + UpdatedAt: updatedAtStr, + }) +} + +// 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, metadata, created_at, updated_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 } - // 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) + } + 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("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, - 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, }) } + +// 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 = ? 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"}) + 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..0bfc5e2 --- /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"` + Description string `json:"description,omitempty"` + InitialSceneLayout string `json:"initial_scene_layout,omitempty"` + 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"` + Description string `json:"description,omitempty"` + InitialSceneLayout string `json:"initial_scene_layout,omitempty"` +} + +// CreateSubsceneResponse represents the response for creating a subscene. +type CreateSubsceneResponse struct { + ID string `json:"id"` + Name string `json:"name"` + CreatedAt string `json:"created_at"` +} + +// 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"` +} + +// 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.PUT("/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"` + Description sql.NullString `db:"description"` + InitialSceneLayout sql.NullString `db:"initial_scene_layout"` + 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 +// @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, + description, + initial_scene_layout, + 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, + Description: description, + InitialSceneLayout: layout, + 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, + description, + initial_scene_layout, + 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, + Description: description, + InitialSceneLayout: layout, + 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.Description = strings.TrimSpace(req.Description) + req.InitialSceneLayout = strings.TrimSpace(req.InitialSceneLayout) + + 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 + } + + // 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 + } + + 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 + 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} + } else { + layoutStr = sceneLayoutTemplate + } + + now := time.Now().UTC() + + result, err := h.db.Exec( + `INSERT INTO subscenes ( + scene_id, + name, + description, + initial_scene_layout, + created_at, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?)`, + sceneID, + req.Name, + descriptionStr, + layoutStr, + 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, + 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} [put] +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, 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"}) + return + } + logger.Printf("[SUBSCENE] Failed to query subscene: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update subscene"}) + 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{}{} + + if req.Name != nil { + name := strings.TrimSpace(*req.Name) + if name != "" { + updates = append(updates, "name = ?") + args = append(args, name) + } + } + + 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) + } + + sceneLayoutTemplate, err := h.getSceneInitialLayoutTemplate(effectiveSceneID) + 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) + if layout != "" { + layoutStr = sql.NullString{String: layout, Valid: true} + } else { + layoutStr = sceneLayoutTemplate + } + } else { + layoutStr = sceneLayoutTemplate + } + 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 + } + + 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 = ? AND deleted_at IS NULL", 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, 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"}) + 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, + Description: description, + InitialSceneLayout: layout, + 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 = ? 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"}) + return + } + + c.Status(http.StatusNoContent) +} 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 == "" { 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") diff --git a/internal/storage/database/migrations/000001_initial_schema.up.sql b/internal/storage/database/migrations/000001_initial_schema.up.sql index b16182c..fd3dd0a 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, 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 (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,9 +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); - -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);