diff --git a/model/account.go b/model/account.go index 0abf44b..d0d81b7 100644 --- a/model/account.go +++ b/model/account.go @@ -17,20 +17,32 @@ type Account struct { UsedSellerCode *string `gorm:"default:null" json:"usedSellerCode"` } +type AccountNotificationEmail struct { + AccountAddress string `gorm:"primaryKey;length:42" json:"accountAddress"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + Email *string `gorm:"default:null" json:"email"` + EmailConfirmed bool `gorm:"not null;default:false" json:"emailConfirmed"` + PendingEmail string `gorm:"default:null" json:"pendingEmail"` +} + type AccountDto struct { - Email string `json:"email"` - EmailConfirmed bool `json:"emailConfirmed"` - PendingEmail string `json:"pendingEmail"` - ApplicantType string `json:"applicantType"` - Address string `json:"address"` - Uuid string `json:"uuid"` - KycStatus string `json:"kycStatus"` - ReceiveUpdates bool `json:"receiveUpdates"` - IsActive bool `json:"isActive"` - IsBlacklisted bool `json:"isBlacklisted"` - BlacklistedReason *string `json:"blacklistedReason"` - UsdBuyLimit int `json:"usdBuyLimit"` - VatPercentage int64 `json:"vatPercentage"` - ViesRegistered bool `json:"viesRegistered"` - UsedSellerCode *string `json:"referral"` + Email string `json:"email"` + EmailConfirmed bool `json:"emailConfirmed"` + PendingEmail string `json:"pendingEmail"` + AdditionalNotificationEmail string `json:"additionalNotificationEmail"` + AdditionalNotificationEmailConfirmed bool `json:"additionalNotificationEmailConfirmed"` + PendingAdditionalNotificationEmail string `json:"pendingAdditionalNotificationEmail"` + ApplicantType string `json:"applicantType"` + Address string `json:"address"` + Uuid string `json:"uuid"` + KycStatus string `json:"kycStatus"` + ReceiveUpdates bool `json:"receiveUpdates"` + IsActive bool `json:"isActive"` + IsBlacklisted bool `json:"isBlacklisted"` + BlacklistedReason *string `json:"blacklistedReason"` + UsdBuyLimit int `json:"usdBuyLimit"` + VatPercentage int64 `json:"vatPercentage"` + ViesRegistered bool `json:"viesRegistered"` + UsedSellerCode *string `json:"referral"` } diff --git a/proxy/handlers/account.go b/proxy/handlers/account.go index b851b9f..7beed65 100644 --- a/proxy/handlers/account.go +++ b/proxy/handlers/account.go @@ -12,16 +12,18 @@ import ( ) const ( - baseAccountEndpoint = "/accounts" - getAccountEndpoint = "/account" - registerEmailEndpoint = "/email/register" - confirmEmailEndpoint = "/email/confirm" - subscribeEndpoint = "/subscribe" - unsubscribeEndpoint = "/unsubscribe" - blacklistEndpoint = "/blacklist" - addSellerCodeEndpoint = "/add-seller-code" - getKycinfoEndpoint = "/kyc-info" - getIsKybEndpoint = "/is-kyb" + baseAccountEndpoint = "/accounts" + getAccountEndpoint = "/account" + registerEmailEndpoint = "/email/register" + confirmEmailEndpoint = "/email/confirm" + registerNotificationEmailEndpoint = "/notification-email/register" + deleteNotificationEmailEndpoint = "/notification-email" + subscribeEndpoint = "/subscribe" + unsubscribeEndpoint = "/unsubscribe" + blacklistEndpoint = "/blacklist" + addSellerCodeEndpoint = "/add-seller-code" + getKycinfoEndpoint = "/kyc-info" + getIsKybEndpoint = "/is-kyb" ) type registerEmailRequest struct { @@ -29,6 +31,10 @@ type registerEmailRequest struct { ReceiveUpdates bool `json:"receiveUpdates"` } +type notificationEmailRequest struct { + Email string `json:"email"` +} + type blaclistUserRequest struct { Address string `json:"address"` Reasons string `json:"reasons"` @@ -65,6 +71,8 @@ func NewAccountHandler(groupHandler *groupHandler) { authEndpoints := []EndpointHandler{ {Method: http.MethodGet, Path: getAccountEndpoint, HandlerFunc: h.getOrCreateAccount}, {Method: http.MethodPost, Path: registerEmailEndpoint, HandlerFunc: h.registerEmail}, + {Method: http.MethodPost, Path: registerNotificationEmailEndpoint, HandlerFunc: h.registerNotificationEmail}, + {Method: http.MethodDelete, Path: deleteNotificationEmailEndpoint, HandlerFunc: h.deleteNotificationEmail}, {Method: http.MethodGet, Path: subscribeEndpoint, HandlerFunc: h.subscribe}, {Method: http.MethodGet, Path: unsubscribeEndpoint, HandlerFunc: h.unsubscribe}, {Method: http.MethodPost, Path: blacklistEndpoint, HandlerFunc: h.blackListAccount}, @@ -185,6 +193,98 @@ func (h *accountHandler) registerEmail(c *gin.Context) { model.JsonResponse(c, http.StatusOK, accountDto, nodeAddress, "") } +func (h *accountHandler) registerNotificationEmail(c *gin.Context) { + nodeAddress, err := service.GetAddress() + if err != nil { + log.Error("error while retrieving node address: " + err.Error()) + model.JsonResponse(c, http.StatusInternalServerError, nil, "", err.Error()) + return + } + + address, err := middleware.AddressFromBearer(c) + if err != nil { + log.Error("error while retrieving address from bearer: " + err.Error()) + model.JsonResponse(c, http.StatusBadRequest, nil, nodeAddress, err.Error()) + return + } + + var req notificationEmailRequest + err = c.Bind(&req) + if err != nil { + log.Error("error while binding request: " + err.Error()) + model.JsonResponse(c, http.StatusBadRequest, nil, nodeAddress, err.Error()) + return + } + + account, err := service.RegisterNotificationEmail(address, req.Email) + if err != nil { + log.Error("error while register notification email: " + err.Error()) + model.JsonResponse(c, http.StatusBadRequest, nil, nodeAddress, err.Error()) + return + } + + var kyc *model.Kyc + if account.Email != nil { + kyc, _, err = storage.GetKycByEmail(*account.Email) + if err != nil { + log.Error("error while retrieving kyc information from storage: " + err.Error()) + model.JsonResponse(c, http.StatusInternalServerError, nil, nodeAddress, err.Error()) + return + } + } + + accountDto, err := service.NewAccountDto(account, kyc) + if err != nil { + log.Error("error while creating account dto: " + err.Error()) + model.JsonResponse(c, http.StatusInternalServerError, nil, nodeAddress, err.Error()) + return + } + + model.JsonResponse(c, http.StatusOK, accountDto, nodeAddress, "") +} + +func (h *accountHandler) deleteNotificationEmail(c *gin.Context) { + nodeAddress, err := service.GetAddress() + if err != nil { + log.Error("error while retrieving node address: " + err.Error()) + model.JsonResponse(c, http.StatusInternalServerError, nil, "", err.Error()) + return + } + + address, err := middleware.AddressFromBearer(c) + if err != nil { + log.Error("error while retrieving address from bearer: " + err.Error()) + model.JsonResponse(c, http.StatusBadRequest, nil, nodeAddress, err.Error()) + return + } + + account, err := service.DeleteNotificationEmail(address) + if err != nil { + log.Error("error while deleting notification email: " + err.Error()) + model.JsonResponse(c, http.StatusBadRequest, nil, nodeAddress, err.Error()) + return + } + + var kyc *model.Kyc + if account.Email != nil { + kyc, _, err = storage.GetKycByEmail(*account.Email) + if err != nil { + log.Error("error while retrieving kyc information from storage: " + err.Error()) + model.JsonResponse(c, http.StatusInternalServerError, nil, nodeAddress, err.Error()) + return + } + } + + accountDto, err := service.NewAccountDto(account, kyc) + if err != nil { + log.Error("error while creating account dto: " + err.Error()) + model.JsonResponse(c, http.StatusInternalServerError, nil, nodeAddress, err.Error()) + return + } + + model.JsonResponse(c, http.StatusOK, accountDto, nodeAddress, "") +} + func (h *accountHandler) confirmEmail(c *gin.Context) { nodeAddress, err := service.GetAddress() if err != nil { diff --git a/service/accountService.go b/service/accountService.go index f5a4415..f3b7ff9 100644 --- a/service/accountService.go +++ b/service/accountService.go @@ -83,9 +83,10 @@ func ConfirmEmail(token string) (*model.Account, error) { if err != nil { return nil, errors.New("error while validating confirm jwt: " + err.Error()) } - if claims.Address == "" && claims.Email == "" { + if claims.Address == "" || claims.Email == "" { return nil, errors.New("found bad claims in confirm token") } + email := TrimWhitespacesAndToLower(claims.Email) account, err := getAcocunt(claims.Address) if err != nil { @@ -94,19 +95,37 @@ func ConfirmEmail(token string) (*model.Account, error) { return nil, ErrorAccountNotFound } - if account.EmailConfirmed { - if *account.Email == claims.Email { - return account, nil // already confirmed - } else { - return nil, errors.New("account already has another email") - } + if account.EmailConfirmed && account.Email != nil && TrimWhitespacesAndToLower(*account.Email) == email { + return account, nil // already confirmed } - if account.PendingEmail != claims.Email { - return nil, errors.New("wrong confirmation token") + if !account.EmailConfirmed && TrimWhitespacesAndToLower(account.PendingEmail) == email { + return confirmPrimaryEmail(account, email) + } + + notificationEmail, found, err := storage.GetAccountNotificationEmailByAddress(claims.Address) + if err != nil { + return nil, errors.New("error while retrieving notification email from storage: " + err.Error()) + } + if found { + confirmed, err := confirmPendingNotificationEmail(notificationEmail, email) + if err != nil { + return nil, err + } + if confirmed { + err = storage.CreateOrUpdateAccountNotificationEmail(notificationEmail) + if err != nil { + return nil, errors.New("error while updating notification email on storage: " + err.Error()) + } + return account, nil + } } - account.Email = &claims.Email + return nil, errors.New("wrong confirmation token") +} + +func confirmPrimaryEmail(account *model.Account, email string) (*model.Account, error) { + account.Email = &email account.EmailConfirmed = true account.PendingEmail = "" receiveUpdates := account.PendingReceiveUpdates @@ -119,7 +138,7 @@ func ConfirmEmail(token string) (*model.Account, error) { } account.PendingReceiveUpdates = false - err = storage.UpdateAccount(account) + err := storage.UpdateAccount(account) if err != nil { return nil, errors.New("error while updating account on storage: " + err.Error()) } @@ -175,6 +194,19 @@ func UnsubscribeEmail(kyc *model.Kyc) error { } func NewAccountDto(account *model.Account, kyc *model.Kyc) (*model.AccountDto, error) { + notificationEmail, found, err := storage.GetAccountNotificationEmailByAddress(account.Address) + if err != nil { + return nil, errors.New("error while retrieving notification email from storage: " + err.Error()) + } + additionalNotificationEmail := "" + additionalNotificationEmailConfirmed := false + pendingAdditionalNotificationEmail := "" + if found && notificationEmail != nil { + additionalNotificationEmail = StringOrEmpty(notificationEmail.Email) + additionalNotificationEmailConfirmed = notificationEmail.EmailConfirmed + pendingAdditionalNotificationEmail = notificationEmail.PendingEmail + } + if kyc != nil { limit := 0 if kyc.ApplicantType == model.BusinessCustomer { @@ -202,21 +234,24 @@ func NewAccountDto(account *model.Account, kyc *model.Kyc) (*model.AccountDto, e } return &model.AccountDto{ - Email: StringOrEmpty(account.Email), - EmailConfirmed: account.EmailConfirmed, - PendingEmail: account.PendingEmail, - Address: account.Address, - ApplicantType: kyc.ApplicantType, - Uuid: kyc.Uuid.String(), - KycStatus: kyc.KycStatus, - ReceiveUpdates: *kyc.ReceiveUpdates, - IsActive: kyc.IsActive, - IsBlacklisted: account.IsBlacklisted, - BlacklistedReason: account.BlacklistedReason, - UsdBuyLimit: limit, - VatPercentage: vatPercentage, - ViesRegistered: kyc.ViesRegistered, - UsedSellerCode: account.UsedSellerCode, + Email: StringOrEmpty(account.Email), + EmailConfirmed: account.EmailConfirmed, + PendingEmail: account.PendingEmail, + AdditionalNotificationEmail: additionalNotificationEmail, + AdditionalNotificationEmailConfirmed: additionalNotificationEmailConfirmed, + PendingAdditionalNotificationEmail: pendingAdditionalNotificationEmail, + Address: account.Address, + ApplicantType: kyc.ApplicantType, + Uuid: kyc.Uuid.String(), + KycStatus: kyc.KycStatus, + ReceiveUpdates: *kyc.ReceiveUpdates, + IsActive: kyc.IsActive, + IsBlacklisted: account.IsBlacklisted, + BlacklistedReason: account.BlacklistedReason, + UsdBuyLimit: limit, + VatPercentage: vatPercentage, + ViesRegistered: kyc.ViesRegistered, + UsedSellerCode: account.UsedSellerCode, }, nil } @@ -227,20 +262,23 @@ func NewAccountDto(account *model.Account, kyc *model.Kyc) (*model.AccountDto, e vatPercentage = 2200 } return &model.AccountDto{ - Email: StringOrEmpty(account.Email), - EmailConfirmed: account.EmailConfirmed, - PendingEmail: account.PendingEmail, - Address: account.Address, - ApplicantType: "", - Uuid: "", - KycStatus: "", - ReceiveUpdates: false, - IsActive: false, - IsBlacklisted: account.IsBlacklisted, - BlacklistedReason: account.BlacklistedReason, - UsdBuyLimit: UsdBuyLimit, - VatPercentage: int64(vatPercentage), - UsedSellerCode: account.UsedSellerCode, + Email: StringOrEmpty(account.Email), + EmailConfirmed: account.EmailConfirmed, + PendingEmail: account.PendingEmail, + AdditionalNotificationEmail: additionalNotificationEmail, + AdditionalNotificationEmailConfirmed: additionalNotificationEmailConfirmed, + PendingAdditionalNotificationEmail: pendingAdditionalNotificationEmail, + Address: account.Address, + ApplicantType: "", + Uuid: "", + KycStatus: "", + ReceiveUpdates: false, + IsActive: false, + IsBlacklisted: account.IsBlacklisted, + BlacklistedReason: account.BlacklistedReason, + UsdBuyLimit: UsdBuyLimit, + VatPercentage: int64(vatPercentage), + UsedSellerCode: account.UsedSellerCode, }, nil } diff --git a/service/jobsEndingEmails.go b/service/jobsEndingEmails.go index fa9e950..c11ef98 100644 --- a/service/jobsEndingEmails.go +++ b/service/jobsEndingEmails.go @@ -169,20 +169,35 @@ func sendEmailForEndingJobs(usersWithJobs map[string][]EndingJob) { log.Error("error while retrieving account for address %s: %v", ownerAddress, err) continue } - if !found || account == nil || account.Email == nil { + if !found || account == nil { continue } - email := strings.TrimSpace(*account.Email) - if email == "" { + notificationEmail, found, err := storage.GetAccountNotificationEmailByAddress(ownerAddress) + if err != nil { + log.Error("error while retrieving notification email for address %s: %v", ownerAddress, err) continue } + if !found { + notificationEmail = nil + } - err = SendJobsEndingEmail(email, jobs) - if err != nil { - log.Error("error while sending ending jobs email to %s: %v", email, err) + emails := notificationEmailsForAccount(account, notificationEmail) + if len(emails) == 0 { continue } + for _, email := range emails { + email = strings.TrimSpace(email) + if email == "" { + continue + } + + err = SendJobsEndingEmail(email, jobs) + if err != nil { + log.Error("error while sending ending jobs email to %s: %v", email, err) + continue + } + } } } diff --git a/service/notificationEmailService.go b/service/notificationEmailService.go new file mode 100644 index 0000000..26230a1 --- /dev/null +++ b/service/notificationEmailService.go @@ -0,0 +1,120 @@ +package service + +import ( + "errors" + "net/mail" + "time" + + "github.com/NaeuralEdgeProtocol/ratio1-backend/model" + "github.com/NaeuralEdgeProtocol/ratio1-backend/storage" +) + +func RegisterNotificationEmail(address, email string) (*model.Account, error) { + email = TrimWhitespacesAndToLower(email) + if _, err := mail.ParseAddress(email); err != nil { + return nil, errors.New("invalid email address: " + email) + } + + account, err := getAcocunt(address) + if err != nil { + return nil, errors.New("error while retrieving account from storage: " + err.Error()) + } else if account == nil { + return nil, ErrorAccountNotFound + } + if account.Email == nil || !account.EmailConfirmed { + return nil, errors.New("default notification email is not confirmed") + } + if email == TrimWhitespacesAndToLower(*account.Email) || email == TrimWhitespacesAndToLower(account.PendingEmail) { + return nil, errors.New("additional notification email must be different from default notification email") + } + + notificationEmail, found, err := storage.GetAccountNotificationEmailByAddress(address) + if err != nil { + return nil, errors.New("error while retrieving notification email from storage: " + err.Error()) + } + if !found { + notificationEmail = &model.AccountNotificationEmail{ + AccountAddress: address, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + } + if notificationEmail.EmailConfirmed && notificationEmail.Email != nil && TrimWhitespacesAndToLower(*notificationEmail.Email) == email { + return account, nil + } + + notificationEmail.PendingEmail = email + notificationEmail.UpdatedAt = time.Now() + + err = storage.CreateOrUpdateAccountNotificationEmail(notificationEmail) + if err != nil { + return nil, errors.New("error while updating notification email on storage: " + err.Error()) + } + + err = SendConfirmEmail(address, email) + if err != nil { + return nil, errors.New("error while sending confirmation email: " + err.Error()) + } + + return account, nil +} + +func DeleteNotificationEmail(address string) (*model.Account, error) { + account, err := getAcocunt(address) + if err != nil { + return nil, errors.New("error while retrieving account from storage: " + err.Error()) + } else if account == nil { + return nil, ErrorAccountNotFound + } + + err = storage.DeleteAccountNotificationEmail(address) + if err != nil { + return nil, errors.New("error while deleting notification email from storage: " + err.Error()) + } + + return account, nil +} + +func confirmPendingNotificationEmail(notificationEmail *model.AccountNotificationEmail, email string) (bool, error) { + if notificationEmail == nil { + return false, nil + } + + email = TrimWhitespacesAndToLower(email) + if notificationEmail.EmailConfirmed && notificationEmail.Email != nil && TrimWhitespacesAndToLower(*notificationEmail.Email) == email { + return true, nil + } + if TrimWhitespacesAndToLower(notificationEmail.PendingEmail) != email { + return false, nil + } + + notificationEmail.Email = &email + notificationEmail.EmailConfirmed = true + notificationEmail.PendingEmail = "" + notificationEmail.UpdatedAt = time.Now() + + return true, nil +} + +func notificationEmailsForAccount(account *model.Account, notificationEmail *model.AccountNotificationEmail) []string { + emails := make([]string, 0, 2) + seen := make(map[string]bool) + + addEmail := func(email string) { + email = TrimWhitespacesAndToLower(email) + if email == "" || seen[email] { + return + } + seen[email] = true + emails = append(emails, email) + } + + if account != nil && account.EmailConfirmed && account.Email != nil { + addEmail(*account.Email) + } + if notificationEmail != nil && notificationEmail.EmailConfirmed && notificationEmail.Email != nil { + addEmail(*notificationEmail.Email) + } + + return emails +} diff --git a/service/notificationEmailService_test.go b/service/notificationEmailService_test.go new file mode 100644 index 0000000..b4798d3 --- /dev/null +++ b/service/notificationEmailService_test.go @@ -0,0 +1,84 @@ +package service + +import ( + "testing" + + "github.com/NaeuralEdgeProtocol/ratio1-backend/model" + "github.com/stretchr/testify/require" +) + +func TestNotificationEmailsForAccount_DeduplicatesConfirmedRecipients(t *testing.T) { + primaryEmail := " owner@example.com " + additionalEmail := "OWNER@example.com" + + account := &model.Account{ + Email: &primaryEmail, + EmailConfirmed: true, + } + notificationEmail := &model.AccountNotificationEmail{ + Email: &additionalEmail, + EmailConfirmed: true, + } + + require.Equal( + t, + []string{"owner@example.com"}, + notificationEmailsForAccount(account, notificationEmail), + ) +} + +func TestNotificationEmailsForAccount_SkipsUnconfirmedAdditionalEmail(t *testing.T) { + primaryEmail := "owner@example.com" + additionalEmail := "ops@example.com" + + account := &model.Account{ + Email: &primaryEmail, + EmailConfirmed: true, + } + notificationEmail := &model.AccountNotificationEmail{ + Email: &additionalEmail, + EmailConfirmed: false, + } + + require.Equal( + t, + []string{"owner@example.com"}, + notificationEmailsForAccount(account, notificationEmail), + ) +} + +func TestConfirmPendingNotificationEmail_ReplacesActiveEmailOnlyWhenPendingMatches(t *testing.T) { + activeEmail := "old-ops@example.com" + notificationEmail := &model.AccountNotificationEmail{ + Email: &activeEmail, + EmailConfirmed: true, + PendingEmail: "new-ops@example.com", + } + + confirmed, err := confirmPendingNotificationEmail(notificationEmail, "new-ops@example.com") + + require.NoError(t, err) + require.True(t, confirmed) + require.NotNil(t, notificationEmail.Email) + require.Equal(t, "new-ops@example.com", *notificationEmail.Email) + require.True(t, notificationEmail.EmailConfirmed) + require.Empty(t, notificationEmail.PendingEmail) +} + +func TestConfirmPendingNotificationEmail_LeavesActiveEmailWhenTokenDoesNotMatch(t *testing.T) { + activeEmail := "old-ops@example.com" + notificationEmail := &model.AccountNotificationEmail{ + Email: &activeEmail, + EmailConfirmed: true, + PendingEmail: "new-ops@example.com", + } + + confirmed, err := confirmPendingNotificationEmail(notificationEmail, "other@example.com") + + require.NoError(t, err) + require.False(t, confirmed) + require.NotNil(t, notificationEmail.Email) + require.Equal(t, "old-ops@example.com", *notificationEmail.Email) + require.True(t, notificationEmail.EmailConfirmed) + require.Equal(t, "new-ops@example.com", notificationEmail.PendingEmail) +} diff --git a/storage/accountNotificationEmailStorer.go b/storage/accountNotificationEmailStorer.go new file mode 100644 index 0000000..b44bbc3 --- /dev/null +++ b/storage/accountNotificationEmailStorer.go @@ -0,0 +1,57 @@ +package storage + +import ( + "github.com/NaeuralEdgeProtocol/ratio1-backend/model" +) + +func GetAccountNotificationEmailByAddress(address string) (*model.AccountNotificationEmail, bool, error) { + db, err := GetDB() + if err != nil { + return nil, false, err + } + + var notificationEmail model.AccountNotificationEmail + txRead := db.Find(¬ificationEmail, "account_address = ?", address) + if txRead.Error != nil { + return nil, false, txRead.Error + } + if txRead.RowsAffected == 0 { + return nil, false, nil + } + + return ¬ificationEmail, true, nil +} + +func CreateOrUpdateAccountNotificationEmail(notificationEmail *model.AccountNotificationEmail) error { + db, err := GetDB() + if err != nil { + return err + } + + txUpdate := db.Save(notificationEmail) + if txUpdate.Error != nil { + txUpdate.Rollback() + return txUpdate.Error + } + if txUpdate.RowsAffected == 0 { + txUpdate.Rollback() + return nil + } + + return nil +} + +func DeleteAccountNotificationEmail(address string) error { + db, err := GetDB() + if err != nil { + return err + } + + txDelete := db.Delete(&model.AccountNotificationEmail{}, "account_address = ?", address) + if txDelete.Error != nil { + txDelete.Rollback() + return txDelete.Error + } + + return nil +} diff --git a/storage/conn.go b/storage/conn.go index 9e26ec1..1c15e89 100644 --- a/storage/conn.go +++ b/storage/conn.go @@ -44,6 +44,7 @@ func Connect() { func TryMigrate() error { err := database.AutoMigrate( &model.Account{}, + &model.AccountNotificationEmail{}, &model.Kyc{}, &model.InvoiceClient{}, &model.Seller{},