diff --git a/Makefile b/Makefile index 1a5c76c..d84a36d 100644 --- a/Makefile +++ b/Makefile @@ -92,7 +92,6 @@ init-db: fi @echo "Database schema initialized successfully" - # Start Docker containers docker-up: @echo "Starting Docker containers..." diff --git a/internal/api/validation/validation.go b/internal/api/validation/validation.go index 4b07be4..2c88b71 100644 --- a/internal/api/validation/validation.go +++ b/internal/api/validation/validation.go @@ -10,6 +10,7 @@ import ( "time" "github.com/formbricks/hub/internal/api/response" + "github.com/formbricks/hub/internal/models" "github.com/go-playground/form/v4" "github.com/go-playground/validator/v10" ) @@ -48,6 +49,18 @@ func init() { } return &t, nil }, (*time.Time)(nil)) + + // Handle *models.FieldType (pointer type used in filters) + decoder.RegisterCustomTypeFunc(func(vals []string) (interface{}, error) { + if len(vals) == 0 || vals[0] == "" { + return (*models.FieldType)(nil), nil + } + ft, err := models.ParseFieldType(vals[0]) + if err != nil { + return nil, fmt.Errorf("invalid field type: %w", err) + } + return &ft, nil + }, (*models.FieldType)(nil)) } // ValidateStruct validates a struct using go-playground/validator @@ -157,20 +170,23 @@ func ValidateAndDecodeQueryParams(r *http.Request, dst interface{}) error { } // validateFieldType is a custom validator for field_type enum +// It validates both string and FieldType types func validateFieldType(fl validator.FieldLevel) bool { - value := fl.Field().String() - validTypes := map[string]bool{ - "text": true, - "categorical": true, - "nps": true, - "csat": true, - "ces": true, - "rating": true, - "number": true, - "boolean": true, - "date": true, + field := fl.Field() + + // Handle FieldType enum type directly + if field.Type() == reflect.TypeOf(models.FieldType("")) { + ft := models.FieldType(field.String()) + return ft.IsValid() } - return validTypes[value] + + // Handle string type (from JSON/query params) + if field.Kind() == reflect.String { + _, err := models.ParseFieldType(field.String()) + return err == nil + } + + return false } // validateNoNullBytes checks that a string field does not contain NULL bytes diff --git a/internal/models/feedback_records.go b/internal/models/feedback_records.go index 50cd24b..bee5881 100644 --- a/internal/models/feedback_records.go +++ b/internal/models/feedback_records.go @@ -2,11 +2,69 @@ package models import ( "encoding/json" + "fmt" "time" "github.com/google/uuid" ) +// FieldType represents the type of feedback field +type FieldType string + +const ( + FieldTypeText FieldType = "text" + FieldTypeCategorical FieldType = "categorical" + FieldTypeNPS FieldType = "nps" + FieldTypeCSAT FieldType = "csat" + FieldTypeCES FieldType = "ces" + FieldTypeRating FieldType = "rating" + FieldTypeNumber FieldType = "number" + FieldTypeBoolean FieldType = "boolean" + FieldTypeDate FieldType = "date" +) + +// ValidFieldTypes contains all valid field type values (set membership) +var ValidFieldTypes = map[FieldType]struct{}{ + FieldTypeText: {}, + FieldTypeCategorical: {}, + FieldTypeNPS: {}, + FieldTypeCSAT: {}, + FieldTypeCES: {}, + FieldTypeRating: {}, + FieldTypeNumber: {}, + FieldTypeBoolean: {}, + FieldTypeDate: {}, +} + +// IsValid returns true if the FieldType is valid +func (ft FieldType) IsValid() bool { + _, valid := ValidFieldTypes[ft] + return valid +} + +// ParseFieldType parses a string to FieldType, returns error if invalid +func ParseFieldType(s string) (FieldType, error) { + ft := FieldType(s) + if !ft.IsValid() { + return "", fmt.Errorf("invalid field type: %s", s) + } + return ft, nil +} + +// UnmarshalJSON implements json.Unmarshaler to validate field type during JSON unmarshaling +func (ft *FieldType) UnmarshalJSON(data []byte) error { + var s string + if err := json.Unmarshal(data, &s); err != nil { + return err + } + parsed, err := ParseFieldType(s) + if err != nil { + return err + } + *ft = parsed + return nil +} + // FeedbackRecord represents a single feedback record type FeedbackRecord struct { ID uuid.UUID `json:"id"` @@ -18,7 +76,7 @@ type FeedbackRecord struct { SourceName *string `json:"source_name,omitempty"` FieldID string `json:"field_id"` FieldLabel *string `json:"field_label,omitempty"` - FieldType string `json:"field_type"` + FieldType FieldType `json:"field_type"` FieldGroupID *string `json:"field_group_id,omitempty"` FieldGroupLabel *string `json:"field_group_label,omitempty"` ValueText *string `json:"value_text,omitempty"` @@ -39,7 +97,7 @@ type CreateFeedbackRecordRequest struct { SourceName *string `json:"source_name,omitempty"` FieldID string `json:"field_id" validate:"required,no_null_bytes,min=1,max=255"` FieldLabel *string `json:"field_label,omitempty"` - FieldType string `json:"field_type" validate:"required,field_type,min=1,max=255"` + FieldType FieldType `json:"field_type" validate:"required,field_type"` FieldGroupID *string `json:"field_group_id,omitempty" validate:"omitempty,no_null_bytes,max=255"` FieldGroupLabel *string `json:"field_group_label,omitempty"` ValueText *string `json:"value_text,omitempty" validate:"omitempty,no_null_bytes"` @@ -71,7 +129,7 @@ type ListFeedbackRecordsFilters struct { SourceID *string `form:"source_id" validate:"omitempty,no_null_bytes"` FieldID *string `form:"field_id" validate:"omitempty,no_null_bytes"` FieldGroupID *string `form:"field_group_id" validate:"omitempty,no_null_bytes"` - FieldType *string `form:"field_type" validate:"omitempty,no_null_bytes"` + FieldType *FieldType `form:"field_type" validate:"omitempty,field_type"` UserIdentifier *string `form:"user_identifier" validate:"omitempty,no_null_bytes"` Since *time.Time `form:"since" validate:"omitempty"` Until *time.Time `form:"until" validate:"omitempty"` diff --git a/openapi.yaml b/openapi.yaml index 512cfd6..c1f94ed 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -80,6 +80,16 @@ paths: schema: type: string description: Filter by field type. NULL bytes not allowed. + enum: + - text + - categorical + - nps + - csat + - ces + - rating + - number + - boolean + - date pattern: '^[^\x00]*$' - name: user_identifier in: query @@ -735,6 +745,16 @@ components: field_type: type: string description: Type of field + enum: + - text + - categorical + - nps + - csat + - ces + - rating + - number + - boolean + - date id: type: string format: uuid diff --git a/sql/001_initial_schema.sql b/sql/001_initial_schema.sql index d6038c7..e85466d 100644 --- a/sql/001_initial_schema.sql +++ b/sql/001_initial_schema.sql @@ -4,6 +4,11 @@ CREATE EXTENSION IF NOT EXISTS vector; CREATE EXTENSION IF NOT EXISTS "pgcrypto"; +-- Create ENUM types +CREATE TYPE field_type_enum AS ENUM ( + 'text', 'categorical', 'nps', 'csat', 'ces', 'rating', 'number', 'boolean', 'date' +); + -- Feedback records table CREATE TABLE feedback_records ( id UUID PRIMARY KEY DEFAULT uuidv7(), @@ -18,7 +23,7 @@ CREATE TABLE feedback_records ( field_id VARCHAR(255) NOT NULL, field_label VARCHAR, - field_type VARCHAR NOT NULL, + field_type field_type_enum NOT NULL, -- Field grouping for composite questions (ranking, matrix, grid) field_group_id VARCHAR(255), diff --git a/tests/README.md b/tests/README.md index af6a648..3e0383e 100644 --- a/tests/README.md +++ b/tests/README.md @@ -6,9 +6,9 @@ This directory contains integration tests for the Formbricks Hub API. Before running the tests, ensure: -1. PostgreSQL is running (via docker-compose) -2. Database schema has been initialized -3. The `API_KEY` environment variable is set (tests will set it automatically) +1. **PostgreSQL is running** with the default test credentials (e.g. `make docker-up`). The tests use `postgres://postgres:postgres@localhost:5432/test_db` by default. If you see `password authentication failed for user "postgres"`, start the stack with `make docker-up` and run `make init-db`. +2. **Database schema** has been initialized (`make init-db`). +3. **API_KEY** is set automatically by the tests; you do not need to set it. ## Running Tests diff --git a/tests/integration_test.go b/tests/integration_test.go index a1cd3dc..a0ed8db 100644 --- a/tests/integration_test.go +++ b/tests/integration_test.go @@ -21,12 +21,18 @@ import ( "github.com/stretchr/testify/require" ) +// defaultTestDatabaseURL is the default Postgres URL used by docker-compose (postgres/postgres/test_db). +// Setting it here before config.Load() ensures tests do not use a different DATABASE_URL from .env, +// which would cause "password authentication failed" when .env points at another database. +const defaultTestDatabaseURL = "postgres://postgres:postgres@localhost:5432/test_db?sslmode=disable" + // setupTestServer creates a test HTTP server with all routes configured func setupTestServer(t *testing.T) (*httptest.Server, func()) { ctx := context.Background() - // Set test API key in environment for authentication (must be set before loading config) + // Set test env before loading config so config.Load() uses test values and is not affected by .env. t.Setenv("API_KEY", testAPIKey) + t.Setenv("DATABASE_URL", defaultTestDatabaseURL) // Load configuration cfg, err := config.Load() @@ -214,7 +220,7 @@ func TestCreateFeedbackRecord(t *testing.T) { assert.NotEmpty(t, result.ID) assert.Equal(t, "formbricks", result.SourceType) assert.Equal(t, "feedback", result.FieldID) - assert.Equal(t, "text", result.FieldType) + assert.Equal(t, models.FieldTypeText, result.FieldType) assert.NotNil(t, result.ValueText) assert.Equal(t, "Great product!", *result.ValueText) })