diff --git a/backend/internal/api/handlers/custom_fields.go b/backend/internal/api/handlers/custom_fields.go deleted file mode 100644 index f2c46d8..0000000 --- a/backend/internal/api/handlers/custom_fields.go +++ /dev/null @@ -1,203 +0,0 @@ -package handlers - -import ( - "encoding/json" - "net/http" - "regexp" - - "github.com/FluidifyAI/Regen/backend/internal/models" - "github.com/FluidifyAI/Regen/backend/internal/repository" - "github.com/gin-gonic/gin" - "github.com/google/uuid" -) - -var validKeyPattern = regexp.MustCompile(`^[a-z][a-z0-9_]*$`) - -type createCustomFieldRequest struct { - Name string `json:"name" binding:"required"` - Key string `json:"key" binding:"required"` - FieldType string `json:"field_type" binding:"required"` - Options json.RawMessage `json:"options"` - DisplayOrder int `json:"display_order"` -} - -type reorderItem struct { - ID uuid.UUID `json:"id" binding:"required"` - Order int `json:"order"` -} - -func ListCustomFields(repo repository.CustomFieldRepository) gin.HandlerFunc { - return func(c *gin.Context) { - fields, err := repo.List() - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list custom fields"}) - return - } - if fields == nil { - fields = []models.CustomFieldDefinition{} - } - c.JSON(http.StatusOK, fields) - } -} - -func CreateCustomField(repo repository.CustomFieldRepository) gin.HandlerFunc { - return func(c *gin.Context) { - var req createCustomFieldRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - if !validKeyPattern.MatchString(req.Key) { - c.JSON(http.StatusBadRequest, gin.H{"error": "key must be snake_case starting with a letter"}) - return - } - - switch req.FieldType { - case models.FieldTypeString, models.FieldTypeNumber, models.FieldTypeDropdown: - default: - c.JSON(http.StatusBadRequest, gin.H{"error": "field_type must be string, number, or dropdown"}) - return - } - - if req.FieldType == models.FieldTypeDropdown { - var opts []models.DropdownOption - if len(req.Options) == 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "dropdown fields require at least one option"}) - return - } - if err := json.Unmarshal(req.Options, &opts); err != nil || len(opts) == 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "dropdown fields require at least one option"}) - return - } - } - - options := req.Options - if len(options) == 0 { - options = json.RawMessage("[]") - } - - def := &models.CustomFieldDefinition{ - Name: req.Name, - Key: req.Key, - FieldType: req.FieldType, - Options: options, - DisplayOrder: req.DisplayOrder, - } - - if err := repo.Create(def); err != nil { - c.JSON(http.StatusConflict, gin.H{"error": "a field with that key already exists"}) - return - } - - c.JSON(http.StatusCreated, def) - } -} - -func UpdateCustomField(repo repository.CustomFieldRepository) gin.HandlerFunc { - return func(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) - return - } - - var req createCustomFieldRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - if !validKeyPattern.MatchString(req.Key) { - c.JSON(http.StatusBadRequest, gin.H{"error": "key must be snake_case starting with a letter"}) - return - } - - switch req.FieldType { - case models.FieldTypeString, models.FieldTypeNumber, models.FieldTypeDropdown: - default: - c.JSON(http.StatusBadRequest, gin.H{"error": "field_type must be string, number, or dropdown"}) - return - } - - options := req.Options - if len(options) == 0 { - options = json.RawMessage("[]") - } - - def := &models.CustomFieldDefinition{ - ID: id, - Name: req.Name, - Key: req.Key, - FieldType: req.FieldType, - Options: options, - DisplayOrder: req.DisplayOrder, - } - - if err := repo.Update(def); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update custom field"}) - return - } - - c.JSON(http.StatusOK, def) - } -} - -func DeleteCustomField(repo repository.CustomFieldRepository) gin.HandlerFunc { - return func(c *gin.Context) { - id, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) - return - } - - // Look up the field to get its key for usage check - var key string - fields, listErr := repo.List() - if listErr == nil { - for _, f := range fields { - if f.ID == id { - key = f.Key - break - } - } - } - - if key != "" { - count, countErr := repo.CountUsage(key) - if countErr == nil && count > 0 { - c.JSON(http.StatusConflict, gin.H{"error": "field is in use", "count": count}) - return - } - } - - if err := repo.Delete(id); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete custom field"}) - return - } - - c.Status(http.StatusNoContent) - } -} - -func ReorderCustomFields(repo repository.CustomFieldRepository) gin.HandlerFunc { - return func(c *gin.Context) { - var items []reorderItem - if err := c.ShouldBindJSON(&items); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - reorderItems := make([]repository.ReorderItem, len(items)) - for i, item := range items { - reorderItems[i] = repository.ReorderItem{ID: item.ID, Order: item.Order} - } - - if err := repo.Reorder(reorderItems); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reorder custom fields"}) - return - } - - c.JSON(http.StatusOK, gin.H{"ok": true}) - } -} diff --git a/backend/internal/api/handlers/custom_fields_test.go b/backend/internal/api/handlers/custom_fields_test.go deleted file mode 100644 index 054da41..0000000 --- a/backend/internal/api/handlers/custom_fields_test.go +++ /dev/null @@ -1,204 +0,0 @@ -package handlers_test - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "github.com/FluidifyAI/Regen/backend/internal/api/handlers" - "github.com/FluidifyAI/Regen/backend/internal/database" - "github.com/FluidifyAI/Regen/backend/internal/repository" - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func setupCustomFieldsRouter(t *testing.T) (*gin.Engine, repository.CustomFieldRepository) { - db := database.SetupTestDB(t) - repo := repository.NewCustomFieldRepository(db) - - gin.SetMode(gin.TestMode) - r := gin.New() - v1 := r.Group("/api/v1") - v1.GET("/custom-fields", handlers.ListCustomFields(repo)) - v1.POST("/custom-fields", handlers.CreateCustomField(repo)) - v1.PUT("/custom-fields/:id", handlers.UpdateCustomField(repo)) - v1.DELETE("/custom-fields/:id", handlers.DeleteCustomField(repo)) - v1.PATCH("/custom-fields/reorder", handlers.ReorderCustomFields(repo)) - - return r, repo -} - -func TestCustomFields_List_Empty(t *testing.T) { - r, _ := setupCustomFieldsRouter(t) - - w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodGet, "/api/v1/custom-fields", nil) - r.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - var resp []interface{} - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) - assert.Empty(t, resp) -} - -func TestCustomFields_Create_Valid(t *testing.T) { - r, _ := setupCustomFieldsRouter(t) - - body := `{"name":"Affected Service","key":"affected_service","field_type":"string"}` - w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodPost, "/api/v1/custom-fields", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - r.ServeHTTP(w, req) - - assert.Equal(t, http.StatusCreated, w.Code) - var resp map[string]interface{} - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) - assert.Equal(t, "affected_service", resp["key"]) - assert.NotEmpty(t, resp["id"]) -} - -func TestCustomFields_Create_InvalidKeyFormat(t *testing.T) { - r, _ := setupCustomFieldsRouter(t) - - body := `{"name":"Bad Key","key":"Bad-Key","field_type":"string"}` - w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodPost, "/api/v1/custom-fields", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - r.ServeHTTP(w, req) - - assert.Equal(t, http.StatusBadRequest, w.Code) -} - -func TestCustomFields_Create_InvalidType(t *testing.T) { - r, _ := setupCustomFieldsRouter(t) - - body := `{"name":"Field","key":"my_field","field_type":"boolean"}` - w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodPost, "/api/v1/custom-fields", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - r.ServeHTTP(w, req) - - assert.Equal(t, http.StatusBadRequest, w.Code) -} - -func TestCustomFields_Create_DropdownWithoutOptions(t *testing.T) { - r, _ := setupCustomFieldsRouter(t) - - body := `{"name":"Priority","key":"priority","field_type":"dropdown","options":[]}` - w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodPost, "/api/v1/custom-fields", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - r.ServeHTTP(w, req) - - assert.Equal(t, http.StatusBadRequest, w.Code) -} - -func TestCustomFields_Create_DropdownWithOptions(t *testing.T) { - r, _ := setupCustomFieldsRouter(t) - - body := `{"name":"Priority","key":"priority","field_type":"dropdown","options":[{"label":"High","value":"high"}]}` - w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodPost, "/api/v1/custom-fields", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - r.ServeHTTP(w, req) - - assert.Equal(t, http.StatusCreated, w.Code) -} - -func TestCustomFields_Create_DuplicateKey(t *testing.T) { - r, _ := setupCustomFieldsRouter(t) - - body := `{"name":"Service","key":"service","field_type":"string"}` - for range 2 { - w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodPost, "/api/v1/custom-fields", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - r.ServeHTTP(w, req) - } - - // Second create must return conflict - w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodPost, "/api/v1/custom-fields", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - r.ServeHTTP(w, req) - assert.Equal(t, http.StatusConflict, w.Code) -} - -func TestCustomFields_Update(t *testing.T) { - r, repo := setupCustomFieldsRouter(t) - - // Create first - body := `{"name":"Team","key":"team","field_type":"string"}` - w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodPost, "/api/v1/custom-fields", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - r.ServeHTTP(w, req) - require.Equal(t, http.StatusCreated, w.Code) - - fields, _ := repo.List() - require.Len(t, fields, 1) - id := fields[0].ID.String() - - // Update - update := `{"name":"Team (updated)","key":"team","field_type":"string"}` - w2 := httptest.NewRecorder() - req2, _ := http.NewRequest(http.MethodPut, "/api/v1/custom-fields/"+id, bytes.NewBufferString(update)) - req2.Header.Set("Content-Type", "application/json") - r.ServeHTTP(w2, req2) - assert.Equal(t, http.StatusOK, w2.Code) - var resp map[string]interface{} - require.NoError(t, json.Unmarshal(w2.Body.Bytes(), &resp)) - assert.Equal(t, "Team (updated)", resp["name"]) -} - -func TestCustomFields_Delete_NoUsage(t *testing.T) { - r, repo := setupCustomFieldsRouter(t) - - body := `{"name":"Region","key":"region","field_type":"string"}` - w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodPost, "/api/v1/custom-fields", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - r.ServeHTTP(w, req) - require.Equal(t, http.StatusCreated, w.Code) - - fields, _ := repo.List() - id := fields[0].ID.String() - - w2 := httptest.NewRecorder() - req2, _ := http.NewRequest(http.MethodDelete, "/api/v1/custom-fields/"+id, nil) - r.ServeHTTP(w2, req2) - assert.Equal(t, http.StatusNoContent, w2.Code) -} - -func TestCustomFields_Reorder(t *testing.T) { - r, repo := setupCustomFieldsRouter(t) - - // Create two fields - for _, b := range []string{ - `{"name":"Alpha","key":"alpha","field_type":"string"}`, - `{"name":"Beta","key":"beta","field_type":"string"}`, - } { - w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodPost, "/api/v1/custom-fields", bytes.NewBufferString(b)) - req.Header.Set("Content-Type", "application/json") - r.ServeHTTP(w, req) - require.Equal(t, http.StatusCreated, w.Code) - } - - fields, _ := repo.List() - require.Len(t, fields, 2) - - reorderBody, _ := json.Marshal([]map[string]interface{}{ - {"id": fields[0].ID.String(), "order": 10}, - {"id": fields[1].ID.String(), "order": 1}, - }) - - w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodPatch, "/api/v1/custom-fields/reorder", bytes.NewBuffer(reorderBody)) - req.Header.Set("Content-Type", "application/json") - r.ServeHTTP(w, req) - assert.Equal(t, http.StatusOK, w.Code) -} diff --git a/backend/internal/api/handlers/dto/incident_request.go b/backend/internal/api/handlers/dto/incident_request.go index 17031eb..1eb627a 100644 --- a/backend/internal/api/handlers/dto/incident_request.go +++ b/backend/internal/api/handlers/dto/incident_request.go @@ -25,26 +25,23 @@ type UpdateIncidentRequest struct { // AIEnabled can toggle AI agent processing on/off after creation. AIEnabled *bool `json:"ai_enabled"` // CommanderID assigns the incident commander. Use null/omit to leave unchanged. - CommanderID *uuid.UUID `json:"commander_id"` - CustomFields map[string]string `json:"custom_fields"` + CommanderID *uuid.UUID `json:"commander_id"` } // IncidentFilters holds query parameters for filtering incidents type IncidentFilters struct { - Status string `form:"status" binding:"omitempty,oneof=triggered acknowledged resolved canceled"` - Severity string `form:"severity" binding:"omitempty,oneof=critical high medium low"` - CreatedAfter *time.Time `form:"created_after" time_format:"2006-01-02T15:04:05Z07:00"` - CreatedBefore *time.Time `form:"created_before" time_format:"2006-01-02T15:04:05Z07:00"` - CustomFields map[string]string `form:"cf"` + Status string `form:"status" binding:"omitempty,oneof=triggered acknowledged resolved canceled"` + Severity string `form:"severity" binding:"omitempty,oneof=critical high medium low"` + CreatedAfter *time.Time `form:"created_after" time_format:"2006-01-02T15:04:05Z07:00"` + CreatedBefore *time.Time `form:"created_before" time_format:"2006-01-02T15:04:05Z07:00"` } // ToRepository converts API filters to repository filters func (f *IncidentFilters) ToRepository() repository.IncidentFilters { return repository.IncidentFilters{ - Status: models.IncidentStatus(f.Status), - Severity: models.IncidentSeverity(f.Severity), - StartDate: f.CreatedAfter, - EndDate: f.CreatedBefore, - CustomFields: f.CustomFields, + Status: models.IncidentStatus(f.Status), + Severity: models.IncidentSeverity(f.Severity), + StartDate: f.CreatedAfter, + EndDate: f.CreatedBefore, } } diff --git a/backend/internal/api/handlers/dto/incident_response.go b/backend/internal/api/handlers/dto/incident_response.go index bc08436..c2677dc 100644 --- a/backend/internal/api/handlers/dto/incident_response.go +++ b/backend/internal/api/handlers/dto/incident_response.go @@ -34,9 +34,6 @@ type IncidentResponse struct { // AI Agents (v0.9+) AIEnabled bool `json:"ai_enabled"` - - // Custom fields (OPE-109) - CustomFields map[string]interface{} `json:"custom_fields,omitempty"` } // SlackChannelInfo contains Slack channel details @@ -101,9 +98,6 @@ func ToIncidentResponse(incident *models.Incident) IncidentResponse { // AI Agents (v0.9+) AIEnabled: incident.AIEnabled, - - // Custom fields (OPE-109) - CustomFields: incident.CustomFields, } // Add Slack channel info if available diff --git a/backend/internal/api/handlers/incidents.go b/backend/internal/api/handlers/incidents.go index 4aeb186..6a08b41 100644 --- a/backend/internal/api/handlers/incidents.go +++ b/backend/internal/api/handlers/incidents.go @@ -264,23 +264,14 @@ func UpdateIncident(incidentSvc services.IncidentService) gin.HandlerFunc { } // Update incident - var cfJSONB models.JSONB - if req.CustomFields != nil { - cfJSONB = make(models.JSONB, len(req.CustomFields)) - for k, v := range req.CustomFields { - cfJSONB[k] = v - } - } - params := services.UpdateIncidentParams{ - Status: models.IncidentStatus(req.Status), - Severity: models.IncidentSeverity(req.Severity), - Summary: req.Summary, - UpdatedBy: actorIDFromContext(c), - ClientIP: c.ClientIP(), - AIEnabled: req.AIEnabled, - CommanderID: req.CommanderID, - CustomFields: cfJSONB, + Status: models.IncidentStatus(req.Status), + Severity: models.IncidentSeverity(req.Severity), + Summary: req.Summary, + UpdatedBy: actorIDFromContext(c), + ClientIP: c.ClientIP(), + AIEnabled: req.AIEnabled, + CommanderID: req.CommanderID, } updatedIncident, err := incidentSvc.UpdateIncident(incident.ID, ¶ms) diff --git a/backend/internal/api/routes.go b/backend/internal/api/routes.go index d3a3198..293ece6 100644 --- a/backend/internal/api/routes.go +++ b/backend/internal/api/routes.go @@ -35,7 +35,6 @@ func SetupRoutes(router *gin.Engine, db *gorm.DB, cfg *config.Config, teamsSvc * alertRepo := repository.NewAlertRepository(db) incidentRepo := repository.NewIncidentRepository(db) timelineRepo := repository.NewTimelineRepository(db) - customFieldRepo := repository.NewCustomFieldRepository(db) groupingRuleRepo := repository.NewGroupingRuleRepository(db) routingRuleRepo := repository.NewRoutingRuleRepository(db) escalationPolicyRepo := repository.NewEscalationPolicyRepository(db) @@ -326,13 +325,6 @@ func SetupRoutes(router *gin.Engine, db *gorm.DB, cfg *config.Config, teamsSvc * protected.POST("/incidents/:id/postmortem/comments", handlers.CreatePostMortemComment(incidentSvc, postMortemSvc)) protected.DELETE("/incidents/:id/postmortem/comments/:commentId", handlers.DeletePostMortemComment(incidentSvc, postMortemSvc)) - // Custom Fields - protected.GET("/custom-fields", handlers.ListCustomFields(customFieldRepo)) - protected.POST("/custom-fields", handlers.CreateCustomField(customFieldRepo)) - protected.PUT("/custom-fields/:id", handlers.UpdateCustomField(customFieldRepo)) - protected.DELETE("/custom-fields/:id", handlers.DeleteCustomField(customFieldRepo)) - protected.PATCH("/custom-fields/reorder", handlers.ReorderCustomFields(customFieldRepo)) - // Grouping Rules (v0.3) protected.GET("/grouping-rules", handlers.ListGroupingRules(groupingRuleRepo)) protected.GET("/grouping-rules/:id", handlers.GetGroupingRule(groupingRuleRepo)) diff --git a/backend/internal/database/test_helper.go b/backend/internal/database/test_helper.go index 786c315..2635e3f 100644 --- a/backend/internal/database/test_helper.go +++ b/backend/internal/database/test_helper.go @@ -49,32 +49,6 @@ CREATE TABLE IF NOT EXISTS users ( updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP )` -const createCustomFieldDefinitionsTableSQL = ` -CREATE TABLE IF NOT EXISTS custom_field_definitions ( - id TEXT NOT NULL PRIMARY KEY, - name TEXT NOT NULL, - key TEXT NOT NULL UNIQUE, - field_type TEXT NOT NULL, - options TEXT NOT NULL DEFAULT '[]', - display_order INTEGER NOT NULL DEFAULT 0, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP -)` - -const createIncidentsTableSQL = ` -CREATE TABLE IF NOT EXISTS incidents ( - id TEXT NOT NULL PRIMARY KEY, - incident_number INTEGER NOT NULL DEFAULT 0, - title TEXT NOT NULL DEFAULT '', - slug TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL DEFAULT 'triggered', - severity TEXT NOT NULL DEFAULT 'low', - summary TEXT NOT NULL DEFAULT '', - custom_fields TEXT NOT NULL DEFAULT '{}', - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - triggered_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP -)` - // SetupTestDB creates an isolated in-memory SQLite database for a single test, // creates the users table, and registers a cleanup function that closes the // connection when the test finishes. @@ -96,15 +70,8 @@ func SetupTestDB(t *testing.T) *gorm.DB { if err != nil { t.Fatalf("SetupTestDB: get sql.DB: %v", err) } - - for _, ddl := range []string{ - createUsersTableSQL, - createCustomFieldDefinitionsTableSQL, - createIncidentsTableSQL, - } { - if _, err := sqlDB.Exec(ddl); err != nil { - t.Fatalf("SetupTestDB: create table: %v", err) - } + if _, err := sqlDB.Exec(createUsersTableSQL); err != nil { + t.Fatalf("SetupTestDB: create users table: %v", err) } t.Cleanup(func() { diff --git a/backend/internal/models/custom_field_definition.go b/backend/internal/models/custom_field_definition.go deleted file mode 100644 index ec4038c..0000000 --- a/backend/internal/models/custom_field_definition.go +++ /dev/null @@ -1,38 +0,0 @@ -package models - -import ( - "encoding/json" - "time" - - "github.com/google/uuid" - "gorm.io/gorm" -) - -const ( - FieldTypeString = "string" - FieldTypeNumber = "number" - FieldTypeDropdown = "dropdown" -) - -type DropdownOption struct { - Label string `json:"label"` - Value string `json:"value"` -} - -type CustomFieldDefinition struct { - ID uuid.UUID `gorm:"primarykey;type:uuid;default:gen_random_uuid()" json:"id"` - Name string `gorm:"not null" json:"name"` - Key string `gorm:"uniqueIndex;not null" json:"key"` - FieldType string `gorm:"column:field_type;not null" json:"field_type"` - Options json.RawMessage `gorm:"type:jsonb;default:'[]'" json:"options"` - DisplayOrder int `gorm:"default:0" json:"display_order"` - CreatedAt time.Time ` json:"created_at"` - UpdatedAt time.Time ` json:"updated_at"` -} - -func (c *CustomFieldDefinition) BeforeCreate(tx *gorm.DB) error { - if c.ID == uuid.Nil { - c.ID = uuid.New() - } - return nil -} diff --git a/backend/internal/repository/custom_field_repository.go b/backend/internal/repository/custom_field_repository.go deleted file mode 100644 index 1e067a0..0000000 --- a/backend/internal/repository/custom_field_repository.go +++ /dev/null @@ -1,91 +0,0 @@ -package repository - -import ( - "fmt" - - "github.com/FluidifyAI/Regen/backend/internal/models" - "github.com/google/uuid" - "gorm.io/gorm" -) - -type ReorderItem struct { - ID uuid.UUID - Order int -} - -type CustomFieldRepository interface { - List() ([]models.CustomFieldDefinition, error) - Create(def *models.CustomFieldDefinition) error - Update(def *models.CustomFieldDefinition) error - Delete(id uuid.UUID) error - Reorder(items []ReorderItem) error - GetByKey(key string) (*models.CustomFieldDefinition, error) - CountUsage(key string) (int64, error) -} - -type customFieldRepository struct { - db *gorm.DB -} - -func NewCustomFieldRepository(db *gorm.DB) CustomFieldRepository { - return &customFieldRepository{db: db} -} - -func (r *customFieldRepository) List() ([]models.CustomFieldDefinition, error) { - var defs []models.CustomFieldDefinition - if err := r.db.Order("display_order ASC, created_at ASC").Find(&defs).Error; err != nil { - return nil, err - } - return defs, nil -} - -func (r *customFieldRepository) Create(def *models.CustomFieldDefinition) error { - return r.db.Create(def).Error -} - -func (r *customFieldRepository) Update(def *models.CustomFieldDefinition) error { - return r.db.Save(def).Error -} - -func (r *customFieldRepository) Delete(id uuid.UUID) error { - return r.db.Delete(&models.CustomFieldDefinition{}, id).Error -} - -func (r *customFieldRepository) Reorder(items []ReorderItem) error { - return r.db.Transaction(func(tx *gorm.DB) error { - for _, item := range items { - if err := tx.Model(&models.CustomFieldDefinition{}). - Where("id = ?", item.ID). - Update("display_order", item.Order).Error; err != nil { - return err - } - } - return nil - }) -} - -func (r *customFieldRepository) GetByKey(key string) (*models.CustomFieldDefinition, error) { - var def models.CustomFieldDefinition - if err := r.db.Where("key = ?", key).First(&def).Error; err != nil { - if err == gorm.ErrRecordNotFound { - return nil, &NotFoundError{Resource: "custom_field", ID: key} - } - return nil, err - } - return &def, nil -} - -func (r *customFieldRepository) CountUsage(key string) (int64, error) { - var count int64 - var err error - // Postgres uses the JSONB key-exists operator; SQLite (test env) uses json_extract. - if r.db.Dialector.Name() == "postgres" { - err = r.db.Raw("SELECT COUNT(*) FROM incidents WHERE custom_fields ? ?", key).Scan(&count).Error - } else { - err = r.db.Raw( - "SELECT COUNT(*) FROM incidents WHERE json_extract(custom_fields, ?) IS NOT NULL", - fmt.Sprintf("$.%s", key), - ).Scan(&count).Error - } - return count, err -} diff --git a/backend/internal/repository/custom_field_repository_test.go b/backend/internal/repository/custom_field_repository_test.go deleted file mode 100644 index e6d404b..0000000 --- a/backend/internal/repository/custom_field_repository_test.go +++ /dev/null @@ -1,134 +0,0 @@ -package repository_test - -import ( - "encoding/json" - "testing" - - "github.com/FluidifyAI/Regen/backend/internal/database" - "github.com/FluidifyAI/Regen/backend/internal/models" - "github.com/FluidifyAI/Regen/backend/internal/repository" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func makeField(name, key, fieldType string) *models.CustomFieldDefinition { - opts, _ := json.Marshal([]models.DropdownOption{}) - return &models.CustomFieldDefinition{ - Name: name, - Key: key, - FieldType: fieldType, - Options: json.RawMessage(opts), - } -} - -func makeDropdownField(name, key string, opts []models.DropdownOption) *models.CustomFieldDefinition { - raw, _ := json.Marshal(opts) - return &models.CustomFieldDefinition{ - Name: name, - Key: key, - FieldType: models.FieldTypeDropdown, - Options: json.RawMessage(raw), - } -} - -func TestCustomFieldRepository_CRUD(t *testing.T) { - db := database.SetupTestDB(t) - repo := repository.NewCustomFieldRepository(db) - - // Create - f := makeField("Affected Service", "affected_service", models.FieldTypeString) - require.NoError(t, repo.Create(f)) - assert.NotEmpty(t, f.ID) - - // List - fields, err := repo.List() - require.NoError(t, err) - assert.Len(t, fields, 1) - assert.Equal(t, "affected_service", fields[0].Key) - - // GetByKey - found, err := repo.GetByKey("affected_service") - require.NoError(t, err) - assert.Equal(t, f.ID, found.ID) - - // Update - f.Name = "Affected Service (updated)" - require.NoError(t, repo.Update(f)) - found, err = repo.GetByKey("affected_service") - require.NoError(t, err) - assert.Equal(t, "Affected Service (updated)", found.Name) - - // Delete - require.NoError(t, repo.Delete(f.ID)) - fields, err = repo.List() - require.NoError(t, err) - assert.Empty(t, fields) -} - -func TestCustomFieldRepository_KeyUniqueness(t *testing.T) { - db := database.SetupTestDB(t) - repo := repository.NewCustomFieldRepository(db) - - f1 := makeField("Service", "service", models.FieldTypeString) - require.NoError(t, repo.Create(f1)) - - f2 := makeField("Service Dupe", "service", models.FieldTypeString) - err := repo.Create(f2) - require.Error(t, err) -} - -func TestCustomFieldRepository_Reorder(t *testing.T) { - db := database.SetupTestDB(t) - repo := repository.NewCustomFieldRepository(db) - - f1 := makeField("Alpha", "alpha", models.FieldTypeString) - f2 := makeField("Beta", "beta", models.FieldTypeString) - require.NoError(t, repo.Create(f1)) - require.NoError(t, repo.Create(f2)) - - require.NoError(t, repo.Reorder([]repository.ReorderItem{ - {ID: f1.ID, Order: 5}, - {ID: f2.ID, Order: 2}, - })) - - fields, err := repo.List() - require.NoError(t, err) - require.Len(t, fields, 2) - // List is ordered by display_order ASC — beta (2) first, alpha (5) second - assert.Equal(t, "beta", fields[0].Key) - assert.Equal(t, "alpha", fields[1].Key) -} - -func TestCustomFieldRepository_CountUsage(t *testing.T) { - db := database.SetupTestDB(t) - repo := repository.NewCustomFieldRepository(db) - - f := makeField("Team", "team", models.FieldTypeString) - require.NoError(t, repo.Create(f)) - - // No incidents yet — usage is zero - count, err := repo.CountUsage("team") - require.NoError(t, err) - assert.Equal(t, int64(0), count) -} - -func TestCustomFieldRepository_DropdownOptions(t *testing.T) { - db := database.SetupTestDB(t) - repo := repository.NewCustomFieldRepository(db) - - opts := []models.DropdownOption{ - {Label: "High", Value: "high"}, - {Label: "Low", Value: "low"}, - } - f := makeDropdownField("Priority", "priority", opts) - require.NoError(t, repo.Create(f)) - - found, err := repo.GetByKey("priority") - require.NoError(t, err) - assert.Equal(t, models.FieldTypeDropdown, found.FieldType) - - var decoded []models.DropdownOption - require.NoError(t, json.Unmarshal(found.Options, &decoded)) - require.Len(t, decoded, 2) - assert.Equal(t, "High", decoded[0].Label) -} diff --git a/backend/internal/repository/incident_repository.go b/backend/internal/repository/incident_repository.go index bb02aef..2025b34 100644 --- a/backend/internal/repository/incident_repository.go +++ b/backend/internal/repository/incident_repository.go @@ -2,7 +2,6 @@ package repository import ( "errors" - "fmt" "time" "github.com/FluidifyAI/Regen/backend/internal/models" @@ -40,7 +39,6 @@ type IncidentFilters struct { Severity models.IncidentSeverity StartDate *time.Time EndDate *time.Time - CustomFields map[string]string ResolvedSince *time.Time // filters on resolved_at >= value } @@ -131,9 +129,6 @@ func (r *incidentRepository) List(filters IncidentFilters, pagination Pagination if filters.EndDate != nil { query = query.Where("triggered_at <= ?", filters.EndDate) } - for k, v := range filters.CustomFields { - query = query.Where("custom_fields @> ?", fmt.Sprintf(`{"%s":"%s"}`, k, v)) - } if filters.ResolvedSince != nil { query = query.Where("resolved_at >= ?", filters.ResolvedSince) } diff --git a/backend/internal/services/incident_service.go b/backend/internal/services/incident_service.go index 8c37d22..3c7dbc7 100644 --- a/backend/internal/services/incident_service.go +++ b/backend/internal/services/incident_service.go @@ -32,14 +32,13 @@ type CreateIncidentParams struct { // UpdateIncidentParams holds parameters for updating an incident type UpdateIncidentParams struct { - Status models.IncidentStatus - Severity models.IncidentSeverity - Summary string - UpdatedBy string - ClientIP string // For audit logging - AIEnabled *bool // Controls whether AI agents process this incident. nil = no change. - CommanderID *uuid.UUID // nil = no change - CustomFields models.JSONB // nil = no change; non-nil replaces the entire map + Status models.IncidentStatus + Severity models.IncidentSeverity + Summary string + UpdatedBy string + ClientIP string // For audit logging + AIEnabled *bool // Controls whether AI agents process this incident. nil = no change. + CommanderID *uuid.UUID // nil = no change } // CreateTimelineEntryParams holds parameters for creating a timeline entry @@ -1137,11 +1136,6 @@ func (s *incidentService) UpdateIncident(id uuid.UUID, params *UpdateIncidentPar } } - // Replace custom fields if explicitly provided - if params.CustomFields != nil { - incident.CustomFields = params.CustomFields - } - // Update incident if err := s.incidentRepo.Update(incident); err != nil { return err diff --git a/backend/migrations/000039_custom_field_definitions.down.sql b/backend/migrations/000039_custom_field_definitions.down.sql deleted file mode 100644 index 9ed36e9..0000000 --- a/backend/migrations/000039_custom_field_definitions.down.sql +++ /dev/null @@ -1,2 +0,0 @@ -DROP INDEX IF EXISTS idx_incidents_custom_fields; -DROP TABLE IF EXISTS custom_field_definitions; diff --git a/backend/migrations/000039_custom_field_definitions.up.sql b/backend/migrations/000039_custom_field_definitions.up.sql deleted file mode 100644 index 26c04f7..0000000 --- a/backend/migrations/000039_custom_field_definitions.up.sql +++ /dev/null @@ -1,12 +0,0 @@ -CREATE TABLE custom_field_definitions ( - id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, - name TEXT NOT NULL, - key TEXT NOT NULL UNIQUE, - field_type TEXT NOT NULL CHECK (field_type IN ('string', 'number', 'dropdown')), - options JSONB NOT NULL DEFAULT '[]', - display_order INT NOT NULL DEFAULT 0, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE INDEX idx_incidents_custom_fields ON incidents USING GIN (custom_fields); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index cd7ab0c..bdea861 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -16,7 +16,6 @@ import { PostMortemTemplatesPage } from './pages/PostMortemTemplatesPage' import { SettingsUsersPage } from './pages/SettingsUsersPage' import { SystemSettingsPage } from './pages/SystemSettingsPage' import { SettingsMigrationsPage } from './pages/SettingsMigrationsPage' -import { SettingsIncidentsPage } from './pages/SettingsIncidentsPage' import { AnalyticsPage } from './pages/AnalyticsPage' import { IntegrationsPage } from './pages/IntegrationsPage' import { LogoutPage } from './pages/LogoutPage' @@ -89,7 +88,6 @@ function App() { } /> } /> } /> - } /> } /> diff --git a/frontend/src/api/customFields.ts b/frontend/src/api/customFields.ts deleted file mode 100644 index 896faf1..0000000 --- a/frontend/src/api/customFields.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { apiClient } from './client' - -export type FieldType = 'string' | 'number' | 'dropdown' - -export interface DropdownOption { - label: string - value: string -} - -export interface CustomFieldDefinition { - id: string - name: string - key: string - field_type: FieldType - options: DropdownOption[] - display_order: number - created_at: string - updated_at: string -} - -export interface CreateCustomFieldPayload { - name: string - key: string - field_type: FieldType - options?: DropdownOption[] - display_order?: number -} - -export async function listCustomFields(): Promise { - return apiClient.get('/api/v1/custom-fields') -} - -export async function createCustomField(payload: CreateCustomFieldPayload): Promise { - return apiClient.post('/api/v1/custom-fields', payload) -} - -export async function updateCustomField( - id: string, - payload: CreateCustomFieldPayload, -): Promise { - return apiClient.put(`/api/v1/custom-fields/${id}`, payload) -} - -export async function deleteCustomField(id: string): Promise { - return apiClient.delete(`/api/v1/custom-fields/${id}`) -} - -export async function reorderCustomFields(items: { id: string; order: number }[]): Promise { - return apiClient.patch('/api/v1/custom-fields/reorder', items) -} diff --git a/frontend/src/api/incidents.ts b/frontend/src/api/incidents.ts index 76b44fb..428fbff 100644 --- a/frontend/src/api/incidents.ts +++ b/frontend/src/api/incidents.ts @@ -14,14 +14,7 @@ import type { export async function listIncidents( params?: ListIncidentsParams ): Promise> { - const { customFields, ...rest } = params ?? {} - const query: Record = { ...rest } - if (customFields) { - for (const [k, v] of Object.entries(customFields)) { - if (v) query[`cf[${k}]`] = v - } - } - return apiClient.get>('/api/v1/incidents', query) + return apiClient.get>('/api/v1/incidents', params) } /** diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 1e0cf3e..313e606 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -28,9 +28,6 @@ export interface Incident { ai_summary_generated_at?: string // AI Agents (v0.9+) ai_enabled: boolean - - // Custom fields (OPE-109) - custom_fields?: Record } // AI response types (v0.6+) @@ -121,7 +118,6 @@ export interface CreateIncidentRequest { severity?: 'critical' | 'high' | 'medium' | 'low' description?: string ai_enabled?: boolean - custom_fields?: Record } export interface UpdateIncidentRequest { @@ -130,7 +126,6 @@ export interface UpdateIncidentRequest { summary?: string ai_enabled?: boolean commander_id?: string | null - custom_fields?: Record } // AI Agents (v0.9+) @@ -157,7 +152,6 @@ export interface ListIncidentsParams { limit?: number page?: number offset?: number - customFields?: Record } export interface ListTimelineParams { diff --git a/frontend/src/components/incidents/CreateIncidentModal.tsx b/frontend/src/components/incidents/CreateIncidentModal.tsx index b897c82..2eaa833 100644 --- a/frontend/src/components/incidents/CreateIncidentModal.tsx +++ b/frontend/src/components/incidents/CreateIncidentModal.tsx @@ -2,7 +2,6 @@ import { type FormEvent, useEffect, useRef, useState } from 'react' import { X, AlertTriangle, Sparkles, Loader2, ChevronDown, ChevronUp } from 'lucide-react' import { Button } from '../ui/Button' import { createIncident, enhanceIncidentDraft } from '../../api/incidents' -import { listCustomFields, CustomFieldDefinition } from '../../api/customFields' import type { Incident } from '../../api/types' interface CreateIncidentModalProps { @@ -97,10 +96,6 @@ export function CreateIncidentModal({ isOpen, onClose, onCreated }: CreateIncide const [isSubmitting, setIsSubmitting] = useState(false) const [error, setError] = useState(null) - // Custom fields - const [customFieldDefs, setCustomFieldDefs] = useState([]) - const [customFieldValues, setCustomFieldValues] = useState>({}) - // AI assist state const [aiOpen, setAiOpen] = useState(false) const [brief, setBrief] = useState('') @@ -115,10 +110,6 @@ export function CreateIncidentModal({ isOpen, onClose, onCreated }: CreateIncide return () => document.removeEventListener('keydown', handleKey) }, [isOpen, onClose]) - useEffect(() => { - listCustomFields().then(setCustomFieldDefs).catch(() => {}) - }, []) - useEffect(() => { if (!isOpen) { setTitle('') @@ -128,7 +119,6 @@ export function CreateIncidentModal({ isOpen, onClose, onCreated }: CreateIncide setAiOpen(false) setBrief('') setAiError(null) - setCustomFieldValues({}) } }, [isOpen]) @@ -169,15 +159,11 @@ export function CreateIncidentModal({ isOpen, onClose, onCreated }: CreateIncide setIsSubmitting(true) setError(null) try { - const cfValues = Object.fromEntries( - Object.entries(customFieldValues).filter(([, v]) => v.trim() !== '') - ) const incident = await createIncident({ title: title.trim(), severity, description: summary.trim() || undefined, ai_enabled: false, - custom_fields: Object.keys(cfValues).length > 0 ? cfValues : undefined, }) onCreated(incident) onClose() @@ -354,41 +340,6 @@ export function CreateIncidentModal({ isOpen, onClose, onCreated }: CreateIncide disabled={isSubmitting} /> - - {/* Custom fields */} - {customFieldDefs.length > 0 && ( -
-

