Skip to content

Commit 7e58936

Browse files
authored
chore: Convert field_type from VARCHAR to PostgreSQL ENUM (#8)
* chore: refactor field type to enum * chore: undo makefile changes * chore: fix integration tests
1 parent 2446f44 commit 7e58936

File tree

7 files changed

+126
-22
lines changed

7 files changed

+126
-22
lines changed

Makefile

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,6 @@ init-db:
9292
fi
9393
@echo "Database schema initialized successfully"
9494

95-
9695
# Start Docker containers
9796
docker-up:
9897
@echo "Starting Docker containers..."

internal/api/validation/validation.go

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"time"
1111

1212
"github.com/formbricks/hub/internal/api/response"
13+
"github.com/formbricks/hub/internal/models"
1314
"github.com/go-playground/form/v4"
1415
"github.com/go-playground/validator/v10"
1516
)
@@ -48,6 +49,18 @@ func init() {
4849
}
4950
return &t, nil
5051
}, (*time.Time)(nil))
52+
53+
// Handle *models.FieldType (pointer type used in filters)
54+
decoder.RegisterCustomTypeFunc(func(vals []string) (interface{}, error) {
55+
if len(vals) == 0 || vals[0] == "" {
56+
return (*models.FieldType)(nil), nil
57+
}
58+
ft, err := models.ParseFieldType(vals[0])
59+
if err != nil {
60+
return nil, fmt.Errorf("invalid field type: %w", err)
61+
}
62+
return &ft, nil
63+
}, (*models.FieldType)(nil))
5164
}
5265

5366
// ValidateStruct validates a struct using go-playground/validator
@@ -157,20 +170,23 @@ func ValidateAndDecodeQueryParams(r *http.Request, dst interface{}) error {
157170
}
158171

