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
2 changes: 1 addition & 1 deletion .github/workflows/api-contract-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version: '1.26.2'
go-version: '1.26.3'

- name: Cache Go modules
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/code-quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ jobs:
id: setup-go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version: '1.26.2'
go-version: '1.26.3'

- name: Cache Go modules
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/migrations-validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version: '1.26.2'
go-version: '1.26.3'

- name: Install goose
run: go install github.com/pressly/goose/v3/cmd/goose@v3.27.1
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version: '1.26.2'
go-version: '1.26.3'

- name: Cache Go modules
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
Expand Down Expand Up @@ -85,7 +85,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version: '1.26.2'
go-version: '1.26.3'

- name: Cache Go modules
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
Expand Down Expand Up @@ -148,7 +148,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version: '1.26.2'
go-version: '1.26.3'

- name: Cache Go modules
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
Expand Down
2 changes: 1 addition & 1 deletion .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ run:
timeout: 5m
concurrency: 4
modules-download-mode: readonly
go: "1.26.2"
go: "1.26.3"
# Allow linting test files but suppress specific strict rules via exclusion below
tests: true

Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# Stage 1: Build
# =============================================================================
# TARGETOS/TARGETARCH are set by Docker Buildx for multi-platform builds (e.g. linux/arm64 on Mac M1).
FROM golang:1.26.2-alpine AS builder
FROM golang:1.26.3-alpine AS builder
ARG TARGETOS=linux
ARG TARGETARCH

Expand Down
20 changes: 10 additions & 10 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,12 @@ help:
@echo " make mcp-smoke - Run the live MCP package smoke test (requires Hub env vars)"
@echo " make test-all - Run all tests (unit + integration)"
@echo " make tests-coverage - Run tests with coverage report"
@echo " make check-coverage - Run tests and fail if coverage below COVERAGE_THRESHOLD (excludes cmd/api)"
@echo " make check-coverage - Run tests and fail if coverage below COVERAGE_THRESHOLD"
@echo " make init-db - Initialize database schema (run migrations with goose)"
@echo " make migrate-status - Show migration status"
@echo " make migrate-validate - Validate migration files (no DB)"
@echo " make river-migrate - Run River job queue migrations (required for webhook delivery)"
@echo " make fmt - Format code (golangci-lint run --fix)"
@echo " make fmt - Format code"
@echo " make lint - Run linter (includes format checks)"
@echo " make lint-new - Run linter only on new code since base (default origin/main; for CI set LINT_BASE_REV to PR base SHA)"
@echo " make deps - Install Go dependencies"
Expand Down Expand Up @@ -60,28 +60,28 @@ mcp-smoke:
# Run unit tests (fast, no database required)
test-unit:
@echo "Running unit tests..."
go test ./internal/... -v
go test ./cmd/api ./internal/... -v

# Run all tests (unit + integration)
test-all: test-unit tests
@echo "All tests passed!"

# Run tests with coverage (unit + integration).
# cmd/api (app.go, main.go) is excluded—coverage is for internal/, pkg/, and tests/.
# Run tests with package-instrumented coverage (unit + integration).
# Integration tests live in tests/, so coverpkg is required for those tests to count against app packages.
tests-coverage:
@echo "Running tests with coverage..."
go test ./internal/... ./pkg/... ./tests/... -v -cover -coverprofile=coverage.out
@(set -a && [ -f .env ] && . ./.env && set +a; go test ./cmd/api ./internal/... ./pkg/... ./tests/... -v -coverpkg=./cmd/api,./internal/...,./pkg/... -coverprofile=coverage.out)
go tool cover -html=coverage.out -o coverage.html
@echo "Coverage report generated: coverage.html"

# Minimum coverage threshold (percent). Fails if coverage falls below this.
COVERAGE_THRESHOLD ?= 15

# Check coverage threshold (fail if below COVERAGE_THRESHOLD).
# Excludes cmd/api (app.go, main.go) from coverage. Includes internal/, tests/, and pkg/.
# Uses package-instrumented coverage so integration tests contribute to app package coverage.
check-coverage:
@echo "Running tests with coverage (threshold: $(COVERAGE_THRESHOLD)%)..."
@(set -a && [ -f .env ] && . ./.env && set +a; go test ./internal/... ./pkg/... ./tests/... -coverprofile=coverage.out)
@(set -a && [ -f .env ] && . ./.env && set +a; go test ./cmd/api ./internal/... ./pkg/... ./tests/... -coverpkg=./cmd/api,./internal/...,./pkg/... -coverprofile=coverage.out)
@COV=$$(go tool cover -func=coverage.out | \tail -1 | awk '{gsub(/%/, ""); print $$3}') && \
if [ -z "$$COV" ] || ! awk -v c="$$COV" -v t="$(COVERAGE_THRESHOLD)" 'BEGIN { exit (c+0 >= t) ? 0 : 1 }'; then \
echo ""; \
Expand Down Expand Up @@ -292,11 +292,11 @@ install-tools:
go install github.com/riverqueue/river/cmd/river@$(RIVER_VERSION)
@echo "Tools installed (golangci-lint $(GOLANGCI_LINT_VERSION), govulncheck $(GOVULNCHECK_VERSION), goose $(GOOSE_VERSION), river $(RIVER_VERSION))"

