diff --git a/backend/pkg/analytics/api/dashboard-handlers.go b/backend/pkg/analytics/api/dashboard-handlers.go index 3fb383d0b..48d3e3c5b 100644 --- a/backend/pkg/analytics/api/dashboard-handlers.go +++ b/backend/pkg/analytics/api/dashboard-handlers.go @@ -192,12 +192,6 @@ func (e *handlersImpl) pinDashboard(w http.ResponseWriter, r *http.Request) { startTime := time.Now() bodySize := 0 - //id, err := getDashboardId(r) - //if err != nil { - // e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize) - // return - //} - e.log.Info(r.Context(), "Dashboard pinned") e.responser.ResponseOK(e.log, r.Context(), w, startTime, r.URL.Path, bodySize) @@ -208,13 +202,52 @@ func (e *handlersImpl) addCardToDashboard(w http.ResponseWriter, r *http.Request startTime := time.Now() bodySize := 0 - //id, err := getDashboardId(r) - //if err != nil { - // e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize) - // return - //} + 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 + } - e.log.Info(r.Context(), "Card added to dashboard") + dashboardID, err := getIDFromRequest(r, "id") + if err != nil { + e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize) + return + } + + u := r.Context().Value("userData").(*user.User) + + 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) + return + } + + bodySize = len(bodyBytes) + + req := &models.AddCardToDashboardRequest{} + if err := json.Unmarshal(bodyBytes, req); err != nil { + e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize) + return + } + + 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 + } + + err = e.service.AddCardsToDashboard(projectID, dashboardID, u.ID, req) + if err != nil { + if err.Error() == "not_found: dashboard not found" { + e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusNotFound, err, startTime, r.URL.Path, bodySize) + } else if err.Error() == "access_denied: user does not have access" { + e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusForbidden, err, startTime, r.URL.Path, bodySize) + } else { + e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize) + } + return + } e.responser.ResponseOK(e.log, r.Context(), w, startTime, r.URL.Path, bodySize) } @@ -224,11 +257,41 @@ func (e *handlersImpl) removeCardFromDashboard(w http.ResponseWriter, r *http.Re startTime := time.Now() bodySize := 0 - //id, err := getDashboardId(r) - //if err != nil { - // e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize) - // return - //} + 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 + } + + dashboardID, err := getIDFromRequest(r, "id") + if err != nil { + e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize) + return + } + + cardID, err := getIDFromRequest(r, "cardId") + if err != nil { + e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize) + return + } + + u := r.Context().Value("userData").(*user.User) + _, err = e.service.GetDashboard(projectID, dashboardID, u.ID) + if err != nil { + if err.Error() == "not_found: dashboard not found" { + e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusNotFound, err, startTime, r.URL.Path, bodySize) + } else if err.Error() == "access_denied: user does not have access" { + e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusForbidden, err, startTime, r.URL.Path, bodySize) + } else { + e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize) + } + } + + err = e.service.DeleteCardFromDashboard(dashboardID, cardID) + if err != nil { + e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize) + return + } e.responser.ResponseOK(e.log, r.Context(), w, startTime, r.URL.Path, bodySize) } diff --git a/backend/pkg/analytics/api/handlers.go b/backend/pkg/analytics/api/handlers.go index 89248c42e..d05c6e699 100644 --- a/backend/pkg/analytics/api/handlers.go +++ b/backend/pkg/analytics/api/handlers.go @@ -29,6 +29,8 @@ func (e *handlersImpl) GetAll() []*api.Description { {"/v1/analytics/{projectId}/dashboards/{id}", e.getDashboard, "GET"}, {"/v1/analytics/{projectId}/dashboards/{id}", e.updateDashboard, "PUT"}, {"/v1/analytics/{projectId}/dashboards/{id}", e.deleteDashboard, "DELETE"}, + {"/v1/analytics/{projectId}/dashboards/{id}/cards", e.addCardToDashboard, "POST"}, + {"/v1/analytics/{projectId}/dashboards/{id}/cards/{cardId}", e.removeCardFromDashboard, "DELETE"}, {"/v1/analytics/{projectId}/cards", e.createCard, "POST"}, {"/v1/analytics/{projectId}/cards", e.getCardsPaginated, "GET"}, {"/v1/analytics/{projectId}/cards/{id}", e.getCard, "GET"}, diff --git a/backend/pkg/analytics/api/models/card.go b/backend/pkg/analytics/api/models/card.go index 67797c164..3d2be511a 100644 --- a/backend/pkg/analytics/api/models/card.go +++ b/backend/pkg/analytics/api/models/card.go @@ -11,6 +11,7 @@ type CardBase struct { Name string `json:"name" validate:"required"` IsPublic bool `json:"isPublic" validate:"omitempty"` DefaultConfig map[string]any `json:"defaultConfig"` + Config map[string]any `json:"config"` 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"` diff --git a/backend/pkg/analytics/api/models/model.go b/backend/pkg/analytics/api/models/model.go index 8e45af454..377f67abd 100644 --- a/backend/pkg/analytics/api/models/model.go +++ b/backend/pkg/analytics/api/models/model.go @@ -1,15 +1,16 @@ package models type Dashboard struct { - DashboardID int `json:"dashboardId"` - ProjectID int `json:"projectId"` - UserID int `json:"userId"` - Name string `json:"name"` - Description string `json:"description"` - IsPublic bool `json:"isPublic"` - IsPinned bool `json:"isPinned"` - OwnerEmail string `json:"ownerEmail"` - OwnerName string `json:"ownerName"` + DashboardID int `json:"dashboardId"` + ProjectID int `json:"projectId"` + UserID int `json:"userId"` + Name string `json:"name"` + Description string `json:"description"` + IsPublic bool `json:"isPublic"` + IsPinned bool `json:"isPinned"` + OwnerEmail string `json:"ownerEmail"` + OwnerName string `json:"ownerName"` + Metrics []CardBase `json:"cards"` } type CreateDashboardResponse struct { @@ -61,9 +62,6 @@ type PinDashboardRequest struct { } type AddCardToDashboardRequest struct { - CardIDs []int `json:"card_ids"` -} - -type DeleteCardFromDashboardRequest struct { - CardIDs []int `json:"card_ids"` + MetricIDs []int `json:"metric_ids" validate:"required,min=1,dive,gt=0"` + Config map[string]interface{} `json:"config"` // Optional } diff --git a/backend/pkg/analytics/service/analytics.go b/backend/pkg/analytics/service/analytics.go index 4c0faf13c..f6844e8bf 100644 --- a/backend/pkg/analytics/service/analytics.go +++ b/backend/pkg/analytics/service/analytics.go @@ -17,6 +17,8 @@ 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 + AddCardsToDashboard(projectId int, dashboardId int, userId uint64, req *models.AddCardToDashboardRequest) error + DeleteCardFromDashboard(dashboardId int, cardId int) error GetCard(projectId int, cardId int) (*models.CardGetResponse, error) GetCardWithSeries(projectId int, cardId int) (*models.CardGetResponse, error) GetCards(projectId int) (*models.GetCardsResponse, error) diff --git a/backend/pkg/analytics/service/card-service.go b/backend/pkg/analytics/service/card-service.go index 65060c682..a478187b6 100644 --- a/backend/pkg/analytics/service/card-service.go +++ b/backend/pkg/analytics/service/card-service.go @@ -10,7 +10,7 @@ import ( "strings" ) -func (s serviceImpl) CreateCard(projectId int, userID uint64, req *models.CardCreateRequest) (*models.CardGetResponse, error) { +func (s *serviceImpl) CreateCard(projectId int, userID uint64, req *models.CardCreateRequest) (*models.CardGetResponse, error) { if req.MetricValue == nil { req.MetricValue = []string{} } @@ -73,7 +73,7 @@ func (s serviceImpl) CreateCard(projectId int, userID uint64, req *models.CardCr return card, nil } -func (s serviceImpl) CreateSeries(ctx context.Context, tx pgx.Tx, metricId int64, series []models.CardSeriesBase) []models.CardSeries { +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 } @@ -127,7 +127,7 @@ func (s serviceImpl) CreateSeries(ctx context.Context, tx pgx.Tx, metricId int64 return seriesList } -func (s serviceImpl) GetCard(projectId int, cardID int) (*models.CardGetResponse, error) { +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 @@ -145,7 +145,7 @@ func (s serviceImpl) GetCard(projectId int, cardID int) (*models.CardGetResponse return card, nil } -func (s serviceImpl) GetCardWithSeries(projectId int, cardID int) (*models.CardGetResponse, error) { +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, @@ -184,7 +184,7 @@ func (s serviceImpl) GetCardWithSeries(projectId int, cardID int) (*models.CardG return card, nil } -func (s serviceImpl) GetCards(projectId int) (*models.GetCardsResponse, error) { +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 @@ -211,7 +211,7 @@ func (s serviceImpl) GetCards(projectId int) (*models.GetCardsResponse, error) { return &models.GetCardsResponse{Cards: cards}, nil } -func (s serviceImpl) GetCardsPaginated( +func (s *serviceImpl) GetCardsPaginated( projectId int, filters models.CardListFilter, sort models.CardListSort, @@ -321,7 +321,7 @@ func (s serviceImpl) GetCardsPaginated( }, nil } -func (s serviceImpl) UpdateCard(projectId int, cardID int64, userID uint64, req *models.CardUpdateRequest) (*models.CardGetResponse, error) { +func (s *serviceImpl) UpdateCard(projectId int, cardID int64, userID uint64, req *models.CardUpdateRequest) (*models.CardGetResponse, error) { if req.MetricValue == nil { req.MetricValue = []string{} } @@ -380,7 +380,7 @@ func (s serviceImpl) UpdateCard(projectId int, cardID int64, userID uint64, req return card, nil } -func (s serviceImpl) DeleteCardSeries(cardId int64) error { +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 { @@ -389,7 +389,7 @@ func (s serviceImpl) DeleteCardSeries(cardId int64) error { return nil } -func (s serviceImpl) DeleteCard(projectId int, cardID int64, userID uint64) error { +func (s *serviceImpl) DeleteCard(projectId int, cardID int64, userID uint64) error { sql := ` UPDATE public.metrics SET deleted_at = now() @@ -402,7 +402,7 @@ func (s serviceImpl) DeleteCard(projectId int, cardID int64, userID uint64) erro return nil } -func (s serviceImpl) GetCardChartData(projectId int, userID uint64, req *models.GetCardChartDataRequest) ([]models.DataPoint, error) { +func (s *serviceImpl) GetCardChartData(projectId int, userID uint64, req *models.GetCardChartDataRequest) ([]models.DataPoint, error) { jsonInput := ` { "data": [ diff --git a/backend/pkg/analytics/service/dashboard-service.go b/backend/pkg/analytics/service/dashboard-service.go index a547aad6d..bbbf81b3f 100644 --- a/backend/pkg/analytics/service/dashboard-service.go +++ b/backend/pkg/analytics/service/dashboard-service.go @@ -1,13 +1,15 @@ package service import ( + "context" + "encoding/json" "errors" "fmt" "openreplay/backend/pkg/analytics/api/models" ) // CreateDashboard Create a new dashboard -func (s serviceImpl) CreateDashboard(projectId int, userID uint64, req *models.CreateDashboardRequest) (*models.GetDashboardResponse, error) { +func (s *serviceImpl) CreateDashboard(projectId int, userID uint64, req *models.CreateDashboardRequest) (*models.GetDashboardResponse, error) { sql := ` INSERT INTO dashboards (project_id, user_id, name, description, is_public, is_pinned) VALUES ($1, $2, $3, $4, $5, $6) @@ -30,14 +32,53 @@ func (s serviceImpl) CreateDashboard(projectId int, userID uint64, req *models.C } // GetDashboard Fetch a specific dashboard by ID -func (s serviceImpl) GetDashboard(projectId int, dashboardID int, userID uint64) (*models.GetDashboardResponse, error) { +func (s *serviceImpl) GetDashboard(projectId int, dashboardID int, userID uint64) (*models.GetDashboardResponse, error) { sql := ` - SELECT dashboard_id, project_id, name, description, is_public, is_pinned, user_id - FROM dashboards - WHERE dashboard_id = $1 AND project_id = $2 AND deleted_at IS NULL` + WITH series_agg AS ( + SELECT + ms.metric_id, + json_agg( + json_build_object( + 'index', ms.index, + 'name', ms.name, + 'filter', ms.filter + ) + ) AS series + FROM metric_series ms + GROUP BY ms.metric_id + ) + SELECT + d.dashboard_id, + d.project_id, + d.name, + d.description, + d.is_public, + d.is_pinned, + d.user_id, + COALESCE(json_agg( + json_build_object( + 'config', dw.config, + 'metric_id', m.metric_id, + 'name', m.name, + 'metric_type', m.metric_type, + 'view_type', m.view_type, + 'metric_of', m.metric_of, + 'metric_value', m.metric_value, + 'metric_format', m.metric_format, + 'series', s.series + ) + ) FILTER (WHERE m.metric_id IS NOT NULL), '[]') AS metrics + FROM dashboards d + LEFT JOIN dashboard_widgets dw ON d.dashboard_id = dw.dashboard_id + LEFT JOIN metrics m ON dw.metric_id = m.metric_id + LEFT JOIN series_agg s ON m.metric_id = s.metric_id + WHERE d.dashboard_id = $1 AND d.project_id = $2 AND d.deleted_at IS NULL + GROUP BY d.dashboard_id, d.project_id, d.name, d.description, d.is_public, d.is_pinned, d.user_id` dashboard := &models.GetDashboardResponse{} var ownerID int + var metricsJSON []byte + err := s.pgconn.QueryRow(sql, dashboardID, projectId).Scan( &dashboard.DashboardID, &dashboard.ProjectID, @@ -46,6 +87,7 @@ func (s serviceImpl) GetDashboard(projectId int, dashboardID int, userID uint64) &dashboard.IsPublic, &dashboard.IsPinned, &ownerID, + &metricsJSON, ) if err != nil { @@ -55,7 +97,10 @@ func (s serviceImpl) GetDashboard(projectId int, dashboardID int, userID uint64) return nil, fmt.Errorf("error fetching dashboard: %w", err) } - // Access control + if err := json.Unmarshal(metricsJSON, &dashboard.Metrics); err != nil { + return nil, fmt.Errorf("error unmarshalling metrics: %w", err) + } + if !dashboard.IsPublic && uint64(ownerID) != userID { return nil, fmt.Errorf("access_denied: user does not have access") } @@ -63,7 +108,7 @@ func (s serviceImpl) GetDashboard(projectId int, dashboardID int, userID uint64) return dashboard, nil } -func (s serviceImpl) GetDashboards(projectId int, userID uint64) (*models.GetDashboardsResponse, error) { +func (s *serviceImpl) GetDashboards(projectId int, userID uint64) (*models.GetDashboardsResponse, error) { sql := ` SELECT d.dashboard_id, d.user_id, d.project_id, d.name, d.description, d.is_public, d.is_pinned, u.email AS owner_email, u.name AS owner_name FROM dashboards d @@ -98,7 +143,7 @@ func (s serviceImpl) GetDashboards(projectId int, userID uint64) (*models.GetDas } // GetDashboardsPaginated Fetch dashboards with pagination -func (s serviceImpl) GetDashboardsPaginated(projectId int, userID uint64, req *models.GetDashboardsRequest) (*models.GetDashboardsResponsePaginated, error) { +func (s *serviceImpl) GetDashboardsPaginated(projectId int, userID uint64, req *models.GetDashboardsRequest) (*models.GetDashboardsResponsePaginated, error) { baseSQL, args := buildBaseQuery(projectId, userID, req) // Count total dashboards @@ -147,7 +192,7 @@ func (s serviceImpl) GetDashboardsPaginated(projectId int, userID uint64, req *m } // UpdateDashboard Update a dashboard -func (s serviceImpl) UpdateDashboard(projectId int, dashboardID int, userID uint64, req *models.UpdateDashboardRequest) (*models.GetDashboardResponse, error) { +func (s *serviceImpl) UpdateDashboard(projectId int, dashboardID int, userID uint64, req *models.UpdateDashboardRequest) (*models.GetDashboardResponse, error) { sql := ` UPDATE dashboards SET name = $1, description = $2, is_public = $3, is_pinned = $4 @@ -171,7 +216,7 @@ func (s serviceImpl) UpdateDashboard(projectId int, dashboardID int, userID uint } // DeleteDashboard Soft-delete a dashboard -func (s serviceImpl) DeleteDashboard(projectId int, dashboardID int, userID uint64) error { +func (s *serviceImpl) DeleteDashboard(projectId int, dashboardID int, userID uint64) error { sql := ` UPDATE dashboards SET deleted_at = now() @@ -236,3 +281,101 @@ func getOrder(order string) string { } return "ASC" } + +func (s *serviceImpl) CardsExist(projectId int, cardIDs []int) (bool, error) { + sql := ` + SELECT COUNT(*) FROM public.metrics + WHERE project_id = $1 AND metric_id = ANY($2) + ` + var count int + err := s.pgconn.QueryRow(sql, projectId, cardIDs).Scan(&count) + if err != nil { + return false, err + } + return count == len(cardIDs), nil +} + +func (s *serviceImpl) AddCardsToDashboard(projectId int, dashboardId int, userId uint64, req *models.AddCardToDashboardRequest) error { + _, err := s.GetDashboard(projectId, dashboardId, userId) + if err != nil { + return fmt.Errorf("failed to get dashboard: %w", err) + } + + // Check if all cards exist + exists, err := s.CardsExist(projectId, req.MetricIDs) + if err != nil { + return fmt.Errorf("failed to check card existence: %w", err) + } + + if !exists { + return errors.New("not_found: one or more cards do not exist") + } + + // Begin a transaction + tx, err := s.pgconn.Begin() // Start transaction + if err != nil { + return 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 metrics into dashboard_widgets + insertedWidgets := 0 + for _, metricID := range req.MetricIDs { + // Check if the widget already exists + var exists bool + err := tx.QueryRow(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM public.dashboard_widgets + WHERE dashboard_id = $1 AND metric_id = $2 + ) + `, dashboardId, metricID).Scan(&exists) + if err != nil { + return fmt.Errorf("failed to check existing widget: %w", err) + } + + if exists { + continue // Skip duplicates + } + + // Insert new widget + _, err = tx.Exec(ctx, ` + INSERT INTO public.dashboard_widgets (dashboard_id, metric_id, user_id, config) + VALUES ($1, $2, $3, $4) + `, dashboardId, metricID, userId, req.Config) + if err != nil { + return fmt.Errorf("failed to insert widget: %w", err) + } + insertedWidgets++ + } + + // Commit transaction + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("failed to commit transaction: %w", err) + } + + return nil +} + +func (s *serviceImpl) DeleteCardFromDashboard(dashboardId int, cardId int) error { + sql := `DELETE FROM public.dashboard_widgets WHERE dashboard_id = $1 AND metric_id = $2` + err := s.pgconn.Exec(sql, dashboardId, cardId) + if err != nil { + return fmt.Errorf("failed to delete card from dashboard: %w", err) + } + + return nil +}