Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

(BEDS-469) DA: notifications dashboard table #893

Merged
merged 19 commits into from
Oct 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions backend/pkg/api/data_access/general.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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) {
Eisei24 marked this conversation as resolved.
Show resolved Hide resolved
// 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
}
134 changes: 133 additions & 1 deletion backend/pkg/api/data_access/notifications.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"fmt"
"io"
"maps"
"regexp"
"slices"
"sort"
"strconv"
Expand All @@ -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"
)
Expand Down Expand Up @@ -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{
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thought: Since the default column entries only require those that uniquely identify a row you don't need to add the dashboard and group names to it.
However if you change it this way you would need the offset if again.

Both ways work for me but consider that the query could be faster with less WHERE/ORDER BY.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True, while the ids would be enough it's kind of a workaround that allows unique sorting by name at the cost of increased overhead.

I'd change it if the query becomes an issue

{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),
Eisei24 marked this conversation as resolved.
Show resolved Hide resolved
))
}
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) {
Expand Down
2 changes: 1 addition & 1 deletion backend/pkg/api/data_access/vdb_rewards.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down
37 changes: 33 additions & 4 deletions backend/pkg/api/enums/notifications_enums.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
}

// ------------------------------------------------------------
Expand Down
3 changes: 3 additions & 0 deletions backend/pkg/api/handlers/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -688,6 +688,9 @@ func (v *validationError) checkNetworkParameter(param string) uint64 {
}

func (v *validationError) checkNetworksParameter(param string) []uint64 {
if param == "" {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@LuccaBitfly is this change ok with you?

v.add("networks", "list of networks must not be empty")
}
var chainIds []uint64
for _, network := range splitParameters(param, ',') {
chainIds = append(chainIds, v.checkNetworkParameter(network))
Expand Down
18 changes: 18 additions & 0 deletions backend/pkg/api/types/data_access.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
22 changes: 13 additions & 9 deletions backend/pkg/api/types/notifications.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package types

import "github.com/shopspring/decimal"
import (
"github.com/lib/pq"
"github.com/shopspring/decimal"
)

// ------------------------------------------------------------
// Overview
Expand Down Expand Up @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@LuccaBitfly is this change ok with you?

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]
Expand Down
Loading