# Format code (golangci-lint applies gofumpt + gci from .golangci.yml formatters)
# Format code (golangci-lint applies gofumpt + gci from .golangci.yml formatters).
fmt:
@echo "Formatting code..."
@test -x $(GOLANGCI_LINT) || { echo "Error: golangci-lint not found. Install with: make install-tools"; exit 1; }
$(GOLANGCI_LINT) run --fix ./...
$(GOLANGCI_LINT) fmt ./...
@echo "Code formatted"

# Lint code
Expand Down
9 changes: 7 additions & 2 deletions cmd/api/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ func NewApp(cfg *config.Config, db *pgxpool.Pool) (*App, error) {

feedbackRecordsRepo := repository.NewFeedbackRecordsRepository(db)
embeddingsRepo := repository.NewEmbeddingsRepository(db)
tenantDataRepo := repository.NewTenantDataRepository(db)
embeddingProviderName, embeddingModel := embeddingProviderAndModel(cfg)
embeddingModelForDB := embeddingModel

Expand Down Expand Up @@ -317,6 +318,8 @@ func NewApp(cfg *config.Config, db *pgxpool.Pool) (*App, error) {

webhooksService := service.NewWebhooksService(webhooksRepo, messageManager, cfg.Webhook.MaxCount, cfg.Webhook.URLBlacklist)
webhooksHandler := handlers.NewWebhooksHandler(webhooksService)
tenantDataService := service.NewTenantDataService(tenantDataRepo)
tenantDataHandler := handlers.NewTenantDataHandler(tenantDataService)

feedbackRecordsHandler := handlers.NewFeedbackRecordsHandler(feedbackRecordsService)
healthHandler := handlers.NewHealthHandler()
Expand All @@ -329,7 +332,7 @@ func NewApp(cfg *config.Config, db *pgxpool.Pool) (*App, error) {
}

server := newHTTPServer(
cfg, healthHandler, openapiHandler, feedbackRecordsHandler, webhooksHandler, searchHandler,
cfg, healthHandler, openapiHandler, feedbackRecordsHandler, webhooksHandler, tenantDataHandler, searchHandler,
meterProvider, tracerProvider,
)

Expand All @@ -353,6 +356,7 @@ func newHTTPServer(
openapi *handlers.OpenAPIHandler,
feedback *handlers.FeedbackRecordsHandler,
webhooks *handlers.WebhooksHandler,
tenantData *handlers.TenantDataHandler,
search *handlers.SearchHandler,
meterProvider *sdkmetric.MeterProvider,
tracerProvider *sdktrace.TracerProvider,
Expand All @@ -368,13 +372,14 @@ func newHTTPServer(
protected.HandleFunc("GET /v1/feedback-records/{id}", feedback.Get)
protected.HandleFunc("PATCH /v1/feedback-records/{id}", feedback.Update)
protected.HandleFunc("DELETE /v1/feedback-records/{id}", feedback.Delete)
protected.HandleFunc("DELETE /v1/feedback-records", feedback.BulkDelete)
protected.HandleFunc("DELETE /v1/feedback-records", feedback.DeleteByUser)

protected.HandleFunc("POST /v1/webhooks", webhooks.Create)
protected.HandleFunc("GET /v1/webhooks", webhooks.List)
protected.HandleFunc("GET /v1/webhooks/{id}", webhooks.Get)
protected.HandleFunc("PATCH /v1/webhooks/{id}", webhooks.Update)
protected.HandleFunc("DELETE /v1/webhooks/{id}", webhooks.Delete)
protected.HandleFunc("DELETE /v1/tenants/{tenant_id}/data", tenantData.Delete)

// Search endpoints are always registered; when embeddings are disabled, the handler returns 503.
protected.HandleFunc("POST /v1/feedback-records/search/semantic", search.SemanticSearch)
Expand Down
19 changes: 19 additions & 0 deletions cmd/api/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,24 @@ func TestNewHTTPServerKeepsV1RoutesProtected(t *testing.T) {
}
}

func TestNewHTTPServerKeepsTenantDataRoutesProtected(t *testing.T) {
server := newTestHTTPServer(t)

recorder := httptest.NewRecorder()
request := httptest.NewRequestWithContext(
context.Background(),
http.MethodDelete,
"/v1/tenants/test-tenant-id/data",
http.NoBody,
)

server.Handler.ServeHTTP(recorder, request)

if recorder.Code != http.StatusUnauthorized {
t.Fatalf("DELETE /v1/tenants/{tenant_id}/data status = %d, want %d", recorder.Code, http.StatusUnauthorized)
}
}

func newTestHTTPServer(t *testing.T) *http.Server {
t.Helper()

Expand All @@ -262,6 +280,7 @@ func newTestHTTPServerWithPublicBaseURL(t *testing.T, publicBaseURL string) *htt
newTestOpenAPIHandler(t, publicBaseURL),
handlers.NewFeedbackRecordsHandler(nil),
handlers.NewWebhooksHandler(nil),
handlers.NewTenantDataHandler(nil),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
handlers.NewSearchHandler(nil),
nil,
nil,
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module github.com/formbricks/hub

go 1.26.2
go 1.26.3

require (
github.com/go-playground/form/v4 v4.3.0
Expand Down
24 changes: 13 additions & 11 deletions internal/api/handlers/feedback_records_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ type FeedbackRecordsService interface {
ListFeedbackRecords(ctx context.Context, filters *models.ListFeedbackRecordsFilters) (*models.ListFeedbackRecordsResponse, error)
UpdateFeedbackRecord(ctx context.Context, id uuid.UUID, req *models.UpdateFeedbackRecordRequest) (*models.FeedbackRecord, error)
DeleteFeedbackRecord(ctx context.Context, id uuid.UUID) error
BulkDeleteFeedbackRecords(ctx context.Context, filters *models.BulkDeleteFilters) (int, error)
DeleteFeedbackRecordsByUser(ctx context.Context, filters *models.DeleteFeedbackRecordsByUserFilters) (int, error)
}

// FeedbackRecordsHandler handles HTTP requests for feedback records.
Expand Down Expand Up @@ -226,9 +226,9 @@ func (h *FeedbackRecordsHandler) Delete(w http.ResponseWriter, r *http.Request)
w.WriteHeader(http.StatusNoContent)
}

// BulkDelete handles DELETE /v1/feedback-records?user_id=<id>[&tenant_id=<id>].
func (h *FeedbackRecordsHandler) BulkDelete(w http.ResponseWriter, r *http.Request) {
filters := &models.BulkDeleteFilters{}
// DeleteByUser handles DELETE /v1/feedback-records?user_id=<id>[&tenant_id=<id>].
func (h *FeedbackRecordsHandler) DeleteByUser(w http.ResponseWriter, r *http.Request) {
filters := &models.DeleteFeedbackRecordsByUserFilters{}

// Decode and validate query parameters
if err := validation.ValidateAndDecodeQueryParams(r, filters); err != nil {
Expand All @@ -237,24 +237,26 @@ func (h *FeedbackRecordsHandler) BulkDelete(w http.ResponseWriter, r *http.Reque
return
}

deletedCount, err := h.service.BulkDeleteFeedbackRecords(r.Context(), filters)
deletedCount, err := h.service.DeleteFeedbackRecordsByUser(r.Context(), filters)
if err != nil {
if errors.Is(err, huberrors.ErrValidation) {
validation.RespondValidationError(w, err)

return
}

var tenantID string
tenantIDLength := 0
if filters.TenantID != nil {
tenantID = *filters.TenantID
tenantIDLength = len(*filters.TenantID)
}

slog.Error("Failed to bulk delete feedback records", // #nosec G706 -- slog key-values
slog.Error("Failed to delete feedback records by user", // #nosec G706 -- slog key-values
"method", r.Method,
"path", r.URL.Path,
"user_id", filters.UserID,
"tenant_id", tenantID,
"user_id_present", filters.UserID != "",
"user_id_length", len(filters.UserID),
"tenant_id_present", tenantIDLength > 0,
"tenant_id_length", tenantIDLength,
"error", err,
)

Expand All @@ -263,7 +265,7 @@ func (h *FeedbackRecordsHandler) BulkDelete(w http.ResponseWriter, r *http.Reque
return
}

resp := models.BulkDeleteResponse{
resp := models.DeleteFeedbackRecordsByUserResponse{
DeletedCount: int64(deletedCount),
Message: fmt.Sprintf("Successfully deleted %d feedback records", deletedCount),
}
Expand Down
Loading
Loading