diff --git a/backend/pkg/api/data_access/general.go b/backend/pkg/api/data_access/general.go index 7debc5dfc..3c94db8a1 100644 --- a/backend/pkg/api/data_access/general.go +++ b/backend/pkg/api/data_access/general.go @@ -3,6 +3,8 @@ package dataaccess import ( "context" + "github.com/doug-martin/goqu/v9" + "github.com/doug-martin/goqu/v9/exp" "github.com/ethereum/go-ethereum/common/hexutil" "github.com/gobitfly/beaconchain/pkg/api/types" "github.com/gobitfly/beaconchain/pkg/commons/db" @@ -46,3 +48,58 @@ func (d *DataAccessService) GetNamesAndEnsForAddresses(ctx context.Context, addr } return nil } + +// helper function to sort and apply pagination to a query +// 1st param is the list of all columns necessary to sort the table deterministically; it defines their precedence and sort direction +// 2nd param is the requested sort column; it may or may not be part of the default columns (if it is, you don't have to specify the cursor limit again) +func applySortAndPagination(defaultColumns []types.SortColumn, primary types.SortColumn, cursor types.GenericCursor) ([]exp.OrderedExpression, exp.Expression) { + // prepare ordering columns; always need all columns to ensure consistent ordering + queryOrderColumns := make([]types.SortColumn, 0, len(defaultColumns)) + queryOrderColumns = append(queryOrderColumns, primary) + // secondary sorts according to default + for _, column := range defaultColumns { + if column.Column == primary.Column { + if primary.Offset == nil { + queryOrderColumns[0].Offset = column.Offset + } + continue + } + queryOrderColumns = append(queryOrderColumns, column) + } + + // apply ordering + queryOrder := []exp.OrderedExpression{} + for i := range queryOrderColumns { + column := &queryOrderColumns[i] + if cursor.IsReverse() { + column.Desc = !column.Desc + } + colOrder := goqu.C(column.Column).Asc() + if column.Desc { + colOrder = goqu.C(column.Column).Desc() + } + queryOrder = append(queryOrder, colOrder) + } + + // apply cursor offsets + var queryWhere exp.Expression + if cursor.IsValid() { + // reverse order to nest conditions + for i := len(queryOrderColumns) - 1; i >= 0; i-- { + column := queryOrderColumns[i] + colWhere := goqu.C(column.Column).Gt(column.Offset) + if column.Desc { + colWhere = goqu.C(column.Column).Lt(column.Offset) + } + + if queryWhere == nil { + queryWhere = colWhere + } else { + queryWhere = goqu.And(goqu.C(column.Column).Eq(column.Offset), queryWhere) + queryWhere = goqu.Or(colWhere, queryWhere) + } + } + } + + return queryOrder, queryWhere +} diff --git a/backend/pkg/api/data_access/notifications.go b/backend/pkg/api/data_access/notifications.go index 7b055609d..2dd5fb207 100644 --- a/backend/pkg/api/data_access/notifications.go +++ b/backend/pkg/api/data_access/notifications.go @@ -9,6 +9,7 @@ import ( "fmt" "io" "maps" + "regexp" "slices" "sort" "strconv" @@ -26,6 +27,7 @@ import ( "github.com/gobitfly/beaconchain/pkg/commons/types" "github.com/gobitfly/beaconchain/pkg/commons/utils" "github.com/gobitfly/beaconchain/pkg/notification" + "github.com/lib/pq" "github.com/shopspring/decimal" "golang.org/x/sync/errgroup" ) @@ -95,8 +97,138 @@ const ( func (d *DataAccessService) GetNotificationOverview(ctx context.Context, userId uint64) (*t.NotificationOverviewData, error) { return d.dummy.GetNotificationOverview(ctx, userId) } + func (d *DataAccessService) GetDashboardNotifications(ctx context.Context, userId uint64, chainIds []uint64, cursor string, colSort t.Sort[enums.NotificationDashboardsColumn], search string, limit uint64) ([]t.NotificationDashboardsTableRow, *t.Paging, error) { - return d.dummy.GetDashboardNotifications(ctx, userId, chainIds, cursor, colSort, search, limit) + response := []t.NotificationDashboardsTableRow{} + var err error + + var currentCursor t.NotificationsDashboardsCursor + if cursor != "" { + if currentCursor, err = utils.StringToCursor[t.NotificationsDashboardsCursor](cursor); err != nil { + return nil, nil, fmt.Errorf("failed to parse passed cursor as NotificationsDashboardsCursor: %w", err) + } + } + + // validator query + vdbQuery := goqu.Dialect("postgres"). + From(goqu.T("users_val_dashboards_notifications_history").As("uvdnh")). + Select( + goqu.L("false").As("is_account_dashboard"), + goqu.I("uvd.network").As("chain_id"), + goqu.I("uvdnh.epoch"), + goqu.I("uvd.id").As("dashboard_id"), + goqu.I("uvd.name").As("dashboard_name"), + goqu.I("uvdg.id").As("group_id"), + goqu.I("uvdg.name").As("group_name"), + goqu.SUM("uvdnh.event_count").As("entity_count"), + goqu.L("ARRAY_AGG(DISTINCT event_type)").As("event_types"), + ). + InnerJoin(goqu.T("users_val_dashboards").As("uvd"), goqu.On( + goqu.Ex{"uvd.id": goqu.I("uvdnh.dashboard_id")})). + InnerJoin(goqu.T("users_val_dashboards_groups").As("uvdg"), goqu.On( + goqu.Ex{"uvdg.id": goqu.I("uvdnh.group_id")}, + goqu.Ex{"uvdg.dashboard_id": goqu.I("uvd.id")}, + )). + Where( + goqu.Ex{"uvd.user_id": userId}, + goqu.L("uvd.network = ANY(?)", pq.Array(chainIds)), + ). + GroupBy( + goqu.I("uvdnh.epoch"), + goqu.I("uvd.network"), + goqu.I("uvd.id"), + goqu.I("uvdg.id"), + goqu.I("uvdg.name"), + ) + + // TODO account dashboards + /*adbQuery := goqu.Dialect("postgres"). + From(goqu.T("adb_notifications_history").As("anh")). + Select( + goqu.L("true").As("is_account_dashboard"), + goqu.I("anh.network").As("chain_id"), + goqu.I("anh.epoch"), + goqu.I("uad.id").As("dashboard_id"), + goqu.I("uad.name").As("dashboard_name"), + goqu.I("uadg.id").As("group_id"), + goqu.I("uadg.name").As("group_name"), + goqu.SUM("anh.event_count").As("entity_count"), + goqu.L("ARRAY_AGG(DISTINCT event_type)").As("event_types"), + ). + InnerJoin(goqu.T("users_acc_dashboards").As("uad"), goqu.On( + goqu.Ex{"uad.id": goqu.I("anh.dashboard_id"), + })). + InnerJoin(goqu.T("users_acc_dashboards_groups").As("uadg"), goqu.On( + goqu.Ex{"uadg.id": goqu.I("anh.group_id"), + goqu.Ex{"uadg.dashboard_id": goqu.I("uad.id")}, + })). + Where( + goqu.Ex{"uad.user_id": userId}, + goqu.L("anh.network = ANY(?)", pq.Array(chainIds)), + ). + GroupBy( + goqu.I("anh.epoch"), + goqu.I("anh.network"), + goqu.I("uad.id"), + goqu.I("uadg.id"), + goqu.I("uadg.name"), + ) + + unionQuery := vdbQuery.Union(adbQuery)*/ + unionQuery := goqu.From(vdbQuery) + + // sorting + defaultColumns := []t.SortColumn{ + {Column: enums.NotificationDashboardTimestamp.ToString(), Desc: true, Offset: currentCursor.Epoch}, + {Column: enums.NotificationDashboardDashboardName.ToString(), Desc: false, Offset: currentCursor.DashboardName}, + {Column: enums.NotificationDashboardDashboardId.ToString(), Desc: false, Offset: currentCursor.DashboardId}, + {Column: enums.NotificationDashboardGroupName.ToString(), Desc: false, Offset: currentCursor.GroupName}, + {Column: enums.NotificationDashboardGroupId.ToString(), Desc: false, Offset: currentCursor.GroupId}, + {Column: enums.NotificationDashboardChainId.ToString(), Desc: true, Offset: currentCursor.ChainId}, + } + order, directions := applySortAndPagination(defaultColumns, t.SortColumn{Column: colSort.Column.ToString(), Desc: colSort.Desc}, currentCursor.GenericCursor) + unionQuery = unionQuery.Order(order...) + if directions != nil { + unionQuery = unionQuery.Where(directions) + } + + // search + searchName := regexp.MustCompile(`^[a-zA-Z0-9_\-.\ ]+$`).MatchString(search) + if searchName { + searchLower := strings.ToLower(strings.Replace(search, "_", "\\_", -1)) + "%" + unionQuery = unionQuery.Where(exp.NewExpressionList( + exp.OrType, + goqu.L("LOWER(?)", goqu.I("dashboard_name")).Like(searchLower), + goqu.L("LOWER(?)", goqu.I("group_name")).Like(searchLower), + )) + } + unionQuery = unionQuery.Limit(uint(limit + 1)) + + query, args, err := unionQuery.ToSQL() + if err != nil { + return nil, nil, err + } + err = d.alloyReader.SelectContext(ctx, &response, query, args...) + if err != nil { + return nil, nil, err + } + + moreDataFlag := len(response) > int(limit) + if moreDataFlag { + response = response[:len(response)-1] + } + if currentCursor.IsReverse() { + slices.Reverse(response) + } + if !moreDataFlag && !currentCursor.IsValid() { + // No paging required + return response, &t.Paging{}, nil + } + paging, err := utils.GetPagingFromData(response, currentCursor, moreDataFlag) + if err != nil { + return nil, nil, err + } + return response, paging, nil } func (d *DataAccessService) GetValidatorDashboardNotificationDetails(ctx context.Context, dashboardId t.VDBIdPrimary, groupId uint64, epoch uint64, search string) (*t.NotificationValidatorDashboardDetail, error) { diff --git a/backend/pkg/api/data_access/vdb_rewards.go b/backend/pkg/api/data_access/vdb_rewards.go index 06d51bbc2..7f68e1b64 100644 --- a/backend/pkg/api/data_access/vdb_rewards.go +++ b/backend/pkg/api/data_access/vdb_rewards.go @@ -35,7 +35,7 @@ func (d *DataAccessService) GetValidatorDashboardRewards(ctx context.Context, da if cursor != "" { currentCursor, err = utils.StringToCursor[t.RewardsCursor](cursor) if err != nil { - return nil, nil, fmt.Errorf("failed to parse passed cursor as WithdrawalsCursor: %w", err) + return nil, nil, fmt.Errorf("failed to parse passed cursor as RewardsCursor: %w", err) } } diff --git a/backend/pkg/api/enums/notifications_enums.go b/backend/pkg/api/enums/notifications_enums.go index c71fb381b..116a0a5d2 100644 --- a/backend/pkg/api/enums/notifications_enums.go +++ b/backend/pkg/api/enums/notifications_enums.go @@ -10,7 +10,10 @@ var _ EnumFactory[NotificationDashboardsColumn] = NotificationDashboardsColumn(0 const ( NotificationDashboardChainId NotificationDashboardsColumn = iota NotificationDashboardTimestamp - NotificationDashboardDashboardName // sort by name + NotificationDashboardDashboardName // sort by dashboard name + NotificationDashboardDashboardId // internal use + NotificationDashboardGroupName // internal use + NotificationDashboardGroupId // internal use ) func (c NotificationDashboardsColumn) Int() int { @@ -30,14 +33,40 @@ func (NotificationDashboardsColumn) NewFromString(s string) NotificationDashboar } } +// internal use, used to map to query column names +func (c NotificationDashboardsColumn) ToString() string { + switch c { + case NotificationDashboardChainId: + return "chain_id" + case NotificationDashboardTimestamp: + return "epoch" + case NotificationDashboardDashboardName: + return "dashboard_name" + case NotificationDashboardDashboardId: + return "dashboard_id" + case NotificationDashboardGroupName: + return "group_name" + case NotificationDashboardGroupId: + return "group_id" + default: + return "" + } +} + var NotificationsDashboardsColumns = struct { - ChainId NotificationDashboardsColumn - Timestamp NotificationDashboardsColumn - DashboardId NotificationDashboardsColumn + ChainId NotificationDashboardsColumn + Timestamp NotificationDashboardsColumn + DashboardName NotificationDashboardsColumn + DashboardId NotificationDashboardsColumn + GroupName NotificationDashboardsColumn + GroupId NotificationDashboardsColumn }{ NotificationDashboardChainId, NotificationDashboardTimestamp, NotificationDashboardDashboardName, + NotificationDashboardDashboardId, + NotificationDashboardGroupName, + NotificationDashboardGroupId, } // ------------------------------------------------------------ diff --git a/backend/pkg/api/handlers/common.go b/backend/pkg/api/handlers/common.go index 579719ef9..25140790d 100644 --- a/backend/pkg/api/handlers/common.go +++ b/backend/pkg/api/handlers/common.go @@ -688,6 +688,9 @@ func (v *validationError) checkNetworkParameter(param string) uint64 { } func (v *validationError) checkNetworksParameter(param string) []uint64 { + if param == "" { + v.add("networks", "list of networks must not be empty") + } var chainIds []uint64 for _, network := range splitParameters(param, ',') { chainIds = append(chainIds, v.checkNetworkParameter(network)) diff --git a/backend/pkg/api/types/data_access.go b/backend/pkg/api/types/data_access.go index 4e3c88b17..0808e2f7d 100644 --- a/backend/pkg/api/types/data_access.go +++ b/backend/pkg/api/types/data_access.go @@ -22,6 +22,13 @@ type Sort[T enums.Enum] struct { Desc bool } +type SortColumn struct { + Column string + Desc bool + // represents value from cursor + Offset any +} + type VDBIdPrimary int type VDBIdPublic string type VDBIdValidatorSet []VDBValidator @@ -166,6 +173,17 @@ type BlocksCursor struct { Reward decimal.Decimal } +type NotificationsDashboardsCursor struct { + GenericCursor + + Epoch uint64 + ChainId uint64 + DashboardName string + DashboardId uint64 + GroupName string + GroupId uint64 +} + type NetworkInfo struct { ChainId uint64 Name string diff --git a/backend/pkg/api/types/notifications.go b/backend/pkg/api/types/notifications.go index 7f0b7f1ef..560a83960 100644 --- a/backend/pkg/api/types/notifications.go +++ b/backend/pkg/api/types/notifications.go @@ -1,6 +1,9 @@ package types -import "github.com/shopspring/decimal" +import ( + "github.com/lib/pq" + "github.com/shopspring/decimal" +) // ------------------------------------------------------------ // Overview @@ -29,14 +32,15 @@ type InternalGetUserNotificationsResponse ApiDataResponse[NotificationOverviewDa // ------------------------------------------------------------ // Dashboards Table type NotificationDashboardsTableRow struct { - IsAccountDashboard bool `json:"is_account_dashboard"` // if false it's a validator dashboard - ChainId uint64 `json:"chain_id"` - Epoch uint64 `json:"epoch"` - DashboardId uint64 `json:"dashboard_id"` - GroupId uint64 `json:"group_id"` - GroupName string `json:"group_name"` - EntityCount uint64 `json:"entity_count"` - EventTypes []string `json:"event_types" tstype:"('validator_online' | 'validator_offline' | 'group_online' | 'group_offline' | 'attestation_missed' | 'proposal_success' | 'proposal_missed' | 'proposal_upcoming' | 'max_collateral' | 'min_collateral' | 'sync' | 'withdrawal' | 'validator_got_slashed' | 'validator_has_slashed' | 'incoming_tx' | 'outgoing_tx' | 'transfer_erc20' | 'transfer_erc721' | 'transfer_erc1155')[]" faker:"slice_len=2, oneof: validator_online, validator_offline, group_online, group_offline, attestation_missed, proposal_success, proposal_missed, proposal_upcoming, max_collateral, min_collateral, sync, withdrawal, validator_got_slashed, validator_has_slashed, incoming_tx, outgoing_tx, transfer_erc20, transfer_erc721, transfer_erc1155"` + IsAccountDashboard bool `db:"is_account_dashboard" json:"is_account_dashboard"` // if false it's a validator dashboard + ChainId uint64 `db:"chain_id" json:"chain_id"` + Epoch uint64 `db:"epoch" json:"epoch"` + DashboardId uint64 `db:"dashboard_id" json:"dashboard_id"` + DashboardName string `db:"dashboard_name" json:"-"` // not exported, internal use only + GroupId uint64 `db:"group_id" json:"group_id"` + GroupName string `db:"group_name" json:"group_name"` + EntityCount uint64 `db:"entity_count" json:"entity_count"` + EventTypes pq.StringArray `db:"event_types" json:"event_types" tstype:"('validator_online' | 'validator_offline' | 'group_online' | 'group_offline' | 'attestation_missed' | 'proposal_success' | 'proposal_missed' | 'proposal_upcoming' | 'max_collateral' | 'min_collateral' | 'sync' | 'withdrawal' | 'validator_got_slashed' | 'validator_has_slashed' | 'incoming_tx' | 'outgoing_tx' | 'transfer_erc20' | 'transfer_erc721' | 'transfer_erc1155')[]" faker:"slice_len=2, oneof: validator_online, validator_offline, group_online, group_offline, attestation_missed, proposal_success, proposal_missed, proposal_upcoming, max_collateral, min_collateral, sync, withdrawal, validator_got_slashed, validator_has_slashed, incoming_tx, outgoing_tx, transfer_erc20, transfer_erc721, transfer_erc1155"` } type InternalGetUserNotificationDashboardsResponse ApiPagingResponse[NotificationDashboardsTableRow]