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
5 changes: 3 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,11 @@ help:
@echo " make clean - Clean build artifacts"
@echo " make schemathesis - Run Schemathesis API tests (requires API server running)"

# Run all tests (integration tests in tests/ directory)
# Run all tests (integration tests in tests/ directory).
# Loads .env if present so DATABASE_URL (e.g. port 5433) is used when Postgres runs on a non-default port.
tests:
@echo "Running integration tests..."
go test ./tests/... -v
@(set -a && [ -f .env ] && . ./.env && set +a; go test ./tests/... -v)

# Run unit tests (fast, no database required)
test-unit:
Expand Down
6 changes: 6 additions & 0 deletions internal/api/handlers/feedback_records_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,12 @@ func (h *FeedbackRecordsHandler) Create(w http.ResponseWriter, r *http.Request)
return
}

if errors.Is(err, huberrors.ErrConflict) {
response.RespondConflict(w, err.Error())

return
}

response.RespondInternalServerError(w, "An unexpected error occurred")

return
Expand Down
5 changes: 5 additions & 0 deletions internal/api/response/response.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ func RespondNotFound(w http.ResponseWriter, detail string) {
RespondError(w, http.StatusNotFound, "Not Found", detail)
}

// RespondConflict writes a 409 Conflict error response.
func RespondConflict(w http.ResponseWriter, detail string) {
RespondError(w, http.StatusConflict, "Conflict", detail)
}

