Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,6 @@ init-db:
fi
@echo "Database schema initialized successfully"


# Start Docker containers
docker-up:
@echo "Starting Docker containers..."
Expand Down
40 changes: 28 additions & 12 deletions internal/api/validation/validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
64 changes: 61 additions & 3 deletions internal/models/feedback_records.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -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"`
Expand All @@ -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"`
Expand Down Expand Up @@ -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"`
Expand Down
20 changes: 20 additions & 0 deletions openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion sql/001_initial_schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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),
Expand Down
6 changes: 3 additions & 3 deletions tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 8 additions & 2 deletions tests/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)
})
Expand Down
Loading