From 67adb39a52fe524ba3e522ae5f959d9298b31bc9 Mon Sep 17 00:00:00 2001 From: Tiago Farto Date: Tue, 27 Jan 2026 11:57:49 +0400 Subject: [PATCH 1/3] chore: refactor field type to enum --- Makefile | 20 +++++++++ internal/api/validation/validation.go | 40 ++++++++++++----- internal/models/feedback_records.go | 64 +++++++++++++++++++++++++-- openapi.yaml | 20 +++++++++ sql/001_initial_schema.sql | 7 ++- 5 files changed, 135 insertions(+), 16 deletions(-) diff --git a/Makefile b/Makefile index 1a5c76c..d049134 100644 --- a/Makefile +++ b/Makefile @@ -12,6 +12,7 @@ help: @echo " make test-all - Run all tests (unit + integration)" @echo " make tests-coverage - Run tests with coverage report" @echo " make init-db - Initialize database schema" + @echo " make migrate - Run migration on existing database" @echo " make fmt - Format code with gofumpt" @echo " make fmt-check - Check if code is formatted" @echo " make lint - Run linter" @@ -92,6 +93,25 @@ init-db: fi @echo "Database schema initialized successfully" +# Run migration on existing database +migrate: + @echo "Running migration on existing database..." + @if [ -f .env ]; then \ + export $$(grep -v '^#' .env | xargs) && \ + if [ -z "$$DATABASE_URL" ]; then \ + echo "Error: DATABASE_URL not found in .env file"; \ + exit 1; \ + fi && \ + psql "$$DATABASE_URL" -f sql/002_convert_to_enums.sql; \ + else \ + if [ -z "$$DATABASE_URL" ]; then \ + echo "Error: DATABASE_URL environment variable is not set"; \ + echo "Please set it or create a .env file with DATABASE_URL"; \ + exit 1; \ + fi && \ + psql "$$DATABASE_URL" -f sql/002_convert_to_enums.sql; \ + fi + @echo "Migration completed successfully" # Start Docker containers docker-up: 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 b328c3c..d1967af 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"` ValueText *string `json:"value_text,omitempty"` ValueNumber *float64 `json:"value_number,omitempty"` ValueBoolean *bool `json:"value_boolean,omitempty"` @@ -38,7 +96,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"` ValueText *string `json:"value_text,omitempty" validate:"omitempty,no_null_bytes"` ValueNumber *float64 `json:"value_number,omitempty"` ValueBoolean *bool `json:"value_boolean,omitempty"` @@ -69,7 +127,7 @@ type ListFeedbackRecordsFilters struct { SourceType *string `form:"source_type" validate:"omitempty,no_null_bytes"` SourceID *string `form:"source_id" validate:"omitempty,no_null_bytes"` FieldID *string `form:"field_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 bf13bb0..ef7d558 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 @@ -695,6 +705,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 721d62a..b0c08ce 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, value_text TEXT, value_number DOUBLE PRECISION, From e89e8fd09865b1becfa8fe03558edac3d047fa4d Mon Sep 17 00:00:00 2001 From: Tiago Farto Date: Tue, 27 Jan 2026 12:01:00 +0400 Subject: [PATCH 2/3] chore: undo makefile changes --- Makefile | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/Makefile b/Makefile index d049134..d84a36d 100644 --- a/Makefile +++ b/Makefile @@ -12,7 +12,6 @@ help: @echo " make test-all - Run all tests (unit + integration)" @echo " make tests-coverage - Run tests with coverage report" @echo " make init-db - Initialize database schema" - @echo " make migrate - Run migration on existing database" @echo " make fmt - Format code with gofumpt" @echo " make fmt-check - Check if code is formatted" @echo " make lint - Run linter" @@ -93,26 +92,6 @@ init-db: fi @echo "Database schema initialized successfully" -# Run migration on existing database -migrate: - @echo "Running migration on existing database..." - @if [ -f .env ]; then \ - export $$(grep -v '^#' .env | xargs) && \ - if [ -z "$$DATABASE_URL" ]; then \ - echo "Error: DATABASE_URL not found in .env file"; \ - exit 1; \ - fi && \ - psql "$$DATABASE_URL" -f sql/002_convert_to_enums.sql; \ - else \ - if [ -z "$$DATABASE_URL" ]; then \ - echo "Error: DATABASE_URL environment variable is not set"; \ - echo "Please set it or create a .env file with DATABASE_URL"; \ - exit 1; \ - fi && \ - psql "$$DATABASE_URL" -f sql/002_convert_to_enums.sql; \ - fi - @echo "Migration completed successfully" - # Start Docker containers docker-up: @echo "Starting Docker containers..." From 3b947065298243a2b825e41ffc2fa7812a85bc76 Mon Sep 17 00:00:00 2001 From: Tiago Farto Date: Mon, 2 Feb 2026 11:23:10 +0000 Subject: [PATCH 3/3] chore: fix integration tests --- tests/README.md | 6 +++--- tests/integration_test.go | 10 ++++++++-- 2 files changed, 11 insertions(+), 5 deletions(-) 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) })