// RespondInternalServerError writes a 500 Internal Server Error response.
func RespondInternalServerError(w http.ResponseWriter, detail string) {
RespondError(w, http.StatusInternalServerError, "Internal Server Error", detail)
Expand Down
29 changes: 29 additions & 0 deletions internal/huberrors/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,3 +106,32 @@ func (e *LimitExceededError) Is(target error) bool {

return ok
}

// ErrConflict is the sentinel for conflict errors (e.g. duplicate tenant_id + submission_id + field_id).
var ErrConflict = &ConflictError{}

// ConflictError is a sentinel error for resource conflicts.
type ConflictError struct {
Message string
}

// NewConflictError creates a ConflictError with a custom message.
func NewConflictError(message string) *ConflictError {
return &ConflictError{Message: message}
}

// Error implements the error interface.
func (e *ConflictError) Error() string {
if e.Message != "" {
return e.Message
}

return "conflict"
}

// Is implements the error interface for error comparison.
func (e *ConflictError) Is(target error) bool {
_, ok := target.(*ConflictError)

return ok
}
3 changes: 3 additions & 0 deletions internal/models/feedback_records.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ type FeedbackRecord struct {
Language *string `json:"language,omitempty"`
UserIdentifier *string `json:"user_identifier,omitempty"`
TenantID *string `json:"tenant_id,omitempty"`
SubmissionID *string `json:"submission_id,omitempty"`
}

// CreateFeedbackRecordRequest represents the request to create a feedback record.
Expand All @@ -123,6 +124,7 @@ type CreateFeedbackRecordRequest struct {
Language *string `json:"language,omitempty" validate:"omitempty,no_null_bytes,max=10"`
UserIdentifier *string `json:"user_identifier,omitempty"`
TenantID *string `json:"tenant_id,omitempty" validate:"omitempty,no_null_bytes,max=255"`
SubmissionID string `json:"submission_id" validate:"required,no_null_bytes,min=1,max=255"`
}

// UpdateFeedbackRecordRequest represents the request to update a feedback record
Expand Down Expand Up @@ -174,6 +176,7 @@ func (r *UpdateFeedbackRecordRequest) ChangedFields() []string {
// ListFeedbackRecordsFilters represents filters for listing feedback records.
type ListFeedbackRecordsFilters struct {
TenantID *string `form:"tenant_id" validate:"omitempty,no_null_bytes"`
SubmissionID *string `form:"submission_id" validate:"omitempty,no_null_bytes"`
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"`
Expand Down
34 changes: 23 additions & 11 deletions internal/repository/feedback_records_repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (

"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"

"github.com/formbricks/hub/internal/huberrors"
Expand Down Expand Up @@ -38,14 +39,14 @@ func (r *FeedbackRecordsRepository) Create(ctx context.Context, req *models.Crea
collected_at, source_type, source_id, source_name,
field_id, field_label, field_type, field_group_id, field_group_label,
value_text, value_number, value_boolean, value_date,
metadata, language, user_identifier, tenant_id
metadata, language, user_identifier, tenant_id, submission_id
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)
RETURNING id, collected_at, created_at, updated_at,
source_type, source_id, source_name,
field_id, field_label, field_type, field_group_id, field_group_label,
value_text, value_number, value_boolean, value_date,
metadata, language, user_identifier, tenant_id
metadata, language, user_identifier, tenant_id, submission_id
`

var record models.FeedbackRecord
Expand All @@ -54,15 +55,20 @@ func (r *FeedbackRecordsRepository) Create(ctx context.Context, req *models.Crea
collectedAt, req.SourceType, req.SourceID, req.SourceName,
req.FieldID, req.FieldLabel, req.FieldType, req.FieldGroupID, req.FieldGroupLabel,
req.ValueText, req.ValueNumber, req.ValueBoolean, req.ValueDate,
req.Metadata, req.Language, req.UserIdentifier, req.TenantID,
req.Metadata, req.Language, req.UserIdentifier, req.TenantID, req.SubmissionID,
).Scan(
&record.ID, &record.CollectedAt, &record.CreatedAt, &record.UpdatedAt,
&record.SourceType, &record.SourceID, &record.SourceName,
&record.FieldID, &record.FieldLabel, &record.FieldType, &record.FieldGroupID, &record.FieldGroupLabel,
&record.ValueText, &record.ValueNumber, &record.ValueBoolean, &record.ValueDate,
&record.Metadata, &record.Language, &record.UserIdentifier, &record.TenantID,
&record.Metadata, &record.Language, &record.UserIdentifier, &record.TenantID, &record.SubmissionID,
)
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return nil, huberrors.NewConflictError("a feedback record with this tenant_id, submission_id, and field_id already exists")
}

return nil, fmt.Errorf("failed to create feedback record: %w", err)
}

Expand All @@ -76,7 +82,7 @@ func (r *FeedbackRecordsRepository) GetByID(ctx context.Context, id uuid.UUID) (
source_type, source_id, source_name,
field_id, field_label, field_type, field_group_id, field_group_label,
value_text, value_number, value_boolean, value_date,
metadata, language, user_identifier, tenant_id
metadata, language, user_identifier, tenant_id, submission_id
FROM feedback_records
WHERE id = $1
`
Expand All @@ -88,7 +94,7 @@ func (r *FeedbackRecordsRepository) GetByID(ctx context.Context, id uuid.UUID) (
&record.SourceType, &record.SourceID, &record.SourceName,
&record.FieldID, &record.FieldLabel, &record.FieldType, &record.FieldGroupID, &record.FieldGroupLabel,
&record.ValueText, &record.ValueNumber, &record.ValueBoolean, &record.ValueDate,
&record.Metadata, &record.Language, &record.UserIdentifier, &record.TenantID,
&record.Metadata, &record.Language, &record.UserIdentifier, &record.TenantID, &record.SubmissionID,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
Expand All @@ -114,6 +120,12 @@ func buildFilterConditions(filters *models.ListFeedbackRecordsFilters) (whereCla
argCount++
}

if filters.SubmissionID != nil {
conditions = append(conditions, fmt.Sprintf("submission_id = $%d", argCount))
args = append(args, *filters.SubmissionID)
argCount++
}

if filters.SourceType != nil {
conditions = append(conditions, fmt.Sprintf("source_type = $%d", argCount))
args = append(args, *filters.SourceType)
Expand Down Expand Up @@ -175,7 +187,7 @@ func (r *FeedbackRecordsRepository) List(ctx context.Context, filters *models.Li
source_type, source_id, source_name,
field_id, field_label, field_type, field_group_id, field_group_label,
value_text, value_number, value_boolean, value_date,
metadata, language, user_identifier, tenant_id
metadata, language, user_identifier, tenant_id, submission_id
FROM feedback_records
`

Expand Down Expand Up @@ -214,7 +226,7 @@ func (r *FeedbackRecordsRepository) List(ctx context.Context, filters *models.Li
&record.SourceType, &record.SourceID, &record.SourceName,
&record.FieldID, &record.FieldLabel, &record.FieldType, &record.FieldGroupID, &record.FieldGroupLabel,
&record.ValueText, &record.ValueNumber, &record.ValueBoolean, &record.ValueDate,
&record.Metadata, &record.Language, &record.UserIdentifier, &record.TenantID,
&record.Metadata, &record.Language, &record.UserIdentifier, &record.TenantID, &record.SubmissionID,
)
if err != nil {
return nil, fmt.Errorf("failed to scan feedback record: %w", err)
Expand Down Expand Up @@ -316,7 +328,7 @@ func buildUpdateQuery(
source_type, source_id, source_name,
field_id, field_label, field_type, field_group_id, field_group_label,
value_text, value_number, value_boolean, value_date,
metadata, language, user_identifier, tenant_id
metadata, language, user_identifier, tenant_id, submission_id
`, strings.Join(updates, ", "), argCount)

return query, args, true
Expand All @@ -339,7 +351,7 @@ func (r *FeedbackRecordsRepository) Update(
&record.SourceType, &record.SourceID, &record.SourceName,
&record.FieldID, &record.FieldLabel, &record.FieldType, &record.FieldGroupID, &record.FieldGroupLabel,
&record.ValueText, &record.ValueNumber, &record.ValueBoolean, &record.ValueDate,
&record.Metadata, &record.Language, &record.UserIdentifier, &record.TenantID,
&record.Metadata, &record.Language, &record.UserIdentifier, &record.TenantID, &record.SubmissionID,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
Expand Down
22 changes: 22 additions & 0 deletions migrations/003_add_feedback_records_submission_id.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
-- +goose up
-- Add submission_id to feedback_records for grouping records belonging to one logical submission.
-- Enables idempotent multi-field ingestion and simpler grouped reads (e.g. GET ?submission_id=...).
-- Mandatory (NOT NULL): every record must belong to a submission; use e.g. field_id if single-field.

ALTER TABLE feedback_records
ADD COLUMN submission_id VARCHAR(255) NOT NULL;

-- Index for filtering by submission_id (and tenant-scoped list by submission)
CREATE INDEX idx_feedback_records_submission_id ON feedback_records(submission_id);
CREATE INDEX idx_feedback_records_tenant_submission_id ON feedback_records(tenant_id, submission_id);

-- One value per field_id per submission per tenant (supports idempotent webhook retries).
-- NULLS NOT DISTINCT (PG 15+): treat NULL tenant_id as equal so duplicate (NULL, submission_id, field_id) conflicts.
CREATE UNIQUE INDEX idx_feedback_records_tenant_submission_field
ON feedback_records(tenant_id, submission_id, field_id) NULLS NOT DISTINCT;

-- +goose down
DROP INDEX IF EXISTS idx_feedback_records_tenant_submission_field;
DROP INDEX IF EXISTS idx_feedback_records_tenant_submission_id;
DROP INDEX IF EXISTS idx_feedback_records_submission_id;
ALTER TABLE feedback_records DROP COLUMN IF EXISTS submission_id;
30 changes: 30 additions & 0 deletions openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@ paths:
type: string
description: Filter by tenant ID (for multi-tenant deployments). NULL bytes not allowed.
pattern: '^[^\x00]*$'
- name: submission_id
in: query
description: Filter by submission ID to group records belonging to one logical submission. NULL bytes not allowed.
schema:
type: string
description: Filter by submission ID (e.g. UUID from webhook idempotency). NULL bytes not allowed.
pattern: '^[^\x00]*$'
- name: source_type
in: query
description: Filter by source type. NULL bytes not allowed.
Expand Down Expand Up @@ -323,6 +330,12 @@ paths:
parameters:
id: '$response.body#/id'
description: Delete the created feedback record by ID
"409":
description: Conflict – a feedback record with the same tenant_id, submission_id, and field_id already exists (idempotent create).
content:
application/problem+json:
schema:
$ref: '#/components/schemas/ErrorModel'
default:
description: Error
content:
Expand Down Expand Up @@ -828,6 +841,17 @@ components:
minLength: 1
maxLength: 255
pattern: '^[^\x00]*$'
submission_id:
type: string
description: |
Identifier for the logical submission this record belongs to (tenant-scoped). Required.
Enables grouping multi-field submissions and idempotent ingestion.
Unique per (tenant_id, submission_id, field_id). If a record has no logical submission, use e.g. field_id.
examples:
- "550e8400-e29b-41d4-a716-446655440000"
minLength: 1
maxLength: 255
pattern: '^[^\x00]*$'
tenant_id:
type: string
description: Tenant/organization identifier for multi-tenancy. NULL bytes not allowed.
Expand Down Expand Up @@ -867,6 +891,7 @@ components:
- source_type
- field_id
- field_type
- submission_id
ErrorDetail:
type: object
additionalProperties: false
Expand Down Expand Up @@ -975,6 +1000,10 @@ components:
source_type:
type: string
description: Type of feedback source
submission_id:
type: string
description: Identifier for the logical submission this record belongs to (required).
pattern: '^[^\x00]*$'
tenant_id:
type: string
description: Tenant/organization identifier. NULL bytes not allowed.
Expand Down Expand Up @@ -1009,6 +1038,7 @@ components:
- source_type
- field_id
- field_type
- submission_id
ListFeedbackRecordsOutputBody:
type: object
additionalProperties: false
Expand Down
Loading
Loading