Additional fields - (optional) -

- {customFieldDefs.map((def) => ( -
- - {def.field_type === 'dropdown' ? ( - - ) : ( - setCustomFieldValues(prev => ({ ...prev, [def.key]: e.target.value }))} - disabled={isSubmitting} - className="w-full px-3 py-2 border border-border rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-brand-primary/30 focus:border-brand-primary transition-colors" - /> - )} -
- ))} -
- )} {/* Footer */} diff --git a/frontend/src/components/layout/PropertiesPanel.tsx b/frontend/src/components/layout/PropertiesPanel.tsx index 732abf5..0e91506 100644 --- a/frontend/src/components/layout/PropertiesPanel.tsx +++ b/frontend/src/components/layout/PropertiesPanel.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useRef } from 'react' import { ChevronDown, ChevronUp, Hash, Clock, ExternalLink, Timer, - Activity, AlertCircle, Type, ChevronRight, User, Search, X, + Activity, AlertCircle, ChevronRight, User, Search, X, } from 'lucide-react' import { Badge } from '../ui/Badge' import { Avatar } from '../ui/Avatar' @@ -10,7 +10,6 @@ import type { Alert, TimelineEntry } from '../../api/types' import { updateIncident } from '../../api/incidents' import { useAuth } from '../../contexts/AuthContext' import { listUsers, type UserSummary } from '../../api/users' -import { listCustomFields, type CustomFieldDefinition } from '../../api/customFields' type StatusType = 'triggered' | 'acknowledged' | 'resolved' | 'canceled' type SeverityType = 'critical' | 'high' | 'medium' | 'low' @@ -33,7 +32,6 @@ interface Incident { commander_id?: string commander_name?: string ai_enabled: boolean - custom_fields?: Record alerts: Alert[] timeline: TimelineEntry[] } @@ -46,14 +44,13 @@ interface PropertiesPanelProps { // ── Section collapse ─────────────────────────────────────────────────────────── -type SectionKey = 'status' | 'commander' | 'time' | 'customFields' | 'channels' | 'alerts' +type SectionKey = 'status' | 'commander' | 'time' | 'channels' | 'alerts' const SECTION_STORAGE_KEY = 'properties-panel-sections-v2' function useSectionCollapse() { const defaults: Record = { - status: true, commander: true, time: true, - customFields: true, channels: true, alerts: true, + status: true, commander: true, time: true, channels: true, alerts: true, } const [open, setOpen] = useState>(() => { try { @@ -79,31 +76,6 @@ export function PropertiesPanel({ incident, onIncidentUpdated, lastActivityAt }: const [panelCollapsed, setPanelCollapsed] = useState(false) const { open, toggle } = useSectionCollapse() - // Custom fields state - const [customFieldDefs, setCustomFieldDefs] = useState([]) - const [editingCfKey, setEditingCfKey] = useState(null) - const [cfDraft, setCfDraft] = useState('') - const [flashingCfKey, setFlashingCfKey] = useState(null) - - useEffect(() => { - listCustomFields().then(setCustomFieldDefs).catch(() => {}) - }, []) - - async function saveCfValue(key: string, value: string) { - const existing = incident.custom_fields ?? {} - const updated = { ...existing } - if (value.trim() === '') { - delete updated[key] - } else { - updated[key] = value.trim() - } - await updateIncident(incident.id, { custom_fields: updated } as never) - onIncidentUpdated?.() - setEditingCfKey(null) - setFlashingCfKey(key) - setTimeout(() => setFlashingCfKey(null), 700) - } - // Commander state const { user: currentUser } = useAuth() const [users, setUsers] = useState([]) @@ -283,80 +255,6 @@ export function PropertiesPanel({ incident, onIncidentUpdated, lastActivityAt }: - {/* CUSTOM FIELDS */} - {customFieldDefs.length > 0 && ( -
toggle('customFields')}> -
- {customFieldDefs.map((def, idx) => { - const currentValue = incident.custom_fields?.[def.key] ?? '' - const isEditing = editingCfKey === def.key - const isFlashing = flashingCfKey === def.key - return ( -
- - - {def.name} - - {isEditing ? ( -
- {def.field_type === 'dropdown' ? ( - - ) : ( - setCfDraft(e.target.value)} - onBlur={() => saveCfValue(def.key, cfDraft)} - onKeyDown={e => { - if (e.key === 'Enter') saveCfValue(def.key, cfDraft) - if (e.key === 'Escape') setEditingCfKey(null) - }} - className="w-full px-1.5 py-0.5 text-xs border border-brand-primary rounded bg-white focus:outline-none" - /> - )} -
- ) : ( - - )} -
- ) - })} -
-
- )} - {/* CHANNELS */} {(incident.slack_channel_name || incident.teams_channel_name) && (
toggle('channels')}> @@ -453,12 +351,6 @@ function Section({ ) } -function FieldTypeIcon({ type }: { type: string }) { - if (type === 'number') return - if (type === 'dropdown') return - return -} - function TimeRow({ label, ts }: { label: string; ts: string }) { return (
diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 3f1ed27..057a270 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -17,7 +17,6 @@ import { Puzzle, Settings, ArrowRightLeft, - Sliders, } from 'lucide-react' import { Tooltip } from '../ui/Tooltip' import { ProfileModal } from '../ProfileModal' @@ -158,13 +157,6 @@ export function Sidebar() { href: '/settings/system', matchPaths: ['/settings/system'], }, - { - id: 'settings-incidents', - label: 'Incidents', - icon: Sliders, - href: '/settings/incidents', - matchPaths: ['/settings/incidents'], - }, { id: 'settings-migrations', label: 'Migrations', diff --git a/frontend/src/hooks/useIncidents.ts b/frontend/src/hooks/useIncidents.ts index 3beaf61..4976b34 100644 --- a/frontend/src/hooks/useIncidents.ts +++ b/frontend/src/hooks/useIncidents.ts @@ -47,8 +47,6 @@ export function useIncidents(params: ListIncidentsParams = {}): UseIncidentsResu params.severity, params.limit, params.page, - // eslint-disable-next-line react-hooks/exhaustive-deps - JSON.stringify(params.customFields), ]) useEffect(() => { @@ -80,8 +78,6 @@ export function useIncidents(params: ListIncidentsParams = {}): UseIncidentsResu params.severity, params.limit, params.page, - // eslint-disable-next-line react-hooks/exhaustive-deps - JSON.stringify(params.customFields), ]) return { diff --git a/frontend/src/pages/IncidentsListPage.tsx b/frontend/src/pages/IncidentsListPage.tsx index 10ad4d5..3c422bb 100644 --- a/frontend/src/pages/IncidentsListPage.tsx +++ b/frontend/src/pages/IncidentsListPage.tsx @@ -7,7 +7,6 @@ import { SkeletonTable } from '../components/ui/Skeleton' import { EmptyIncidentsList } from '../components/ui/EmptyState' import { GeneralError } from '../components/ui/ErrorState' import { useIncidents } from '../hooks/useIncidents' -import { listCustomFields, CustomFieldDefinition } from '../api/customFields' import { Search, Plus, ChevronLeft, ChevronRight } from 'lucide-react' import type { Incident } from '../api/types' @@ -23,28 +22,17 @@ export function IncidentsListPage() { const [searchQuery, setSearchQuery] = useState('') const [currentPage, setCurrentPage] = useState(1) const [showCreateModal, setShowCreateModal] = useState(false) - const [customFieldDefs, setCustomFieldDefs] = useState([]) - const [customFieldFilters, setCustomFieldFilters] = useState>({}) - - useEffect(() => { - listCustomFields().then(setCustomFieldDefs).catch(() => {}) - }, []) // Reset to page 1 whenever server-side filters change useEffect(() => { setCurrentPage(1) - }, [statusFilter, severityFilter, customFieldFilters]) - - const activeCustomFieldFilters = Object.fromEntries( - Object.entries(customFieldFilters).filter(([, v]) => v.trim() !== '') - ) + }, [statusFilter, severityFilter]) const { incidents, loading, error, total, refetch } = useIncidents({ status: statusFilter || undefined, severity: severityFilter || undefined, limit: PAGE_SIZE, page: currentPage, - customFields: Object.keys(activeCustomFieldFilters).length > 0 ? activeCustomFieldFilters : undefined, }) // Client-side search filters the current page's results @@ -122,32 +110,6 @@ export function IncidentsListPage() { - {/* Custom field filters */} - {customFieldDefs.map(def => ( - def.field_type === 'dropdown' ? ( - - ) : ( - setCustomFieldFilters(prev => ({ ...prev, [def.key]: e.target.value }))} - className="px-3 py-2 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-primary focus:border-transparent w-36" - /> - ) - ))} - {/* Results Count */}
{loading ? '...' : searchQuery @@ -169,7 +131,7 @@ export function IncidentsListPage() { {!loading && !error && filteredIncidents.length === 0 && ( 0)} + hasFilters={!!(statusFilter || severityFilter || searchQuery)} /> )} diff --git a/frontend/src/pages/SettingsIncidentsPage.tsx b/frontend/src/pages/SettingsIncidentsPage.tsx deleted file mode 100644 index c1c2b6d..0000000 --- a/frontend/src/pages/SettingsIncidentsPage.tsx +++ /dev/null @@ -1,372 +0,0 @@ -import { useState, useEffect } from 'react' -import { Plus, Trash2, ChevronUp, ChevronDown, Pencil, X } from 'lucide-react' -import { Button } from '../components/ui/Button' -import { - listCustomFields, - createCustomField, - updateCustomField, - deleteCustomField, - reorderCustomFields, - CustomFieldDefinition, - DropdownOption, - FieldType, -} from '../api/customFields' - -type FormState = { - name: string - key: string - field_type: FieldType - options: DropdownOption[] - newOptionLabel: string - newOptionValue: string -} - -const emptyForm = (): FormState => ({ - name: '', - key: '', - field_type: 'string', - options: [], - newOptionLabel: '', - newOptionValue: '', -}) - -function toSlug(name: string): string { - return name - .toLowerCase() - .replace(/[^a-z0-9]+/g, '_') - .replace(/^_+|_+$/g, '') - .replace(/^([^a-z])/, '_$1') -} - -export function SettingsIncidentsPage() { - const [fields, setFields] = useState([]) - const [loading, setLoading] = useState(true) - const [error, setError] = useState('') - const [showForm, setShowForm] = useState(false) - const [editingId, setEditingId] = useState(null) - const [form, setForm] = useState(emptyForm()) - const [saving, setSaving] = useState(false) - const [formError, setFormError] = useState('') - - useEffect(() => { - load() - }, []) - - async function load() { - setLoading(true) - try { - setFields(await listCustomFields()) - setError('') - } catch { - setError('Failed to load custom fields') - } finally { - setLoading(false) - } - } - - function openCreate() { - setEditingId(null) - setForm(emptyForm()) - setFormError('') - setShowForm(true) - } - - function openEdit(f: CustomFieldDefinition) { - setEditingId(f.id) - setForm({ - name: f.name, - key: f.key, - field_type: f.field_type, - options: f.options ?? [], - newOptionLabel: '', - newOptionValue: '', - }) - setFormError('') - setShowForm(true) - } - - function cancelForm() { - setShowForm(false) - setEditingId(null) - setForm(emptyForm()) - setFormError('') - } - - function handleNameChange(name: string) { - setForm(prev => ({ - ...prev, - name, - key: editingId ? prev.key : toSlug(name), - })) - } - - function addOption() { - const label = form.newOptionLabel.trim() - const value = form.newOptionValue.trim() || toSlug(label) - if (!label || !value) return - setForm(prev => ({ - ...prev, - options: [...prev.options, { label, value }], - newOptionLabel: '', - newOptionValue: '', - })) - } - - function removeOption(idx: number) { - setForm(prev => ({ ...prev, options: prev.options.filter((_, i) => i !== idx) })) - } - - async function handleSave() { - setFormError('') - if (!form.name.trim()) { setFormError('Name is required'); return } - if (!form.key.trim()) { setFormError('Key is required'); return } - if (form.field_type === 'dropdown' && form.options.length === 0) { - setFormError('Dropdown fields require at least one option') - return - } - - setSaving(true) - try { - const payload = { - name: form.name.trim(), - key: form.key.trim(), - field_type: form.field_type, - options: form.options, - } - if (editingId) { - await updateCustomField(editingId, payload) - } else { - await createCustomField(payload) - } - await load() - cancelForm() - } catch (e: unknown) { - const msg = e instanceof Error ? e.message : 'Failed to save' - setFormError(msg.includes('409') || msg.includes('already exists') ? 'A field with that key already exists' : msg) - } finally { - setSaving(false) - } - } - - async function handleDelete(f: CustomFieldDefinition) { - if (!confirm(`Delete field "${f.name}"? This cannot be undone.`)) return - try { - await deleteCustomField(f.id) - await load() - } catch (e: unknown) { - const msg = e instanceof Error ? e.message : '' - if (msg.includes('409') || msg.includes('in use')) { - alert(`Cannot delete "${f.name}" — it has values on existing incidents.`) - } else { - alert('Failed to delete field') - } - } - } - - async function move(idx: number, dir: -1 | 1) { - const next = [...fields] - const target = idx + dir - if (target < 0 || target >= next.length) return - const a = next[idx]! - const b = next[target]! - next[idx] = b - next[target] = a - setFields(next) - await reorderCustomFields(next.map((f, i) => ({ id: f.id, order: i }))) - } - - return ( -
-
-
-
-

Incident Settings

-

Configure custom fields and incident schema

-
-
-
- -
- {error && ( -
{error}
- )} - - {/* Custom Fields card */} -
-
-
-

Custom Fields

-

- Define metadata fields that appear on every incident -

-
- {!showForm && ( - - )} -
- - {/* Inline form */} - {showForm && ( -
-

- {editingId ? 'Edit field' : 'New field'} -

- {formError && ( -
{formError}
- )} -
-
- - handleNameChange(e.target.value)} - /> -
-
- - setForm(prev => ({ ...prev, key: e.target.value }))} - readOnly={!!editingId} - /> -
-
- - -
-
- - {form.field_type === 'dropdown' && ( -
- - {form.options.map((opt, i) => ( -
- {opt.label} - {opt.value} - -
- ))} -
- setForm(prev => ({ ...prev, newOptionLabel: e.target.value }))} - onKeyDown={e => e.key === 'Enter' && addOption()} - /> - setForm(prev => ({ ...prev, newOptionValue: e.target.value }))} - onKeyDown={e => e.key === 'Enter' && addOption()} - /> - -
-
- )} - -
- - -
-
- )} - - {/* Field list */} - {loading ? ( -
Loading…
- ) : fields.length === 0 ? ( -
- No custom fields yet. Add one to start capturing extra metadata on incidents. -
- ) : ( - - - - - - - - - - - - - {fields.map((f, idx) => ( - - - - - - - - - ))} - -
NameKeyTypeOptionsActions
-
- - -
-
{f.name}{f.key} - - {f.field_type === 'string' ? 'Text' : f.field_type === 'number' ? 'Number' : 'Dropdown'} - - - {f.field_type === 'dropdown' && f.options?.length > 0 - ? f.options.map(o => o.label).join(', ') - : '—'} - -
- - -
-
- )} -
-
-
- ) -}