package dashboards import ( "context" "encoding/json" "errors" "fmt" "openreplay/backend/pkg/db/postgres/pool" "openreplay/backend/pkg/logger" ) type Dashboards interface { Create(projectId int, userId uint64, req *CreateDashboardRequest) (*GetDashboardResponse, error) Get(projectId int, dashboardId int, userId uint64) (*GetDashboardResponse, error) GetAll(projectId int, userId uint64) (*GetDashboardsResponse, error) GetAllPaginated(projectId int, userId uint64, req *GetDashboardsRequest) (*GetDashboardsResponsePaginated, error) Update(projectId int, dashboardId int, userId uint64, req *UpdateDashboardRequest) (*GetDashboardResponse, error) Delete(projectId int, dashboardId int, userId uint64) error AddCards(projectId int, dashboardId int, userId uint64, req *AddCardToDashboardRequest) error DeleteCard(dashboardId int, cardId int) error } type dashboardsImpl struct { log logger.Logger pgconn pool.Pool } func New(log logger.Logger, conn pool.Pool) (Dashboards, error) { return &dashboardsImpl{ log: log, pgconn: conn, }, nil } // CreateDashboard Create a new dashboard func (s *dashboardsImpl) Create(projectId int, userID uint64, req *CreateDashboardRequest) (*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 := &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 *dashboardsImpl) Get(projectId int, dashboardID int, userID uint64) (*GetDashboardResponse, error) { sql := ` 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 := &GetDashboardResponse{} var ownerID int var metricsJSON []byte err := s.pgconn.QueryRow(sql, dashboardID, projectId).Scan( &dashboard.DashboardID, &dashboard.ProjectID, &dashboard.Name, &dashboard.Description, &dashboard.IsPublic, &dashboard.IsPinned, &ownerID, &metricsJSON, ) 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) } 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") } return dashboard, nil } func (s *dashboardsImpl) GetAll(projectId int, userID uint64) (*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 []Dashboard for rows.Next() { var dashboard 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 &GetDashboardsResponse{ Dashboards: dashboards, }, nil } // GetDashboardsPaginated Fetch dashboards with pagination func (s *dashboardsImpl) GetAllPaginated(projectId int, userID uint64, req *GetDashboardsRequest) (*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 []Dashboard for rows.Next() { var dashboard 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 &GetDashboardsResponsePaginated{ Dashboards: dashboards, Total: total, }, nil } // UpdateDashboard Update a dashboard func (s *dashboardsImpl) Update(projectId int, dashboardID int, userID uint64, req *UpdateDashboardRequest) (*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 := &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 *dashboardsImpl) Delete(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 *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" } func (s *dashboardsImpl) 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 *dashboardsImpl) AddCards(projectId int, dashboardId int, userId uint64, req *AddCardToDashboardRequest) error { _, err := s.Get(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 *dashboardsImpl) DeleteCard(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 }