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

Open
wants to merge 11 commits into
base: staging
Choose a base branch
from
54 changes: 54 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,55 @@ 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
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 {
queryOrderColumns = append(queryOrderColumns, column)
}
}

// apply ordering
queryOrder := []exp.OrderedExpression{}
for i := range queryOrderColumns {
if cursor.IsReverse() {
queryOrderColumns[i].Desc = !queryOrderColumns[i].Desc
}
column := queryOrderColumns[i]
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)
}

equal := goqu.C(column.Column).Eq(column.Offset)
if queryWhere == nil {
queryWhere = equal
} else {
queryWhere = goqu.And(equal, queryWhere)
}
queryWhere = goqu.Or(colWhere, queryWhere)
}
}

return queryOrder, queryWhere
}
171 changes: 170 additions & 1 deletion backend/pkg/api/data_access/notifications.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,17 @@ package dataaccess

import (
"context"
"fmt"
"regexp"
"slices"
"strings"

"github.com/doug-martin/goqu/v9"
"github.com/doug-martin/goqu/v9/exp"
"github.com/gobitfly/beaconchain/pkg/api/enums"
t "github.com/gobitfly/beaconchain/pkg/api/types"
"github.com/gobitfly/beaconchain/pkg/commons/utils"
"github.com/lib/pq"
)

type NotificationsRepository interface {
Expand Down Expand Up @@ -34,8 +42,169 @@ type NotificationsRepository interface {
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("vdb_notifications_history").As("vnh")).
Select(
goqu.L("false").As("is_account_dashboard"),
goqu.I("uvd.network").As("chain_id"),
goqu.I("vnh.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("vnh.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("vnh.dashboard_id")})).
InnerJoin(goqu.T("users_val_dashboards_groups").As("uvdg"), goqu.On(
goqu.Ex{"uvdg.id": goqu.I("vnh.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("vnh.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.NotificationDashboardGroupName.ToString(), Desc: false, Offset: currentCursor.GroupName},
{Column: enums.NotificationDashboardChainId.ToString(), Desc: true, Offset: currentCursor.ChainId},
}
var offset any
if currentCursor.IsValid() {
switch colSort.Column {
case enums.NotificationDashboardTimestamp:
offset = currentCursor.Epoch
case enums.NotificationDashboardDashboardName:
offset = currentCursor.DashboardName
case enums.NotificationDashboardGroupName:
offset = currentCursor.GroupName
case enums.NotificationDashboardChainId:
offset = currentCursor.ChainId
}
}
order, directions := applySortAndPagination(defaultColumns, t.SortColumn{Column: colSort.Column.ToString(), Desc: colSort.Desc, Offset: offset}, 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...)
rows, err := d.alloyReader.QueryContext(ctx, query, args...)
if err != nil {
return nil, nil, err
}
defer rows.Close()
for rows.Next() {
var row t.NotificationDashboardsTableRow
err = rows.Scan(
&row.IsAccountDashboard,
&row.ChainId,
&row.Epoch,
&row.DashboardId,
&row.DashboardName,
&row.GroupId,
&row.GroupName,
&row.EntityCount,
pq.Array(&row.EventTypes),
)
if err != nil {
return nil, nil, err
}
response = append(response, row)
}

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) (*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
21 changes: 20 additions & 1 deletion backend/pkg/api/enums/notifications_enums.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ var _ EnumFactory[NotificationDashboardsColumn] = NotificationDashboardsColumn(0
const (
NotificationDashboardChainId NotificationDashboardsColumn = iota
NotificationDashboardTimestamp
NotificationDashboardDashboardName // sort by name
NotificationDashboardDashboardName // sort by dashboard name
NotificationDashboardGroupName // sort by group name, internal use only
)

func (c NotificationDashboardsColumn) Int() int {
Expand All @@ -30,14 +31,32 @@ 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 NotificationDashboardGroupName:
return "group_name"
default:
return ""
}
}

var NotificationsDashboardsColumns = struct {
ChainId NotificationDashboardsColumn
Timestamp NotificationDashboardsColumn
DashboardId NotificationDashboardsColumn
GroupId NotificationDashboardsColumn
}{
NotificationDashboardChainId,
NotificationDashboardTimestamp,
NotificationDashboardDashboardName,
NotificationDashboardGroupName,
}

// ------------------------------------------------------------
Expand Down
16 changes: 16 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 @@ -125,6 +132,15 @@ type BlocksCursor struct {
Reward decimal.Decimal
}

type NotificationsDashboardsCursor struct {
GenericCursor

Epoch uint64
ChainId uint64
DashboardName string
GroupName string
}

type NetworkInfo struct {
ChainId uint64
Name string
Expand Down
17 changes: 9 additions & 8 deletions backend/pkg/api/types/notifications.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,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"`
Timestamp int64 `json:"timestamp"`
DashboardId uint64 `json:"dashboard_id"`
GroupName string `json:"group_name"`
NotificationId uint64 `json:"notification_id"` // may be string? db schema is not defined afaik
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' | 'sync' | 'withdrawal' | 'got_slashed' | 'has_slashed' | 'incoming_tx' | 'outgoing_tx' | 'transfer_erc20' | 'transfer_erc721' | 'transfer_erc1155')[]" faker:"oneof: validator_offline, group_offline, attestation_missed, proposal_success, proposal_missed, proposal_upcoming, sync, withdrawal, slashed_own, 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 []string `db:"event_types" json:"event_types" tstype:"('validator_online' | 'validator_offline' | 'group_online' | 'group_offline' | 'attestation_missed' | 'proposal_success' | 'proposal_missed' | 'proposal_upcoming' | 'sync' | 'withdrawal' | 'got_slashed' | 'has_slashed' | 'incoming_tx' | 'outgoing_tx' | 'transfer_erc20' | 'transfer_erc721' | 'transfer_erc1155')[]" faker:"oneof: validator_offline, group_offline, attestation_missed, proposal_success, proposal_missed, proposal_upcoming, sync, withdrawal, slashed_own, incoming_tx, outgoing_tx, transfer_erc20, transfer_erc721, transfer_erc1155"`
}

type InternalGetUserNotificationDashboardsResponse ApiPagingResponse[NotificationDashboardsTableRow]
Expand Down
4 changes: 2 additions & 2 deletions frontend/types/api/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,10 @@ export type InternalGetUserNotificationsResponse = ApiDataResponse<NotificationO
export interface NotificationDashboardsTableRow {
is_account_dashboard: boolean; // if false it's a validator dashboard
chain_id: number /* uint64 */;
timestamp: number /* int64 */;
epoch: number /* uint64 */;
dashboard_id: number /* uint64 */;
group_id: number /* uint64 */;
group_name: string;
notification_id: number /* uint64 */; // may be string? db schema is not defined afaik
entity_count: number /* uint64 */;
event_types: ('validator_online' | 'validator_offline' | 'group_online' | 'group_offline' | 'attestation_missed' | 'proposal_success' | 'proposal_missed' | 'proposal_upcoming' | 'sync' | 'withdrawal' | 'got_slashed' | 'has_slashed' | 'incoming_tx' | 'outgoing_tx' | 'transfer_erc20' | 'transfer_erc721' | 'transfer_erc1155')[];
}
Expand Down
Loading