diff --git a/backend/pkg/analytics/api/dashboard-handlers.go b/backend/pkg/analytics/api/dashboard-handlers.go index 46ee84722..255b41a6c 100644 --- a/backend/pkg/analytics/api/dashboard-handlers.go +++ b/backend/pkg/analytics/api/dashboard-handlers.go @@ -104,12 +104,14 @@ func (e *handlersImpl) getDashboard(w http.ResponseWriter, r *http.Request) { u := r.Context().Value("userData").(*user.User) res, err := e.service.GetDashboard(projectID, dashboardID, u.ID) if err != nil { - e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize) - return - } - - if res == nil { - e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusNotFound, fmt.Errorf("Dashboard not found"), startTime, r.URL.Path, bodySize) + // Map errors to appropriate HTTP status codes + 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 } @@ -139,6 +141,20 @@ func (e *handlersImpl) updateDashboard(w http.ResponseWriter, r *http.Request) { } bodySize = len(bodyBytes) + u := r.Context().Value("userData").(*user.User) + _, err = e.service.GetDashboard(projectID, dashboardID, u.ID) + if err != nil { + // Map errors to appropriate HTTP status codes + 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 + } + req := &models.UpdateDashboardRequest{} if err := json.Unmarshal(bodyBytes, req); err != nil { e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize) @@ -168,6 +184,19 @@ func (e *handlersImpl) deleteDashboard(w http.ResponseWriter, r *http.Request) { } u := r.Context().Value("userData").(*user.User) + _, err = e.service.GetDashboard(projectID, dashboardID, u.ID) + if err != nil { + // Map errors to appropriate HTTP status codes + 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 + } + err = e.service.DeleteDashboard(projectID, dashboardID, u.ID) if err != nil { e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize) diff --git a/backend/pkg/analytics/api/models/model.go b/backend/pkg/analytics/api/models/model.go index ed9513308..8e45af454 100644 --- a/backend/pkg/analytics/api/models/model.go +++ b/backend/pkg/analytics/api/models/model.go @@ -42,9 +42,10 @@ type CreateDashboardRequest struct { type GetDashboardsRequest struct { Page uint64 `json:"page"` Limit uint64 `json:"limit"` + IsPublic bool `json:"is_public"` Order string `json:"order"` Query string `json:"query"` - FilterBy string `json:"filterBy"` + OrderBy string `json:"orderBy"` } type UpdateDashboardRequest struct { diff --git a/backend/pkg/analytics/service/analytics.go b/backend/pkg/analytics/service/analytics.go index df0132ba1..64a5db7d4 100644 --- a/backend/pkg/analytics/service/analytics.go +++ b/backend/pkg/analytics/service/analytics.go @@ -10,6 +10,7 @@ import ( type Service interface { GetDashboard(projectId int, dashboardId int, userId uint64) (*models.GetDashboardResponse, error) + GetDashboardsPaginated(projectId int, userId uint64, req *models.GetDashboardsRequest) (*models.GetDashboardsResponsePaginated, error) GetDashboards(projectId int, userId uint64) (*models.GetDashboardsResponse, error) CreateDashboard(projectId int, userId uint64, req *models.CreateDashboardRequest) (*models.GetDashboardResponse, error) UpdateDashboard(projectId int, dashboardId int, userId uint64, req *models.UpdateDashboardRequest) (*models.GetDashboardResponse, error) diff --git a/backend/pkg/analytics/service/dashboard-service.go b/backend/pkg/analytics/service/dashboard-service.go index 33be80856..a547aad6d 100644 --- a/backend/pkg/analytics/service/dashboard-service.go +++ b/backend/pkg/analytics/service/dashboard-service.go @@ -1,10 +1,12 @@ package service import ( + "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) { sql := ` INSERT INTO dashboards (project_id, user_id, name, description, is_public, is_pinned) @@ -12,7 +14,6 @@ func (s serviceImpl) CreateDashboard(projectId int, userID uint64, req *models.C RETURNING dashboard_id, project_id, user_id, name, description, is_public, is_pinned` dashboard := &models.GetDashboardResponse{} - err := s.pgconn.QueryRow(sql, projectId, userID, req.Name, req.Description, req.IsPublic, req.IsPinned).Scan( &dashboard.DashboardID, &dashboard.ProjectID, @@ -23,27 +24,40 @@ func (s serviceImpl) CreateDashboard(projectId int, userID uint64, req *models.C &dashboard.IsPinned, ) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to create dashboard: %w", err) } - return dashboard, nil } +// GetDashboard Fetch a specific dashboard by ID 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` - dashboard := &models.GetDashboardResponse{} + WHERE dashboard_id = $1 AND project_id = $2 AND deleted_at IS NULL` + dashboard := &models.GetDashboardResponse{} var ownerID int - err := s.pgconn.QueryRow(sql, dashboardID, projectId).Scan(&dashboard.DashboardID, &dashboard.ProjectID, &dashboard.Name, &dashboard.Description, &dashboard.IsPublic, &dashboard.IsPinned, &ownerID) + err := s.pgconn.QueryRow(sql, dashboardID, projectId).Scan( + &dashboard.DashboardID, + &dashboard.ProjectID, + &dashboard.Name, + &dashboard.Description, + &dashboard.IsPublic, + &dashboard.IsPinned, + &ownerID, + ) + if err != nil { - return nil, err + if err.Error() == "no rows in result set" { + return nil, errors.New("not_found: dashboard not found") + } + return nil, fmt.Errorf("error fetching dashboard: %w", err) } + // Access control if !dashboard.IsPublic && uint64(ownerID) != userID { - return nil, fmt.Errorf("access denied: user %d does not own dashboard %d", userID, dashboardID) + return nil, fmt.Errorf("access_denied: user does not have access") } return dashboard, nil @@ -83,15 +97,64 @@ func (s serviceImpl) GetDashboards(projectId int, userID uint64) (*models.GetDas }, nil } +// GetDashboardsPaginated Fetch dashboards with pagination +func (s serviceImpl) GetDashboardsPaginated(projectId int, userID uint64, req *models.GetDashboardsRequest) (*models.GetDashboardsResponsePaginated, error) { + baseSQL, args := buildBaseQuery(projectId, userID, req) + + // Count total dashboards + countSQL := fmt.Sprintf("SELECT COUNT(*) FROM (%s) AS count_query", baseSQL) + var total uint64 + err := s.pgconn.QueryRow(countSQL, args...).Scan(&total) + if err != nil { + return nil, fmt.Errorf("error counting dashboards: %w", err) + } + + // Fetch paginated dashboards + paginatedSQL := fmt.Sprintf("%s ORDER BY %s %s LIMIT $%d OFFSET $%d", + baseSQL, getOrderBy(req.OrderBy), getOrder(req.Order), len(args)+1, len(args)+2) + args = append(args, req.Limit, req.Limit*(req.Page-1)) + + rows, err := s.pgconn.Query(paginatedSQL, args...) + if err != nil { + return nil, fmt.Errorf("error fetching paginated dashboards: %w", err) + } + defer rows.Close() + + var dashboards []models.Dashboard + for rows.Next() { + var dashboard models.Dashboard + err := rows.Scan( + &dashboard.DashboardID, + &dashboard.UserID, + &dashboard.ProjectID, + &dashboard.Name, + &dashboard.Description, + &dashboard.IsPublic, + &dashboard.IsPinned, + &dashboard.OwnerEmail, + &dashboard.OwnerName, + ) + if err != nil { + return nil, fmt.Errorf("error scanning dashboard: %w", err) + } + dashboards = append(dashboards, dashboard) + } + + return &models.GetDashboardsResponsePaginated{ + Dashboards: dashboards, + Total: total, + }, nil +} + +// UpdateDashboard Update a dashboard 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 - WHERE dashboard_id = $5 AND project_id = $6 AND user_id = $7 + WHERE dashboard_id = $5 AND project_id = $6 AND user_id = $7 AND deleted_at IS NULL RETURNING dashboard_id, project_id, user_id, name, description, is_public, is_pinned` dashboard := &models.GetDashboardResponse{} - err := s.pgconn.QueryRow(sql, req.Name, req.Description, req.IsPublic, req.IsPinned, dashboardID, projectId, userID).Scan( &dashboard.DashboardID, &dashboard.ProjectID, @@ -102,12 +165,12 @@ func (s serviceImpl) UpdateDashboard(projectId int, dashboardID int, userID uint &dashboard.IsPinned, ) if err != nil { - return nil, err + return nil, fmt.Errorf("error updating dashboard: %w", err) } - return dashboard, nil } +// DeleteDashboard Soft-delete a dashboard func (s serviceImpl) DeleteDashboard(projectId int, dashboardID int, userID uint64) error { sql := ` UPDATE dashboards @@ -116,8 +179,60 @@ func (s serviceImpl) DeleteDashboard(projectId int, dashboardID int, userID uint err := s.pgconn.Exec(sql, dashboardID, projectId, userID) if err != nil { - return err + return fmt.Errorf("error deleting dashboard: %w", err) } return nil } + +// Helper to build the base query for dashboards +func buildBaseQuery(projectId int, userID uint64, req *models.GetDashboardsRequest) (string, []interface{}) { + var conditions []string + args := []interface{}{projectId} + + conditions = append(conditions, "d.project_id = $1") + + // Handle is_public filter + if req.IsPublic { + conditions = append(conditions, "d.is_public = true") + } else { + conditions = append(conditions, "(d.is_public = true OR d.user_id = $2)") + args = append(args, userID) + } + + // Handle search query + if req.Query != "" { + conditions = append(conditions, "(d.name ILIKE $3 OR d.description ILIKE $3)") + args = append(args, "%"+req.Query+"%") + } + + conditions = append(conditions, "d.deleted_at IS NULL") + whereClause := "WHERE " + fmt.Sprint(conditions) + + baseSQL := fmt.Sprintf(` + 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 + LEFT JOIN users u ON d.user_id = u.user_id + %s`, whereClause) + + return baseSQL, args +} + +func getOrderBy(orderBy string) string { + if orderBy == "" { + return "d.dashboard_id" + } + allowed := map[string]bool{"dashboard_id": true, "name": true, "description": true} + if allowed[orderBy] { + return fmt.Sprintf("d.%s", orderBy) + } + return "d.dashboard_id" +} + +func getOrder(order string) string { + if order == "DESC" { + return "DESC" + } + return "ASC" +}