diff --git a/backend/pkg/analytics/api/card-handlers.go b/backend/pkg/analytics/api/card-handlers.go index edb8e3559..7fe880e20 100644 --- a/backend/pkg/analytics/api/card-handlers.go +++ b/backend/pkg/analytics/api/card-handlers.go @@ -3,7 +3,6 @@ package api import ( "encoding/json" "fmt" - "github.com/gorilla/mux" "net/http" "openreplay/backend/pkg/analytics/api/models" "openreplay/backend/pkg/server/api" @@ -14,22 +13,6 @@ import ( "github.com/go-playground/validator/v10" ) -// getCardId returns the ID from the request -func getCardId(r *http.Request) (int64, error) { - vars := mux.Vars(r) - idStr := vars["id"] - if idStr == "" { - return 0, fmt.Errorf("invalid Card ID") - } - - id, err := strconv.ParseInt(idStr, 10, 64) - if err != nil { - return 0, fmt.Errorf("invalid Card ID") - } - - return id, nil -} - func (e *handlersImpl) createCard(w http.ResponseWriter, r *http.Request) { startTime := time.Now() bodySize := 0 @@ -54,30 +37,18 @@ func (e *handlersImpl) createCard(w http.ResponseWriter, r *http.Request) { return } - // TODO save card to DB - - resp := &models.CardGetResponse{ - Card: models.Card{ - CardID: 1, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - DeletedAt: nil, - EditedAt: nil, - ProjectID: 1, - UserID: 1, - CardBase: models.CardBase{ - Name: req.Name, - IsPublic: req.IsPublic, - Thumbnail: req.Thumbnail, - MetricType: req.MetricType, - MetricOf: req.MetricOf, - Series: req.Series, - }, - }, + projectID, err := getIDFromRequest(r, "projectId") + if err != nil { + e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize) + return } currentUser := r.Context().Value("userData").(*user.User) - e.log.Info(r.Context(), "User ID: ", currentUser.ID) + resp, err := e.service.CreateCard(projectID, currentUser.ID, req) + if err != nil { + e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize) + return + } e.responser.ResponseWithJSON(e.log, r.Context(), w, resp, startTime, r.URL.Path, bodySize) } @@ -87,68 +58,128 @@ func (e *handlersImpl) getCard(w http.ResponseWriter, r *http.Request) { startTime := time.Now() bodySize := 0 - id, err := getCardId(r) + projectID, err := getIDFromRequest(r, "projectId") if err != nil { e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize) return } - thumbnail := "https://example.com/image.png" + id, err := getIDFromRequest(r, "id") + if err != nil { + e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize) + return + } - // TODO get card from DB - - resp := &models.CardGetResponse{ - Card: models.Card{ - CardID: id, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - DeletedAt: nil, - EditedAt: nil, - ProjectID: 1, - UserID: 1, - CardBase: models.CardBase{ - Name: "My Card", - IsPublic: true, - Thumbnail: &thumbnail, - MetricType: "timeseries", - MetricOf: "session_count", - }, - }, + resp, err := e.service.GetCardWithSeries(projectID, id) + if err != nil { + e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize) + return } e.responser.ResponseWithJSON(e.log, r.Context(), w, resp, startTime, r.URL.Path, bodySize) } -// get cards paginated func (e *handlersImpl) getCards(w http.ResponseWriter, r *http.Request) { startTime := time.Now() bodySize := 0 - // TODO get cards from DB - thumbnail := "https://example.com/image.png" - - resp := &models.GetCardsResponse{ - Cards: []models.Card{ - { - CardID: 1, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - DeletedAt: nil, - EditedAt: nil, - ProjectID: 1, - UserID: 1, - CardBase: models.CardBase{ - Name: "My Card", - IsPublic: true, - Thumbnail: &thumbnail, - MetricType: "timeseries", - MetricOf: "session_count", - }, - }, - }, - Total: 10, + projectID, err := getIDFromRequest(r, "projectId") + if err != nil { + e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize) + return } + //currentUser := r.Context().Value("userData").(*user.User) + resp, err := e.service.GetCards(projectID) + if err != nil { + e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize) + return + } + + e.responser.ResponseWithJSON(e.log, r.Context(), w, resp, startTime, r.URL.Path, bodySize) +} + +func (e *handlersImpl) getCardsPaginated(w http.ResponseWriter, r *http.Request) { + startTime := time.Now() + bodySize := 0 + + // Extract projectID from request + projectID, err := getIDFromRequest(r, "projectId") + if err != nil { + e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize) + return + } + + // Parse query parameters + query := r.URL.Query() + + // Filters + filters := models.CardListFilter{ + Filters: make(map[string]interface{}), + } + + if name := query.Get("name"); name != "" { + filters.Filters["name"] = name + } + if metricType := query.Get("metric_type"); metricType != "" { + filters.Filters["metric_type"] = metricType + } + if dashboardIDs := query["dashboard_ids"]; len(dashboardIDs) > 0 { + // Parse dashboard_ids into []int + var ids []int + for _, id := range dashboardIDs { + if val, err := strconv.Atoi(id); err == nil { + ids = append(ids, val) + } + } + filters.Filters["dashboard_ids"] = ids + } + + // Sorting + sort := models.CardListSort{ + Field: query.Get("sort_field"), + Order: query.Get("sort_order"), + } + if sort.Field == "" { + sort.Field = "created_at" // Default sort field + } + if sort.Order == "" { + sort.Order = "desc" // Default sort order + } + + // Pagination + limit := 10 // Default limit + page := 1 // Default page number + if val := query.Get("limit"); val != "" { + if l, err := strconv.Atoi(val); err == nil && l > 0 { + limit = l + } + } + if val := query.Get("page"); val != "" { + if p, err := strconv.Atoi(val); err == nil && p > 0 { + page = p + } + } + offset := (page - 1) * limit + + // Validate inputs + if err := models.ValidateStruct(filters); err != nil { + e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, fmt.Errorf("invalid filters: %w", err), startTime, r.URL.Path, bodySize) + return + } + if err := models.ValidateStruct(sort); err != nil { + e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, fmt.Errorf("invalid sort: %w", err), startTime, r.URL.Path, bodySize) + return + } + + // Call the service + resp, err := e.service.GetCardsPaginated(projectID, filters, sort, limit, offset) + if err != nil { + e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize) + return + } + + // Respond with JSON e.responser.ResponseWithJSON(e.log, r.Context(), w, resp, startTime, r.URL.Path, bodySize) } @@ -156,7 +187,13 @@ func (e *handlersImpl) updateCard(w http.ResponseWriter, r *http.Request) { startTime := time.Now() bodySize := 0 - id, err := getCardId(r) + projectID, err := getIDFromRequest(r, "projectId") + if err != nil { + e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize) + return + } + + cardId, err := getIDFromRequest(r, "id") if err != nil { e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize) return @@ -182,25 +219,11 @@ func (e *handlersImpl) updateCard(w http.ResponseWriter, r *http.Request) { return } - // TODO update card in DB - - resp := &models.CardGetResponse{ - Card: models.Card{ - CardID: id, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - DeletedAt: nil, - EditedAt: nil, - ProjectID: 1, - UserID: 1, - CardBase: models.CardBase{ - Name: req.Name, - IsPublic: req.IsPublic, - Thumbnail: req.Thumbnail, - MetricType: req.MetricType, - MetricOf: req.MetricOf, - }, - }, + currentUser := r.Context().Value("userData").(*user.User) + resp, err := e.service.UpdateCard(projectID, int64(cardId), currentUser.ID, req) + if err != nil { + e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize) + return } e.responser.ResponseWithJSON(e.log, r.Context(), w, resp, startTime, r.URL.Path, bodySize) @@ -210,13 +233,24 @@ func (e *handlersImpl) deleteCard(w http.ResponseWriter, r *http.Request) { startTime := time.Now() bodySize := 0 - _, err := getCardId(r) + projectID, err := getIDFromRequest(r, "projectId") if err != nil { e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize) return } - // TODO delete card from DB + cardId, err := getIDFromRequest(r, "id") + if err != nil { + e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize) + return + } + + currentUser := r.Context().Value("userData").(*user.User) + err = e.service.DeleteCard(projectID, int64(cardId), currentUser.ID) + if err != nil { + e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize) + return + } e.responser.ResponseWithJSON(e.log, r.Context(), w, nil, startTime, r.URL.Path, bodySize) } @@ -225,6 +259,12 @@ func (e *handlersImpl) getCardChartData(w http.ResponseWriter, r *http.Request) startTime := time.Now() bodySize := 0 + projectID, err := getIDFromRequest(r, "projectId") + if err != nil { + e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize) + return + } + bodyBytes, err := api.ReadBody(e.log, w, r, e.jsonSizeLimit) if err != nil { e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusRequestEntityTooLarge, err, startTime, r.URL.Path, bodySize) @@ -240,26 +280,13 @@ func (e *handlersImpl) getCardChartData(w http.ResponseWriter, r *http.Request) validate := validator.New() err = validate.Struct(req) + if err != nil { + e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize) + return + } - // TODO get card chart data from ClickHouse - jsonInput := ` - { - "data": [ - { - "timestamp": 1733934939000, - "Series A": 100, - "Series B": 200 - }, - { - "timestamp": 1733935939000, - "Series A": 150, - "Series B": 250 - } - ] - }` - - var resp models.GetCardChartDataResponse - err = json.Unmarshal([]byte(jsonInput), &resp) + currentUser := r.Context().Value("userData").(*user.User) + resp, err := e.service.GetCardChartData(projectID, currentUser.ID, req) if err != nil { e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize) return diff --git a/backend/pkg/analytics/api/dashboard-handlers.go b/backend/pkg/analytics/api/dashboard-handlers.go index 255b41a6c..3fb383d0b 100644 --- a/backend/pkg/analytics/api/dashboard-handlers.go +++ b/backend/pkg/analytics/api/dashboard-handlers.go @@ -2,32 +2,14 @@ package api import ( "encoding/json" - "fmt" "github.com/go-playground/validator/v10" - "github.com/gorilla/mux" "net/http" "openreplay/backend/pkg/analytics/api/models" "openreplay/backend/pkg/server/api" "openreplay/backend/pkg/server/user" - "strconv" "time" ) -func getIDFromRequest(r *http.Request, key string) (int, error) { - vars := mux.Vars(r) - idStr := vars[key] - if idStr == "" { - return 0, fmt.Errorf("missing %s in request", key) - } - - id, err := strconv.Atoi(idStr) - if err != nil { - return 0, fmt.Errorf("invalid %s format", key) - } - - return id, nil -} - func (e *handlersImpl) createDashboard(w http.ResponseWriter, r *http.Request) { startTime := time.Now() bodySize := 0 diff --git a/backend/pkg/analytics/api/handlers.go b/backend/pkg/analytics/api/handlers.go index 010a8cd78..5dda6a998 100644 --- a/backend/pkg/analytics/api/handlers.go +++ b/backend/pkg/analytics/api/handlers.go @@ -1,12 +1,16 @@ package api import ( + "fmt" + "github.com/gorilla/mux" + "net/http" config "openreplay/backend/internal/config/analytics" "openreplay/backend/pkg/analytics/service" "openreplay/backend/pkg/logger" "openreplay/backend/pkg/objectstorage" "openreplay/backend/pkg/server/api" "openreplay/backend/pkg/server/keys" + "strconv" ) type handlersImpl struct { @@ -26,7 +30,7 @@ func (e *handlersImpl) GetAll() []*api.Description { {"/v1/analytics/{projectId}/dashboards/{id}", e.updateDashboard, "PUT"}, {"/v1/analytics/{projectId}/dashboards/{id}", e.deleteDashboard, "DELETE"}, {"/v1/analytics/{projectId}/cards", e.createCard, "POST"}, - {"/v1/analytics/{projectId}/cards", e.getCards, "GET"}, + {"/v1/analytics/{projectId}/cards", e.getCardsPaginated, "GET"}, {"/v1/analytics/{projectId}/cards/{id}", e.getCard, "GET"}, {"/v1/analytics/{projectId}/cards/{id}", e.updateCard, "PUT"}, {"/v1/analytics/{projectId}/cards/{id}", e.deleteCard, "DELETE"}, @@ -45,3 +49,18 @@ func NewHandlers(log logger.Logger, cfg *config.Config, responser *api.Responser service: service, }, nil } + +func getIDFromRequest(r *http.Request, key string) (int, error) { + vars := mux.Vars(r) + idStr := vars[key] + if idStr == "" { + return 0, fmt.Errorf("missing %s in request", key) + } + + id, err := strconv.Atoi(idStr) + if err != nil { + return 0, fmt.Errorf("invalid %s format", key) + } + + return id, nil +} diff --git a/backend/pkg/analytics/api/models/card.go b/backend/pkg/analytics/api/models/card.go index 271d8c080..67797c164 100644 --- a/backend/pkg/analytics/api/models/card.go +++ b/backend/pkg/analytics/api/models/card.go @@ -1,22 +1,24 @@ package models import ( + "github.com/go-playground/validator/v10" + "strings" "time" ) // CardBase Common fields for the Card entity type CardBase struct { - Name string `json:"name" validate:"required"` - IsPublic bool `json:"isPublic" validate:"omitempty"` - DefaultConfig map[string]any `json:"defaultConfig"` - Thumbnail *string `json:"thumbnail" validate:"omitempty,url"` - MetricType string `json:"metricType" validate:"required,oneof=timeseries table funnel"` - MetricOf string `json:"metricOf" validate:"required,oneof=session_count user_count"` - MetricFormat string `json:"metricFormat" validate:"required,oneof=default percentage"` - ViewType string `json:"viewType" validate:"required,oneof=line_chart table_view"` - MetricValue []string `json:"metricValue" validate:"omitempty"` - SessionID *int64 `json:"sessionId" validate:"omitempty"` - Series []CardSeries `json:"series" validate:"required,dive"` + Name string `json:"name" validate:"required"` + IsPublic bool `json:"isPublic" validate:"omitempty"` + DefaultConfig map[string]any `json:"defaultConfig"` + Thumbnail *string `json:"thumbnail" validate:"omitempty,url"` + MetricType string `json:"metricType" validate:"required,oneof=timeseries table funnel"` + MetricOf string `json:"metricOf" validate:"required,oneof=session_count user_count"` + MetricFormat string `json:"metricFormat" validate:"required,oneof=default percentage"` + ViewType string `json:"viewType" validate:"required,oneof=line_chart table_view"` + MetricValue []string `json:"metricValue" validate:"omitempty"` + SessionID *int64 `json:"sessionId" validate:"omitempty"` + Series []CardSeriesBase `json:"series" validate:"required,dive"` } // Card Fields specific to database operations @@ -31,9 +33,7 @@ type Card struct { EditedAt *time.Time `json:"edited_at,omitempty"` } -type CardSeries struct { - SeriesID int64 `json:"seriesId" validate:"omitempty"` - MetricID int64 `json:"metricId" validate:"omitempty"` +type CardSeriesBase struct { Name string `json:"name" validate:"required"` CreatedAt time.Time `json:"createdAt" validate:"omitempty"` DeletedAt *time.Time `json:"deletedAt" validate:"omitempty"` @@ -41,6 +41,12 @@ type CardSeries struct { Filter SeriesFilter `json:"filter"` } +type CardSeries struct { + SeriesID int64 `json:"seriesId" validate:"omitempty"` + MetricID int64 `json:"metricId" validate:"omitempty"` + CardSeriesBase +} + type SeriesFilter struct { EventOrder string `json:"eventOrder" validate:"required,oneof=then or and"` Filters []FilterItem `json:"filters"` @@ -62,6 +68,7 @@ type CardCreateRequest struct { type CardGetResponse struct { Card + Series []CardSeries `json:"series"` } type CardUpdateRequest struct { @@ -70,7 +77,11 @@ type CardUpdateRequest struct { type GetCardsResponse struct { Cards []Card `json:"cards"` - Total int64 `json:"total"` +} + +type GetCardsResponsePaginated struct { + Cards []Card `json:"cards"` + Total int `json:"total"` } type DataPoint struct { @@ -79,14 +90,122 @@ type DataPoint struct { } type GetCardChartDataRequest struct { - ProjectID int64 `json:"projectId" validate:"required"` MetricType string `json:"metricType" validate:"required,oneof=timeseries table funnel"` MetricOf string `json:"metricOf" validate:"required,oneof=session_count user_count"` + ViewType string `json:"viewType" validate:"required,oneof=line_chart table_view"` MetricFormat string `json:"metricFormat" validate:"required,oneof=default percentage"` - SessionID int64 `json:"sessionId" validate:"required"` - Series []CardSeries `json:"series"` + SessionID int64 `json:"sessionId"` + Series []CardSeries `json:"series" validate:"required,dive"` } type GetCardChartDataResponse struct { Data []DataPoint `json:"data"` } + +/************************************************************ + * CardListFilter and Sorter + */ + +// Supported filters, fields, and orders +var ( + SupportedFilterKeys = map[string]bool{ + "name": true, + "metric_type": true, + "dashboard_ids": true, + } + SupportedSortFields = map[string]string{ + "name": "m.name", + "created_at": "m.created_at", + "metric_type": "m.metric_type", + } + SupportedSortOrders = map[string]bool{ + "asc": true, + "desc": true, + } +) + +// CardListFilter holds filtering criteria for listing cards. +type CardListFilter struct { + // Keys: "name" (string), "metric_type" (string), "dashboard_ids" ([]int) + Filters map[string]interface{} `validate:"supportedFilters"` +} + +// CardListSort holds sorting criteria. +type CardListSort struct { + Field string `validate:"required,supportedSortField"` + Order string `validate:"required,supportedSortOrder"` +} + +// Validator singleton +var validate *validator.Validate + +func GetValidator() *validator.Validate { + if validate == nil { + validate = validator.New() + // Register custom validations + _ = validate.RegisterValidation("supportedFilters", supportedFiltersValidator) + _ = validate.RegisterValidation("supportedSortField", supportedSortFieldValidator) + _ = validate.RegisterValidation("supportedSortOrder", supportedSortOrderValidator) + } + return validate +} + +func ValidateStruct(obj interface{}) error { + return GetValidator().Struct(obj) +} + +// Custom validations +func supportedFiltersValidator(fl validator.FieldLevel) bool { + filters, ok := fl.Field().Interface().(map[string]interface{}) + if !ok { + return false + } + for k := range filters { + if !SupportedFilterKeys[k] { + return false + } + } + return true +} + +func supportedSortFieldValidator(fl validator.FieldLevel) bool { + field := strings.ToLower(fl.Field().String()) + _, ok := SupportedSortFields[field] + return ok +} + +func supportedSortOrderValidator(fl validator.FieldLevel) bool { + order := strings.ToLower(fl.Field().String()) + return SupportedSortOrders[order] +} + +// Filter helpers +func (f *CardListFilter) GetNameFilter() *string { + if val, ok := f.Filters["name"].(string); ok && val != "" { + return &val + } + return nil +} + +func (f *CardListFilter) GetMetricTypeFilter() *string { + if val, ok := f.Filters["metric_type"].(string); ok && val != "" { + return &val + } + return nil +} + +func (f *CardListFilter) GetDashboardIDs() []int { + if val, ok := f.Filters["dashboard_ids"].([]int); ok && len(val) > 0 { + return val + } + return nil +} + +// Sort helpers +func (s *CardListSort) GetSQLField() string { + return SupportedSortFields[strings.ToLower(s.Field)] +} + +func (s *CardListSort) GetSQLOrder() string { + return strings.ToUpper(s.Order) +} diff --git a/backend/pkg/analytics/service/analytics.go b/backend/pkg/analytics/service/analytics.go index 64a5db7d4..c705eb5cd 100644 --- a/backend/pkg/analytics/service/analytics.go +++ b/backend/pkg/analytics/service/analytics.go @@ -1,6 +1,7 @@ package service import ( + "context" "errors" "openreplay/backend/pkg/analytics/api/models" "openreplay/backend/pkg/db/postgres/pool" @@ -15,12 +16,21 @@ type Service interface { CreateDashboard(projectId int, userId uint64, req *models.CreateDashboardRequest) (*models.GetDashboardResponse, error) UpdateDashboard(projectId int, dashboardId int, userId uint64, req *models.UpdateDashboardRequest) (*models.GetDashboardResponse, error) DeleteDashboard(projectId int, dashboardId int, userId uint64) error + GetCard(projectId int, cardId int) (*models.CardGetResponse, error) + GetCardWithSeries(projectId int, cardId int) (*models.CardGetResponse, error) + GetCards(projectId int) (*models.GetCardsResponse, error) + GetCardsPaginated(projectId int, filters models.CardListFilter, sort models.CardListSort, limit int, offset int) (*models.GetCardsResponsePaginated, error) + CreateCard(projectId int, userId uint64, req *models.CardCreateRequest) (*models.CardGetResponse, error) + UpdateCard(projectId int, cardId int64, userId uint64, req *models.CardUpdateRequest) (*models.CardGetResponse, error) + DeleteCard(projectId int, cardId int64, userId uint64) error + GetCardChartData(projectId int, userId uint64, req *models.GetCardChartDataRequest) ([]models.DataPoint, error) } type serviceImpl struct { log logger.Logger pgconn pool.Pool storage objectstorage.ObjectStorage + ctx context.Context } func NewService(log logger.Logger, conn pool.Pool, storage objectstorage.ObjectStorage) (Service, error) { @@ -37,5 +47,6 @@ func NewService(log logger.Logger, conn pool.Pool, storage objectstorage.ObjectS log: log, pgconn: conn, storage: storage, + ctx: context.Background(), }, nil } diff --git a/backend/pkg/analytics/service/card-service.go b/backend/pkg/analytics/service/card-service.go new file mode 100644 index 000000000..65060c682 --- /dev/null +++ b/backend/pkg/analytics/service/card-service.go @@ -0,0 +1,428 @@ +package service + +import ( + "context" + "encoding/json" + "fmt" + "github.com/jackc/pgx/v4" + "github.com/lib/pq" + "openreplay/backend/pkg/analytics/api/models" + "strings" +) + +func (s serviceImpl) CreateCard(projectId int, userID uint64, req *models.CardCreateRequest) (*models.CardGetResponse, error) { + if req.MetricValue == nil { + req.MetricValue = []string{} + } + + tx, err := s.pgconn.Begin() // Start transaction + if err != nil { + return nil, fmt.Errorf("failed to start transaction: %w", err) + } + + ctx := context.Background() + defer func() { + if err != nil { + tx.Rollback(ctx) + if err != nil { + return + } + } else { + err := tx.Commit(ctx) + if err != nil { + return + } + } + }() + + // Insert the card + sql := ` + INSERT INTO public.metrics (project_id, user_id, name, metric_type, view_type, metric_of, metric_value, metric_format, is_public) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING metric_id, project_id, user_id, name, metric_type, view_type, metric_of, metric_value, metric_format, is_public, created_at, edited_at` + + card := &models.CardGetResponse{} + err = tx.QueryRow( + ctx, sql, + projectId, userID, req.Name, req.MetricType, req.ViewType, req.MetricOf, req.MetricValue, req.MetricFormat, req.IsPublic, + ).Scan( + &card.CardID, + &card.ProjectID, + &card.UserID, + &card.Name, + &card.MetricType, + &card.ViewType, + &card.MetricOf, + &card.MetricValue, + &card.MetricFormat, + &card.IsPublic, + &card.CreatedAt, + &card.EditedAt, + ) + if err != nil { + return nil, fmt.Errorf("failed to create card: %w", err) + } + + // Create series for the card + seriesList := s.CreateSeries(ctx, tx, card.CardID, req.Series) + if len(seriesList) != len(req.Series) { + return nil, fmt.Errorf("not all series were created successfully") + } + + card.Series = seriesList + return card, nil +} + +func (s serviceImpl) CreateSeries(ctx context.Context, tx pgx.Tx, metricId int64, series []models.CardSeriesBase) []models.CardSeries { + if len(series) == 0 { + return nil // No series to create + } + + // Batch insert for better performance + sql := ` + INSERT INTO public.metric_series (metric_id, name, index, filter) VALUES %s + RETURNING series_id, metric_id, name, index, filter` + + // Generate the VALUES placeholders dynamically + var values []string + var args []interface{} + for i, ser := range series { + values = append(values, fmt.Sprintf("($%d, $%d, $%d, $%d)", i*4+1, i*4+2, i*4+3, i*4+4)) + + filterJSON, err := json.Marshal(ser.Filter) // Convert struct to JSON + if err != nil { + s.log.Error(ctx, "failed to serialize filter to JSON: %v", err) + return nil + } + fmt.Println(string(filterJSON)) + args = append(args, metricId, ser.Name, i, string(filterJSON)) + } + + query := fmt.Sprintf(sql, strings.Join(values, ",")) + s.log.Info(ctx, "Executing query: %s with args: %v", query, args) + + rows, err := tx.Query(ctx, query, args...) + if err != nil { + s.log.Error(ctx, "failed to execute batch insert for series: %v", err) + return nil + } + defer rows.Close() + + if rows.Err() != nil { + s.log.Error(ctx, "Query returned an error: %v", rows.Err()) + return nil + } + + // Collect inserted series + var seriesList []models.CardSeries + for rows.Next() { + cardSeries := models.CardSeries{} + if err := rows.Scan(&cardSeries.SeriesID, &cardSeries.MetricID, &cardSeries.Name, &cardSeries.Index, &cardSeries.Filter); err != nil { + s.log.Error(ctx, "failed to scan series: %v", err) + continue + } + seriesList = append(seriesList, cardSeries) + } + + return seriesList +} + +func (s serviceImpl) GetCard(projectId int, cardID int) (*models.CardGetResponse, error) { + sql := + `SELECT metric_id, project_id, user_id, name, metric_type, view_type, metric_of, metric_value, metric_format, is_public, created_at, edited_at + FROM public.metrics + WHERE metric_id = $1 AND project_id = $2 AND deleted_at IS NULL` + + card := &models.CardGetResponse{} + err := s.pgconn.QueryRow(sql, cardID, projectId).Scan( + &card.CardID, &card.ProjectID, &card.UserID, &card.Name, &card.MetricType, &card.ViewType, &card.MetricOf, &card.MetricValue, &card.MetricFormat, &card.IsPublic, &card.CreatedAt, &card.EditedAt, + ) + + if err != nil { + return nil, fmt.Errorf("failed to get card: %w", err) + } + + return card, nil +} + +func (s serviceImpl) GetCardWithSeries(projectId int, cardID int) (*models.CardGetResponse, error) { + sql := ` + SELECT m.metric_id, m.project_id, m.user_id, m.name, m.metric_type, m.view_type, m.metric_of, + m.metric_value, m.metric_format, m.is_public, m.created_at, m.edited_at, + COALESCE( + json_agg( + json_build_object( + 'seriesId', ms.series_id, + 'index', ms.index, + 'name', ms.name, + 'filter', ms.filter + ) + ) FILTER (WHERE ms.series_id IS NOT NULL), '[]' + ) AS series + FROM public.metrics m + LEFT JOIN public.metric_series ms ON m.metric_id = ms.metric_id + WHERE m.metric_id = $1 AND m.project_id = $2 AND m.deleted_at IS NULL + GROUP BY m.metric_id, m.project_id, m.user_id, m.name, m.metric_type, m.view_type, + m.metric_of, m.metric_value, m.metric_format, m.is_public, m.created_at, m.edited_at + ` + + card := &models.CardGetResponse{} + var seriesJSON []byte + err := s.pgconn.QueryRow(sql, cardID, projectId).Scan( + &card.CardID, &card.ProjectID, &card.UserID, &card.Name, &card.MetricType, &card.ViewType, &card.MetricOf, + &card.MetricValue, &card.MetricFormat, &card.IsPublic, &card.CreatedAt, &card.EditedAt, &seriesJSON, + ) + + if err != nil { + return nil, fmt.Errorf("failed to get card: %w", err) + } + + if err := json.Unmarshal(seriesJSON, &card.Series); err != nil { + return nil, fmt.Errorf("failed to unmarshal series: %w", err) + } + + return card, nil +} + +func (s serviceImpl) GetCards(projectId int) (*models.GetCardsResponse, error) { + sql := ` + SELECT metric_id, project_id, user_id, name, metric_type, view_type, metric_of, metric_value, metric_format, is_public, created_at, edited_at + FROM public.metrics + WHERE project_id = $1 AND deleted_at IS NULL` + + rows, err := s.pgconn.Query(sql, projectId) + if err != nil { + return nil, fmt.Errorf("failed to get cards: %w", err) + } + defer rows.Close() + + cards := make([]models.Card, 0) + for rows.Next() { + card := models.Card{} + if err := rows.Scan( + &card.CardID, &card.ProjectID, &card.UserID, &card.Name, &card.MetricType, &card.ViewType, &card.MetricOf, + &card.MetricValue, &card.MetricFormat, &card.IsPublic, &card.CreatedAt, &card.EditedAt, + ); err != nil { + return nil, fmt.Errorf("failed to scan card: %w", err) + } + cards = append(cards, card) + } + + return &models.GetCardsResponse{Cards: cards}, nil +} + +func (s serviceImpl) GetCardsPaginated( + projectId int, + filters models.CardListFilter, + sort models.CardListSort, + limit, + offset int, +) (*models.GetCardsResponsePaginated, error) { + // Validate inputs + if err := models.ValidateStruct(filters); err != nil { + return nil, fmt.Errorf("invalid filters: %w", err) + } + if err := models.ValidateStruct(sort); err != nil { + return nil, fmt.Errorf("invalid sort: %w", err) + } + + var ( + conditions []string + params []interface{} + paramIndex = 1 + ) + + // Project ID is mandatory + conditions = append(conditions, fmt.Sprintf("m.project_id = $%d", paramIndex)) + params = append(params, projectId) + paramIndex++ + + // Apply filters + if nameFilter := filters.GetNameFilter(); nameFilter != nil { + conditions = append(conditions, fmt.Sprintf("m.name ILIKE $%d", paramIndex)) + params = append(params, "%"+*nameFilter+"%") + paramIndex++ + } + + if typeFilter := filters.GetMetricTypeFilter(); typeFilter != nil { + conditions = append(conditions, fmt.Sprintf("m.metric_type = $%d", paramIndex)) + params = append(params, *typeFilter) + paramIndex++ + } + + var joinClause string + if dashboardIDs := filters.GetDashboardIDs(); len(dashboardIDs) > 0 { + joinClause = "LEFT JOIN public.dashboard_widgets dw ON m.metric_id = dw.metric_id" + conditions = append(conditions, fmt.Sprintf("dw.dashboard_id = ANY($%d)", paramIndex)) + params = append(params, pq.Array(dashboardIDs)) + paramIndex++ + } + + // Exclude deleted + conditions = append(conditions, "m.deleted_at IS NULL") + + whereClause := "WHERE " + strings.Join(conditions, " AND ") + + orderClause := fmt.Sprintf("ORDER BY %s %s", sort.GetSQLField(), sort.GetSQLOrder()) + limitClause := fmt.Sprintf("LIMIT $%d", paramIndex) + params = append(params, limit) + paramIndex++ + offsetClause := fmt.Sprintf("OFFSET $%d", paramIndex) + params = append(params, offset) + paramIndex++ + + // Main query + query := fmt.Sprintf(` + SELECT m.metric_id, m.project_id, m.user_id, m.name, m.metric_type, m.view_type, m.metric_of, + m.metric_value, m.metric_format, m.is_public, m.created_at, m.edited_at + FROM public.metrics m + %s + %s + %s + %s + %s + `, joinClause, whereClause, orderClause, limitClause, offsetClause) + + rows, err := s.pgconn.Query(query, params...) + if err != nil { + return nil, fmt.Errorf("failed to get cards: %w", err) + } + defer rows.Close() + + var cards []models.Card + for rows.Next() { + var card models.Card + if err := rows.Scan( + &card.CardID, &card.ProjectID, &card.UserID, &card.Name, &card.MetricType, &card.ViewType, &card.MetricOf, + &card.MetricValue, &card.MetricFormat, &card.IsPublic, &card.CreatedAt, &card.EditedAt, + ); err != nil { + return nil, fmt.Errorf("failed to scan card: %w", err) + } + cards = append(cards, card) + } + + // Count total (exclude limit, offset, order) + countParams := params[0 : len(params)-2] // all filter params without limit/offset + countQuery := fmt.Sprintf(` + SELECT COUNT(*) + FROM public.metrics m + %s + %s + `, joinClause, whereClause) + + var total int + if err := s.pgconn.QueryRow(countQuery, countParams...).Scan(&total); err != nil { + return nil, fmt.Errorf("failed to get total count: %w", err) + } + + return &models.GetCardsResponsePaginated{ + Cards: cards, + Total: total, + }, nil +} + +func (s serviceImpl) UpdateCard(projectId int, cardID int64, userID uint64, req *models.CardUpdateRequest) (*models.CardGetResponse, error) { + if req.MetricValue == nil { + req.MetricValue = []string{} + } + + tx, err := s.pgconn.Begin() // Start transaction + if err != nil { + return nil, fmt.Errorf("failed to start transaction: %w", err) + } + + ctx := context.Background() + defer func() { + if err != nil { + tx.Rollback(ctx) + if err != nil { + return + } + } else { + err := tx.Commit(ctx) + if err != nil { + return + } + } + }() + + // Update the card + sql := ` + UPDATE public.metrics + SET name = $1, metric_type = $2, view_type = $3, metric_of = $4, metric_value = $5, metric_format = $6, is_public = $7 + WHERE metric_id = $8 AND project_id = $9 AND deleted_at IS NULL + RETURNING metric_id, project_id, user_id, name, metric_type, view_type, metric_of, metric_value, metric_format, is_public, created_at, edited_at` + + card := &models.CardGetResponse{} + err = tx.QueryRow(ctx, sql, + req.Name, req.MetricType, req.ViewType, req.MetricOf, req.MetricValue, req.MetricFormat, req.IsPublic, cardID, projectId, + ).Scan( + &card.CardID, &card.ProjectID, &card.UserID, &card.Name, &card.MetricType, &card.ViewType, &card.MetricOf, + &card.MetricValue, &card.MetricFormat, &card.IsPublic, &card.CreatedAt, &card.EditedAt, + ) + + if err != nil { + return nil, fmt.Errorf("failed to update card: %w", err) + } + + // delete all series for the card and create new ones + err = s.DeleteCardSeries(card.CardID) + if err != nil { + return nil, fmt.Errorf("failed to delete series: %w", err) + } + + seriesList := s.CreateSeries(ctx, tx, card.CardID, req.Series) + if len(seriesList) != len(req.Series) { + return nil, fmt.Errorf("not all series were created successfully") + } + + card.Series = seriesList + return card, nil +} + +func (s serviceImpl) DeleteCardSeries(cardId int64) error { + sql := `DELETE FROM public.metric_series WHERE metric_id = $1` + err := s.pgconn.Exec(sql, cardId) + if err != nil { + return fmt.Errorf("failed to delete series: %w", err) + } + return nil +} + +func (s serviceImpl) DeleteCard(projectId int, cardID int64, userID uint64) error { + sql := ` + UPDATE public.metrics + SET deleted_at = now() + WHERE metric_id = $1 AND project_id = $2 AND user_id = $3 AND deleted_at IS NULL` + + err := s.pgconn.Exec(sql, cardID, projectId, userID) + if err != nil { + return fmt.Errorf("failed to delete card: %w", err) + } + return nil +} + +func (s serviceImpl) GetCardChartData(projectId int, userID uint64, req *models.GetCardChartDataRequest) ([]models.DataPoint, error) { + jsonInput := ` + { + "data": [ + { + "timestamp": 1733934939000, + "Series A": 100, + "Series B": 200 + }, + { + "timestamp": 1733935939000, + "Series A": 150, + "Series B": 250 + } + ] + }` + + var resp models.GetCardChartDataResponse + if err := json.Unmarshal([]byte(jsonInput), &resp); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + return resp.Data, nil +}