diff --git a/backend/pkg/api/data_access/dummy.go b/backend/pkg/api/data_access/dummy.go index 5b6996433..dc0e1745b 100644 --- a/backend/pkg/api/data_access/dummy.go +++ b/backend/pkg/api/data_access/dummy.go @@ -586,10 +586,10 @@ func (d *DummyService) GetNotificationSettingsDashboards(ctx context.Context, us } return r, p, err } -func (d *DummyService) UpdateNotificationSettingsValidatorDashboard(ctx context.Context, dashboardId t.VDBIdPrimary, groupId uint64, settings t.NotificationSettingsValidatorDashboard) error { +func (d *DummyService) UpdateNotificationSettingsValidatorDashboard(ctx context.Context, userId uint64, dashboardId t.VDBIdPrimary, groupId uint64, settings t.NotificationSettingsValidatorDashboard) error { return nil } -func (d *DummyService) UpdateNotificationSettingsAccountDashboard(ctx context.Context, dashboardId t.VDBIdPrimary, groupId uint64, settings t.NotificationSettingsAccountDashboard) error { +func (d *DummyService) UpdateNotificationSettingsAccountDashboard(ctx context.Context, userId uint64, dashboardId t.VDBIdPrimary, groupId uint64, settings t.NotificationSettingsAccountDashboard) error { return nil } func (d *DummyService) CreateAdConfiguration(ctx context.Context, key, jquerySelector string, insertMode enums.AdInsertMode, refreshInterval uint64, forAllUsers bool, bannerId uint64, htmlContent string, enabled bool) error { diff --git a/backend/pkg/api/data_access/notifications.go b/backend/pkg/api/data_access/notifications.go index 7b5924ea4..7e6006e42 100644 --- a/backend/pkg/api/data_access/notifications.go +++ b/backend/pkg/api/data_access/notifications.go @@ -4,7 +4,9 @@ import ( "context" "database/sql" "fmt" + "maps" "slices" + "sort" "strings" "time" @@ -40,11 +42,21 @@ type NotificationsRepository interface { DeleteNotificationSettingsPairedDevice(ctx context.Context, userId uint64, pairedDeviceId string) error UpdateNotificationSettingsClients(ctx context.Context, userId uint64, clientId uint64, IsSubscribed bool) (*t.NotificationSettingsClient, error) GetNotificationSettingsDashboards(ctx context.Context, userId uint64, cursor string, colSort t.Sort[enums.NotificationSettingsDashboardColumn], search string, limit uint64) ([]t.NotificationSettingsDashboardsTableRow, *t.Paging, error) - UpdateNotificationSettingsValidatorDashboard(ctx context.Context, dashboardId t.VDBIdPrimary, groupId uint64, settings t.NotificationSettingsValidatorDashboard) error - UpdateNotificationSettingsAccountDashboard(ctx context.Context, dashboardId t.VDBIdPrimary, groupId uint64, settings t.NotificationSettingsAccountDashboard) error + UpdateNotificationSettingsValidatorDashboard(ctx context.Context, userId uint64, dashboardId t.VDBIdPrimary, groupId uint64, settings t.NotificationSettingsValidatorDashboard) error + UpdateNotificationSettingsAccountDashboard(ctx context.Context, userId uint64, dashboardId t.VDBIdPrimary, groupId uint64, settings t.NotificationSettingsAccountDashboard) error } const ( + ValidatorDashboardEventPrefix string = "vdb" + AccountDashboardEventPrefix string = "adb" + + DiscordWebhookFormat string = "discord" + + GroupOfflineThresholdDefault float64 = 0.1 + MaxCollateralThresholdDefault float64 = 1.0 + MinCollateralThresholdDefault float64 = 0.2 + ERC20TokenTransfersValueThresholdDefault float64 = 0.1 + MachineStorageUsageThresholdDefault float64 = 0.9 MachineCpuUsageThresholdDefault float64 = 0.6 MachineMemoryUsageThresholdDefault float64 = 0.8 @@ -1184,13 +1196,528 @@ func (d *DataAccessService) UpdateNotificationSettingsClients(ctx context.Contex return result, nil } func (d *DataAccessService) GetNotificationSettingsDashboards(ctx context.Context, userId uint64, cursor string, colSort t.Sort[enums.NotificationSettingsDashboardColumn], search string, limit uint64) ([]t.NotificationSettingsDashboardsTableRow, *t.Paging, error) { - return d.dummy.GetNotificationSettingsDashboards(ctx, userId, cursor, colSort, search, limit) + result := make([]t.NotificationSettingsDashboardsTableRow, 0) + var paging t.Paging + + wg := errgroup.Group{} + + // Initialize the cursor + var currentCursor t.NotificationSettingsCursor + var err error + if cursor != "" { + currentCursor, err = utils.StringToCursor[t.NotificationSettingsCursor](cursor) + if err != nil { + return nil, nil, fmt.Errorf("failed to parse passed cursor as NotificationSettingsCursor: %w", err) + } + } + + isReverseDirection := (colSort.Desc && !currentCursor.IsReverse()) || (!colSort.Desc && currentCursor.IsReverse()) + + // ------------------------------------- + // Get the events + events := []struct { + Name types.EventName `db:"event_name"` + Filter string `db:"event_filter"` + Threshold float64 `db:"event_threshold"` + }{} + wg.Go(func() error { + err := d.userReader.SelectContext(ctx, &events, ` + SELECT + event_name, + event_filter, + event_threshold + FROM users_subscriptions + WHERE user_id = $1`, userId) + if err != nil { + return fmt.Errorf(`error retrieving data for account dashboard notifications: %w`, err) + } + + return nil + }) + + // ------------------------------------- + // Get the validator dashboards + valDashboards := []struct { + DashboardId uint64 `db:"dashboard_id"` + DashboardName string `db:"dashboard_name"` + GroupId uint64 `db:"group_id"` + GroupName string `db:"group_name"` + Network uint64 `db:"network"` + WebhookUrl sql.NullString `db:"webhook_target"` + IsWebhookDiscordEnabled sql.NullBool `db:"discord_webhook"` + IsRealTimeModeEnabled sql.NullBool `db:"realtime_notifications"` + }{} + wg.Go(func() error { + err := d.alloyReader.SelectContext(ctx, &valDashboards, ` + SELECT + d.id AS dashboard_id, + d.name AS dashboard_name, + g.id AS group_id, + g.name AS group_name, + d.network, + g.webhook_target, + (g.webhook_format = $1) AS discord_webhook, + g.realtime_notifications + FROM users_val_dashboards d + INNER JOIN users_val_dashboards_groups g ON d.id = g.dashboard_id + WHERE d.user_id = $2`, DiscordWebhookFormat, userId) + if err != nil { + return fmt.Errorf(`error retrieving data for validator dashboard notifications: %w`, err) + } + + return nil + }) + + // ------------------------------------- + // Get the account dashboards + accDashboards := []struct { + DashboardId uint64 `db:"dashboard_id"` + DashboardName string `db:"dashboard_name"` + GroupId uint64 `db:"group_id"` + GroupName string `db:"group_name"` + WebhookUrl sql.NullString `db:"webhook_target"` + IsWebhookDiscordEnabled sql.NullBool `db:"discord_webhook"` + IsIgnoreSpamTransactionsEnabled bool `db:"ignore_spam_transactions"` + SubscribedChainIds []uint64 `db:"subscribed_chain_ids"` + }{} + // TODO: Account dashboard handling will be handled later + // wg.Go(func() error { + // err := d.alloyReader.SelectContext(ctx, &accDashboards, ` + // SELECT + // d.id AS dashboard_id, + // d.name AS dashboard_name, + // g.id AS group_id, + // g.name AS group_name, + // g.webhook_target, + // (g.webhook_format = $1) AS discord_webhook, + // g.ignore_spam_transactions, + // g.subscribed_chain_ids + // FROM users_acc_dashboards d + // INNER JOIN users_acc_dashboards_groups g ON d.id = g.dashboard_id + // WHERE d.user_id = $2`, DiscordWebhookFormat, userId) + // if err != nil { + // return fmt.Errorf(`error retrieving data for validator dashboard notifications: %w`, err) + // } + + // return nil + // }) + + err = wg.Wait() + if err != nil { + return nil, nil, fmt.Errorf("error retrieving dashboard notification data: %w", err) + } + + // ------------------------------------- + // Evaluate the data + type NotificationSettingsDashboardsInfo struct { + IsAccountDashboard bool // if false it's a validator dashboard + DashboardId uint64 + DashboardName string + GroupId uint64 + GroupName string + // if it's a validator dashboard, Settings is NotificationSettingsAccountDashboard, otherwise NotificationSettingsValidatorDashboard + Settings interface{} + ChainIds []uint64 + } + settingsMap := make(map[string]*NotificationSettingsDashboardsInfo) + + for _, event := range events { + eventSplit := strings.Split(event.Filter, ":") + if len(eventSplit) != 3 { + continue + } + dashboardType := eventSplit[0] + + if _, ok := settingsMap[event.Filter]; !ok { + if dashboardType == ValidatorDashboardEventPrefix { + settingsMap[event.Filter] = &NotificationSettingsDashboardsInfo{ + Settings: t.NotificationSettingsValidatorDashboard{ + GroupOfflineThreshold: GroupOfflineThresholdDefault, + MaxCollateralThreshold: MaxCollateralThresholdDefault, + MinCollateralThreshold: MinCollateralThresholdDefault, + }, + } + } else if dashboardType == AccountDashboardEventPrefix { + settingsMap[event.Filter] = &NotificationSettingsDashboardsInfo{ + Settings: t.NotificationSettingsAccountDashboard{ + ERC20TokenTransfersValueThreshold: ERC20TokenTransfersValueThresholdDefault, + }, + } + } + } + + switch settings := settingsMap[event.Filter].Settings.(type) { + case t.NotificationSettingsValidatorDashboard: + switch event.Name { + case types.ValidatorIsOfflineEventName: + settings.IsValidatorOfflineSubscribed = true + case types.GroupIsOfflineEventName: + settings.IsGroupOfflineSubscribed = true + settings.GroupOfflineThreshold = event.Threshold + case types.ValidatorMissedAttestationEventName: + settings.IsAttestationsMissedSubscribed = true + case types.ValidatorProposalEventName: + settings.IsBlockProposalSubscribed = true + case types.ValidatorUpcomingProposalEventName: + settings.IsUpcomingBlockProposalSubscribed = true + case types.SyncCommitteeSoon: + settings.IsSyncSubscribed = true + case types.ValidatorReceivedWithdrawalEventName: + settings.IsWithdrawalProcessedSubscribed = true + case types.ValidatorGotSlashedEventName: + settings.IsSlashedSubscribed = true + case types.RocketpoolCollateralMinReached: + settings.IsMinCollateralSubscribed = true + settings.MinCollateralThreshold = event.Threshold + case types.RocketpoolCollateralMaxReached: + settings.IsMaxCollateralSubscribed = true + settings.MaxCollateralThreshold = event.Threshold + } + settingsMap[event.Filter].Settings = settings + case t.NotificationSettingsAccountDashboard: + switch event.Name { + case types.IncomingTransactionEventName: + settings.IsIncomingTransactionsSubscribed = true + case types.OutgoingTransactionEventName: + settings.IsOutgoingTransactionsSubscribed = true + case types.ERC20TokenTransferEventName: + settings.IsERC20TokenTransfersSubscribed = true + settings.ERC20TokenTransfersValueThreshold = event.Threshold + case types.ERC721TokenTransferEventName: + settings.IsERC721TokenTransfersSubscribed = true + case types.ERC1155TokenTransferEventName: + settings.IsERC1155TokenTransfersSubscribed = true + } + settingsMap[event.Filter].Settings = settings + } + } + + // Validator dashboards + for _, valDashboard := range valDashboards { + key := fmt.Sprintf("%s:%d:%d", ValidatorDashboardEventPrefix, valDashboard.DashboardId, valDashboard.GroupId) + + if _, ok := settingsMap[key]; !ok { + settingsMap[key] = &NotificationSettingsDashboardsInfo{ + Settings: t.NotificationSettingsValidatorDashboard{ + GroupOfflineThreshold: GroupOfflineThresholdDefault, + MaxCollateralThreshold: MaxCollateralThresholdDefault, + MinCollateralThreshold: MinCollateralThresholdDefault, + }, + } + } + + // Set general info + settingsMap[key].IsAccountDashboard = false + settingsMap[key].DashboardId = valDashboard.DashboardId + settingsMap[key].DashboardName = valDashboard.DashboardName + settingsMap[key].GroupId = valDashboard.GroupId + settingsMap[key].GroupName = valDashboard.GroupName + settingsMap[key].ChainIds = []uint64{valDashboard.Network} + + // Set the settings + if valSettings, ok := settingsMap[key].Settings.(*t.NotificationSettingsValidatorDashboard); ok { + valSettings.WebhookUrl = valDashboard.WebhookUrl.String + valSettings.IsWebhookDiscordEnabled = valDashboard.IsWebhookDiscordEnabled.Bool + valSettings.IsRealTimeModeEnabled = valDashboard.IsRealTimeModeEnabled.Bool + } + } + + // Account dashboards + for _, accDashboard := range accDashboards { + key := fmt.Sprintf("%s:%d:%d", AccountDashboardEventPrefix, accDashboard.DashboardId, accDashboard.GroupId) + + if _, ok := settingsMap[key]; !ok { + settingsMap[key] = &NotificationSettingsDashboardsInfo{ + Settings: t.NotificationSettingsAccountDashboard{ + ERC20TokenTransfersValueThreshold: ERC20TokenTransfersValueThresholdDefault, + }, + } + } + + // Set general info + settingsMap[key].IsAccountDashboard = true + settingsMap[key].DashboardId = accDashboard.DashboardId + settingsMap[key].DashboardName = accDashboard.DashboardName + settingsMap[key].GroupId = accDashboard.GroupId + settingsMap[key].GroupName = accDashboard.GroupName + settingsMap[key].ChainIds = accDashboard.SubscribedChainIds + + // Set the settings + if accSettings, ok := settingsMap[key].Settings.(*t.NotificationSettingsAccountDashboard); ok { + accSettings.WebhookUrl = accDashboard.WebhookUrl.String + accSettings.IsWebhookDiscordEnabled = accDashboard.IsWebhookDiscordEnabled.Bool + accSettings.IsIgnoreSpamTransactionsEnabled = accDashboard.IsIgnoreSpamTransactionsEnabled + accSettings.SubscribedChainIds = accDashboard.SubscribedChainIds + } + } + + // Apply filter + if search != "" { + lowerSearch := strings.ToLower(search) + for key, setting := range settingsMap { + if !strings.HasPrefix(strings.ToLower(setting.DashboardName), lowerSearch) && + !strings.HasPrefix(strings.ToLower(setting.GroupName), lowerSearch) { + delete(settingsMap, key) + } + } + } + + // Convert to a slice for sorting and paging + settings := slices.Collect(maps.Values(settingsMap)) + + // ------------------------------------- + // Sort + // Each row is uniquely defined by the dashboardId, groupId, and isAccountDashboard so the sort order is DashboardName/GroupName => DashboardId => GroupId => IsAccountDashboard + var primarySortParam func(resultEntry *NotificationSettingsDashboardsInfo) string + switch colSort.Column { + case enums.NotificationSettingsDashboardColumns.DashboardName: + primarySortParam = func(resultEntry *NotificationSettingsDashboardsInfo) string { return resultEntry.DashboardName } + case enums.NotificationSettingsDashboardColumns.GroupName: + primarySortParam = func(resultEntry *NotificationSettingsDashboardsInfo) string { return resultEntry.GroupName } + default: + return nil, nil, fmt.Errorf("invalid sort column for notification subscriptions: %v", colSort.Column) + } + sort.Slice(settings, func(i, j int) bool { + if isReverseDirection { + if primarySortParam(settings[i]) == primarySortParam(settings[j]) { + if settings[i].DashboardId == settings[j].DashboardId { + if settings[i].GroupId == settings[j].GroupId { + return settings[i].IsAccountDashboard + } + return settings[i].GroupId > settings[j].GroupId + } + return settings[i].DashboardId > settings[j].DashboardId + } + return primarySortParam(settings[i]) > primarySortParam(settings[j]) + } else { + if primarySortParam(settings[i]) == primarySortParam(settings[j]) { + if settings[i].DashboardId == settings[j].DashboardId { + if settings[i].GroupId == settings[j].GroupId { + return settings[j].IsAccountDashboard + } + return settings[i].GroupId < settings[j].GroupId + } + return settings[i].DashboardId < settings[j].DashboardId + } + return primarySortParam(settings[i]) < primarySortParam(settings[j]) + } + }) + + // ------------------------------------- + // Convert to the final result format + for _, setting := range settings { + result = append(result, t.NotificationSettingsDashboardsTableRow{ + IsAccountDashboard: setting.IsAccountDashboard, + DashboardId: setting.DashboardId, + GroupId: setting.GroupId, + GroupName: setting.GroupName, + Settings: setting.Settings, + ChainIds: setting.ChainIds, + }) + } + + // ------------------------------------- + // Paging + + // Find the index for the cursor and limit the data + if currentCursor.IsValid() { + for idx, row := range settings { + if row.DashboardId == currentCursor.DashboardId && + row.GroupId == currentCursor.GroupId && + row.IsAccountDashboard == currentCursor.IsAccountDashboard { + result = result[idx+1:] + break + } + } + } + + // Flag if above limit + moreDataFlag := len(result) > int(limit) + if !moreDataFlag && !currentCursor.IsValid() { + // No paging required + return result, &paging, nil + } + + // Remove the last entries from data + if moreDataFlag { + result = result[:limit] + } + + if currentCursor.IsReverse() { + slices.Reverse(result) + } + + p, err := utils.GetPagingFromData(result, currentCursor, moreDataFlag) + if err != nil { + return nil, nil, fmt.Errorf("failed to get paging: %w", err) + } + + return result, p, nil } -func (d *DataAccessService) UpdateNotificationSettingsValidatorDashboard(ctx context.Context, dashboardId t.VDBIdPrimary, groupId uint64, settings t.NotificationSettingsValidatorDashboard) error { - return d.dummy.UpdateNotificationSettingsValidatorDashboard(ctx, dashboardId, groupId, settings) +func (d *DataAccessService) UpdateNotificationSettingsValidatorDashboard(ctx context.Context, userId uint64, dashboardId t.VDBIdPrimary, groupId uint64, settings t.NotificationSettingsValidatorDashboard) error { + // For the given dashboardId and groupId update users_subscriptions and users_val_dashboards_groups with the given settings + epoch := utils.TimeToEpoch(time.Now()) + + var eventsToInsert []goqu.Record + var eventsToDelete []goqu.Expression + + tx, err := d.userWriter.BeginTxx(ctx, nil) + if err != nil { + return fmt.Errorf("error starting db transactions to update validator dashboard notification settings: %w", err) + } + defer utils.Rollback(tx) + + eventFilter := fmt.Sprintf("%s:%d:%d", ValidatorDashboardEventPrefix, dashboardId, groupId) + + d.AddOrRemoveEvent(&eventsToInsert, &eventsToDelete, settings.IsValidatorOfflineSubscribed, userId, string(types.ValidatorIsOfflineEventName), eventFilter, epoch, 0) + d.AddOrRemoveEvent(&eventsToInsert, &eventsToDelete, settings.IsGroupOfflineSubscribed, userId, string(types.GroupIsOfflineEventName), eventFilter, epoch, settings.GroupOfflineThreshold) + d.AddOrRemoveEvent(&eventsToInsert, &eventsToDelete, settings.IsAttestationsMissedSubscribed, userId, string(types.ValidatorMissedAttestationEventName), eventFilter, epoch, 0) + d.AddOrRemoveEvent(&eventsToInsert, &eventsToDelete, settings.IsBlockProposalSubscribed, userId, string(types.ValidatorProposalEventName), eventFilter, epoch, 0) + d.AddOrRemoveEvent(&eventsToInsert, &eventsToDelete, settings.IsUpcomingBlockProposalSubscribed, userId, string(types.ValidatorUpcomingProposalEventName), eventFilter, epoch, 0) + d.AddOrRemoveEvent(&eventsToInsert, &eventsToDelete, settings.IsSyncSubscribed, userId, string(types.SyncCommitteeSoon), eventFilter, epoch, 0) + d.AddOrRemoveEvent(&eventsToInsert, &eventsToDelete, settings.IsWithdrawalProcessedSubscribed, userId, string(types.ValidatorReceivedWithdrawalEventName), eventFilter, epoch, 0) + d.AddOrRemoveEvent(&eventsToInsert, &eventsToDelete, settings.IsSlashedSubscribed, userId, string(types.ValidatorGotSlashedEventName), eventFilter, epoch, 0) + d.AddOrRemoveEvent(&eventsToInsert, &eventsToDelete, settings.IsMaxCollateralSubscribed, userId, string(types.RocketpoolCollateralMaxReached), eventFilter, epoch, settings.MaxCollateralThreshold) + d.AddOrRemoveEvent(&eventsToInsert, &eventsToDelete, settings.IsMinCollateralSubscribed, userId, string(types.RocketpoolCollateralMinReached), eventFilter, epoch, settings.MinCollateralThreshold) + + // Insert all the events or update the threshold if they already exist + if len(eventsToInsert) > 0 { + insertDs := goqu.Dialect("postgres"). + Insert("users_subscriptions"). + Cols("user_id", "event_name", "event_filter", "created_ts", "created_epoch", "event_threshold"). + Rows(eventsToInsert). + OnConflict(goqu.DoUpdate( + "user_id, event_name, event_filter", + goqu.Record{"event_threshold": goqu.L("EXCLUDED.event_threshold")}, + )) + + query, args, err := insertDs.Prepared(true).ToSQL() + if err != nil { + return fmt.Errorf("error preparing query: %v", err) + } + + _, err = tx.ExecContext(ctx, query, args...) + if err != nil { + return err + } + } + + // Delete all the events + if len(eventsToDelete) > 0 { + deleteDs := goqu.Dialect("postgres"). + Delete("users_subscriptions"). + Where(goqu.Or(eventsToDelete...)) + + query, args, err := deleteDs.Prepared(true).ToSQL() + if err != nil { + return fmt.Errorf("error preparing query: %v", err) + } + + _, err = tx.ExecContext(ctx, query, args...) + if err != nil { + return err + } + } + + err = tx.Commit() + if err != nil { + return fmt.Errorf("error committing tx to update validator dashboard notification settings: %w", err) + } + + // Set non-event settings + _, err = d.alloyWriter.ExecContext(ctx, ` + UPDATE users_val_dashboards_groups + SET + webhook_target = NULLIF($1, ''), + webhook_format = CASE WHEN $2 THEN $3 ELSE NULL END, + realtime_notifications = CASE WHEN $4 THEN TRUE ELSE NULL END + WHERE dashboard_id = $5 AND id = $6`, settings.WebhookUrl, settings.IsWebhookDiscordEnabled, DiscordWebhookFormat, settings.IsRealTimeModeEnabled, dashboardId, groupId) + if err != nil { + return err + } + + return nil } -func (d *DataAccessService) UpdateNotificationSettingsAccountDashboard(ctx context.Context, dashboardId t.VDBIdPrimary, groupId uint64, settings t.NotificationSettingsAccountDashboard) error { - return d.dummy.UpdateNotificationSettingsAccountDashboard(ctx, dashboardId, groupId, settings) +func (d *DataAccessService) UpdateNotificationSettingsAccountDashboard(ctx context.Context, userId uint64, dashboardId t.VDBIdPrimary, groupId uint64, settings t.NotificationSettingsAccountDashboard) error { + // TODO: Account dashboard handling will be handled later + // // For the given dashboardId and groupId update users_subscriptions and users_acc_dashboards_groups with the given settings + // epoch := utils.TimeToEpoch(time.Now()) + + // var eventsToInsert []goqu.Record + // var eventsToDelete []goqu.Expression + + // tx, err := d.userWriter.BeginTxx(ctx, nil) + // if err != nil { + // return fmt.Errorf("error starting db transactions to update validator dashboard notification settings: %w", err) + // } + // defer utils.Rollback(tx) + + // eventFilter := fmt.Sprintf("%s:%d:%d", AccountDashboardEventPrefix, dashboardId, groupId) + + // d.AddOrRemoveEvent(&eventsToInsert, &eventsToDelete, settings.IsIncomingTransactionsSubscribed, userId, string(types.IncomingTransactionEventName), eventFilter, epoch, 0) + // d.AddOrRemoveEvent(&eventsToInsert, &eventsToDelete, settings.IsOutgoingTransactionsSubscribed, userId, string(types.OutgoingTransactionEventName), eventFilter, epoch, 0) + // d.AddOrRemoveEvent(&eventsToInsert, &eventsToDelete, settings.IsERC20TokenTransfersSubscribed, userId, string(types.ERC20TokenTransferEventName), eventFilter, epoch, settings.ERC20TokenTransfersValueThreshold) + // d.AddOrRemoveEvent(&eventsToInsert, &eventsToDelete, settings.IsERC721TokenTransfersSubscribed, userId, string(types.ERC721TokenTransferEventName), eventFilter, epoch, 0) + // d.AddOrRemoveEvent(&eventsToInsert, &eventsToDelete, settings.IsERC1155TokenTransfersSubscribed, userId, string(types.ERC1155TokenTransferEventName), eventFilter, epoch, 0) + + // // Insert all the events or update the threshold if they already exist + // if len(eventsToInsert) > 0 { + // insertDs := goqu.Dialect("postgres"). + // Insert("users_subscriptions"). + // Cols("user_id", "event_name", "event_filter", "created_ts", "created_epoch", "event_threshold"). + // Rows(eventsToInsert). + // OnConflict(goqu.DoUpdate( + // "user_id, event_name, event_filter", + // goqu.Record{"event_threshold": goqu.L("EXCLUDED.event_threshold")}, + // )) + + // query, args, err := insertDs.Prepared(true).ToSQL() + // if err != nil { + // return fmt.Errorf("error preparing query: %v", err) + // } + + // _, err = tx.ExecContext(ctx, query, args...) + // if err != nil { + // return err + // } + // } + + // // Delete all the events + // if len(eventsToDelete) > 0 { + // deleteDs := goqu.Dialect("postgres"). + // Delete("users_subscriptions"). + // Where(goqu.Or(eventsToDelete...)) + + // query, args, err := deleteDs.Prepared(true).ToSQL() + // if err != nil { + // return fmt.Errorf("error preparing query: %v", err) + // } + + // _, err = tx.ExecContext(ctx, query, args...) + // if err != nil { + // return err + // } + // } + + // err = tx.Commit() + // if err != nil { + // return fmt.Errorf("error committing tx to update validator dashboard notification settings: %w", err) + // } + + // // Set non-event settings + // _, err = d.alloyWriter.ExecContext(ctx, ` + // UPDATE users_acc_dashboards_groups + // SET + // webhook_target = NULLIF($1, ''), + // webhook_format = CASE WHEN $2 THEN $3 ELSE NULL END, + // ignore_spam_transactions = $4, + // subscribed_chain_ids = $5 + // WHERE dashboard_id = $6 AND id = $7`, settings.WebhookUrl, settings.IsWebhookDiscordEnabled, DiscordWebhookFormat, settings.IsIgnoreSpamTransactionsEnabled, settings.SubscribedChainIds, dashboardId, groupId) + // if err != nil { + // return err + // } + + return d.dummy.UpdateNotificationSettingsAccountDashboard(ctx, userId, dashboardId, groupId, settings) } func (d *DataAccessService) AddOrRemoveEvent(eventsToInsert *[]goqu.Record, eventsToDelete *[]goqu.Expression, isSubscribed bool, userId uint64, eventName string, eventFilter string, epoch int64, threshold float64) { diff --git a/backend/pkg/api/data_access/vdb_management.go b/backend/pkg/api/data_access/vdb_management.go index 92824909e..0026bf845 100644 --- a/backend/pkg/api/data_access/vdb_management.go +++ b/backend/pkg/api/data_access/vdb_management.go @@ -235,6 +235,16 @@ func (d *DataAccessService) RemoveValidatorDashboard(ctx context.Context, dashbo _, err := d.alloyWriter.ExecContext(ctx, ` DELETE FROM users_val_dashboards WHERE id = $1 `, dashboardId) + if err != nil { + return err + } + + prefix := fmt.Sprintf("%s:%d:", ValidatorDashboardEventPrefix, dashboardId) + + // Remove all events related to the dashboard + _, err = d.userWriter.ExecContext(ctx, ` + DELETE FROM users_subscriptions WHERE event_filter LIKE ($1 || '%') + `, prefix) return err } @@ -583,6 +593,16 @@ func (d *DataAccessService) RemoveValidatorDashboardGroup(ctx context.Context, d _, err := d.alloyWriter.ExecContext(ctx, ` DELETE FROM users_val_dashboards_groups WHERE dashboard_id = $1 AND id = $2 `, dashboardId, groupId) + if err != nil { + return err + } + + prefix := fmt.Sprintf("%s:%d:%d", ValidatorDashboardEventPrefix, dashboardId, groupId) + + // Remove all events related to the group + _, err = d.userWriter.ExecContext(ctx, ` + DELETE FROM users_subscriptions WHERE event_filter = $1 + `, prefix) return err } diff --git a/backend/pkg/api/enums/notifications_enums.go b/backend/pkg/api/enums/notifications_enums.go index 554ea2afe..c71fb381b 100644 --- a/backend/pkg/api/enums/notifications_enums.go +++ b/backend/pkg/api/enums/notifications_enums.go @@ -223,8 +223,8 @@ func (NotificationSettingsDashboardColumn) NewFromString(s string) NotificationS } var NotificationSettingsDashboardColumns = struct { - DashboardId NotificationSettingsDashboardColumn - GroupName NotificationSettingsDashboardColumn + DashboardName NotificationSettingsDashboardColumn + GroupName NotificationSettingsDashboardColumn }{ NotificationSettingsDashboardDashboardName, NotificationSettingsDashboardGroupName, diff --git a/backend/pkg/api/handlers/public.go b/backend/pkg/api/handlers/public.go index 03c7afa17..de844a90f 100644 --- a/backend/pkg/api/handlers/public.go +++ b/backend/pkg/api/handlers/public.go @@ -2485,6 +2485,12 @@ func (h *HandlerService) PublicGetUserNotificationSettingsDashboards(w http.Resp // @Router /users/me/notifications/settings/validator-dashboards/{dashboard_id}/groups/{group_id} [put] func (h *HandlerService) PublicPutUserNotificationSettingsValidatorDashboard(w http.ResponseWriter, r *http.Request) { var v validationError + userId, err := GetUserIdByContext(r) + if err != nil { + handleErr(w, r, err) + return + } + var req types.NotificationSettingsValidatorDashboard if err := v.checkBody(&req, r); err != nil { handleErr(w, r, err) @@ -2501,7 +2507,7 @@ func (h *HandlerService) PublicPutUserNotificationSettingsValidatorDashboard(w h handleErr(w, r, v) return } - err := h.dai.UpdateNotificationSettingsValidatorDashboard(r.Context(), dashboardId, groupId, req) + err = h.dai.UpdateNotificationSettingsValidatorDashboard(r.Context(), userId, dashboardId, groupId, req) if err != nil { handleErr(w, r, err) return @@ -2527,6 +2533,12 @@ func (h *HandlerService) PublicPutUserNotificationSettingsValidatorDashboard(w h // @Router /users/me/notifications/settings/account-dashboards/{dashboard_id}/groups/{group_id} [put] func (h *HandlerService) PublicPutUserNotificationSettingsAccountDashboard(w http.ResponseWriter, r *http.Request) { var v validationError + userId, err := GetUserIdByContext(r) + if err != nil { + handleErr(w, r, err) + return + } + // uses a different struct due to `subscribed_chain_ids`, which is a slice of intOrString in the payload but a slice of uint64 in the response type request struct { WebhookUrl string `json:"webhook_url"` @@ -2568,7 +2580,7 @@ func (h *HandlerService) PublicPutUserNotificationSettingsAccountDashboard(w htt IsERC721TokenTransfersSubscribed: req.IsERC721TokenTransfersSubscribed, IsERC1155TokenTransfersSubscribed: req.IsERC1155TokenTransfersSubscribed, } - err := h.dai.UpdateNotificationSettingsAccountDashboard(r.Context(), dashboardId, groupId, settings) + err = h.dai.UpdateNotificationSettingsAccountDashboard(r.Context(), userId, dashboardId, groupId, settings) if err != nil { handleErr(w, r, err) return diff --git a/backend/pkg/api/types/data_access.go b/backend/pkg/api/types/data_access.go index 6ef3f1a2e..d0c3dc804 100644 --- a/backend/pkg/api/types/data_access.go +++ b/backend/pkg/api/types/data_access.go @@ -105,6 +105,14 @@ type WithdrawalsCursor struct { Amount uint64 } +type NotificationSettingsCursor struct { + GenericCursor + + IsAccountDashboard bool // if false it's a validator dashboard + DashboardId uint64 + GroupId uint64 +} + type NotificationMachinesCursor struct { GenericCursor diff --git a/backend/pkg/api/types/notifications.go b/backend/pkg/api/types/notifications.go index 0094c6ee2..0ebd096aa 100644 --- a/backend/pkg/api/types/notifications.go +++ b/backend/pkg/api/types/notifications.go @@ -239,7 +239,7 @@ type NotificationSettingsDashboardsTableRow struct { DashboardId uint64 `json:"dashboard_id"` GroupId uint64 `json:"group_id"` GroupName string `json:"group_name"` - // if it's a validator dashboard, SubscribedEvents is NotificationSettingsAccountDashboard, otherwise NotificationSettingsValidatorDashboard + // if it's a validator dashboard, Settings is NotificationSettingsAccountDashboard, otherwise NotificationSettingsValidatorDashboard Settings interface{} `json:"settings" tstype:"NotificationSettingsAccountDashboard | NotificationSettingsValidatorDashboard" faker:"-"` ChainIds []uint64 `json:"chain_ids" faker:"chain_ids"` } diff --git a/backend/pkg/commons/types/frontend.go b/backend/pkg/commons/types/frontend.go index b00881235..08836faeb 100644 --- a/backend/pkg/commons/types/frontend.go +++ b/backend/pkg/commons/types/frontend.go @@ -51,11 +51,7 @@ const ( ValidatorBalanceDecreasedEventName EventName = "validator_balance_decreased" ValidatorMissedProposalEventName EventName = "validator_proposal_missed" ValidatorExecutedProposalEventName EventName = "validator_proposal_submitted" - ValidatorMissedAttestationEventName EventName = "validator_attestation_missed" - ValidatorGotSlashedEventName EventName = "validator_got_slashed" ValidatorDidSlashEventName EventName = "validator_did_slash" - ValidatorIsOfflineEventName EventName = "validator_is_offline" - ValidatorReceivedWithdrawalEventName EventName = "validator_withdrawal" ValidatorReceivedDepositEventName EventName = "validator_received_deposit" NetworkSlashingEventName EventName = "network_slashing" NetworkValidatorActivationQueueFullEventName EventName = "network_validator_activation_queue_full" @@ -63,23 +59,43 @@ const ( NetworkValidatorExitQueueFullEventName EventName = "network_validator_exit_queue_full" NetworkValidatorExitQueueNotFullEventName EventName = "network_validator_exit_queue_not_full" NetworkLivenessIncreasedEventName EventName = "network_liveness_increased" - NetworkGasAboveThresholdEventName EventName = "network_gas_above_threshold" - NetworkGasBelowThresholdEventName EventName = "network_gas_below_threshold" - NetworkParticipationRateThresholdEventName EventName = "network_participation_rate_threshold" - EthClientUpdateEventName EventName = "eth_client_update" - MonitoringMachineOfflineEventName EventName = "monitoring_machine_offline" - MonitoringMachineDiskAlmostFullEventName EventName = "monitoring_hdd_almostfull" - MonitoringMachineCpuLoadEventName EventName = "monitoring_cpu_load" - MonitoringMachineMemoryUsageEventName EventName = "monitoring_memory_usage" TaxReportEventName EventName = "user_tax_report" //nolint:misspell - RocketpoolCommissionThresholdEventName EventName = "rocketpool_commision_threshold" - RocketpoolNewClaimRoundStartedEventName EventName = "rocketpool_new_claimround" - //nolint:misspell - RocketpoolCollateralMinReached EventName = "rocketpool_colleteral_min" - //nolint:misspell - RocketpoolCollateralMaxReached EventName = "rocketpool_colleteral_max" - SyncCommitteeSoon EventName = "validator_synccommittee_soon" + RocketpoolCommissionThresholdEventName EventName = "rocketpool_commision_threshold" + + // Validator dashboard events + ValidatorIsOfflineEventName EventName = "validator_is_offline" + GroupIsOfflineEventName EventName = "group_is_offline" + ValidatorMissedAttestationEventName EventName = "validator_attestation_missed" + ValidatorProposalEventName EventName = "validator_proposal" + ValidatorUpcomingProposalEventName EventName = "validator_proposal_upcoming" + SyncCommitteeSoon EventName = "validator_synccommittee_soon" + ValidatorReceivedWithdrawalEventName EventName = "validator_withdrawal" + ValidatorGotSlashedEventName EventName = "validator_got_slashed" + RocketpoolCollateralMinReached EventName = "rocketpool_colleteral_min" //nolint:misspell + RocketpoolCollateralMaxReached EventName = "rocketpool_colleteral_max" //nolint:misspell + + // Account dashboard events + IncomingTransactionEventName EventName = "incoming_transaction" + OutgoingTransactionEventName EventName = "outgoing_transaction" + ERC20TokenTransferEventName EventName = "erc20_token_transfer" // #nosec G101 + ERC721TokenTransferEventName EventName = "erc721_token_transfer" // #nosec G101 + ERC1155TokenTransferEventName EventName = "erc1155_token_transfer" // #nosec G101 + + // Machine events + MonitoringMachineOfflineEventName EventName = "monitoring_machine_offline" + MonitoringMachineDiskAlmostFullEventName EventName = "monitoring_hdd_almostfull" + MonitoringMachineCpuLoadEventName EventName = "monitoring_cpu_load" + MonitoringMachineMemoryUsageEventName EventName = "monitoring_memory_usage" + + // Client events + EthClientUpdateEventName EventName = "eth_client_update" + + // Network events + RocketpoolNewClaimRoundStartedEventName EventName = "rocketpool_new_claimround" + NetworkGasAboveThresholdEventName EventName = "network_gas_above_threshold" + NetworkGasBelowThresholdEventName EventName = "network_gas_below_threshold" + NetworkParticipationRateThresholdEventName EventName = "network_participation_rate_threshold" ) var MachineEvents = []EventName{ diff --git a/frontend/types/api/notifications.ts b/frontend/types/api/notifications.ts index b237514e4..dc9353422 100644 --- a/frontend/types/api/notifications.ts +++ b/frontend/types/api/notifications.ts @@ -223,7 +223,7 @@ export interface NotificationSettingsDashboardsTableRow { group_id: number /* uint64 */; group_name: string; /** - * if it's a validator dashboard, SubscribedEvents is NotificationSettingsAccountDashboard, otherwise NotificationSettingsValidatorDashboard + * if it's a validator dashboard, Settings is NotificationSettingsAccountDashboard, otherwise NotificationSettingsValidatorDashboard */ settings: NotificationSettingsAccountDashboard | NotificationSettingsValidatorDashboard; chain_ids: number /* uint64 */[];