159172
// validateFieldType is a custom validator for field_type enum
173+
// It validates both string and FieldType types
160174
func validateFieldType(fl validator.FieldLevel) bool {
161-
value := fl.Field().String()
162-
validTypes := map[string]bool{
163-
"text": true,
164-
"categorical": true,
165-
"nps": true,
166-
"csat": true,
167-
"ces": true,
168-
"rating": true,
169-
"number": true,
170-
"boolean": true,
171-
"date": true,
175+
field := fl.Field()
176+
177+
// Handle FieldType enum type directly
178+
if field.Type() == reflect.TypeOf(models.FieldType("")) {
179+
ft := models.FieldType(field.String())
180+
return ft.IsValid()
172181
}
173-
return validTypes[value]
182+
183+
// Handle string type (from JSON/query params)
184+
if field.Kind() == reflect.String {
185+
_, err := models.ParseFieldType(field.String())
186+
return err == nil
187+
}
188+
189+
return false
174190
}
175191

176192
// validateNoNullBytes checks that a string field does not contain NULL bytes

internal/models/feedback_records.go

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,69 @@ package models
22

33
import (
44
"encoding/json"
5+
"fmt"
56
"time"
67

78
"github.com/google/uuid"
89
)
910

11+
// FieldType represents the type of feedback field
12+
type FieldType string
13+
14+
const (
15+
FieldTypeText FieldType = "text"
16+
FieldTypeCategorical FieldType = "categorical"
17+
FieldTypeNPS FieldType = "nps"
18+
FieldTypeCSAT FieldType = "csat"
19+
FieldTypeCES FieldType = "ces"
20+
FieldTypeRating FieldType = "rating"
21+
FieldTypeNumber FieldType = "number"
22+
FieldTypeBoolean FieldType = "boolean"
23+
FieldTypeDate FieldType = "date"
24+
)
25+
26+
// ValidFieldTypes contains all valid field type values (set membership)
27+
var ValidFieldTypes = map[FieldType]struct{}{
28+
FieldTypeText: {},
29+
FieldTypeCategorical: {},
30+
FieldTypeNPS: {},
31+
FieldTypeCSAT: {},
32+
FieldTypeCES: {},
33+
FieldTypeRating: {},
34+
FieldTypeNumber: {},
35+
FieldTypeBoolean: {},
36+
FieldTypeDate: {},
37+
}
38+
39+
// IsValid returns true if the FieldType is valid
40+
func (ft FieldType) IsValid() bool {
41+
_, valid := ValidFieldTypes[ft]
42+
return valid
43+
}
44+
45+
// ParseFieldType parses a string to FieldType, returns error if invalid
46+
func ParseFieldType(s string) (FieldType, error) {
47+
ft := FieldType(s)
48+
if !ft.IsValid() {
49+
return "", fmt.Errorf("invalid field type: %s", s)
50+
}
51+
return ft, nil
52+
}
53+
54+
// UnmarshalJSON implements json.Unmarshaler to validate field type during JSON unmarshaling
55+
func (ft *FieldType) UnmarshalJSON(data []byte) error {
56+
var s string
57+
if err := json.Unmarshal(data, &s); err != nil {
58+
return err
59+
}
60+
parsed, err := ParseFieldType(s)
61+
if err != nil {
62+
return err
63+
}
64+
*ft = parsed
65+
return nil
66+
}
67+
1068
// FeedbackRecord represents a single feedback record
1169
type FeedbackRecord struct {
1270
ID uuid.UUID `json:"id"`
@@ -18,7 +76,7 @@ type FeedbackRecord struct {
1876
SourceName *string `json:"source_name,omitempty"`
1977
FieldID string `json:"field_id"`
2078
FieldLabel *string `json:"field_label,omitempty"`
21-
FieldType string `json:"field_type"`
79+
FieldType FieldType `json:"field_type"`
2280
FieldGroupID *string `json:"field_group_id,omitempty"`
2381
FieldGroupLabel *string `json:"field_group_label,omitempty"`
2482
ValueText *string `json:"value_text,omitempty"`
@@ -39,7 +97,7 @@ type CreateFeedbackRecordRequest struct {
3997
SourceName *string `json:"source_name,omitempty"`
4098
FieldID string `json:"field_id" validate:"required,no_null_bytes,min=1,max=255"`
4199
FieldLabel *string `json:"field_label,omitempty"`
42-
FieldType string `json:"field_type" validate:"required,field_type,min=1,max=255"`
100+
FieldType FieldType `json:"field_type" validate:"required,field_type"`
43101
FieldGroupID *string `json:"field_group_id,omitempty" validate:"omitempty,no_null_bytes,max=255"`
44102
FieldGroupLabel *string `json:"field_group_label,omitempty"`
45103
ValueText *string `json:"value_text,omitempty" validate:"omitempty,no_null_bytes"`
@@ -71,7 +129,7 @@ type ListFeedbackRecordsFilters struct {
71129
SourceID *string `form:"source_id" validate:"omitempty,no_null_bytes"`
72130
FieldID *string `form:"field_id" validate:"omitempty,no_null_bytes"`
73131
FieldGroupID *string `form:"field_group_id" validate:"omitempty,no_null_bytes"`
74-
FieldType *string `form:"field_type" validate:"omitempty,no_null_bytes"`
132+
FieldType *FieldType `form:"field_type" validate:"omitempty,field_type"`
75133
UserIdentifier *string `form:"user_identifier" validate:"omitempty,no_null_bytes"`
76134
Since *time.Time `form:"since" validate:"omitempty"`
77135
Until *time.Time `form:"until" validate:"omitempty"`

openapi.yaml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,16 @@ paths:
8080
schema:
8181
type: string
8282
description: Filter by field type. NULL bytes not allowed.
83+
enum:
84+
- text
85+
- categorical
86+
- nps
87+
- csat
88+
- ces
89+
- rating
90+
- number
91+
- boolean
92+
- date
8393
pattern: '^[^\x00]*$'
8494
- name: user_identifier
8595
in: query
@@ -735,6 +745,16 @@ components:
735745
field_type:
736746
type: string
737747
description: Type of field
748+
enum:
749+
- text
750+
- categorical
751+
- nps
752+
- csat
753+
- ces
754+
- rating
755+
- number
756+
- boolean
757+
- date
738758
id:
739759
type: string
740760
format: uuid

sql/001_initial_schema.sql

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@
44
CREATE EXTENSION IF NOT EXISTS vector;
55
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
66

7+
-- Create ENUM types
8+
CREATE TYPE field_type_enum AS ENUM (
9+
'text', 'categorical', 'nps', 'csat', 'ces', 'rating', 'number', 'boolean', 'date'
10+
);
11+
712
-- Feedback records table
813
CREATE TABLE feedback_records (
914
id UUID PRIMARY KEY DEFAULT uuidv7(),
@@ -18,7 +23,7 @@ CREATE TABLE feedback_records (
1823

1924
field_id VARCHAR(255) NOT NULL,
2025
field_label VARCHAR,
21-
field_type VARCHAR NOT NULL,
26+
field_type field_type_enum NOT NULL,
2227

2328
-- Field grouping for composite questions (ranking, matrix, grid)
2429
field_group_id VARCHAR(255),

tests/README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@ This directory contains integration tests for the Formbricks Hub API.
66

77
Before running the tests, ensure:
88

9-
1. PostgreSQL is running (via docker-compose)
10-
2. Database schema has been initialized
11-
3. The `API_KEY` environment variable is set (tests will set it automatically)
9+
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`.
10+
2. **Database schema** has been initialized (`make init-db`).
11+
3. **API_KEY** is set automatically by the tests; you do not need to set it.
1212

1313
## Running Tests
1414

tests/integration_test.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,18 @@ import (
2121
"github.com/stretchr/testify/require"
2222
)
2323

24+
// defaultTestDatabaseURL is the default Postgres URL used by docker-compose (postgres/postgres/test_db).
25+
// Setting it here before config.Load() ensures tests do not use a different DATABASE_URL from .env,
26+
// which would cause "password authentication failed" when .env points at another database.
27+
const defaultTestDatabaseURL = "postgres://postgres:postgres@localhost:5432/test_db?sslmode=disable"
28+
2429
// setupTestServer creates a test HTTP server with all routes configured
2530
func setupTestServer(t *testing.T) (*httptest.Server, func()) {
2631
ctx := context.Background()
2732

28-
// Set test API key in environment for authentication (must be set before loading config)
33+
// Set test env before loading config so config.Load() uses test values and is not affected by .env.
2934
t.Setenv("API_KEY", testAPIKey)
35+
t.Setenv("DATABASE_URL", defaultTestDatabaseURL)
3036

3137
// Load configuration
3238
cfg, err := config.Load()
@@ -214,7 +220,7 @@ func TestCreateFeedbackRecord(t *testing.T) {
214220
assert.NotEmpty(t, result.ID)
215221
assert.Equal(t, "formbricks", result.SourceType)
216222
assert.Equal(t, "feedback", result.FieldID)
217-
assert.Equal(t, "text", result.FieldType)
223+
assert.Equal(t, models.FieldTypeText, result.FieldType)
218224
assert.NotNil(t, result.ValueText)
219225
assert.Equal(t, "Great product!", *result.ValueText)
220226
})

0 commit comments

Comments
 (0)