From 99085a95a11a643975ac3d7510399fed1d927c7c Mon Sep 17 00:00:00 2001 From: Shekar Siri Date: Wed, 27 Nov 2024 16:13:26 +0100 Subject: [PATCH 1/8] feat(analytics): dashboards --- backend/cmd/analytics/main.go | 43 ++++ backend/internal/config/analytics/config.go | 29 +++ .../pkg/analytics/api/dashboard-handlers.go | 205 ++++++++++++++++++ backend/pkg/analytics/api/handlers.go | 40 ++++ backend/pkg/analytics/api/model.go | 60 +++++ backend/pkg/analytics/builder.go | 57 +++++ backend/pkg/analytics/service/analytics.go | 34 +++ backend/pkg/metrics/analytics/analytics.go | 22 ++ 8 files changed, 490 insertions(+) create mode 100644 backend/cmd/analytics/main.go create mode 100644 backend/internal/config/analytics/config.go create mode 100644 backend/pkg/analytics/api/dashboard-handlers.go create mode 100644 backend/pkg/analytics/api/handlers.go create mode 100644 backend/pkg/analytics/api/model.go create mode 100644 backend/pkg/analytics/builder.go create mode 100644 backend/pkg/analytics/service/analytics.go create mode 100644 backend/pkg/metrics/analytics/analytics.go diff --git a/backend/cmd/analytics/main.go b/backend/cmd/analytics/main.go new file mode 100644 index 000000000..8ea792438 --- /dev/null +++ b/backend/cmd/analytics/main.go @@ -0,0 +1,43 @@ +package main + +import ( + "context" + analyticsConfig "openreplay/backend/internal/config/analytics" + "openreplay/backend/pkg/analytics" + "openreplay/backend/pkg/db/postgres/pool" + "openreplay/backend/pkg/logger" + "openreplay/backend/pkg/metrics" + analyticsMetrics "openreplay/backend/pkg/metrics/analytics" + databaseMetrics "openreplay/backend/pkg/metrics/database" + "openreplay/backend/pkg/metrics/web" + "openreplay/backend/pkg/server" + "openreplay/backend/pkg/server/api" +) + +func main() { + ctx := context.Background() + log := logger.New() + cfg := analyticsConfig.New(log) + webMetrics := web.New("analytics") + metrics.New(log, append(webMetrics.List(), append(analyticsMetrics.List(), databaseMetrics.List()...)...)) + + pgConn, err := pool.New(cfg.Postgres.String()) + if err != nil { + log.Fatal(ctx, "can't init postgres connection: %s", err) + } + defer pgConn.Close() + + builder, err := analytics.NewServiceBuilder(log, cfg, webMetrics, pgConn) + if err != nil { + log.Fatal(ctx, "can't init services: %s", err) + } + + router, err := api.NewRouter(&cfg.HTTP, log) + if err != nil { + log.Fatal(ctx, "failed while creating router: %s", err) + } + router.AddHandlers(api.NoPrefix, builder.AnalyticsAPI) + router.AddMiddlewares(builder.Auth.Middleware, builder.RateLimiter.Middleware, builder.AuditTrail.Middleware) + + server.Run(ctx, log, &cfg.HTTP, router) +} diff --git a/backend/internal/config/analytics/config.go b/backend/internal/config/analytics/config.go new file mode 100644 index 000000000..b6ca5ce4c --- /dev/null +++ b/backend/internal/config/analytics/config.go @@ -0,0 +1,29 @@ +package analytics + +import ( + "time" + + "openreplay/backend/internal/config/common" + "openreplay/backend/internal/config/configurator" + "openreplay/backend/internal/config/objectstorage" + "openreplay/backend/internal/config/redis" + "openreplay/backend/pkg/env" + "openreplay/backend/pkg/logger" +) + +type Config struct { + common.Config + common.Postgres + redis.Redis + objectstorage.ObjectsConfig + common.HTTP + FSDir string `env:"FS_DIR,required"` + ProjectExpiration time.Duration `env:"PROJECT_EXPIRATION,default=10m"` + WorkerID uint16 +} + +func New(log logger.Logger) *Config { + cfg := &Config{WorkerID: env.WorkerID()} + configurator.Process(log, cfg) + return cfg +} diff --git a/backend/pkg/analytics/api/dashboard-handlers.go b/backend/pkg/analytics/api/dashboard-handlers.go new file mode 100644 index 000000000..ca8e22ba2 --- /dev/null +++ b/backend/pkg/analytics/api/dashboard-handlers.go @@ -0,0 +1,205 @@ +package api + +import ( + "encoding/json" + "fmt" + "github.com/gorilla/mux" + "net/http" + "openreplay/backend/pkg/server/api" + "openreplay/backend/pkg/server/user" + "strconv" + "time" +) + +func getId(r *http.Request) (int, error) { + vars := mux.Vars(r) + idStr := vars["id"] + if idStr == "" { + return 0, fmt.Errorf("invalid dashboard ID") + } + + id, err := strconv.Atoi(idStr) + if err != nil { + return 0, fmt.Errorf("invalid dashboard ID") + } + + return id, nil +} + +func (e *handlersImpl) createDashboard(w http.ResponseWriter, r *http.Request) { + startTime := time.Now() + bodySize := 0 + + 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 := &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, + }, + } + + currentUser := r.Context().Value("userData").(*user.User) + e.log.Info(r.Context(), "User ID: ", currentUser.ID) + + e.responser.ResponseWithJSON(e.log, r.Context(), w, resp, startTime, r.URL.Path, bodySize) +} + +// getDashboards +func (e *handlersImpl) getDashboards(w http.ResponseWriter, r *http.Request) { + startTime := time.Now() + bodySize := 0 + + //id, err := getId(r) + //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, + } + + e.responser.ResponseWithJSON(e.log, r.Context(), w, resp, startTime, r.URL.Path, bodySize) +} + +func (e *handlersImpl) getDashboard(w http.ResponseWriter, r *http.Request) { + startTime := time.Now() + bodySize := 0 + + id, err := getId(r) + 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, + }, + } + + e.responser.ResponseWithJSON(e.log, r.Context(), w, resp, startTime, r.URL.Path, bodySize) +} + +func (e *handlersImpl) updateDashboard(w http.ResponseWriter, r *http.Request) { + startTime := time.Now() + bodySize := 0 + + //id, err := getId(r) + //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 { + e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusRequestEntityTooLarge, err, startTime, r.URL.Path, bodySize) + return + } + bodySize = len(bodyBytes) + + req := &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, + }, + } + + e.responser.ResponseWithJSON(e.log, r.Context(), w, resp, startTime, r.URL.Path, bodySize) +} + +func (e *handlersImpl) deleteDashboard(w http.ResponseWriter, r *http.Request) { + startTime := time.Now() + bodySize := 0 + + //id, err := getId(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") + + e.responser.ResponseOK(e.log, r.Context(), w, startTime, r.URL.Path, bodySize) +} + +func (e *handlersImpl) pinDashboard(w http.ResponseWriter, r *http.Request) { + startTime := time.Now() + bodySize := 0 + + //id, err := getId(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) +} + +// add card to dashboard +func (e *handlersImpl) addCardToDashboard(w http.ResponseWriter, r *http.Request) { + startTime := time.Now() + bodySize := 0 + + //id, err := getId(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(), "Card added to dashboard") + + e.responser.ResponseOK(e.log, r.Context(), w, startTime, r.URL.Path, bodySize) +} + +// remove card from dashboard +func (e *handlersImpl) removeCardFromDashboard(w http.ResponseWriter, r *http.Request) { + startTime := time.Now() + bodySize := 0 + + //id, err := getId(r) + //if err != nil { + // e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, 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 new file mode 100644 index 000000000..05ee6dbfc --- /dev/null +++ b/backend/pkg/analytics/api/handlers.go @@ -0,0 +1,40 @@ +package api + +import ( + config "openreplay/backend/internal/config/analytics" + "openreplay/backend/pkg/analytics/service" + "openreplay/backend/pkg/logger" + "openreplay/backend/pkg/objectstorage" + "openreplay/backend/pkg/server/api" + "openreplay/backend/pkg/server/keys" +) + +type handlersImpl struct { + log logger.Logger + responser *api.Responser + objStorage objectstorage.ObjectStorage + jsonSizeLimit int64 + keys keys.Keys + service service.Service +} + +func (e *handlersImpl) GetAll() []*api.Description { + return []*api.Description{ + {"/v1/analytics/{projectId}/dashboards", e.createDashboard, "POST"}, + {"/v1/analytics/{projectId}/dashboards", e.getDashboards, "GET"}, + {"/v1/analytics/{projectId}/dashboards/{id}", e.getDashboard, "GET"}, + {"/v1/analytics/{projectId}/dashboards/{id}", e.updateDashboard, "PUT"}, + {"/v1/analytics/{projectId}/dashboards/{id}", e.deleteDashboard, "DELETE"}, + } +} + +func NewHandlers(log logger.Logger, cfg *config.Config, responser *api.Responser, objStore objectstorage.ObjectStorage, keys keys.Keys, service service.Service) (api.Handlers, error) { + return &handlersImpl{ + log: log, + responser: responser, + objStorage: objStore, + jsonSizeLimit: cfg.JsonSizeLimit, + keys: keys, + service: service, + }, nil +} diff --git a/backend/pkg/analytics/api/model.go b/backend/pkg/analytics/api/model.go new file mode 100644 index 000000000..3342a4b81 --- /dev/null +++ b/backend/pkg/analytics/api/model.go @@ -0,0 +1,60 @@ +package api + +type Dashboard struct { + DashboardID int `json:"dashboard_id"` + Name string `json:"name"` + Description string `json:"description"` + IsPublic bool `json:"is_public"` + IsPinned bool `json:"is_pinned"` +} + +type CreateDashboardResponse struct { + DashboardID int `json:"dashboard_id"` +} + +type GetDashboardResponse struct { + Dashboard +} + +type GetDashboardsResponse struct { + Dashboards []Dashboard `json:"dashboards"` + Total uint64 `json:"total"` +} + +// REQUESTS + +type CreateDashboardRequest struct { + Name string `json:"name"` + Description string `json:"description"` + IsPublic bool `json:"is_public"` + IsPinned bool `json:"is_pinned"` + Metrics []int `json:"metrics"` +} + +type GetDashboardsRequest struct { + Page uint64 `json:"page"` + Limit uint64 `json:"limit"` + Order string `json:"order"` + Query string `json:"query"` + FilterBy string `json:"filterBy"` +} + +type UpdateDashboardRequest struct { + Name string `json:"name"` + Description string `json:"description"` + IsPublic bool `json:"is_public"` + IsPinned bool `json:"is_pinned"` + Metrics []int `json:"metrics"` +} + +type PinDashboardRequest struct { + IsPinned bool `json:"is_pinned"` +} + +type AddCardToDashboardRequest struct { + CardIDs []int `json:"card_ids"` +} + +type DeleteCardFromDashboardRequest struct { + CardIDs []int `json:"card_ids"` +} diff --git a/backend/pkg/analytics/builder.go b/backend/pkg/analytics/builder.go new file mode 100644 index 000000000..333921e0a --- /dev/null +++ b/backend/pkg/analytics/builder.go @@ -0,0 +1,57 @@ +package analytics + +import ( + "openreplay/backend/pkg/metrics/web" + "openreplay/backend/pkg/server/tracer" + "time" + + "openreplay/backend/internal/config/analytics" + analyticsAPI "openreplay/backend/pkg/analytics/api" + "openreplay/backend/pkg/analytics/service" + "openreplay/backend/pkg/db/postgres/pool" + "openreplay/backend/pkg/logger" + "openreplay/backend/pkg/objectstorage/store" + "openreplay/backend/pkg/server/api" + "openreplay/backend/pkg/server/auth" + "openreplay/backend/pkg/server/keys" + "openreplay/backend/pkg/server/limiter" +) + +type ServicesBuilder struct { + Auth auth.Auth + RateLimiter *limiter.UserRateLimiter + AuditTrail tracer.Tracer + AnalyticsAPI api.Handlers +} + +func NewServiceBuilder(log logger.Logger, cfg *analytics.Config, webMetrics web.Web, pgconn pool.Pool) (*ServicesBuilder, error) { + objStore, err := store.NewStore(&cfg.ObjectsConfig) + if err != nil { + return nil, err + } + + newKeys := keys.NewKeys(log, pgconn) + responser := api.NewResponser(webMetrics) + + audiTrail, err := tracer.NewTracer(log, pgconn) + if err != nil { + return nil, err + } + + analyticsService, err := service.NewService(log, pgconn, objStore) + if err != nil { + return nil, err + } + + handlers, err := analyticsAPI.NewHandlers(log, cfg, responser, objStore, keys.NewKeys(log, pgconn), analyticsService) + if err != nil { + return nil, err + } + + return &ServicesBuilder{ + Auth: auth.NewAuth(log, cfg.JWTSecret, cfg.JWTSpotSecret, pgconn, newKeys), + RateLimiter: limiter.NewUserRateLimiter(10, 30, 1*time.Minute, 5*time.Minute), + AuditTrail: audiTrail, + AnalyticsAPI: handlers, + }, nil +} diff --git a/backend/pkg/analytics/service/analytics.go b/backend/pkg/analytics/service/analytics.go new file mode 100644 index 000000000..ce36b0958 --- /dev/null +++ b/backend/pkg/analytics/service/analytics.go @@ -0,0 +1,34 @@ +package service + +import ( + "errors" + "openreplay/backend/pkg/db/postgres/pool" + "openreplay/backend/pkg/logger" + "openreplay/backend/pkg/objectstorage" +) + +type Service interface { +} + +type serviceImpl struct { + log logger.Logger + conn pool.Pool + storage objectstorage.ObjectStorage +} + +func NewService(log logger.Logger, conn pool.Pool, storage objectstorage.ObjectStorage) (Service, error) { + switch { + case log == nil: + return nil, errors.New("logger is empty") + case conn == nil: + return nil, errors.New("connection pool is empty") + case storage == nil: + return nil, errors.New("object storage is empty") + } + + return &serviceImpl{ + log: log, + conn: conn, + storage: storage, + }, nil +} diff --git a/backend/pkg/metrics/analytics/analytics.go b/backend/pkg/metrics/analytics/analytics.go new file mode 100644 index 000000000..7919e77fb --- /dev/null +++ b/backend/pkg/metrics/analytics/analytics.go @@ -0,0 +1,22 @@ +package analytics + +import ( + "github.com/prometheus/client_golang/prometheus" + + "openreplay/backend/pkg/metrics/common" +) + +var cardCreated = prometheus.NewHistogram( + prometheus.HistogramOpts{ + Namespace: "card", + Name: "created", + Help: "Histogram for tracking card creation", + Buckets: common.DefaultBuckets, + }, +) + +func List() []prometheus.Collector { + return []prometheus.Collector{ + cardCreated, + } +} From 0c0cac8fbe26f510d34d4c56c4025f446d6adadb Mon Sep 17 00:00:00 2001 From: Shekar Siri Date: Fri, 13 Dec 2024 11:53:46 +0100 Subject: [PATCH 2/8] feat(analytics): cards api endpoints --- backend/pkg/analytics/api/card-handlers.go | 268 ++++++++++++++++++ backend/pkg/analytics/api/card.go | 92 ++++++ .../pkg/analytics/api/dashboard-handlers.go | 18 +- backend/pkg/analytics/api/handlers.go | 9 +- backend/pkg/analytics/api/model.go | 2 +- 5 files changed, 378 insertions(+), 11 deletions(-) create mode 100644 backend/pkg/analytics/api/card-handlers.go create mode 100644 backend/pkg/analytics/api/card.go diff --git a/backend/pkg/analytics/api/card-handlers.go b/backend/pkg/analytics/api/card-handlers.go new file mode 100644 index 000000000..41079c330 --- /dev/null +++ b/backend/pkg/analytics/api/card-handlers.go @@ -0,0 +1,268 @@ +package models + +import ( + "encoding/json" + "fmt" + "github.com/gorilla/mux" + "net/http" + "openreplay/backend/pkg/server/api" + "openreplay/backend/pkg/server/user" + "strconv" + "time" + + "github.com/go-playground/validator/v10" +) + +// getCardId returns the ID from the request +func getCardId(r *http.Request) (int64, error) { + vars := mux.Vars(r) + idStr := vars["id"] + if idStr == "" { + return 0, fmt.Errorf("invalid Card ID") + } + + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + return 0, fmt.Errorf("invalid Card ID") + } + + return id, nil +} + +func (e *handlersImpl) createCard(w http.ResponseWriter, r *http.Request) { + startTime := time.Now() + bodySize := 0 + + 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 := &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 + } + + 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 + } + + // TODO save card to DB + + resp := &CardGetResponse{ + Card: Card{ + CardID: 1, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + DeletedAt: nil, + EditedAt: nil, + ProjectID: 1, + UserID: 1, + CardBase: CardBase{ + Name: req.Name, + IsPublic: req.IsPublic, + Thumbnail: req.Thumbnail, + MetricType: req.MetricType, + MetricOf: req.MetricOf, + Series: req.Series, + }, + }, + } + + currentUser := r.Context().Value("userData").(*user.User) + e.log.Info(r.Context(), "User ID: ", currentUser.ID) + + e.responser.ResponseWithJSON(e.log, r.Context(), w, resp, startTime, r.URL.Path, bodySize) +} + +// getCard +func (e *handlersImpl) getCard(w http.ResponseWriter, r *http.Request) { + startTime := time.Now() + bodySize := 0 + + id, err := getCardId(r) + if err != nil { + e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize) + return + } + + thumbnail := "https://example.com/image.png" + + // TODO get card from DB + + resp := &CardGetResponse{ + Card: Card{ + CardID: id, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + DeletedAt: nil, + EditedAt: nil, + ProjectID: 1, + UserID: 1, + CardBase: CardBase{ + Name: "My Card", + IsPublic: true, + Thumbnail: &thumbnail, + MetricType: "timeseries", + MetricOf: "session_count", + }, + }, + } + + e.responser.ResponseWithJSON(e.log, r.Context(), w, resp, startTime, r.URL.Path, bodySize) +} + +// get cards paginated +func (e *handlersImpl) getCards(w http.ResponseWriter, r *http.Request) { + startTime := time.Now() + bodySize := 0 + + // TODO get cards from DB + thumbnail := "https://example.com/image.png" + + resp := &GetCardsResponse{ + Cards: []Card{ + { + CardID: 1, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + DeletedAt: nil, + EditedAt: nil, + ProjectID: 1, + UserID: 1, + CardBase: CardBase{ + Name: "My Card", + IsPublic: true, + Thumbnail: &thumbnail, + MetricType: "timeseries", + MetricOf: "session_count", + }, + }, + }, + Total: 10, + } + + e.responser.ResponseWithJSON(e.log, r.Context(), w, resp, startTime, r.URL.Path, bodySize) +} + +func (e *handlersImpl) updateCard(w http.ResponseWriter, r *http.Request) { + startTime := time.Now() + bodySize := 0 + + id, err := getCardId(r) + 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 { + e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusRequestEntityTooLarge, err, startTime, r.URL.Path, bodySize) + return + } + bodySize = len(bodyBytes) + + req := &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 + } + + 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 + } + + // TODO update card in DB + + resp := &CardGetResponse{ + Card: Card{ + CardID: id, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + DeletedAt: nil, + EditedAt: nil, + ProjectID: 1, + UserID: 1, + CardBase: CardBase{ + Name: req.Name, + IsPublic: req.IsPublic, + Thumbnail: req.Thumbnail, + MetricType: req.MetricType, + MetricOf: req.MetricOf, + }, + }, + } + + e.responser.ResponseWithJSON(e.log, r.Context(), w, resp, startTime, r.URL.Path, bodySize) +} + +func (e *handlersImpl) deleteCard(w http.ResponseWriter, r *http.Request) { + startTime := time.Now() + bodySize := 0 + + _, err := getCardId(r) + if err != nil { + e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize) + return + } + + // TODO delete card from DB + + e.responser.ResponseWithJSON(e.log, r.Context(), w, nil, startTime, r.URL.Path, bodySize) +} + +func (e *handlersImpl) getCardChartData(w http.ResponseWriter, r *http.Request) { + startTime := time.Now() + bodySize := 0 + + 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 := &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 + } + + validate := validator.New() + err = validate.Struct(req) + + // TODO get card chart data from ClickHouse + jsonInput := ` + { + "data": [ + { + "timestamp": 1733934939000, + "Series A": 100, + "Series B": 200 + }, + { + "timestamp": 1733935939000, + "Series A": 150, + "Series B": 250 + } + ] + }` + + var resp 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) + return + } + + e.responser.ResponseWithJSON(e.log, r.Context(), w, resp, startTime, r.URL.Path, bodySize) +} diff --git a/backend/pkg/analytics/api/card.go b/backend/pkg/analytics/api/card.go new file mode 100644 index 000000000..271d8c080 --- /dev/null +++ b/backend/pkg/analytics/api/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/dashboard-handlers.go b/backend/pkg/analytics/api/dashboard-handlers.go index ca8e22ba2..777180847 100644 --- a/backend/pkg/analytics/api/dashboard-handlers.go +++ b/backend/pkg/analytics/api/dashboard-handlers.go @@ -1,4 +1,4 @@ -package api +package models import ( "encoding/json" @@ -11,7 +11,7 @@ import ( "time" ) -func getId(r *http.Request) (int, error) { +func getDashboardId(r *http.Request) (int, error) { vars := mux.Vars(r) idStr := vars["id"] if idStr == "" { @@ -64,7 +64,7 @@ func (e *handlersImpl) getDashboards(w http.ResponseWriter, r *http.Request) { startTime := time.Now() bodySize := 0 - //id, err := getId(r) + //id, err := getDashboardId(r) //if err != nil { // e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize) // return @@ -90,7 +90,7 @@ func (e *handlersImpl) getDashboard(w http.ResponseWriter, r *http.Request) { startTime := time.Now() bodySize := 0 - id, err := getId(r) + id, err := getDashboardId(r) if err != nil { e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize) return @@ -113,7 +113,7 @@ func (e *handlersImpl) updateDashboard(w http.ResponseWriter, r *http.Request) { startTime := time.Now() bodySize := 0 - //id, err := getId(r) + //id, err := getDashboardId(r) //if err != nil { // e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize) // return @@ -149,7 +149,7 @@ func (e *handlersImpl) deleteDashboard(w http.ResponseWriter, r *http.Request) { startTime := time.Now() bodySize := 0 - //id, err := getId(r) + //id, err := getDashboardId(r) //if err != nil { // e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize) // return @@ -163,7 +163,7 @@ func (e *handlersImpl) pinDashboard(w http.ResponseWriter, r *http.Request) { startTime := time.Now() bodySize := 0 - //id, err := getId(r) + //id, err := getDashboardId(r) //if err != nil { // e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize) // return @@ -179,7 +179,7 @@ func (e *handlersImpl) addCardToDashboard(w http.ResponseWriter, r *http.Request startTime := time.Now() bodySize := 0 - //id, err := getId(r) + //id, err := getDashboardId(r) //if err != nil { // e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize) // return @@ -195,7 +195,7 @@ func (e *handlersImpl) removeCardFromDashboard(w http.ResponseWriter, r *http.Re startTime := time.Now() bodySize := 0 - //id, err := getId(r) + //id, err := getDashboardId(r) //if err != nil { // e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize) // return diff --git a/backend/pkg/analytics/api/handlers.go b/backend/pkg/analytics/api/handlers.go index 05ee6dbfc..e3ad6ac6d 100644 --- a/backend/pkg/analytics/api/handlers.go +++ b/backend/pkg/analytics/api/handlers.go @@ -1,4 +1,4 @@ -package api +package models import ( config "openreplay/backend/internal/config/analytics" @@ -25,6 +25,13 @@ 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}/cards", e.createCard, "POST"}, + {"/v1/analytics/{projectId}/cards", e.getCards, "GET"}, + {"/v1/analytics/{projectId}/cards/{id}", e.getCard, "GET"}, + {"/v1/analytics/{projectId}/cards/{id}", e.updateCard, "PUT"}, + {"/v1/analytics/{projectId}/cards/{id}", e.deleteCard, "DELETE"}, + {"/v1/analytics/{projectId}/cards/{id}/chart", e.getCardChartData, "POST"}, + {"/v1/analytics/{projectId}/cards/{id}/try", e.getCardChartData, "POST"}, } } diff --git a/backend/pkg/analytics/api/model.go b/backend/pkg/analytics/api/model.go index 3342a4b81..a5c231159 100644 --- a/backend/pkg/analytics/api/model.go +++ b/backend/pkg/analytics/api/model.go @@ -1,4 +1,4 @@ -package api +package models type Dashboard struct { DashboardID int `json:"dashboard_id"` From 00b7f65e311e663e7888b9069e0e9847454bac84 Mon Sep 17 00:00:00 2001 From: Shekar Siri Date: Fri, 13 Dec 2024 13:28:44 +0100 Subject: [PATCH 3/8] feat(analytics): validator dependency --- backend/go.mod | 5 +++++ backend/go.sum | 10 ++++++++++ 2 files changed, 15 insertions(+) diff --git a/backend/go.mod b/backend/go.mod index c3123f334..c14121a16 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -55,8 +55,12 @@ require ( github.com/distribution/reference v0.6.0 // indirect github.com/elastic/elastic-transport-go/v8 v8.5.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/go-logr/logr v1.4.1 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.23.0 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.4 // indirect @@ -70,6 +74,7 @@ require ( github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect github.com/jackc/puddle v1.3.0 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect github.com/paulmach/orb v0.7.1 // indirect github.com/pierrec/lz4/v4 v4.1.15 // indirect diff --git a/backend/go.sum b/backend/go.sum index c088a9a3a..1a73eb0f8 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -171,6 +171,8 @@ github.com/fsnotify/fsevents v0.1.1/go.mod h1:+d+hS27T6k5J8CRaPLKFgwKYcpS7GwW3Ul github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fvbommel/sortorder v1.0.2 h1:mV4o8B2hKboCdkJm+a7uX/SIpZob4JzUpc5GGnM45eo= github.com/fvbommel/sortorder v1.0.2/go.mod h1:uk88iVf1ovNn1iLfgUVU2F9o5eO30ui720w+kxuqRs0= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= github.com/getsentry/sentry-go v0.29.0 h1:YtWluuCFg9OfcqnaujpY918N/AhCCwarIDWOYSBAjCA= github.com/getsentry/sentry-go v0.29.0/go.mod h1:jhPesDAL0Q0W2+2YEuVOvdWmVtdsr1+jtBrlDEVWwLY= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= @@ -192,6 +194,12 @@ github.com/go-openapi/jsonreference v0.20.0 h1:MYlu0sBgChmCfJxxUKZ8g1cPWFOB37YSZ github.com/go-openapi/jsonreference v0.20.0/go.mod h1:Ag74Ico3lPc+zR+qjn4XBUmXymS4zJbYVCZmcgkasdo= github.com/go-openapi/swag v0.19.14 h1:gm3vOOXfiuw5i9p5N9xJvfjvuofpyvLA9Wr6QfK5Fng= github.com/go-openapi/swag v0.19.14/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o= +github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/go-redis/redis v6.15.9+incompatible h1:K0pv1D7EQUjfyoMql+r/jZqCLizCGKFlFgcHWWmHQjg= github.com/go-redis/redis v6.15.9+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA= github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= @@ -364,6 +372,8 @@ github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= From 0a49df3996945be0402a7393330f584bb02fbc38 Mon Sep 17 00:00:00 2001 From: Shekar Siri Date: Mon, 16 Dec 2024 10:36:25 +0100 Subject: [PATCH 4/8] feat(analytics): dashbaord pgconn --- backend/pkg/analytics/api/card-handlers.go | 35 +++++---- .../pkg/analytics/api/dashboard-handlers.go | 77 ++++++++++--------- backend/pkg/analytics/api/handlers.go | 2 +- .../pkg/analytics/api/{ => models}/card.go | 0 .../pkg/analytics/api/{ => models}/model.go | 7 +- backend/pkg/analytics/service/analytics.go | 7 +- .../analytics/service/dashboard-service.go | 59 ++++++++++++++ 7 files changed, 129 insertions(+), 58 deletions(-) rename backend/pkg/analytics/api/{ => models}/card.go (100%) rename backend/pkg/analytics/api/{ => models}/model.go (91%) create mode 100644 backend/pkg/analytics/service/dashboard-service.go 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..7e94aa16b 100644 --- a/backend/pkg/analytics/api/dashboard-handlers.go +++ b/backend/pkg/analytics/api/dashboard-handlers.go @@ -1,26 +1,27 @@ -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" "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,14 +38,14 @@ 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{ + resp := &models.GetDashboardResponse{ + Dashboard: models.Dashboard{ DashboardID: 1, Name: req.Name, Description: req.Description, @@ -64,23 +65,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,23 +85,31 @@ 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 { + 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) + 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) { @@ -126,14 +129,14 @@ func (e *handlersImpl) updateDashboard(w http.ResponseWriter, r *http.Request) { } bodySize = len(bodyBytes) - req := &UpdateDashboardRequest{} + 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{ + resp := &models.GetDashboardResponse{ + Dashboard: models.Dashboard{ DashboardID: 1, Name: req.Name, Description: req.Description, diff --git a/backend/pkg/analytics/api/handlers.go b/backend/pkg/analytics/api/handlers.go index e3ad6ac6d..010a8cd78 100644 --- a/backend/pkg/analytics/api/handlers.go +++ b/backend/pkg/analytics/api/handlers.go @@ -1,4 +1,4 @@ -package models +package api import ( config "openreplay/backend/internal/config/analytics" diff --git a/backend/pkg/analytics/api/card.go b/backend/pkg/analytics/api/models/card.go similarity index 100% rename from backend/pkg/analytics/api/card.go rename to backend/pkg/analytics/api/models/card.go diff --git a/backend/pkg/analytics/api/model.go b/backend/pkg/analytics/api/models/model.go similarity index 91% rename from backend/pkg/analytics/api/model.go rename to backend/pkg/analytics/api/models/model.go index a5c231159..f69b1684a 100644 --- a/backend/pkg/analytics/api/model.go +++ b/backend/pkg/analytics/api/models/model.go @@ -2,6 +2,7 @@ package models type Dashboard struct { DashboardID int `json:"dashboard_id"` + UserID int `json:"user_id"` Name string `json:"name"` Description string `json:"description"` IsPublic bool `json:"is_public"` @@ -16,11 +17,15 @@ 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 { diff --git a/backend/pkg/analytics/service/analytics.go b/backend/pkg/analytics/service/analytics.go index ce36b0958..ddb2aeee3 100644 --- a/backend/pkg/analytics/service/analytics.go +++ b/backend/pkg/analytics/service/analytics.go @@ -2,17 +2,20 @@ 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) + GetDashboards(projectId int, userId uint64) (*models.GetDashboardsResponse, error) } type serviceImpl struct { log logger.Logger - conn pool.Pool + pgconn pool.Pool storage objectstorage.ObjectStorage } @@ -28,7 +31,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..ab7d80ec2 --- /dev/null +++ b/backend/pkg/analytics/service/dashboard-service.go @@ -0,0 +1,59 @@ +package service + +import ( + "fmt" + "openreplay/backend/pkg/analytics/api/models" +) + +func (s serviceImpl) GetDashboard(projectId int, dashboardID int, userID uint64) (*models.GetDashboardResponse, error) { + sql := ` + SELECT dashboard_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.Name, &dashboard.Description, &dashboard.IsPublic, &dashboard.IsPinned, &ownerID) + if err != nil { + return nil, err + } + + if !dashboard.IsPublic && uint64(ownerID) != userID { + return nil, fmt.Errorf("access denied: user %d does not own dashboard %d", userID, dashboardID) + } + + return dashboard, nil +} + +func (s serviceImpl) GetDashboards(projectId int, userID uint64) (*models.GetDashboardsResponse, error) { + sql := ` + SELECT dashboard_id, user_id, name, description, is_public, is_pinned + FROM dashboards + WHERE (is_public = true OR user_id = $1) AND user_id IS NOT NULL AND deleted_at IS NULL AND project_id = $2 + ORDER BY 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.Name, &dashboard.Description, &dashboard.IsPublic, &dashboard.IsPinned) + 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 +} From a88002852d47f195c40c9c3350cdc041d9ace0b4 Mon Sep 17 00:00:00 2001 From: Shekar Siri Date: Mon, 16 Dec 2024 11:43:40 +0100 Subject: [PATCH 5/8] feat(analytics): dashbaord update and delete --- .../pkg/analytics/api/dashboard-handlers.go | 69 ++++++++-------- backend/pkg/analytics/api/models/model.go | 13 ++-- backend/pkg/analytics/service/analytics.go | 3 + .../analytics/service/dashboard-service.go | 78 +++++++++++++++++-- 4 files changed, 120 insertions(+), 43 deletions(-) diff --git a/backend/pkg/analytics/api/dashboard-handlers.go b/backend/pkg/analytics/api/dashboard-handlers.go index 7e94aa16b..161b46f24 100644 --- a/backend/pkg/analytics/api/dashboard-handlers.go +++ b/backend/pkg/analytics/api/dashboard-handlers.go @@ -31,6 +31,12 @@ func (e *handlersImpl) createDashboard(w http.ResponseWriter, r *http.Request) { startTime := time.Now() bodySize := 0 + 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 + } + 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) @@ -44,18 +50,8 @@ func (e *handlersImpl) createDashboard(w http.ResponseWriter, r *http.Request) { return } - resp := &models.GetDashboardResponse{ - Dashboard: models.Dashboard{ - DashboardID: 1, - Name: req.Name, - Description: req.Description, - IsPublic: req.IsPublic, - IsPinned: req.IsPinned, - }, - } - 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) } @@ -116,11 +112,17 @@ 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 { @@ -135,15 +137,8 @@ func (e *handlersImpl) updateDashboard(w http.ResponseWriter, r *http.Request) { return } - resp := &models.GetDashboardResponse{ - Dashboard: models.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) } @@ -152,12 +147,24 @@ 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.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/model.go b/backend/pkg/analytics/api/models/model.go index f69b1684a..8dbdb1c32 100644 --- a/backend/pkg/analytics/api/models/model.go +++ b/backend/pkg/analytics/api/models/model.go @@ -1,12 +1,15 @@ package models type Dashboard struct { - DashboardID int `json:"dashboard_id"` - UserID int `json:"user_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 { @@ -29,7 +32,7 @@ type GetDashboardsResponse struct { // REQUESTS type CreateDashboardRequest struct { - Name string `json:"name"` + Name string `json:"name" validate:"required"` Description string `json:"description"` IsPublic bool `json:"is_public"` IsPinned bool `json:"is_pinned"` diff --git a/backend/pkg/analytics/service/analytics.go b/backend/pkg/analytics/service/analytics.go index ddb2aeee3..df0132ba1 100644 --- a/backend/pkg/analytics/service/analytics.go +++ b/backend/pkg/analytics/service/analytics.go @@ -11,6 +11,9 @@ import ( type Service interface { GetDashboard(projectId int, dashboardId int, userId uint64) (*models.GetDashboardResponse, 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 { diff --git a/backend/pkg/analytics/service/dashboard-service.go b/backend/pkg/analytics/service/dashboard-service.go index ab7d80ec2..33be80856 100644 --- a/backend/pkg/analytics/service/dashboard-service.go +++ b/backend/pkg/analytics/service/dashboard-service.go @@ -5,15 +5,39 @@ import ( "openreplay/backend/pkg/analytics/api/models" ) +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, err + } + + return dashboard, nil +} + func (s serviceImpl) GetDashboard(projectId int, dashboardID int, userID uint64) (*models.GetDashboardResponse, error) { sql := ` - SELECT dashboard_id, name, description, is_public, is_pinned, user_id + 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.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 } @@ -27,10 +51,11 @@ func (s serviceImpl) GetDashboard(projectId int, dashboardID int, userID uint64) func (s serviceImpl) GetDashboards(projectId int, userID uint64) (*models.GetDashboardsResponse, error) { sql := ` - SELECT dashboard_id, user_id, name, description, is_public, is_pinned - FROM dashboards - WHERE (is_public = true OR user_id = $1) AND user_id IS NOT NULL AND deleted_at IS NULL AND project_id = $2 - ORDER BY dashboard_id` + 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 @@ -41,7 +66,7 @@ func (s serviceImpl) GetDashboards(projectId int, userID uint64) (*models.GetDas for rows.Next() { var dashboard models.Dashboard - err := rows.Scan(&dashboard.DashboardID, &dashboard.UserID, &dashboard.Name, &dashboard.Description, &dashboard.IsPublic, &dashboard.IsPinned) + 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 } @@ -57,3 +82,42 @@ func (s serviceImpl) GetDashboards(projectId int, userID uint64) (*models.GetDas Dashboards: dashboards, }, nil } + +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 + 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, err + } + + return dashboard, nil +} + +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 err + } + + return nil +} From 0e00ca19ad4ac6ac77cca0239e97f6214cb7eb6f Mon Sep 17 00:00:00 2001 From: Shekar Siri Date: Mon, 16 Dec 2024 11:51:19 +0100 Subject: [PATCH 6/8] feat(analytics): dashbaord creatge validation --- .../pkg/analytics/api/dashboard-handlers.go | 20 +++++++++++++------ backend/pkg/analytics/api/models/model.go | 2 +- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/backend/pkg/analytics/api/dashboard-handlers.go b/backend/pkg/analytics/api/dashboard-handlers.go index 161b46f24..46ee84722 100644 --- a/backend/pkg/analytics/api/dashboard-handlers.go +++ b/backend/pkg/analytics/api/dashboard-handlers.go @@ -3,6 +3,7 @@ package api import ( "encoding/json" "fmt" + "github.com/go-playground/validator/v10" "github.com/gorilla/mux" "net/http" "openreplay/backend/pkg/analytics/api/models" @@ -31,12 +32,6 @@ func (e *handlersImpl) createDashboard(w http.ResponseWriter, r *http.Request) { startTime := time.Now() bodySize := 0 - 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 - } - 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) @@ -50,6 +45,19 @@ func (e *handlersImpl) createDashboard(w http.ResponseWriter, r *http.Request) { 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 + } + + 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) resp, err := e.service.CreateDashboard(projectID, currentUser.ID, req) diff --git a/backend/pkg/analytics/api/models/model.go b/backend/pkg/analytics/api/models/model.go index 8dbdb1c32..520b92c9e 100644 --- a/backend/pkg/analytics/api/models/model.go +++ b/backend/pkg/analytics/api/models/model.go @@ -32,7 +32,7 @@ type GetDashboardsResponse struct { // REQUESTS type CreateDashboardRequest struct { - Name string `json:"name" validate:"required"` + Name string `json:"name" validate:"required,max=150"` Description string `json:"description"` IsPublic bool `json:"is_public"` IsPinned bool `json:"is_pinned"` From 64d90295541dc1af1e00acb952f83a58df1c8bdb Mon Sep 17 00:00:00 2001 From: Shekar Siri Date: Mon, 16 Dec 2024 11:54:15 +0100 Subject: [PATCH 7/8] feat(analytics): dashbaord creatge validation --- backend/pkg/analytics/api/models/model.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/pkg/analytics/api/models/model.go b/backend/pkg/analytics/api/models/model.go index 520b92c9e..ed9513308 100644 --- a/backend/pkg/analytics/api/models/model.go +++ b/backend/pkg/analytics/api/models/model.go @@ -32,8 +32,8 @@ type GetDashboardsResponse struct { // REQUESTS type CreateDashboardRequest struct { - Name string `json:"name" validate:"required,max=150"` - 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"` From af761693aac1f59388a94a0aae4f15ae4754e093 Mon Sep 17 00:00:00 2001 From: Shekar Siri Date: Mon, 16 Dec 2024 13:10:24 +0100 Subject: [PATCH 8/8] feat(analytics): dashbaord check existence and paginated methods --- .../pkg/analytics/api/dashboard-handlers.go | 41 ++++- backend/pkg/analytics/api/models/model.go | 3 +- backend/pkg/analytics/service/analytics.go | 1 + .../analytics/service/dashboard-service.go | 141 ++++++++++++++++-- 4 files changed, 166 insertions(+), 20 deletions(-) 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" +}