diff --git a/backend/pkg/analytics/api/card-handlers.go b/backend/pkg/analytics/api/card-handlers.go index 41079c330..edb8e3559 100644 --- a/backend/pkg/analytics/api/card-handlers.go +++ b/backend/pkg/analytics/api/card-handlers.go @@ -1,10 +1,11 @@ -package models +package api import ( "encoding/json" "fmt" "github.com/gorilla/mux" "net/http" + "openreplay/backend/pkg/analytics/api/models" "openreplay/backend/pkg/server/api" "openreplay/backend/pkg/server/user" "strconv" @@ -40,7 +41,7 @@ func (e *handlersImpl) createCard(w http.ResponseWriter, r *http.Request) { } bodySize = len(bodyBytes) - req := &CardCreateRequest{} + req := &models.CardCreateRequest{} 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 @@ -55,8 +56,8 @@ func (e *handlersImpl) createCard(w http.ResponseWriter, r *http.Request) { // TODO save card to DB - resp := &CardGetResponse{ - Card: Card{ + resp := &models.CardGetResponse{ + Card: models.Card{ CardID: 1, CreatedAt: time.Now(), UpdatedAt: time.Now(), @@ -64,7 +65,7 @@ func (e *handlersImpl) createCard(w http.ResponseWriter, r *http.Request) { EditedAt: nil, ProjectID: 1, UserID: 1, - CardBase: CardBase{ + CardBase: models.CardBase{ Name: req.Name, IsPublic: req.IsPublic, Thumbnail: req.Thumbnail, @@ -96,8 +97,8 @@ func (e *handlersImpl) getCard(w http.ResponseWriter, r *http.Request) { // TODO get card from DB - resp := &CardGetResponse{ - Card: Card{ + resp := &models.CardGetResponse{ + Card: models.Card{ CardID: id, CreatedAt: time.Now(), UpdatedAt: time.Now(), @@ -105,7 +106,7 @@ func (e *handlersImpl) getCard(w http.ResponseWriter, r *http.Request) { EditedAt: nil, ProjectID: 1, UserID: 1, - CardBase: CardBase{ + CardBase: models.CardBase{ Name: "My Card", IsPublic: true, Thumbnail: &thumbnail, @@ -126,8 +127,8 @@ func (e *handlersImpl) getCards(w http.ResponseWriter, r *http.Request) { // TODO get cards from DB thumbnail := "https://example.com/image.png" - resp := &GetCardsResponse{ - Cards: []Card{ + resp := &models.GetCardsResponse{ + Cards: []models.Card{ { CardID: 1, CreatedAt: time.Now(), @@ -136,7 +137,7 @@ func (e *handlersImpl) getCards(w http.ResponseWriter, r *http.Request) { EditedAt: nil, ProjectID: 1, UserID: 1, - CardBase: CardBase{ + CardBase: models.CardBase{ Name: "My Card", IsPublic: true, Thumbnail: &thumbnail, @@ -168,7 +169,7 @@ func (e *handlersImpl) updateCard(w http.ResponseWriter, r *http.Request) { } bodySize = len(bodyBytes) - req := &CardUpdateRequest{} + req := &models.CardUpdateRequest{} 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 @@ -183,8 +184,8 @@ func (e *handlersImpl) updateCard(w http.ResponseWriter, r *http.Request) { // TODO update card in DB - resp := &CardGetResponse{ - Card: Card{ + resp := &models.CardGetResponse{ + Card: models.Card{ CardID: id, CreatedAt: time.Now(), UpdatedAt: time.Now(), @@ -192,7 +193,7 @@ func (e *handlersImpl) updateCard(w http.ResponseWriter, r *http.Request) { EditedAt: nil, ProjectID: 1, UserID: 1, - CardBase: CardBase{ + CardBase: models.CardBase{ Name: req.Name, IsPublic: req.IsPublic, Thumbnail: req.Thumbnail, @@ -231,7 +232,7 @@ func (e *handlersImpl) getCardChartData(w http.ResponseWriter, r *http.Request) } bodySize = len(bodyBytes) - req := &GetCardChartDataRequest{} + req := &models.GetCardChartDataRequest{} 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 @@ -257,7 +258,7 @@ func (e *handlersImpl) getCardChartData(w http.ResponseWriter, r *http.Request) ] }` - var resp GetCardChartDataResponse + var resp models.GetCardChartDataResponse err = json.Unmarshal([]byte(jsonInput), &resp) 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/dashboard-handlers.go b/backend/pkg/analytics/api/dashboard-handlers.go index 777180847..255b41a6c 100644 --- a/backend/pkg/analytics/api/dashboard-handlers.go +++ b/backend/pkg/analytics/api/dashboard-handlers.go @@ -1,26 +1,28 @@ -package models +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 getDashboardId(r *http.Request) (int, error) { +func getIDFromRequest(r *http.Request, key string) (int, error) { vars := mux.Vars(r) - idStr := vars["id"] + idStr := vars[key] if idStr == "" { - return 0, fmt.Errorf("invalid dashboard ID") + return 0, fmt.Errorf("missing %s in request", key) } id, err := strconv.Atoi(idStr) if err != nil { - return 0, fmt.Errorf("invalid dashboard ID") + return 0, fmt.Errorf("invalid %s format", key) } return id, nil @@ -37,24 +39,27 @@ func (e *handlersImpl) createDashboard(w http.ResponseWriter, r *http.Request) { } bodySize = len(bodyBytes) - req := &CreateDashboardRequest{} + req := &models.CreateDashboardRequest{} 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 } - resp := &GetDashboardResponse{ - Dashboard: Dashboard{ - DashboardID: 1, - Name: req.Name, - Description: req.Description, - IsPublic: req.IsPublic, - IsPinned: req.IsPinned, - }, + 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 + } + + 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.CreateDashboard(projectID, currentUser.ID, req) e.responser.ResponseWithJSON(e.log, r.Context(), w, resp, startTime, r.URL.Path, bodySize) } @@ -64,23 +69,17 @@ func (e *handlersImpl) getDashboards(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 + } - resp := &GetDashboardsResponse{ - Dashboards: []Dashboard{ - { - DashboardID: 1, - Name: "Dashboard", - Description: "Description", - IsPublic: true, - IsPinned: false, - }, - }, - Total: 1, + u := r.Context().Value("userData").(*user.User) + resp, err := e.service.GetDashboards(projectID, u.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) @@ -90,34 +89,50 @@ func (e *handlersImpl) getDashboard(w http.ResponseWriter, r *http.Request) { startTime := time.Now() bodySize := 0 - id, err := getDashboardId(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 } - resp := &GetDashboardResponse{ - Dashboard: Dashboard{ - DashboardID: id, - Name: "Dashboard", - Description: "Description", - IsPublic: true, - IsPinned: false, - }, + 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 } - e.responser.ResponseWithJSON(e.log, r.Context(), w, resp, startTime, r.URL.Path, bodySize) + u := r.Context().Value("userData").(*user.User) + res, 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 + } + + e.responser.ResponseWithJSON(e.log, r.Context(), w, res, startTime, r.URL.Path, bodySize) } func (e *handlersImpl) updateDashboard(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 + } + + 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 + } bodyBytes, err := api.ReadBody(e.log, w, r, e.jsonSizeLimit) if err != nil { @@ -126,21 +141,28 @@ func (e *handlersImpl) updateDashboard(w http.ResponseWriter, r *http.Request) { } bodySize = len(bodyBytes) - req := &UpdateDashboardRequest{} + 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) return } - resp := &GetDashboardResponse{ - Dashboard: Dashboard{ - DashboardID: 1, - Name: req.Name, - Description: req.Description, - IsPublic: req.IsPublic, - IsPinned: req.IsPinned, - }, - } + currentUser := r.Context().Value("userData").(*user.User) + resp, err := e.service.UpdateDashboard(projectID, dashboardID, currentUser.ID, req) e.responser.ResponseWithJSON(e.log, r.Context(), w, resp, startTime, r.URL.Path, bodySize) } @@ -149,12 +171,37 @@ func (e *handlersImpl) deleteDashboard(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 deleted") + 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 + } + + 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) + return + } e.responser.ResponseOK(e.log, r.Context(), w, startTime, r.URL.Path, bodySize) } diff --git a/backend/pkg/analytics/api/models/card.go b/backend/pkg/analytics/api/models/card.go new file mode 100644 index 000000000..271d8c080 --- /dev/null +++ b/backend/pkg/analytics/api/models/card.go @@ -0,0 +1,92 @@ +package models + +import ( + "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"` +} + +// Card Fields specific to database operations +type Card struct { + CardBase + ProjectID int64 `json:"projectId" validate:"required"` + UserID int64 `json:"userId" validate:"required"` + CardID int64 `json:"cardId"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt *time.Time `json:"deleted_at,omitempty"` + EditedAt *time.Time `json:"edited_at,omitempty"` +} + +type CardSeries struct { + SeriesID int64 `json:"seriesId" validate:"omitempty"` + MetricID int64 `json:"metricId" validate:"omitempty"` + Name string `json:"name" validate:"required"` + CreatedAt time.Time `json:"createdAt" validate:"omitempty"` + DeletedAt *time.Time `json:"deletedAt" validate:"omitempty"` + Index int64 `json:"index" validate:"required"` + Filter SeriesFilter `json:"filter"` +} + +type SeriesFilter struct { + EventOrder string `json:"eventOrder" validate:"required,oneof=then or and"` + Filters []FilterItem `json:"filters"` +} + +type FilterItem struct { + Type string `json:"type" validate:"required"` + Operator string `json:"operator" validate:"required"` + Source string `json:"source" validate:"required"` + SourceOperator string `json:"sourceOperator" validate:"required"` + Value []string `json:"value" validate:"required,dive,required"` + IsEvent bool `json:"isEvent"` +} + +// CardCreateRequest Fields required for creating a card (from the frontend) +type CardCreateRequest struct { + CardBase +} + +type CardGetResponse struct { + Card +} + +type CardUpdateRequest struct { + CardBase +} + +type GetCardsResponse struct { + Cards []Card `json:"cards"` + Total int64 `json:"total"` +} + +type DataPoint struct { + Timestamp int64 `json:"timestamp"` + Series map[string]int64 `json:"series"` +} + +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"` + MetricFormat string `json:"metricFormat" validate:"required,oneof=default percentage"` + SessionID int64 `json:"sessionId" validate:"required"` + Series []CardSeries `json:"series"` +} + +type GetCardChartDataResponse struct { + Data []DataPoint `json:"data"` +} diff --git a/backend/pkg/analytics/api/model.go b/backend/pkg/analytics/api/models/model.go similarity index 67% rename from backend/pkg/analytics/api/model.go rename to backend/pkg/analytics/api/models/model.go index a5c231159..8e45af454 100644 --- a/backend/pkg/analytics/api/model.go +++ b/backend/pkg/analytics/api/models/model.go @@ -1,11 +1,15 @@ package models type Dashboard struct { - DashboardID int `json:"dashboard_id"` + DashboardID int `json:"dashboardId"` + ProjectID int `json:"projectId"` + UserID int `json:"userId"` Name string `json:"name"` Description string `json:"description"` - IsPublic bool `json:"is_public"` - IsPinned bool `json:"is_pinned"` + IsPublic bool `json:"isPublic"` + IsPinned bool `json:"isPinned"` + OwnerEmail string `json:"ownerEmail"` + OwnerName string `json:"ownerName"` } type CreateDashboardResponse struct { @@ -16,16 +20,20 @@ type GetDashboardResponse struct { Dashboard } -type GetDashboardsResponse struct { +type GetDashboardsResponsePaginated struct { Dashboards []Dashboard `json:"dashboards"` Total uint64 `json:"total"` } +type GetDashboardsResponse struct { + Dashboards []Dashboard `json:"dashboards"` +} + // REQUESTS type CreateDashboardRequest struct { - Name string `json:"name"` - Description string `json:"description"` + Name string `json:"name" validate:"required,min=3,max=150"` + Description string `json:"description" validate:"max=500"` IsPublic bool `json:"is_public"` IsPinned bool `json:"is_pinned"` Metrics []int `json:"metrics"` @@ -34,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 ce36b0958..64a5db7d4 100644 --- a/backend/pkg/analytics/service/analytics.go +++ b/backend/pkg/analytics/service/analytics.go @@ -2,17 +2,24 @@ package service import ( "errors" + "openreplay/backend/pkg/analytics/api/models" "openreplay/backend/pkg/db/postgres/pool" "openreplay/backend/pkg/logger" "openreplay/backend/pkg/objectstorage" ) 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) + DeleteDashboard(projectId int, dashboardId int, userId uint64) error } type serviceImpl struct { log logger.Logger - conn pool.Pool + pgconn pool.Pool storage objectstorage.ObjectStorage } @@ -28,7 +35,7 @@ func NewService(log logger.Logger, conn pool.Pool, storage objectstorage.ObjectS return &serviceImpl{ log: log, - conn: conn, + pgconn: conn, storage: storage, }, nil } diff --git a/backend/pkg/analytics/service/dashboard-service.go b/backend/pkg/analytics/service/dashboard-service.go new file mode 100644 index 000000000..a547aad6d --- /dev/null +++ b/backend/pkg/analytics/service/dashboard-service.go @@ -0,0 +1,238 @@ +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) + VALUES ($1, $2, $3, $4, $5, $6) + 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, + &dashboard.UserID, + &dashboard.Name, + &dashboard.Description, + &dashboard.IsPublic, + &dashboard.IsPinned, + ) + if err != nil { + 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{} + var ownerID int + err := s.pgconn.QueryRow(sql, dashboardID, projectId).Scan( + &dashboard.DashboardID, + &dashboard.ProjectID, + &dashboard.Name, + &dashboard.Description, + &dashboard.IsPublic, + &dashboard.IsPinned, + &ownerID, + ) + + if err != nil { + 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 does not have access") + } + + return dashboard, nil +} + +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 + LEFT JOIN users u ON d.user_id = u.user_id + WHERE (d.is_public = true OR d.user_id = $1) AND d.user_id IS NOT NULL AND d.deleted_at IS NULL AND d.project_id = $2 + ORDER BY d.dashboard_id` + rows, err := s.pgconn.Query(sql, userID, projectId) + if err != nil { + return nil, 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, err + } + + dashboards = append(dashboards, dashboard) + } + + if err := rows.Err(); err != nil { + return nil, err + } + + return &models.GetDashboardsResponse{ + Dashboards: dashboards, + }, 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 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, + &dashboard.UserID, + &dashboard.Name, + &dashboard.Description, + &dashboard.IsPublic, + &dashboard.IsPinned, + ) + if err != nil { + 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 + SET deleted_at = now() + WHERE dashboard_id = $1 AND project_id = $2 AND user_id = $3 AND deleted_at IS NULL` + + err := s.pgconn.Exec(sql, dashboardID, projectId, userID) + if err != nil { + 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" +}