Merge branch 'product-analytics-go' into dev

This commit is contained in:
Shekar Siri 2024-12-16 15:36:38 +01:00
commit 8ca332e2f0
6 changed files with 482 additions and 88 deletions

View file

@ -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)

View file

@ -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)
}

View file

@ -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"`
}

View file

@ -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 {

View file

@ -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
}

View file

@ -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"
}