From 26ced023840348f6b866895cae968dc60befe194 Mon Sep 17 00:00:00 2001 From: rawdaGastan Date: Mon, 23 Dec 2024 18:44:56 +0200 Subject: [PATCH 1/5] add delete user endpoint --- client/src/components/Toast.vue | 2 + server/app/admin_handler.go | 18 +- server/app/app.go | 4 +- server/app/invoice_handler.go | 414 +++++++++--------- server/app/k8s_handler.go | 8 +- server/app/payments_handler.go | 72 ++- server/app/user_handler.go | 252 +++++++++-- server/app/user_handler_test.go | 1 + server/app/vm_handler.go | 4 +- server/app/voucher_handler.go | 9 +- server/app/voucher_handler_test.go | 6 +- server/deployer/deployer.go | 43 +- server/docs/docs.go | 184 ++++++-- server/docs/swagger.yaml | 141 ++++-- server/go.mod | 8 +- server/go.sum | 12 +- server/internal/config_parser_test.go | 96 +++- server/internal/email_sender.go | 8 +- server/internal/email_sender_test.go | 2 +- .../internal/templates/adminAnnouncement.html | 2 +- server/internal/templates/signup.html | 2 +- server/models/card.go | 11 +- server/models/database.go | 2 +- server/models/invoice.go | 59 +-- server/models/k8s.go | 17 + server/models/user.go | 29 +- server/models/vm.go | 6 + 27 files changed, 993 insertions(+), 419 deletions(-) diff --git a/client/src/components/Toast.vue b/client/src/components/Toast.vue index 5c7c8c2d..732e75b3 100644 --- a/client/src/components/Toast.vue +++ b/client/src/components/Toast.vue @@ -8,12 +8,14 @@ import { createToast, clearToasts } from "mosha-vue-toastify"; export default { setup() { const toast = (title, color = "#217dbb") => { + if (title.length > 0) { createToast(title.charAt(0).toUpperCase() + title.slice(1), { position: "bottom-right", hideProgressBar: true, toastBackgroundColor: color, timeout: 8000, }); + } }; const clear = () => { clearToasts(); diff --git a/server/app/admin_handler.go b/server/app/admin_handler.go index a0dc533a..f0799139 100644 --- a/server/app/admin_handler.go +++ b/server/app/admin_handler.go @@ -18,31 +18,31 @@ import ( // AdminAnnouncement struct for data needed when admin sends new announcement type AdminAnnouncement struct { - Subject string `json:"subject" binding:"required"` - Body string `json:"announcement" binding:"required"` + Subject string `json:"subject" validate:"nonzero" binding:"required"` + Body string `json:"announcement" validate:"nonzero" binding:"required"` } // EmailUser struct for data needed when admin sends new email to a user type EmailUser struct { - Subject string `json:"subject" binding:"required"` - Body string `json:"body" binding:"required"` + Subject string `json:"subject" validate:"nonzero" binding:"required"` + Body string `json:"body" validate:"nonzero" binding:"required"` Email string `json:"email" binding:"required" validate:"mail"` } // UpdateMaintenanceInput struct for data needed when user update maintenance type UpdateMaintenanceInput struct { - ON bool `json:"on" binding:"required"` + ON bool `json:"on" validate:"nonzero" binding:"required"` } // SetAdminInput struct for setting users as admins type SetAdminInput struct { - Email string `json:"email" binding:"required"` - Admin bool `json:"admin" binding:"required"` + Email string `json:"email" binding:"required" validate:"mail"` + Admin bool `json:"admin" validate:"nonzero" binding:"required"` } // UpdateNextLaunchInput struct for data needed when updating next launch state type UpdateNextLaunchInput struct { - Launched bool `json:"launched" binding:"required"` + Launched bool `json:"launched" validate:"nonzero" binding:"required"` } // SetPricesInput struct for setting prices as admins @@ -556,7 +556,7 @@ func (a *App) CreateNewAnnouncementHandler(req *http.Request) (interface{}, Resp // @Failure 401 {object} Response // @Failure 404 {object} Response // @Failure 500 {object} Response -// @Router /announcement [post] +// @Router /email [post] func (a *App) SendEmailHandler(req *http.Request) (interface{}, Response) { var emailUser EmailUser err := json.NewDecoder(req.Body).Decode(&emailUser) diff --git a/server/app/app.go b/server/app/app.go index 644255c4..da38e776 100644 --- a/server/app/app.go +++ b/server/app/app.go @@ -137,12 +137,14 @@ func (a *App) registerHandlers() { unAuthUserRouter.HandleFunc("/signup/verify_email", WrapFunc(a.VerifySignUpCodeHandler)).Methods("POST", "OPTIONS") unAuthUserRouter.HandleFunc("/signin", WrapFunc(a.SignInHandler)).Methods("POST", "OPTIONS") unAuthUserRouter.HandleFunc("/refresh_token", WrapFunc(a.RefreshJWTHandler)).Methods("POST", "OPTIONS") + // TODO: rename it unAuthUserRouter.HandleFunc("/forgot_password", WrapFunc(a.ForgotPasswordHandler)).Methods("POST", "OPTIONS") unAuthUserRouter.HandleFunc("/forget_password/verify_email", WrapFunc(a.VerifyForgetPasswordCodeHandler)).Methods("POST", "OPTIONS") userRouter.HandleFunc("/change_password", WrapFunc(a.ChangePasswordHandler)).Methods("PUT", "OPTIONS") userRouter.HandleFunc("", WrapFunc(a.UpdateUserHandler)).Methods("PUT", "OPTIONS") userRouter.HandleFunc("", WrapFunc(a.GetUserHandler)).Methods("GET", "OPTIONS") + userRouter.HandleFunc("", WrapFunc(a.DeleteUserHandler)).Methods("DELETE", "OPTIONS") userRouter.HandleFunc("/apply_voucher", WrapFunc(a.ApplyForVoucherHandler)).Methods("POST", "OPTIONS") userRouter.HandleFunc("/activate_voucher", WrapFunc(a.ActivateVoucherHandler)).Methods("PUT", "OPTIONS") userRouter.HandleFunc("/charge_balance", WrapFunc(a.ChargeBalance)).Methods("PUT", "OPTIONS") @@ -196,7 +198,7 @@ func (a *App) registerHandlers() { voucherRouter.HandleFunc("", WrapFunc(a.ListVouchersHandler)).Methods("GET", "OPTIONS") voucherRouter.HandleFunc("/{id}", WrapFunc(a.UpdateVoucherHandler)).Methods("PUT", "OPTIONS") voucherRouter.HandleFunc("", WrapFunc(a.ApproveAllVouchersHandler)).Methods("PUT", "OPTIONS") - voucherRouter.HandleFunc("/reset", WrapFunc(a.ResetUsersVoucherBalanceHandler)).Methods("PUT", "OPTIONS") + voucherRouter.HandleFunc("/all/reset", WrapFunc(a.ResetUsersVoucherBalanceHandler)).Methods("PUT", "OPTIONS") // middlewares r.Use(middlewares.LoggingMW) diff --git a/server/app/invoice_handler.go b/server/app/invoice_handler.go index 35a90fb8..5d1ff0af 100644 --- a/server/app/invoice_handler.go +++ b/server/app/invoice_handler.go @@ -38,7 +38,7 @@ var methods = []method{ } type PayInvoiceInput struct { - Method method `json:"method" binding:"required"` + Method method `json:"method" validate:"nonzero" binding:"required"` CardPaymentID string `json:"card_payment_id"` } @@ -179,144 +179,13 @@ func (a *App) PayInvoiceHandler(req *http.Request) (interface{}, Response) { return nil, InternalServerError(errors.New(internalServerErrorMsg)) } - var paymentDetails models.PaymentDetails - - switch input.Method { - case card: - _, err := createPaymentIntent(user.StripeCustomerID, input.CardPaymentID, a.config.Currency, invoice.Total) - if err != nil { - log.Error().Err(err).Send() - return nil, BadRequest(errors.New("payment failed, please try again later or report the problem")) - } - - paymentDetails = models.PaymentDetails{Card: invoice.Total} - - case balance: - if user.Balance < invoice.Total { - return nil, BadRequest(errors.New("balance is not enough to pay the invoice")) - } - - paymentDetails = models.PaymentDetails{Balance: invoice.Total} - - user.Balance -= invoice.Total - if err = a.db.UpdateUserByID(user); err != nil { - log.Error().Err(err).Send() - return nil, InternalServerError(errors.New(internalServerErrorMsg)) - } - - case voucher: - if user.VoucherBalance < invoice.Total { - return nil, BadRequest(errors.New("voucher balance is not enough to pay the invoice")) - } - - paymentDetails = models.PaymentDetails{VoucherBalance: invoice.Total} - - user.VoucherBalance -= invoice.Total - if err = a.db.UpdateUserByID(user); err != nil { - log.Error().Err(err).Send() - return nil, InternalServerError(errors.New(internalServerErrorMsg)) - } - - case voucherAndBalance: - if user.VoucherBalance+user.Balance < invoice.Total { - return nil, BadRequest(errors.New("voucher balance and balance are not enough to pay the invoice")) - } - - if user.VoucherBalance > invoice.Total { - paymentDetails = models.PaymentDetails{VoucherBalance: invoice.Total} - user.VoucherBalance -= invoice.Total - } else { - paymentDetails = models.PaymentDetails{VoucherBalance: user.VoucherBalance, Balance: (invoice.Total - user.VoucherBalance)} - user.Balance = (invoice.Total - user.VoucherBalance) - user.VoucherBalance = 0 - } - - if err = a.db.UpdateUserByID(user); err != nil { - log.Error().Err(err).Send() - return nil, InternalServerError(errors.New(internalServerErrorMsg)) - } - - case voucherAndCard: - if user.VoucherBalance > invoice.Total { - paymentDetails = models.PaymentDetails{VoucherBalance: invoice.Total} - user.VoucherBalance -= invoice.Total - } else { - paymentDetails = models.PaymentDetails{VoucherBalance: user.VoucherBalance, Card: (invoice.Total - user.VoucherBalance)} - _, err := createPaymentIntent(user.StripeCustomerID, input.CardPaymentID, a.config.Currency, invoice.Total-user.VoucherBalance) - if err != nil { - log.Error().Err(err).Send() - return nil, BadRequest(errors.New("payment failed, please try again later or report the problem")) - } - user.VoucherBalance = 0 - } - - if err = a.db.UpdateUserByID(user); err != nil { - log.Error().Err(err).Send() - return nil, InternalServerError(errors.New(internalServerErrorMsg)) - } - - case balanceAndCard: - if user.Balance > invoice.Total { - paymentDetails = models.PaymentDetails{Balance: invoice.Total} - user.Balance -= invoice.Total - } else { - _, err := createPaymentIntent(user.StripeCustomerID, input.CardPaymentID, a.config.Currency, invoice.Total-user.Balance) - if err != nil { - log.Error().Err(err).Send() - return nil, BadRequest(errors.New("payment failed, please try again later or report the problem")) - } - paymentDetails = models.PaymentDetails{Balance: user.Balance, Card: (invoice.Total - user.Balance)} - user.Balance = 0 - } - - if err = a.db.UpdateUserByID(user); err != nil { - log.Error().Err(err).Send() - return nil, InternalServerError(errors.New(internalServerErrorMsg)) - } - - case voucherAndBalanceAndCard: - if user.VoucherBalance > invoice.Total { - paymentDetails = models.PaymentDetails{Balance: invoice.Total} - user.VoucherBalance -= invoice.Total - } else if user.Balance+user.VoucherBalance > invoice.Total { - paymentDetails = models.PaymentDetails{VoucherBalance: user.VoucherBalance, Balance: (invoice.Total - user.VoucherBalance)} - user.Balance = (invoice.Total - user.VoucherBalance) - user.VoucherBalance = 0 - } else { - _, err := createPaymentIntent(user.StripeCustomerID, input.CardPaymentID, a.config.Currency, invoice.Total-user.VoucherBalance-user.Balance) - if err != nil { - log.Error().Err(err).Send() - return nil, BadRequest(errors.New("payment failed, please try again later or report the problem")) - } - paymentDetails = models.PaymentDetails{ - Balance: user.Balance, VoucherBalance: user.VoucherBalance, - Card: (invoice.Total - user.Balance - user.VoucherBalance), - } - user.VoucherBalance = 0 - user.Balance = 0 - } - - if err = a.db.UpdateUserByID(user); err != nil { - log.Error().Err(err).Send() - return nil, InternalServerError(errors.New(internalServerErrorMsg)) - } - - default: - return nil, BadRequest(fmt.Errorf("invalid payment method, only methods allowed %v", methods)) - } - - err = a.db.PayInvoice(id, paymentDetails) - if err == gorm.ErrRecordNotFound { - return nil, NotFound(errors.New("invoice is not found")) - } - if err != nil { - log.Error().Err(err).Send() - return nil, InternalServerError(errors.New(internalServerErrorMsg)) + response := a.payInvoice(&user, input.CardPaymentID, input.Method, invoice.Total, id) + if response.Err() != nil { + return nil, response } return ResponseMsg{ Message: "Invoice is paid successfully", - Data: nil, }, Ok() } @@ -348,19 +217,47 @@ func (a *App) monthlyInvoices() { log.Error().Err(err).Send() } - // 2. Use balance/voucher balance to pay invoices - user.Balance, user.VoucherBalance, err = a.db.PayUserInvoices(user.ID.String(), user.Balance, user.VoucherBalance) + // 2. Pay invoices + invoices, err := a.db.ListUnpaidInvoices(user.ID.String()) if err != nil { log.Error().Err(err).Send() - } else { - if err = a.db.UpdateUserByID(user); err != nil { + } + + for _, invoice := range invoices { + cards, err := a.db.GetUserCards(user.ID.String()) + if err != nil { log.Error().Err(err).Send() } - } - // 3. Use cards to pay invoices - if err = a.payUserInvoicesUsingCards(user.ID.String(), user.StripeCustomerID, user.StripeDefaultPaymentID, true); err != nil { - log.Error().Err(err).Send() + // No cards option + if len(cards) == 0 { + response := a.payInvoice(&user, "", voucherAndBalance, invoice.Total, invoice.ID) + if response.Err() != nil { + log.Error().Err(response.Err()).Send() + } + continue + } + + // Use default card + response := a.payInvoice(&user, user.StripeDefaultPaymentID, voucherAndBalanceAndCard, invoice.Total, invoice.ID) + if response.Err() != nil { + log.Error().Err(response.Err()).Send() + } else { + continue + } + + for _, card := range cards { + if card.PaymentMethodID == user.StripeDefaultPaymentID { + continue + } + + response := a.payInvoice(&user, card.PaymentMethodID, voucherAndBalanceAndCard, invoice.Total, invoice.ID) + if response.Err() != nil { + log.Error().Err(response.Err()).Send() + continue + } + break + } } // 4. Delete expired deployments with invoices not paid for more than 3 months @@ -379,15 +276,14 @@ func (a *App) monthlyInvoices() { } func (a *App) createInvoice(userID string, now time.Time) error { - usagePercentageInMonth := deployer.UsagePercentageInMonth(now) - firstDayOfMonth := time.Date(now.Year(), now.Month(), 0, 0, 0, 0, 0, time.Local) + monthStart := time.Date(now.Year(), now.Month(), 0, 0, 0, 0, 0, time.Local) - vms, err := a.db.GetAllVms(userID) + vms, err := a.db.GetAllSuccessfulVms(userID) if err != nil && err != gorm.ErrRecordNotFound { return err } - k8s, err := a.db.GetAllK8s(userID) + k8s, err := a.db.GetAllSuccessfulK8s(userID) if err != nil && err != gorm.ErrRecordNotFound { return err } @@ -396,6 +292,16 @@ func (a *App) createInvoice(userID string, now time.Time) error { var total float64 for _, vm := range vms { + usageStart := monthStart + if vm.CreatedAt.After(monthStart) { + usageStart = vm.CreatedAt + } + + usagePercentageInMonth, err := deployer.UsagePercentageInMonth(usageStart, now) + if err != nil { + return err + } + cost := float64(vm.PricePerMonth) * usagePercentageInMonth items = append(items, models.DeploymentItem{ @@ -403,7 +309,7 @@ func (a *App) createInvoice(userID string, now time.Time) error { DeploymentType: "vm", DeploymentID: vm.ID, HasPublicIP: vm.Public, - PeriodInHours: time.Since(firstDayOfMonth).Hours(), + PeriodInHours: time.Since(usageStart).Hours(), Cost: cost, }) @@ -411,6 +317,16 @@ func (a *App) createInvoice(userID string, now time.Time) error { } for _, cluster := range k8s { + usageStart := monthStart + if cluster.CreatedAt.After(monthStart) { + usageStart = cluster.CreatedAt + } + + usagePercentageInMonth, err := deployer.UsagePercentageInMonth(usageStart, now) + if err != nil { + return err + } + cost := float64(cluster.PricePerMonth) * usagePercentageInMonth items = append(items, models.DeploymentItem{ @@ -418,68 +334,20 @@ func (a *App) createInvoice(userID string, now time.Time) error { DeploymentType: "k8s", DeploymentID: cluster.ID, HasPublicIP: cluster.Master.Public, - PeriodInHours: time.Since(firstDayOfMonth).Hours(), + PeriodInHours: time.Since(usageStart).Hours(), Cost: cost, }) total += cost } - if err = a.db.CreateInvoice(&models.Invoice{ - UserID: userID, - Total: total, - Deployments: items, - }); err != nil { - return err - } - - return nil -} - -// payUserInvoicesUsingCards tries to pay invoices with user cards -func (a *App) payUserInvoicesUsingCards(userID, customerID, defaultPaymentMethod string, useOtherCards bool) error { - // get unpaid invoices - invoices, err := a.db.ListUnpaidInvoices(userID) - if err != nil { - return err - } - - cards, err := a.db.GetUserCards(userID) - if err != nil { - return err - } - - for _, invoice := range invoices { - // 1. use default payment method - if len(defaultPaymentMethod) != 0 { - _, err := createPaymentIntent(customerID, defaultPaymentMethod, a.config.Currency, invoice.Total) - if err != nil { - log.Error().Err(err).Send() - } else { - if err := a.db.PayInvoice(invoice.ID, models.PaymentDetails{Card: invoice.Total}); err != nil { - log.Error().Err(err).Send() - } - continue - } - } - - if !useOtherCards { - continue - } - - // 2. check other user cards - for _, card := range cards { - if defaultPaymentMethod != card.PaymentMethodID { - _, err := createPaymentIntent(customerID, card.PaymentMethodID, a.config.Currency, invoice.Total) - if err != nil { - log.Error().Err(err).Send() - } else { - if err := a.db.PayInvoice(invoice.ID, models.PaymentDetails{Card: invoice.Total}); err != nil { - log.Error().Err(err).Send() - } - break - } - } + if len(items) > 0 { + if err = a.db.CreateInvoice(&models.Invoice{ + UserID: userID, + Total: total, + Deployments: items, + }); err != nil { + return err } } @@ -598,6 +466,144 @@ func (a *App) sendInvoiceReminderToUser(userID, userEmail, userName string, now return nil } +func (a *App) pay(user *models.User, cardPaymentID string, method method, invoiceTotal float64) (models.PaymentDetails, error) { + var paymentDetails models.PaymentDetails + + switch method { + case card: + _, err := createPaymentIntent(user.StripeCustomerID, cardPaymentID, a.config.Currency, invoiceTotal) + if err != nil { + log.Error().Err(err).Send() + return paymentDetails, errors.New("payment failed, please try again later or report the problem") + } + + paymentDetails = models.PaymentDetails{Card: invoiceTotal} + + case balance: + if user.Balance < invoiceTotal { + return paymentDetails, errors.New("balance is not enough to pay the invoice") + } + + paymentDetails = models.PaymentDetails{Balance: invoiceTotal} + user.Balance -= invoiceTotal + + case voucher: + if user.VoucherBalance < invoiceTotal { + return paymentDetails, errors.New("voucher balance is not enough to pay the invoice") + } + + paymentDetails = models.PaymentDetails{VoucherBalance: invoiceTotal} + user.VoucherBalance -= invoiceTotal + + case voucherAndBalance: + if user.VoucherBalance+user.Balance < invoiceTotal { + return paymentDetails, errors.New("voucher balance and balance are not enough to pay the invoice") + } + + if user.VoucherBalance >= invoiceTotal { + paymentDetails = models.PaymentDetails{VoucherBalance: invoiceTotal} + user.VoucherBalance -= invoiceTotal + } else { + paymentDetails = models.PaymentDetails{VoucherBalance: user.VoucherBalance, Balance: (invoiceTotal - user.VoucherBalance)} + user.Balance = (invoiceTotal - user.VoucherBalance) + user.VoucherBalance = 0 + } + + case voucherAndCard: + if user.VoucherBalance >= invoiceTotal { + paymentDetails = models.PaymentDetails{VoucherBalance: invoiceTotal} + user.VoucherBalance -= invoiceTotal + } else { + paymentDetails = models.PaymentDetails{VoucherBalance: user.VoucherBalance, Card: (invoiceTotal - user.VoucherBalance)} + _, err := createPaymentIntent(user.StripeCustomerID, cardPaymentID, a.config.Currency, invoiceTotal-user.VoucherBalance) + if err != nil { + log.Error().Err(err).Send() + return paymentDetails, errors.New("payment failed, please try again later or report the problem") + } + user.VoucherBalance = 0 + } + + case balanceAndCard: + if user.Balance >= invoiceTotal { + paymentDetails = models.PaymentDetails{Balance: invoiceTotal} + user.Balance -= invoiceTotal + } else { + _, err := createPaymentIntent(user.StripeCustomerID, cardPaymentID, a.config.Currency, invoiceTotal-user.Balance) + if err != nil { + log.Error().Err(err).Send() + return paymentDetails, errors.New("payment failed, please try again later or report the problem") + } + paymentDetails = models.PaymentDetails{Balance: user.Balance, Card: (invoiceTotal - user.Balance)} + user.Balance = 0 + } + + case voucherAndBalanceAndCard: + if user.VoucherBalance >= invoiceTotal { + paymentDetails = models.PaymentDetails{Balance: invoiceTotal} + user.VoucherBalance -= invoiceTotal + + } else if user.Balance+user.VoucherBalance >= invoiceTotal { + paymentDetails = models.PaymentDetails{VoucherBalance: user.VoucherBalance, Balance: (invoiceTotal - user.VoucherBalance)} + user.Balance = (invoiceTotal - user.VoucherBalance) + user.VoucherBalance = 0 + + } else { + _, err := createPaymentIntent(user.StripeCustomerID, cardPaymentID, a.config.Currency, invoiceTotal-user.VoucherBalance-user.Balance) + if err != nil { + log.Error().Err(err).Send() + return paymentDetails, errors.New("payment failed, please try again later or report the problem") + } + + paymentDetails = models.PaymentDetails{ + Balance: user.Balance, VoucherBalance: user.VoucherBalance, + Card: (invoiceTotal - user.Balance - user.VoucherBalance), + } + user.VoucherBalance = 0 + user.Balance = 0 + } + + default: + return paymentDetails, fmt.Errorf("invalid payment method, only methods allowed %v", methods) + } + + return paymentDetails, nil +} + +func (a *App) payInvoice(user *models.User, cardPaymentID string, method method, invoiceTotal float64, invoiceID int) Response { + paymentDetails, err := a.pay(user, cardPaymentID, method, invoiceTotal) + if err != nil { + return BadRequest(errors.New(internalServerErrorMsg)) + } + + // invoice used voucher balance + if paymentDetails.VoucherBalance != 0 { + if err = a.db.UpdateUserVoucherBalance(user.ID.String(), user.VoucherBalance); err != nil { + log.Error().Err(err).Send() + return InternalServerError(errors.New(internalServerErrorMsg)) + } + } + + // invoice used balance + if paymentDetails.Balance != 0 { + if err = a.db.UpdateUserBalance(user.ID.String(), user.Balance); err != nil { + log.Error().Err(err).Send() + return InternalServerError(errors.New(internalServerErrorMsg)) + } + } + + paymentDetails.InvoiceID = invoiceID + err = a.db.PayInvoice(invoiceID, paymentDetails) + if err == gorm.ErrRecordNotFound { + return NotFound(errors.New("invoice is not found")) + } + if err != nil { + log.Error().Err(err).Send() + return InternalServerError(errors.New(internalServerErrorMsg)) + } + + return nil +} + // getCurrencyName returns the full name of the currency based on the currency code. func getCurrencyName(currencyCode string) (string, error) { currencyMap := map[string]string{ diff --git a/server/app/k8s_handler.go b/server/app/k8s_handler.go index 2b59e5e4..70f59942 100644 --- a/server/app/k8s_handler.go +++ b/server/app/k8s_handler.go @@ -21,16 +21,16 @@ import ( // K8sDeployInput deploy k8s cluster input type K8sDeployInput struct { MasterName string `json:"master_name" validate:"min=3,max=20"` - MasterResources string `json:"resources"` - MasterPublic bool `json:"public"` - MasterRegion string `json:"region"` + MasterResources string `json:"resources" validate:"nonzero"` + MasterPublic bool `json:"public" validate:"nonzero"` + MasterRegion string `json:"region" validate:"nonzero"` Workers []WorkerInput `json:"workers"` } // WorkerInput deploy k8s worker input type WorkerInput struct { Name string `json:"name" validate:"min=3,max=20"` - Resources string `json:"resources"` + Resources string `json:"resources" validate:"nonzero"` } // K8sDeployHandler deploy k8s handler diff --git a/server/app/payments_handler.go b/server/app/payments_handler.go index 7c892012..829590a0 100644 --- a/server/app/payments_handler.go +++ b/server/app/payments_handler.go @@ -17,17 +17,17 @@ import ( ) type AddCardInput struct { - PaymentMethodID string `json:"payment_method_id" binding:"required"` - CardType string `json:"card_type" binding:"required"` + TokenID string `json:"token_id" binding:"required" validate:"nonzero"` + TokenType string `json:"token_type" binding:"required" validate:"nonzero"` } type SetDefaultCardInput struct { - PaymentMethodID string `json:"payment_method_id" binding:"required"` + PaymentMethodID string `json:"payment_method_id" binding:"required" validate:"nonzero"` } type ChargeBalance struct { - PaymentMethodID string `json:"payment_method_id" binding:"required"` - Amount float64 `json:"amount" binding:"required"` + PaymentMethodID string `json:"payment_method_id" binding:"required" validate:"nonzero"` + Amount float64 `json:"amount" binding:"required" validate:"nonzero"` } // Example endpoint: Add a new card @@ -88,7 +88,7 @@ func (a *App) AddCardHandler(req *http.Request) (interface{}, Response) { } } - paymentMethod, err := createPaymentMethod(input.CardType, input.PaymentMethodID) + paymentMethod, err := createPaymentMethod(input.TokenType, input.TokenID) if err != nil { log.Error().Err(err).Send() return nil, InternalServerError(errors.New(internalServerErrorMsg)) @@ -101,7 +101,6 @@ func (a *App) AddCardHandler(req *http.Request) (interface{}, Response) { } if !unique { - log.Error().Err(err).Send() return nil, BadRequest(errors.New("card is added before")) } @@ -117,7 +116,7 @@ func (a *App) AddCardHandler(req *http.Request) (interface{}, Response) { UserID: userID, PaymentMethodID: paymentMethod.ID, CustomerID: user.StripeCustomerID, - CardType: input.CardType, + CardType: input.TokenType, ExpMonth: paymentMethod.Card.ExpMonth, ExpYear: paymentMethod.Card.ExpYear, Last4: paymentMethod.Card.Last4, @@ -132,7 +131,7 @@ func (a *App) AddCardHandler(req *http.Request) (interface{}, Response) { // if no payment is added before then we update the user payment ID with it as a default if len(strings.TrimSpace(user.StripeDefaultPaymentID)) == 0 { // Update the default payment method for future payments - err = updateDefaultPaymentMethod(user.StripeCustomerID, input.PaymentMethodID) + err = updateDefaultPaymentMethod(user.StripeCustomerID, paymentMethod.ID) if err != nil { log.Error().Err(err).Send() return nil, InternalServerError(errors.New(internalServerErrorMsg)) @@ -149,9 +148,18 @@ func (a *App) AddCardHandler(req *http.Request) (interface{}, Response) { } } - // settle old invoices using the card - if err = a.payUserInvoicesUsingCards(user.ID.String(), user.StripeCustomerID, paymentMethod.ID, false); err != nil { + // try to settle old invoices using the card + invoices, err := a.db.ListUnpaidInvoices(user.ID.String()) + if err != nil { log.Error().Err(err).Send() + return nil, InternalServerError(errors.New(internalServerErrorMsg)) + } + + for _, invoice := range invoices { + response := a.payInvoice(&user, paymentMethod.ID, voucherAndBalanceAndCard, invoice.Total, invoice.ID) + if response.Err() != nil { + log.Error().Err(response.Err()).Send() + } } return ResponseMsg{ @@ -278,6 +286,15 @@ func (a *App) ListCardHandler(req *http.Request) (interface{}, Response) { func (a *App) DeleteCardHandler(req *http.Request) (interface{}, Response) { userID := req.Context().Value(middlewares.UserIDKey("UserID")).(string) + user, err := a.db.GetUserByID(userID) + if err == gorm.ErrRecordNotFound { + return nil, NotFound(errors.New("user is not found")) + } + if err != nil { + log.Error().Err(err).Send() + return nil, InternalServerError(errors.New(internalServerErrorMsg)) + } + id, err := strconv.Atoi(mux.Vars(req)["id"]) if err != nil { log.Error().Err(err).Send() @@ -315,13 +332,13 @@ func (a *App) DeleteCardHandler(req *http.Request) (interface{}, Response) { var vms []models.VM var k8s []models.K8sCluster if len(cards) == 1 { - vms, err = a.db.GetAllVms(userID) + vms, err = a.db.GetAllSuccessfulVms(userID) if err != nil && err != gorm.ErrRecordNotFound { log.Error().Err(err).Send() return nil, InternalServerError(errors.New(internalServerErrorMsg)) } - k8s, err = a.db.GetAllK8s(userID) + k8s, err = a.db.GetAllSuccessfulK8s(userID) if err != nil && err != gorm.ErrRecordNotFound { log.Error().Err(err).Send() return nil, InternalServerError(errors.New(internalServerErrorMsg)) @@ -332,6 +349,35 @@ func (a *App) DeleteCardHandler(req *http.Request) (interface{}, Response) { return nil, BadRequest(errors.New("you have active deployment and cannot delete the card")) } + // Update the default payment method for future payments (if deleted card is the default) + if card.PaymentMethodID == user.StripeDefaultPaymentID { + var newPaymentMethod string + // no more cards + if len(cards) == 1 { + newPaymentMethod = "" + } + + for _, c := range cards { + if c.PaymentMethodID != user.StripeDefaultPaymentID { + newPaymentMethod = c.PaymentMethodID + if err = updateDefaultPaymentMethod(card.CustomerID, c.PaymentMethodID); err != nil { + log.Error().Err(err).Send() + return nil, InternalServerError(errors.New(internalServerErrorMsg)) + } + break + } + } + + err = a.db.UpdateUserPaymentMethod(userID, newPaymentMethod) + if err == gorm.ErrRecordNotFound { + return nil, NotFound(errors.New("user is not found")) + } + if err != nil { + log.Error().Err(err).Send() + return nil, InternalServerError(errors.New(internalServerErrorMsg)) + } + } + // If user has another cards or no active deployments, so can delete err = detachPaymentMethod(card.PaymentMethodID) if err != nil { diff --git a/server/app/user_handler.go b/server/app/user_handler.go index 4030488c..5f32a936 100644 --- a/server/app/user_handler.go +++ b/server/app/user_handler.go @@ -32,19 +32,19 @@ type SignUpInput struct { // VerifyCodeInput struct takes verification code from user type VerifyCodeInput struct { - Email string `json:"email" binding:"required"` - Code int `json:"code" binding:"required"` + Email string `json:"email" binding:"required" validate:"mail"` + Code int `json:"code" binding:"required" validate:"nonzero"` } // SignInInput struct for data needed when user sign in type SignInInput struct { - Email string `json:"email" binding:"required"` - Password string `json:"password" binding:"required"` + Email string `json:"email" binding:"required" validate:"mail"` + Password string `json:"password" binding:"required" validate:"password"` } // ChangePasswordInput struct for user to change password type ChangePasswordInput struct { - Email string `json:"email" binding:"required"` + Email string `json:"email" binding:"required" validate:"mail"` Password string `json:"password" binding:"required" validate:"password"` ConfirmPassword string `json:"confirm_password" binding:"required" validate:"password"` } @@ -60,7 +60,7 @@ type UpdateUserInput struct { // EmailInput struct for user when forgetting password type EmailInput struct { - Email string `json:"email" binding:"required"` + Email string `json:"email" binding:"required" validate:"mail"` } // ApplyForVoucherInput struct for user to apply for voucher @@ -71,20 +71,26 @@ type ApplyForVoucherInput struct { // AddVoucherInput struct for voucher applied by user type AddVoucherInput struct { - Voucher string `json:"voucher" binding:"required"` + Voucher string `json:"voucher" binding:"required" validate:"nonzero"` } type CodeTimeout struct { - Timeout int `json:"timeout" binding:"required"` + Timeout int `json:"timeout" binding:"required" validate:"nonzero"` +} + +type AccessTokenResponse struct { + Token string `json:"access_token"` + Timeout int `json:"timeout"` } -type AccessToken struct { - Token string `json:"access_token" binding:"required"` +type RefreshTokenResponse struct { + Access string `json:"access_token"` + Refresh string `json:"refresh_token"` + Timeout int `json:"timeout"` } -type RefreshToken struct { - Access string `json:"access_token" binding:"required"` - Refresh string `json:"refresh_token" binding:"required"` +type clientSecretResponse struct { + ClientSecret string `json:"client_secret"` } // SignUpHandler creates account for user @@ -258,7 +264,7 @@ func (a *App) VerifySignUpCodeHandler(req *http.Request) (interface{}, Response) // @Accept json // @Produce json // @Param login body SignInInput true "User login input" -// @Success 201 {object} AccessToken +// @Success 201 {object} AccessTokenResponse // @Failure 400 {object} Response // @Failure 401 {object} Response // @Failure 404 {object} Response @@ -298,7 +304,7 @@ func (a *App) SignInHandler(req *http.Request) (interface{}, Response) { return ResponseMsg{ Message: "You are signed in successfully", - Data: AccessToken{Token: token}, + Data: AccessTokenResponse{Token: token, Timeout: a.config.Token.Timeout}, }, Created() } @@ -310,7 +316,7 @@ func (a *App) SignInHandler(req *http.Request) (interface{}, Response) { // @Accept json // @Produce json // @Security BearerAuth -// @Success 201 {object} RefreshToken +// @Success 201 {object} RefreshTokenResponse // @Failure 400 {object} Response // @Failure 401 {object} Response // @Failure 404 {object} Response @@ -357,7 +363,7 @@ func (a *App) RefreshJWTHandler(req *http.Request) (interface{}, Response) { return ResponseMsg{ Message: "Token is refreshed successfully", - Data: RefreshToken{Access: reqToken, Refresh: newToken}, + Data: RefreshTokenResponse{Access: reqToken, Refresh: newToken, Timeout: a.config.Token.Timeout}, }, Created() } @@ -368,6 +374,7 @@ func (a *App) RefreshJWTHandler(req *http.Request) (interface{}, Response) { // @Tags User // @Accept json // @Produce json +// @Param forgetPassword body EmailInput true "User forget password input" // @Success 201 {object} CodeTimeout // @Failure 400 {object} Response // @Failure 401 {object} Response @@ -430,14 +437,15 @@ func (a *App) ForgotPasswordHandler(req *http.Request) (interface{}, Response) { // @Tags User // @Accept json // @Produce json -// @Success 201 {object} AccessToken +// @Param forgetPassword body VerifyCodeInput true "User Verify forget password input" +// @Success 201 {object} AccessTokenResponse // @Failure 400 {object} Response // @Failure 401 {object} Response // @Failure 404 {object} Response // @Failure 500 {object} Response -// @Router /user/forgot_password/verify_email [post] +// @Router /user/forget_password/verify_email [post] func (a *App) VerifyForgetPasswordCodeHandler(req *http.Request) (interface{}, Response) { - data := VerifyCodeInput{} + var data VerifyCodeInput err := json.NewDecoder(req.Body).Decode(&data) if err != nil { log.Error().Err(err).Send() @@ -474,7 +482,7 @@ func (a *App) VerifyForgetPasswordCodeHandler(req *http.Request) (interface{}, R return ResponseMsg{ Message: "Code is verified", - Data: AccessToken{Token: token}, + Data: AccessTokenResponse{Token: token, Timeout: a.config.Token.Timeout}, }, Ok() } @@ -783,16 +791,27 @@ func (a *App) ActivateVoucherHandler(req *http.Request) (interface{}, Response) user.VoucherBalance += float64(voucherBalance.Balance) - user.Balance, user.VoucherBalance, err = a.db.PayUserInvoices(userID, user.Balance, user.VoucherBalance) + // try to settle old invoices + invoices, err := a.db.ListUnpaidInvoices(user.ID.String()) if err != nil { log.Error().Err(err).Send() return nil, InternalServerError(errors.New(internalServerErrorMsg)) } - err = a.db.UpdateUserByID(user) - if err == gorm.ErrRecordNotFound { - return nil, NotFound(errors.New("user is not found")) + for _, invoice := range invoices { + response := a.payInvoice(&user, "", voucherAndBalance, invoice.Total, invoice.ID) + if response.Err() != nil { + log.Error().Err(response.Err()).Send() + } + } + + err = a.db.UpdateUserVoucherBalance(user.ID.String(), user.VoucherBalance) + if err != nil { + log.Error().Err(err).Send() + return nil, InternalServerError(errors.New(internalServerErrorMsg)) } + + err = a.db.UpdateUserBalance(user.ID.String(), user.Balance) if err != nil { log.Error().Err(err).Send() return nil, InternalServerError(errors.New(internalServerErrorMsg)) @@ -821,6 +840,7 @@ func (a *App) ActivateVoucherHandler(req *http.Request) (interface{}, Response) // @Failure 500 {object} Response // @Router /user/charge_balance [put] func (a *App) ChargeBalance(req *http.Request) (interface{}, Response) { + userID := req.Context().Value(middlewares.UserIDKey("UserID")).(string) var input ChargeBalance @@ -845,7 +865,7 @@ func (a *App) ChargeBalance(req *http.Request) (interface{}, Response) { return nil, InternalServerError(errors.New(internalServerErrorMsg)) } - _, err = createPaymentIntent(user.StripeCustomerID, input.PaymentMethodID, a.config.Currency, input.Amount) + intent, err := createPaymentIntent(user.StripeCustomerID, input.PaymentMethodID, a.config.Currency, input.Amount) if err != nil { log.Error().Err(err).Send() return nil, InternalServerError(errors.New(internalServerErrorMsg)) @@ -853,13 +873,185 @@ func (a *App) ChargeBalance(req *http.Request) (interface{}, Response) { user.Balance += float64(input.Amount) - user.Balance, user.VoucherBalance, err = a.db.PayUserInvoices(userID, user.Balance, user.VoucherBalance) + // try to settle old invoices + invoices, err := a.db.ListUnpaidInvoices(user.ID.String()) + if err != nil { + log.Error().Err(err).Send() + return nil, InternalServerError(errors.New(internalServerErrorMsg)) + } + + for _, invoice := range invoices { + response := a.payInvoice(&user, "", voucherAndBalance, invoice.Total, invoice.ID) + if response.Err() != nil { + log.Error().Err(response.Err()).Send() + } + } + + err = a.db.UpdateUserBalance(user.ID.String(), user.Balance) + if err != nil { + log.Error().Err(err).Send() + return nil, InternalServerError(errors.New(internalServerErrorMsg)) + } + + err = a.db.UpdateUserVoucherBalance(user.ID.String(), user.VoucherBalance) + if err != nil { + log.Error().Err(err).Send() + return nil, InternalServerError(errors.New(internalServerErrorMsg)) + } + + return ResponseMsg{ + Message: "Balance is charged successfully", + Data: clientSecretResponse{ClientSecret: intent.ClientSecret}, + }, Ok() +} + +// DeleteUserHandler deletes account for user +// Example endpoint: Deletes account for user +// @Summary Deletes account for user +// @Description Deletes account for user +// @Tags User +// @Accept json +// @Produce json +// @Security BearerAuth +// @Success 200 {object} Response +// @Failure 401 {object} Response +// @Failure 404 {object} Response +// @Failure 500 {object} Response +// @Router /user [delete] +func (a *App) DeleteUserHandler(req *http.Request) (interface{}, Response) { + userID := req.Context().Value(middlewares.UserIDKey("UserID")).(string) + user, err := a.db.GetUserByID(userID) + if err == gorm.ErrRecordNotFound { + return nil, NotFound(errors.New("user is not found")) + } + if err != nil { + log.Error().Err(err).Send() + return nil, InternalServerError(errors.New(internalServerErrorMsg)) + } + + // 1. Create last invoice to pay if there were active deployments + if err := a.createInvoice(userID, time.Now()); err != nil { + log.Error().Err(err).Send() + return nil, InternalServerError(errors.New(internalServerErrorMsg)) + } + + invoices, err := a.db.ListUnpaidInvoices(userID) + if err != nil && err != gorm.ErrRecordNotFound { + log.Error().Err(err).Send() + return nil, InternalServerError(errors.New(internalServerErrorMsg)) + } + + // 2. Try to pay invoices + for _, invoice := range invoices { + cards, err := a.db.GetUserCards(user.ID.String()) + if err != nil { + log.Error().Err(err).Send() + return nil, InternalServerError(errors.New(internalServerErrorMsg)) + } + + // No cards option + if len(cards) == 0 { + response := a.payInvoice(&user, "", voucherAndBalance, invoice.Total, invoice.ID) + if response.Err() != nil { + log.Error().Err(response.Err()).Send() + return nil, InternalServerError(errors.New(internalServerErrorMsg)) + } + continue + } + + // Use default card + response := a.payInvoice(&user, user.StripeDefaultPaymentID, voucherAndBalanceAndCard, invoice.Total, invoice.ID) + if response.Err() != nil { + log.Error().Err(response.Err()).Send() + } else { + continue + } + + for _, card := range cards { + if card.PaymentMethodID == user.StripeDefaultPaymentID { + continue + } + + response := a.payInvoice(&user, card.PaymentMethodID, voucherAndBalanceAndCard, invoice.Total, invoice.ID) + if response.Err() != nil { + log.Error().Err(response.Err()).Send() + continue + } + break + } + + return nil, BadRequest(errors.New("failed to pay your invoices, please pay them first before deleting your account")) + } + + // 3. Delete user vms + vms, err := a.db.GetAllVms(userID) + if err != nil && err != gorm.ErrRecordNotFound { + log.Error().Err(err).Send() + return nil, InternalServerError(errors.New(internalServerErrorMsg)) + } + + for _, vm := range vms { + err = a.deployer.CancelDeployment(vm.ContractID, vm.NetworkContractID, "vm", vm.Name) + if err != nil && !strings.Contains(err.Error(), "ContractNotExists") { + log.Error().Err(err).Send() + return nil, InternalServerError(errors.New(internalServerErrorMsg)) + } + } + + err = a.db.DeleteAllVms(userID) if err != nil { log.Error().Err(err).Send() return nil, InternalServerError(errors.New(internalServerErrorMsg)) } - err = a.db.UpdateUserByID(user) + // 4. Delete user k8s + clusters, err := a.db.GetAllK8s(userID) + if err != nil && err != gorm.ErrRecordNotFound { + log.Error().Err(err).Send() + return nil, InternalServerError(errors.New(internalServerErrorMsg)) + } + + for _, cluster := range clusters { + err = a.deployer.CancelDeployment(uint64(cluster.ClusterContract), uint64(cluster.NetworkContract), "k8s", cluster.Master.Name) + if err != nil && !strings.Contains(err.Error(), "ContractNotExists") { + log.Error().Err(err).Send() + return nil, InternalServerError(errors.New(internalServerErrorMsg)) + } + } + + if len(clusters) > 0 { + err = a.db.DeleteAllK8s(userID) + if err != nil { + log.Error().Err(err).Send() + return nil, InternalServerError(errors.New(internalServerErrorMsg)) + } + } + + // 5. Remove cards + cards, err := a.db.GetUserCards(userID) + if err != nil && err != gorm.ErrRecordNotFound { + log.Error().Err(err).Send() + return nil, InternalServerError(errors.New(internalServerErrorMsg)) + } + + for _, card := range cards { + err = detachPaymentMethod(card.PaymentMethodID) + if err != nil { + log.Error().Err(err).Send() + return nil, InternalServerError(errors.New(internalServerErrorMsg)) + } + } + + err = a.db.DeleteAllCards(userID) + if err != nil && err != gorm.ErrRecordNotFound { + log.Error().Err(err).Send() + return nil, InternalServerError(errors.New(internalServerErrorMsg)) + } + + // 6. TODO: should invoices be deleted? + + // 7. Remove cards + err = a.db.DeleteUser(userID) if err == gorm.ErrRecordNotFound { return nil, NotFound(errors.New("user is not found")) } @@ -869,8 +1061,6 @@ func (a *App) ChargeBalance(req *http.Request) (interface{}, Response) { } return ResponseMsg{ - Message: "Balance is charged successfully", - // Data: map[string]string{"client_secret": intent.ClientSecret}, - Data: nil, + Message: "User is deleted successfully", }, Ok() } diff --git a/server/app/user_handler_test.go b/server/app/user_handler_test.go index 027053a6..3e74685f 100644 --- a/server/app/user_handler_test.go +++ b/server/app/user_handler_test.go @@ -622,6 +622,7 @@ func TestChangePasswordHandler(t *testing.T) { t.Run("change password: user not found", func(t *testing.T) { body := []byte(`{ "password":"1234567", + "email":"notfound@gmail.com", "confirm_password":"1234567" }`) diff --git a/server/app/vm_handler.go b/server/app/vm_handler.go index 2e21af72..0cf4554b 100644 --- a/server/app/vm_handler.go +++ b/server/app/vm_handler.go @@ -23,8 +23,8 @@ import ( // DeployVMInput struct takes input of vm from user type DeployVMInput struct { Name string `json:"name" binding:"required" validate:"min=3,max=20"` - Resources string `json:"resources" binding:"required"` - Public bool `json:"public"` + Resources string `json:"resources" binding:"required" validate:"nonzero"` + Public bool `json:"public" validate:"nonzero"` Region string `json:"region"` } diff --git a/server/app/voucher_handler.go b/server/app/voucher_handler.go index dc67051f..ab65066c 100644 --- a/server/app/voucher_handler.go +++ b/server/app/voucher_handler.go @@ -18,12 +18,12 @@ import ( // GenerateVoucherInput struct for data needed when user generate vouchers type GenerateVoucherInput struct { Length int `json:"length" binding:"required" validate:"min=3,max=20"` - Balance uint64 `json:"balance" binding:"required"` + Balance uint64 `json:"balance" binding:"required" validate:"nonzero"` } // UpdateVoucherInput struct for data needed when user update voucher type UpdateVoucherInput struct { - Approved bool `json:"approved" binding:"required"` + Approved bool `json:"approved" binding:"required" validate:"nonzero"` } // GenerateVoucherHandler generates a voucher by admin @@ -254,7 +254,7 @@ func (a *App) ApproveAllVouchersHandler(req *http.Request) (interface{}, Respons // @Failure 401 {object} Response // @Failure 404 {object} Response // @Failure 500 {object} Response -// @Router /voucher/reset [put] +// @Router /voucher/all/reset [put] func (a *App) ResetUsersVoucherBalanceHandler(req *http.Request) (interface{}, Response) { users, err := a.db.ListAllUsers() if err == gorm.ErrRecordNotFound || len(users) == 0 { @@ -264,8 +264,7 @@ func (a *App) ResetUsersVoucherBalanceHandler(req *http.Request) (interface{}, R } for _, user := range users { - user.VoucherBalance = 0 - err = a.db.UpdateUserByID(user) + err = a.db.UpdateUserVoucherBalance(user.ID.String(), 0) if err != nil { log.Error().Err(err).Send() return nil, InternalServerError(errors.New(internalServerErrorMsg)) diff --git a/server/app/voucher_handler_test.go b/server/app/voucher_handler_test.go index a9763126..02ce4f4a 100644 --- a/server/app/voucher_handler_test.go +++ b/server/app/voucher_handler_test.go @@ -25,8 +25,7 @@ func TestGenerateVoucherHandler(t *testing.T) { voucherBody := []byte(`{ "length": 5, - "vms": 10, - "public_ips": 1 + "balance": 10 }`) t.Run("Generate voucher: success", func(t *testing.T) { @@ -49,8 +48,7 @@ func TestGenerateVoucherHandler(t *testing.T) { t.Run("Generate voucher: invalid data", func(t *testing.T) { body := []byte(`{ "length": 2, - "vms": 10, - "public_ips": 1 + "balance": 1 }`) req := authHandlerConfig{ diff --git a/server/deployer/deployer.go b/server/deployer/deployer.go index cd16b8e4..1410b3dd 100644 --- a/server/deployer/deployer.go +++ b/server/deployer/deployer.go @@ -274,23 +274,44 @@ func (d *Deployer) canDeploy(userID string, costPerMonth float64) error { // from the start of current month func (d *Deployer) calculateUserDebtInMonth(userID string) (float64, error) { var debt float64 - usagePercentageInMonth := UsagePercentageInMonth(time.Now()) + now := time.Now() + monthStart := time.Date(now.Year(), now.Month(), 0, 0, 0, 0, 0, time.Local) - vms, err := d.db.GetAllVms(userID) + vms, err := d.db.GetAllSuccessfulVms(userID) if err != nil { return 0, err } for _, vm := range vms { + usageStart := monthStart + if vm.CreatedAt.After(monthStart) { + usageStart = vm.CreatedAt + } + + usagePercentageInMonth, err := UsagePercentageInMonth(usageStart, now) + if err != nil { + return 0, err + } + debt += float64(vm.PricePerMonth) * usagePercentageInMonth } - clusters, err := d.db.GetAllK8s(userID) + clusters, err := d.db.GetAllSuccessfulK8s(userID) if err != nil { return 0, err } for _, c := range clusters { + usageStart := monthStart + if c.CreatedAt.After(monthStart) { + usageStart = c.CreatedAt + } + + usagePercentageInMonth, err := UsagePercentageInMonth(usageStart, now) + if err != nil { + return 0, err + } + debt += float64(c.PricePerMonth) * usagePercentageInMonth } @@ -299,8 +320,16 @@ func (d *Deployer) calculateUserDebtInMonth(userID string) (float64, error) { // UsagePercentageInMonth calculates percentage of hours till specific time during the month // according to total hours of the same month -func UsagePercentageInMonth(end time.Time) float64 { - start := time.Date(end.Year(), end.Month(), 0, 0, 0, 0, 0, time.UTC) - endMonth := time.Date(end.Year(), end.Month()+1, 0, 0, 0, 0, 0, time.UTC) - return end.Sub(start).Hours() / endMonth.Sub(start).Hours() +func UsagePercentageInMonth(start time.Time, end time.Time) (float64, error) { + if start.Month() != end.Month() || start.Year() != end.Year() { + return 0, errors.New("start and end time should be the same month and year") + } + + startMonth := time.Date(start.Year(), start.Month(), 0, 0, 0, 0, 0, time.UTC) + endMonth := time.Date(start.Year(), start.Month()+1, 0, 0, 0, 0, 0, time.UTC) + + totalHoursInMonth := endMonth.Sub(startMonth).Hours() + usedHours := end.Sub(start).Hours() + + return usedHours / totalHoursInMonth, nil } diff --git a/server/docs/docs.go b/server/docs/docs.go index 9f75e000..f497c792 100644 --- a/server/docs/docs.go +++ b/server/docs/docs.go @@ -30,7 +30,7 @@ const docTemplate = `{ "BearerAuth": [] } ], - "description": "Creates a new administrator email and sends it to a specific user as an email and notification", + "description": "Creates a new administrator announcement and sends it to all users as an email and notification", "consumes": [ "application/json" ], @@ -40,15 +40,15 @@ const docTemplate = `{ "tags": [ "Admin" ], - "summary": "Creates a new administrator email and sends it to a specific user as an email and notification", + "summary": "Creates a new administrator announcement and sends it to all users as an email and notification", "parameters": [ { - "description": "email to be sent", - "name": "email", + "description": "announcement to be created", + "name": "announcement", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/app.EmailUser" + "$ref": "#/definitions/app.AdminAnnouncement" } } ], @@ -244,6 +244,59 @@ const docTemplate = `{ } } }, + "/email": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Creates a new administrator email and sends it to a specific user as an email and notification", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "Creates a new administrator email and sends it to a specific user as an email and notification", + "parameters": [ + { + "description": "email to be sent", + "name": "email", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/app.EmailUser" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": {} + }, + "400": { + "description": "Bad Request", + "schema": {} + }, + "401": { + "description": "Unauthorized", + "schema": {} + }, + "404": { + "description": "Not Found", + "schema": {} + }, + "500": { + "description": "Internal Server Error", + "schema": {} + } + } + } + }, "/invoice": { "get": { "security": [ @@ -1234,6 +1287,42 @@ const docTemplate = `{ "schema": {} } } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Deletes account for user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "User" + ], + "summary": "Deletes account for user", + "responses": { + "200": { + "description": "OK", + "schema": {} + }, + "401": { + "description": "Unauthorized", + "schema": {} + }, + "404": { + "description": "Not Found", + "schema": {} + }, + "500": { + "description": "Internal Server Error", + "schema": {} + } + } } }, "/user/activate_voucher": { @@ -1697,9 +1786,9 @@ const docTemplate = `{ } } }, - "/user/forgot_password": { + "/user/forget_password/verify_email": { "post": { - "description": "Send code to forget password email for verification", + "description": "Verify user's email to reset password", "consumes": [ "application/json" ], @@ -1709,12 +1798,23 @@ const docTemplate = `{ "tags": [ "User" ], - "summary": "Send code to forget password email for verification", + "summary": "Verify user's email to reset password", + "parameters": [ + { + "description": "User Verify forget password input", + "name": "forgetPassword", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/app.VerifyCodeInput" + } + } + ], "responses": { "201": { "description": "Created", "schema": { - "$ref": "#/definitions/app.CodeTimeout" + "$ref": "#/definitions/app.AccessTokenResponse" } }, "400": { @@ -1736,9 +1836,9 @@ const docTemplate = `{ } } }, - "/user/forgot_password/verify_email": { + "/user/forgot_password": { "post": { - "description": "Verify user's email to reset password", + "description": "Send code to forget password email for verification", "consumes": [ "application/json" ], @@ -1748,12 +1848,23 @@ const docTemplate = `{ "tags": [ "User" ], - "summary": "Verify user's email to reset password", + "summary": "Send code to forget password email for verification", + "parameters": [ + { + "description": "User forget password input", + "name": "forgetPassword", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/app.EmailInput" + } + } + ], "responses": { "201": { "description": "Created", "schema": { - "$ref": "#/definitions/app.AccessToken" + "$ref": "#/definitions/app.CodeTimeout" } }, "400": { @@ -1797,7 +1908,7 @@ const docTemplate = `{ "201": { "description": "Created", "schema": { - "$ref": "#/definitions/app.RefreshToken" + "$ref": "#/definitions/app.RefreshTokenResponse" } }, "400": { @@ -1847,7 +1958,7 @@ const docTemplate = `{ "201": { "description": "Created", "schema": { - "$ref": "#/definitions/app.AccessToken" + "$ref": "#/definitions/app.AccessTokenResponse" } }, "400": { @@ -2380,7 +2491,7 @@ const docTemplate = `{ } } }, - "/voucher/reset": { + "/voucher/all/reset": { "put": { "security": [ { @@ -2486,28 +2597,28 @@ const docTemplate = `{ } }, "definitions": { - "app.AccessToken": { + "app.AccessTokenResponse": { "type": "object", - "required": [ - "access_token" - ], "properties": { "access_token": { "type": "string" + }, + "timeout": { + "type": "integer" } } }, "app.AddCardInput": { "type": "object", "required": [ - "card_type", - "payment_method_id" + "token_id", + "token_type" ], "properties": { - "card_type": { + "token_id": { "type": "string" }, - "payment_method_id": { + "token_type": { "type": "string" } } @@ -2622,6 +2733,17 @@ const docTemplate = `{ } } }, + "app.EmailInput": { + "type": "object", + "required": [ + "email" + ], + "properties": { + "email": { + "type": "string" + } + } + }, "app.EmailUser": { "type": "object", "required": [ @@ -2714,18 +2836,17 @@ const docTemplate = `{ } } }, - "app.RefreshToken": { + "app.RefreshTokenResponse": { "type": "object", - "required": [ - "access_token", - "refresh_token" - ], "properties": { "access_token": { "type": "string" }, "refresh_token": { "type": "string" + }, + "timeout": { + "type": "integer" } } }, @@ -3190,6 +3311,9 @@ const docTemplate = `{ "card": { "type": "number" }, + "id": { + "type": "integer" + }, "invoice_id": { "type": "integer" }, @@ -3241,7 +3365,7 @@ const docTemplate = `{ "stripe_customer_id": { "type": "string" }, - "stripe_payment_method_id": { + "stripe_default_payment_id": { "type": "string" }, "updated_at": { diff --git a/server/docs/swagger.yaml b/server/docs/swagger.yaml index 047a5138..ba534271 100644 --- a/server/docs/swagger.yaml +++ b/server/docs/swagger.yaml @@ -1,20 +1,20 @@ definitions: - app.AccessToken: + app.AccessTokenResponse: properties: access_token: type: string - required: - - access_token + timeout: + type: integer type: object app.AddCardInput: properties: - card_type: + token_id: type: string - payment_method_id: + token_type: type: string required: - - card_type - - payment_method_id + - token_id + - token_type type: object app.AddVoucherInput: properties: @@ -90,6 +90,13 @@ definitions: - name - resources type: object + app.EmailInput: + properties: + email: + type: string + required: + - email + type: object app.EmailUser: properties: body: @@ -152,15 +159,14 @@ definitions: required: - method type: object - app.RefreshToken: + app.RefreshTokenResponse: properties: access_token: type: string refresh_token: type: string - required: - - access_token - - refresh_token + timeout: + type: integer type: object app.SetAdminInput: properties: @@ -474,6 +480,8 @@ definitions: type: number card: type: number + id: + type: integer invoice_id: type: integer voucher_balance: @@ -504,7 +512,7 @@ definitions: type: string stripe_customer_id: type: string - stripe_payment_method_id: + stripe_default_payment_id: type: string updated_at: type: string @@ -632,15 +640,15 @@ paths: post: consumes: - application/json - description: Creates a new administrator email and sends it to a specific user + description: Creates a new administrator announcement and sends it to all users as an email and notification parameters: - - description: email to be sent + - description: announcement to be created in: body - name: email + name: announcement required: true schema: - $ref: '#/definitions/app.EmailUser' + $ref: '#/definitions/app.AdminAnnouncement' produces: - application/json responses: @@ -661,8 +669,8 @@ paths: schema: {} security: - BearerAuth: [] - summary: Creates a new administrator email and sends it to a specific user as - an email and notification + summary: Creates a new administrator announcement and sends it to all users + as an email and notification tags: - Admin /balance: @@ -776,6 +784,43 @@ paths: summary: Get users' deployments count tags: - Admin + /email: + post: + consumes: + - application/json + description: Creates a new administrator email and sends it to a specific user + as an email and notification + parameters: + - description: email to be sent + in: body + name: email + required: true + schema: + $ref: '#/definitions/app.EmailUser' + produces: + - application/json + responses: + "201": + description: Created + schema: {} + "400": + description: Bad Request + schema: {} + "401": + description: Unauthorized + schema: {} + "404": + description: Not Found + schema: {} + "500": + description: Internal Server Error + schema: {} + security: + - BearerAuth: [] + summary: Creates a new administrator email and sends it to a specific user as + an email and notification + tags: + - Admin /invoice: get: consumes: @@ -1372,6 +1417,30 @@ paths: tags: - Admin /user: + delete: + consumes: + - application/json + description: Deletes account for user + produces: + - application/json + responses: + "200": + description: OK + schema: {} + "401": + description: Unauthorized + schema: {} + "404": + description: Not Found + schema: {} + "500": + description: Internal Server Error + schema: {} + security: + - BearerAuth: [] + summary: Deletes account for user + tags: + - User get: consumes: - application/json @@ -1739,18 +1808,25 @@ paths: summary: Charge user balance tags: - User - /user/forgot_password: + /user/forget_password/verify_email: post: consumes: - application/json - description: Send code to forget password email for verification + description: Verify user's email to reset password + parameters: + - description: User Verify forget password input + in: body + name: forgetPassword + required: true + schema: + $ref: '#/definitions/app.VerifyCodeInput' produces: - application/json responses: "201": description: Created schema: - $ref: '#/definitions/app.CodeTimeout' + $ref: '#/definitions/app.AccessTokenResponse' "400": description: Bad Request schema: {} @@ -1763,21 +1839,28 @@ paths: "500": description: Internal Server Error schema: {} - summary: Send code to forget password email for verification + summary: Verify user's email to reset password tags: - User - /user/forgot_password/verify_email: + /user/forgot_password: post: consumes: - application/json - description: Verify user's email to reset password + description: Send code to forget password email for verification + parameters: + - description: User forget password input + in: body + name: forgetPassword + required: true + schema: + $ref: '#/definitions/app.EmailInput' produces: - application/json responses: "201": description: Created schema: - $ref: '#/definitions/app.AccessToken' + $ref: '#/definitions/app.CodeTimeout' "400": description: Bad Request schema: {} @@ -1790,7 +1873,7 @@ paths: "500": description: Internal Server Error schema: {} - summary: Verify user's email to reset password + summary: Send code to forget password email for verification tags: - User /user/refresh_token: @@ -1804,7 +1887,7 @@ paths: "201": description: Created schema: - $ref: '#/definitions/app.RefreshToken' + $ref: '#/definitions/app.RefreshTokenResponse' "400": description: Bad Request schema: {} @@ -1840,7 +1923,7 @@ paths: "201": description: Created schema: - $ref: '#/definitions/app.AccessToken' + $ref: '#/definitions/app.AccessTokenResponse' "400": description: Bad Request schema: {} @@ -2237,7 +2320,7 @@ paths: summary: Update (approve-reject) a voucher tags: - Voucher (only admins) - /voucher/reset: + /voucher/all/reset: put: consumes: - application/json diff --git a/server/go.mod b/server/go.mod index 29d73c46..b8ad0fa7 100644 --- a/server/go.mod +++ b/server/go.mod @@ -6,6 +6,7 @@ toolchain go1.23.4 require ( github.com/caitlin615/nist-password-validator v0.0.0-20190321104149-45ab5d3140de + github.com/cenkalti/backoff v2.2.1+incompatible github.com/go-redis/redis v6.15.9+incompatible github.com/golang-jwt/jwt/v4 v4.5.1 github.com/google/uuid v1.6.0 @@ -32,7 +33,6 @@ require ( github.com/ChainSafe/go-schnorrkel v1.1.0 // indirect github.com/KyleBanks/depth v1.2.1 // indirect github.com/beorn7/perks v1.0.1 // indirect - github.com/cenkalti/backoff v2.2.1+incompatible // indirect github.com/cenkalti/backoff/v3 v3.2.2 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/centrifuge/go-substrate-rpc-client/v4 v4.0.12 // indirect @@ -70,13 +70,13 @@ require ( github.com/mattn/go-sqlite3 v1.14.22 // indirect github.com/mimoo/StrobeGo v0.0.0-20220103164710-9a04d6ca976b // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/onsi/gomega v1.33.1 // indirect + github.com/onsi/gomega v1.34.2 // indirect github.com/pierrec/xxHash v0.1.5 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/common v0.55.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect - github.com/rogpeppe/go-internal v1.12.0 // indirect + github.com/rogpeppe/go-internal v1.13.1 // indirect github.com/rs/cors v1.10.1 // indirect github.com/sendgrid/rest v2.6.9+incompatible // indirect github.com/spf13/pflag v1.0.5 // indirect @@ -88,7 +88,7 @@ require ( github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/vedhavyas/go-subkey v1.0.3 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect - golang.org/x/exp v0.0.0-20240103183307-be819d1f06fc // indirect + golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect golang.org/x/net v0.32.0 // indirect golang.org/x/sync v0.10.0 // indirect golang.org/x/sys v0.28.0 // indirect diff --git a/server/go.sum b/server/go.sum index 43c9a991..fb46f78b 100644 --- a/server/go.sum +++ b/server/go.sum @@ -135,8 +135,8 @@ github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY= github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc= github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc= github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= -github.com/onsi/gomega v1.33.1 h1:dsYjIxxSR755MDmKVsaFQTE22ChNBcuuTWgkUDSubOk= -github.com/onsi/gomega v1.33.1/go.mod h1:U4R44UsT+9eLIaYRB2a5qajjtQYn0hauxvRm16AVYg0= +github.com/onsi/gomega v1.34.2 h1:pNCwDkzrsv7MS9kpaQvVb1aVLahQXyJ/Tv5oAZMI3i8= +github.com/onsi/gomega v1.34.2/go.mod h1:v1xfxRgk0KIsG+QOdm7p8UosrOzPYRo60fd3B/1Dukc= github.com/pierrec/xxHash v0.1.5 h1:n/jBpwTHiER4xYvK3/CdPVnLDPchj8eTJFFLUb4QHBo= github.com/pierrec/xxHash v0.1.5/go.mod h1:w2waW5Zoa/Wc4Yqe0wgrIYAGKqRMf7czn2HNKXmuL+I= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -152,8 +152,8 @@ github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= -github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= -github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/rs/cors v1.10.1 h1:L0uuZVXIKlI1SShY2nhFfo44TYvDPQ1w4oFkUJNfhyo= github.com/rs/cors v1.10.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= @@ -211,8 +211,8 @@ golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.30.0 h1:RwoQn3GkWiMkzlX562cLB7OxWvjH1L8xutO2WoJcRoY= golang.org/x/crypto v0.30.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/exp v0.0.0-20240103183307-be819d1f06fc h1:ao2WRsKSzW6KuUY9IWPwWahcHCgR0s52IfwutMfEbdM= -golang.org/x/exp v0.0.0-20240103183307-be819d1f06fc/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI= +golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk= +golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4= golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= diff --git a/server/internal/config_parser_test.go b/server/internal/config_parser_test.go index 41e6d67a..b640280e 100644 --- a/server/internal/config_parser_test.go +++ b/server/internal/config_parser_test.go @@ -35,7 +35,14 @@ var rightConfig = ` "file": "testing.db" }, "version": "v1", - "salt": "salt" + "currency": "eur", + "prices": { + "public_ip": 2, + "small_vm": 10, + "medium_vm": 20, + "large_vm": 30 + }, + "stripe_secret": "sk_test" } ` @@ -331,7 +338,82 @@ func TestParseConf(t *testing.T) { }) - t.Run("no salt configuration", func(t *testing.T) { + t.Run("no currency configuration", func(t *testing.T) { + config := + ` +{ + "server": { + "host": "localhost", + "port": ":3000" + }, + "mailSender": { + "email": "email", + "sendgrid_key": "my sendgrid_key", + "timeout": 60 + }, + "account": { + "mnemonics": "my mnemonics", + "network": "my network" + }, + "database": { + "file": "testing.db" + }, + "token": { + "secret": "secret", + "timeout": 10 + }, + "version": "v1", +} + ` + dir := t.TempDir() + configPath := filepath.Join(dir, "/config.json") + + err := os.WriteFile(configPath, []byte(config), 0644) + assert.NoError(t, err) + + _, err = ReadConfFile(configPath) + assert.Error(t, err, "currency is required") + }) + + t.Run("no prices configuration", func(t *testing.T) { + config := + ` +{ + "server": { + "host": "localhost", + "port": ":3000" + }, + "mailSender": { + "email": "email", + "sendgrid_key": "my sendgrid_key", + "timeout": 60 + }, + "account": { + "mnemonics": "my mnemonics", + "network": "my network" + }, + "database": { + "file": "testing.db" + }, + "token": { + "secret": "secret", + "timeout": 10 + }, + "version": "v1", + "currency": "eur", +} + ` + dir := t.TempDir() + configPath := filepath.Join(dir, "/config.json") + + err := os.WriteFile(configPath, []byte(config), 0644) + assert.NoError(t, err) + + _, err = ReadConfFile(configPath) + assert.Error(t, err, "prices is required") + }) + + t.Run("no stripe secret configuration", func(t *testing.T) { config := ` { @@ -356,7 +438,13 @@ func TestParseConf(t *testing.T) { "timeout": 10 }, "version": "v1", - "salt": "" + "currency": "eur", + "prices": { + "public_ip": 2, + "small_vm": 10, + "medium_vm": 20, + "large_vm": 30 + }, } ` dir := t.TempDir() @@ -366,7 +454,7 @@ func TestParseConf(t *testing.T) { assert.NoError(t, err) _, err = ReadConfFile(configPath) - assert.Error(t, err, "salt is required") + assert.Error(t, err, "stripe_secret is required") }) } diff --git a/server/internal/email_sender.go b/server/internal/email_sender.go index f48da153..b8ec2c08 100644 --- a/server/internal/email_sender.go +++ b/server/internal/email_sender.go @@ -41,14 +41,14 @@ var ( // SendMail sends verification mails func SendMail(sender, sendGridKey, receiver, subject, body string) error { - from := mail.NewEmail("Cloud4Students", sender) + from := mail.NewEmail("Cloud4All", sender) err := validators.ValidMail(receiver) if err != nil { return fmt.Errorf("email %v is not valid", receiver) } - to := mail.NewEmail("Cloud4Students User", receiver) + to := mail.NewEmail("Cloud4All User", receiver) message := mail.NewSingleEmail(from, subject, to, "", body) client := sendgrid.NewSendClient(sendGridKey) @@ -59,7 +59,7 @@ func SendMail(sender, sendGridKey, receiver, subject, body string) error { // SignUpMailContent gets the email content for sign up func SignUpMailContent(code int, timeout int, username, host string) (string, string) { - subject := "Welcome to Cloud4Students 🎉" + subject := "Welcome to Cloud4All 🎉" body := string(signUpMail) body = strings.ReplaceAll(body, "-code-", fmt.Sprint(code)) @@ -72,7 +72,7 @@ func SignUpMailContent(code int, timeout int, username, host string) (string, st // WelcomeMailContent gets the email content for welcome messages func WelcomeMailContent(username, host string) (string, string) { - subject := "Welcome to Cloud4Students 🎉" + subject := "Welcome to Cloud4All 🎉" body := string(welcomeMail) body = strings.ReplaceAll(body, "-name-", cases.Title(language.Und).String(username)) diff --git a/server/internal/email_sender_test.go b/server/internal/email_sender_test.go index 9153fc4c..8d424d64 100644 --- a/server/internal/email_sender_test.go +++ b/server/internal/email_sender_test.go @@ -24,7 +24,7 @@ func TestSendMail(t *testing.T) { func TestSignUpMailContent(t *testing.T) { subject, body := SignUpMailContent(1234, 60, "user", "") - assert.Equal(t, subject, "Welcome to Cloud4Students 🎉") + assert.Equal(t, subject, "Welcome to Cloud4All 🎉") want := string(signUpMail) want = strings.ReplaceAll(want, "-code-", fmt.Sprint(1234)) diff --git a/server/internal/templates/adminAnnouncement.html b/server/internal/templates/adminAnnouncement.html index 6c98741a..a01b7f1b 100644 --- a/server/internal/templates/adminAnnouncement.html +++ b/server/internal/templates/adminAnnouncement.html @@ -266,7 +266,7 @@ " >

- You received this email because you are a cloud4students user. + You received this email because you are a cloud4all user.

-host- diff --git a/server/internal/templates/signup.html b/server/internal/templates/signup.html index da52f479..1dd1db70 100644 --- a/server/internal/templates/signup.html +++ b/server/internal/templates/signup.html @@ -198,7 +198,7 @@ Welcome, -name-!

- Thank you for signing up with cloud4students. We are so glad + Thank you for signing up with cloud4all. We are so glad to have you here. We strive to produce efficient virtual machines and kubernetes clusters that you can use for your cloud or deployment needs. diff --git a/server/models/card.go b/server/models/card.go index ba34208d..1521e57d 100644 --- a/server/models/card.go +++ b/server/models/card.go @@ -1,6 +1,9 @@ package models -import "gorm.io/gorm" +import ( + "gorm.io/gorm" + "gorm.io/gorm/clause" +) type Card struct { ID int `json:"id" gorm:"primaryKey"` @@ -55,3 +58,9 @@ func (d *DB) DeleteCard(id int) error { var card Card return d.db.Delete(&card, id).Error } + +// DeleteAllCards deletes all cards of user +func (d *DB) DeleteAllCards(userID string) error { + var cards []Card + return d.db.Clauses(clause.Returning{}).Where("user_id = ?", userID).Delete(&cards).Error +} diff --git a/server/models/database.go b/server/models/database.go index ab8ba3fa..c0b0fc4e 100644 --- a/server/models/database.go +++ b/server/models/database.go @@ -31,7 +31,7 @@ func (d *DB) Connect(file string) error { func (d *DB) Migrate() error { err := d.db.AutoMigrate( &User{}, &State{}, &Card{}, &Invoice{}, &VM{}, &K8sCluster{}, &Master{}, &Worker{}, - &Voucher{}, &Maintenance{}, &Notification{}, &NextLaunch{}, + &Voucher{}, &Maintenance{}, &Notification{}, &NextLaunch{}, &DeploymentItem{}, &PaymentDetails{}, ) if err != nil { return err diff --git a/server/models/invoice.go b/server/models/invoice.go index f1549b1b..1ccfb7df 100644 --- a/server/models/invoice.go +++ b/server/models/invoice.go @@ -32,6 +32,7 @@ type DeploymentItem struct { } type PaymentDetails struct { + ID int `json:"id" gorm:"primaryKey"` InvoiceID int `json:"invoice_id"` Card float64 `json:"card"` Balance float64 `json:"balance"` @@ -74,10 +75,13 @@ func (d *DB) UpdateInvoiceLastRemainderDate(id int) error { // PayInvoice updates paid with true and paid at field with current time in the invoice func (d *DB) PayInvoice(id int, payment PaymentDetails) error { var invoice Invoice + if err := d.db.Model(&invoice).Association("PaymentDetails").Append(&payment); err != nil { + return err + } + result := d.db.Model(&invoice). Where("id = ?", id). Update("paid", true). - Update("payment_details", payment). Update("paid_at", time.Now()) if result.RowsAffected == 0 { @@ -86,59 +90,6 @@ func (d *DB) PayInvoice(id int, payment PaymentDetails) error { return result.Error } -// PayUserInvoices tries to pay invoices with a given balance -func (d *DB) PayUserInvoices(userID string, balance, voucherBalance float64) (float64, float64, error) { - // get unpaid invoices - var invoices []Invoice - if err := d.db. - Order("total desc"). - Where("user_id = ?", userID). - Where("paid = ?", false). - Find(&invoices).Error; err != nil && err != gorm.ErrRecordNotFound { - return 0, 0, err - } - - for _, invoice := range invoices { - if balance == 0 && voucherBalance == 0 { - break - } - - // 1. check voucher balance - if invoice.Total <= voucherBalance { - if err := d.PayInvoice(invoice.ID, PaymentDetails{VoucherBalance: invoice.Total}); err != nil { - return 0, 0, err - } - voucherBalance -= invoice.Total - continue - } - - // 2. check balance - if invoice.Total <= balance { - if err := d.PayInvoice(invoice.ID, PaymentDetails{Balance: invoice.Total}); err != nil { - return 0, 0, err - } - balance -= invoice.Total - continue - } - - // 3. check both (total is more than both balance and voucher balance) - if invoice.Total <= balance+voucherBalance { - if err := d.PayInvoice( - invoice.ID, - PaymentDetails{VoucherBalance: voucherBalance, Balance: (invoice.Total - voucherBalance)}, - ); err != nil { - return 0, 0, err - } - - // use voucher first - balance -= (invoice.Total - voucherBalance) - voucherBalance = 0 - } - } - - return balance, voucherBalance, nil -} - // CalcUserDebt calculates the user debt according to invoices func (d *DB) CalcUserDebt(userID string) (float64, error) { var debt float64 diff --git a/server/models/k8s.go b/server/models/k8s.go index 0ffb58c1..3be657a2 100644 --- a/server/models/k8s.go +++ b/server/models/k8s.go @@ -101,6 +101,22 @@ func (d *DB) GetAllK8s(userID string) ([]K8sCluster, error) { return k8sClusters, nil } +// GetAllSuccessfulK8s returns all K8s of user that have a state succeeded +func (d *DB) GetAllSuccessfulK8s(userID string) ([]K8sCluster, error) { + var k8sClusters []K8sCluster + err := d.db.Find(&k8sClusters, "user_id = ? and state = 'CREATED'", userID).Error + if err != nil { + return nil, err + } + for i := range k8sClusters { + k8sClusters[i], err = d.GetK8s(k8sClusters[i].ID) + if err != nil { + return nil, err + } + } + return k8sClusters, nil +} + // DeleteK8s deletes a k8s cluster func (d *DB) DeleteK8s(id int) error { var k8s K8sCluster @@ -118,6 +134,7 @@ func (d *DB) DeleteAllK8s(userID string) error { if err != nil { return err } + return d.db.Select("Master", "Workers").Delete(&k8sClusters).Error } diff --git a/server/models/user.go b/server/models/user.go index 7915e0ea..35467134 100644 --- a/server/models/user.go +++ b/server/models/user.go @@ -13,7 +13,7 @@ import ( type User struct { ID uuid.UUID `gorm:"primary_key; unique; type:uuid; column:id"` StripeCustomerID string `json:"stripe_customer_id"` - StripeDefaultPaymentID string `json:"stripe_payment_method_id"` + StripeDefaultPaymentID string `json:"stripe_default_payment_id"` FirstName string `json:"first_name" binding:"required"` LastName string `json:"last_name" binding:"required"` Email string `json:"email" gorm:"unique" binding:"required"` @@ -105,9 +105,32 @@ func (d *DB) UpdateAdminUserByID(id string, admin bool) error { return d.db.Model(&User{}).Where("id = ?", id).Updates(map[string]interface{}{"admin": admin, "updated_at": time.Now()}).Error } +// UpdateUserPaymentMethod updates user payment method ID +func (d *DB) UpdateUserPaymentMethod(id string, paymentID string) error { + var res User + return d.db.Model(&res).Where("id = ?", id).Update("stripe_default_payment_id", paymentID).Error +} + +// UpdateUserBalance updates user balance +func (d *DB) UpdateUserBalance(id string, balance float64) error { + var res User + return d.db.Model(&res).Where("id = ?", id).Update("balance", balance).Error +} + +// UpdateUserVoucherBalance updates user voucher balance +func (d *DB) UpdateUserVoucherBalance(id string, balance float64) error { + var res User + return d.db.Model(&res).Where("id = ?", id).Update("voucher_balance", balance).Error +} + // UpdateUserVerification updates if user is verified or not func (d *DB) UpdateUserVerification(id string, verified bool) error { var res User - result := d.db.Model(&res).Where("id=?", id).Update("verified", verified) - return result.Error + return d.db.Model(&res).Where("id = ?", id).Update("verified", verified).Error +} + +// DeleteUser deletes user by its id +func (d *DB) DeleteUser(id string) error { + var user User + return d.db.Where("id = ?", id).Delete(&user).Error } diff --git a/server/models/vm.go b/server/models/vm.go index 69918584..c0384b9d 100644 --- a/server/models/vm.go +++ b/server/models/vm.go @@ -47,6 +47,12 @@ func (d *DB) GetAllVms(userID string) ([]VM, error) { return vms, d.db.Where("user_id = ?", userID).Find(&vms).Error } +// GetAllSuccessfulVms returns all vms of user that have a state succeeded +func (d *DB) GetAllSuccessfulVms(userID string) ([]VM, error) { + var vms []VM + return vms, d.db.Where("user_id = ? and state = 'CREATED'", userID).Find(&vms).Error +} + // UpdateVM updates information of vm. empty and unchanged fields are not updated. func (d *DB) UpdateVM(vm VM) error { return d.db.Model(&VM{}).Where("id = ?", vm.ID).Updates(vm).Error From 9825bacc375c75c1b1dbbeb3fe47f6dcdb8801d3 Mon Sep 17 00:00:00 2001 From: rawdaGastan Date: Mon, 13 Jan 2025 15:11:40 +0200 Subject: [PATCH 2/5] support an endpoint to seen all notifications --- server/app/app.go | 1 + server/app/notification_handler.go | 28 +++++++++++++++++++++++ server/docs/docs.go | 36 ++++++++++++++++++++++++++++++ server/docs/swagger.yaml | 24 ++++++++++++++++++++ server/models/notification.go | 5 +++++ 5 files changed, 94 insertions(+) diff --git a/server/app/app.go b/server/app/app.go index da38e776..60c30364 100644 --- a/server/app/app.go +++ b/server/app/app.go @@ -160,6 +160,7 @@ func (a *App) registerHandlers() { notificationRouter.HandleFunc("", WrapFunc(a.ListNotificationsHandler)).Methods("GET", "OPTIONS") notificationRouter.HandleFunc("/{id}", WrapFunc(a.UpdateNotificationsHandler)).Methods("PUT", "OPTIONS") + notificationRouter.HandleFunc("", WrapFunc(a.SeenNotificationsHandler)).Methods("PUT", "OPTIONS") regionRouter.HandleFunc("", WrapFunc(a.ListRegionsHandler)).Methods("GET", "OPTIONS") diff --git a/server/app/notification_handler.go b/server/app/notification_handler.go index c3e7dfc9..2e6e1547 100644 --- a/server/app/notification_handler.go +++ b/server/app/notification_handler.go @@ -79,3 +79,31 @@ func (a *App) UpdateNotificationsHandler(req *http.Request) (interface{}, Respon Data: nil, }, Ok() } + +// SeenNotificationsHandler updates notifications for a user to be seen +// Example endpoint: Set user's notifications as seen +// @Summary Set user's notifications as seen +// @Description Set user's notifications as seen +// @Tags Notification +// @Accept json +// @Produce json +// @Security BearerAuth +// @Success 200 {object} Response +// @Failure 400 {object} Response +// @Failure 401 {object} Response +// @Failure 500 {object} Response +// @Router /notification [put] +func (a *App) SeenNotificationsHandler(req *http.Request) (interface{}, Response) { + userID := req.Context().Value(middlewares.UserIDKey("UserID")).(string) + + err := a.db.UpdateUserNotification(userID, true) + if err != nil { + log.Error().Err(err).Send() + return nil, InternalServerError(errors.New(internalServerErrorMsg)) + } + + return ResponseMsg{ + Message: "Notifications are seen", + Data: nil, + }, Ok() +} diff --git a/server/docs/docs.go b/server/docs/docs.go index f497c792..8f3a40ba 100644 --- a/server/docs/docs.go +++ b/server/docs/docs.go @@ -1008,6 +1008,42 @@ const docTemplate = `{ "schema": {} } } + }, + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Set user's notifications as seen", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Notification" + ], + "summary": "Set user's notifications as seen", + "responses": { + "200": { + "description": "OK", + "schema": {} + }, + "400": { + "description": "Bad Request", + "schema": {} + }, + "401": { + "description": "Unauthorized", + "schema": {} + }, + "500": { + "description": "Internal Server Error", + "schema": {} + } + } } }, "/notification/{id}": { diff --git a/server/docs/swagger.yaml b/server/docs/swagger.yaml index ba534271..fd510e69 100644 --- a/server/docs/swagger.yaml +++ b/server/docs/swagger.yaml @@ -1296,6 +1296,30 @@ paths: summary: Lists user's notifications tags: - Notification + put: + consumes: + - application/json + description: Set user's notifications as seen + produces: + - application/json + responses: + "200": + description: OK + schema: {} + "400": + description: Bad Request + schema: {} + "401": + description: Unauthorized + schema: {} + "500": + description: Internal Server Error + schema: {} + security: + - BearerAuth: [] + summary: Set user's notifications as seen + tags: + - Notification /notification/{id}: put: consumes: diff --git a/server/models/notification.go b/server/models/notification.go index 1bf0f4ae..e6d66d23 100644 --- a/server/models/notification.go +++ b/server/models/notification.go @@ -30,6 +30,11 @@ func (d *DB) UpdateNotification(id int, seen bool) error { return d.db.Model(&Notification{}).Where("id = ?", id).Updates(map[string]interface{}{"seen": seen}).Error } +// UpdateUserNotification updates seen field for user notifications +func (d *DB) UpdateUserNotification(userID string, seen bool) error { + return d.db.Model(&Notification{}).Where("user_id = ?", userID).Updates(map[string]interface{}{"seen": seen}).Error +} + // CreateNotification adds a new notification for a user func (d *DB) CreateNotification(n *Notification) error { return d.db.Create(&n).Error From fc295c04f5123acaa9edb4474171f8d6cff78bbc Mon Sep 17 00:00:00 2001 From: rawdaGastan Date: Tue, 14 Jan 2025 15:57:50 +0200 Subject: [PATCH 3/5] support sending notifications using SSE --- server/app/app.go | 2 +- server/app/notification_handler.go | 109 +++++++++++++++++++---------- server/docs/docs.go | 12 ++-- server/docs/swagger.yaml | 10 +-- server/models/notification.go | 20 ++++-- 5 files changed, 101 insertions(+), 52 deletions(-) diff --git a/server/app/app.go b/server/app/app.go index 60c30364..61542bcb 100644 --- a/server/app/app.go +++ b/server/app/app.go @@ -158,7 +158,7 @@ func (a *App) registerHandlers() { invoiceRouter.HandleFunc("/{id}", WrapFunc(a.GetInvoiceHandler)).Methods("GET", "OPTIONS") invoiceRouter.HandleFunc("/pay/{id}", WrapFunc(a.PayInvoiceHandler)).Methods("PUT", "OPTIONS") - notificationRouter.HandleFunc("", WrapFunc(a.ListNotificationsHandler)).Methods("GET", "OPTIONS") + notificationRouter.HandleFunc("", a.sseNotificationsHandler).Methods("GET", "OPTIONS") notificationRouter.HandleFunc("/{id}", WrapFunc(a.UpdateNotificationsHandler)).Methods("PUT", "OPTIONS") notificationRouter.HandleFunc("", WrapFunc(a.SeenNotificationsHandler)).Methods("PUT", "OPTIONS") diff --git a/server/app/notification_handler.go b/server/app/notification_handler.go index 2e6e1547..ae789c83 100644 --- a/server/app/notification_handler.go +++ b/server/app/notification_handler.go @@ -2,51 +2,17 @@ package app import ( + "encoding/json" "errors" "net/http" "strconv" + "time" "github.com/codescalers/cloud4students/middlewares" "github.com/gorilla/mux" "github.com/rs/zerolog/log" - "gorm.io/gorm" ) -// ListNotificationsHandler lists notifications for a user -// Example endpoint: Lists user's notifications -// @Summary Lists user's notifications -// @Description Lists user's notifications -// @Tags Notification -// @Accept json -// @Produce json -// @Security BearerAuth -// @Success 200 {object} []models.Notification -// @Failure 401 {object} Response -// @Failure 404 {object} Response -// @Failure 500 {object} Response -// @Router /notification [get] -func (a *App) ListNotificationsHandler(req *http.Request) (interface{}, Response) { - userID := req.Context().Value(middlewares.UserIDKey("UserID")).(string) - - notifications, err := a.db.ListNotifications(userID) - if errors.Is(err, gorm.ErrRecordNotFound) || len(notifications) == 0 { - return ResponseMsg{ - Message: "You don't have any notifications yet", - Data: notifications, - }, Ok() - } - - if err != nil { - log.Error().Err(err).Send() - return nil, InternalServerError(errors.New(internalServerErrorMsg)) - } - - return ResponseMsg{ - Message: "You have notifications", - Data: notifications, - }, Ok() -} - // UpdateNotificationsHandler updates notifications for a user // Example endpoint: Set user's notifications as seen // @Summary Set user's notifications as seen @@ -107,3 +73,74 @@ func (a *App) SeenNotificationsHandler(req *http.Request) (interface{}, Response Data: nil, }, Ok() } + +// sseNotificationsHandler to stream notifications +// Example endpoint: Stream user's notifications +// @Summary Stream user's notifications +// @Description Stream user's notifications +// @Tags Notification +// @Accept json +// @Produce json +// @Security BearerAuth +// @Success 200 {object} []models.Notification +// @Failure 401 {object} Response +// @Failure 500 {object} Response +// @Router /notification [get] +func (a *App) sseNotificationsHandler(w http.ResponseWriter, req *http.Request) { + userID := req.Context().Value(middlewares.UserIDKey("UserID")).(string) + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + + // Flush the headers immediately + flusher, ok := w.(http.Flusher) + if !ok { + log.Error().Msg("Streaming unsupported") + internalServerError(w) + return + } + + // Sending notifications every 5 seconds + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + notifications, err := a.db.GetNewNotifications(userID) + if err != nil { + log.Error().Err(err).Send() + internalServerError(w) + return + } + + // Send each notification as a separate SSE message + for _, notification := range notifications { + if _, err := w.Write([]byte(notification.Msg)); err != nil { + log.Error().Err(err).Send() + internalServerError(w) + return + } + flusher.Flush() // Ensure the event is sent immediately + } + + case <-req.Context().Done(): + w.WriteHeader(http.StatusOK) + return + } + } +} + +func internalServerError(w http.ResponseWriter) { + w.WriteHeader(http.StatusInternalServerError) + object := struct { + Error string `json:"err"` + }{ + Error: "Internal server error", + } + + if err := json.NewEncoder(w).Encode(object); err != nil { + log.Error().Err(err).Msg("failed to encode return object") + } +} diff --git a/server/docs/docs.go b/server/docs/docs.go index 8f3a40ba..8bb26744 100644 --- a/server/docs/docs.go +++ b/server/docs/docs.go @@ -974,7 +974,7 @@ const docTemplate = `{ "BearerAuth": [] } ], - "description": "Lists user's notifications", + "description": "Stream user's notifications", "consumes": [ "application/json" ], @@ -984,7 +984,7 @@ const docTemplate = `{ "tags": [ "Notification" ], - "summary": "Lists user's notifications", + "summary": "Stream user's notifications", "responses": { "200": { "description": "OK", @@ -999,10 +999,6 @@ const docTemplate = `{ "description": "Unauthorized", "schema": {} }, - "404": { - "description": "Not Found", - "schema": {} - }, "500": { "description": "Internal Server Error", "schema": {} @@ -3315,6 +3311,7 @@ const docTemplate = `{ "type": "object", "required": [ "msg", + "notified", "seen", "type", "user_id" @@ -3326,6 +3323,9 @@ const docTemplate = `{ "msg": { "type": "string" }, + "notified": { + "type": "boolean" + }, "seen": { "type": "boolean" }, diff --git a/server/docs/swagger.yaml b/server/docs/swagger.yaml index fd510e69..e02399a0 100644 --- a/server/docs/swagger.yaml +++ b/server/docs/swagger.yaml @@ -461,6 +461,8 @@ definitions: type: integer msg: type: string + notified: + type: boolean seen: type: boolean type: @@ -470,6 +472,7 @@ definitions: type: string required: - msg + - notified - seen - type - user_id @@ -1272,7 +1275,7 @@ paths: get: consumes: - application/json - description: Lists user's notifications + description: Stream user's notifications produces: - application/json responses: @@ -1285,15 +1288,12 @@ paths: "401": description: Unauthorized schema: {} - "404": - description: Not Found - schema: {} "500": description: Internal Server Error schema: {} security: - BearerAuth: [] - summary: Lists user's notifications + summary: Stream user's notifications tags: - Notification put: diff --git a/server/models/notification.go b/server/models/notification.go index e6d66d23..a8381659 100644 --- a/server/models/notification.go +++ b/server/models/notification.go @@ -10,10 +10,11 @@ const ( // Notification struct holds data of notifications type Notification struct { - ID int `json:"id" gorm:"primaryKey"` - UserID string `json:"user_id" binding:"required"` - Msg string `json:"msg" binding:"required"` - Seen bool `json:"seen" binding:"required"` + ID int `json:"id" gorm:"primaryKey"` + UserID string `json:"user_id" binding:"required"` + Msg string `json:"msg" binding:"required"` + Seen bool `json:"seen" binding:"required"` + Notified bool `json:"notified" binding:"required"` // to allow redirecting from notifications to the right pages Type string `json:"type" binding:"required"` } @@ -25,6 +26,17 @@ func (d *DB) ListNotifications(userID string) ([]Notification, error) { return res, query.Error } +// GetNewNotifications returns a list of new notifications for a user. +func (d *DB) GetNewNotifications(userID string) ([]Notification, error) { + var res []Notification + query := d.db.Where("user_id = ?", userID).Where("notified = ?", false).Find(&res) + if query.Error != nil { + return nil, query.Error + } + + return res, d.db.Model(&Notification{}).Where("user_id = ?", userID).Updates(map[string]interface{}{"notified": true}).Error +} + // UpdateNotification updates seen field for notification func (d *DB) UpdateNotification(id int, seen bool) error { return d.db.Model(&Notification{}).Where("id = ?", id).Updates(map[string]interface{}{"seen": seen}).Error From 0f918f56820e9085138a1d53feb7bb0ff386303c Mon Sep 17 00:00:00 2001 From: rawdaGastan Date: Wed, 15 Jan 2025 12:00:16 +0200 Subject: [PATCH 4/5] explain why type field exists in notification --- server/models/notification.go | 1 + 1 file changed, 1 insertion(+) diff --git a/server/models/notification.go b/server/models/notification.go index a8381659..5fc1664d 100644 --- a/server/models/notification.go +++ b/server/models/notification.go @@ -16,6 +16,7 @@ type Notification struct { Seen bool `json:"seen" binding:"required"` Notified bool `json:"notified" binding:"required"` // to allow redirecting from notifications to the right pages + // for example if the type is `vm` it will be redirected to the vm page Type string `json:"type" binding:"required"` } From 4525d85706f131d7e90288f8fe1928d845b1c93f Mon Sep 17 00:00:00 2001 From: rawdaGastan Date: Mon, 3 Feb 2025 16:25:35 +0200 Subject: [PATCH 5/5] support listing notifications --- server/app/app.go | 3 +- server/app/notification_handler.go | 38 ++++++++++++++++++++++- server/docs/docs.go | 49 ++++++++++++++++++++++++++++-- server/docs/swagger.yaml | 36 ++++++++++++++++++++-- 4 files changed, 118 insertions(+), 8 deletions(-) diff --git a/server/app/app.go b/server/app/app.go index 61542bcb..d2423dc0 100644 --- a/server/app/app.go +++ b/server/app/app.go @@ -158,7 +158,8 @@ func (a *App) registerHandlers() { invoiceRouter.HandleFunc("/{id}", WrapFunc(a.GetInvoiceHandler)).Methods("GET", "OPTIONS") invoiceRouter.HandleFunc("/pay/{id}", WrapFunc(a.PayInvoiceHandler)).Methods("PUT", "OPTIONS") - notificationRouter.HandleFunc("", a.sseNotificationsHandler).Methods("GET", "OPTIONS") + notificationRouter.HandleFunc("", WrapFunc(a.ListNotificationsHandler)).Methods("GET", "OPTIONS") + notificationRouter.HandleFunc("/stream", a.sseNotificationsHandler).Methods("GET", "OPTIONS") notificationRouter.HandleFunc("/{id}", WrapFunc(a.UpdateNotificationsHandler)).Methods("PUT", "OPTIONS") notificationRouter.HandleFunc("", WrapFunc(a.SeenNotificationsHandler)).Methods("PUT", "OPTIONS") diff --git a/server/app/notification_handler.go b/server/app/notification_handler.go index ae789c83..1e03b79a 100644 --- a/server/app/notification_handler.go +++ b/server/app/notification_handler.go @@ -11,6 +11,7 @@ import ( "github.com/codescalers/cloud4students/middlewares" "github.com/gorilla/mux" "github.com/rs/zerolog/log" + "gorm.io/gorm" ) // UpdateNotificationsHandler updates notifications for a user @@ -74,6 +75,41 @@ func (a *App) SeenNotificationsHandler(req *http.Request) (interface{}, Response }, Ok() } +// ListNotificationsHandler lists notifications for a user +// Example endpoint: Lists user's notifications +// @Summary Lists user's notifications +// @Description Lists user's notifications +// @Tags Notification +// @Accept json +// @Produce json +// @Security BearerAuth +// @Success 200 {object} []models.Notification +// @Failure 401 {object} Response +// @Failure 404 {object} Response +// @Failure 500 {object} Response +// @Router /notification [get] +func (a *App) ListNotificationsHandler(req *http.Request) (interface{}, Response) { + userID := req.Context().Value(middlewares.UserIDKey("UserID")).(string) + + notifications, err := a.db.ListNotifications(userID) + if errors.Is(err, gorm.ErrRecordNotFound) || len(notifications) == 0 { + return ResponseMsg{ + Message: "You don't have any notifications yet", + Data: notifications, + }, Ok() + } + + if err != nil { + log.Error().Err(err).Send() + return nil, InternalServerError(errors.New(internalServerErrorMsg)) + } + + return ResponseMsg{ + Message: "You have notifications", + Data: notifications, + }, Ok() +} + // sseNotificationsHandler to stream notifications // Example endpoint: Stream user's notifications // @Summary Stream user's notifications @@ -85,7 +121,7 @@ func (a *App) SeenNotificationsHandler(req *http.Request) (interface{}, Response // @Success 200 {object} []models.Notification // @Failure 401 {object} Response // @Failure 500 {object} Response -// @Router /notification [get] +// @Router /notification/stream [get] func (a *App) sseNotificationsHandler(w http.ResponseWriter, req *http.Request) { userID := req.Context().Value(middlewares.UserIDKey("UserID")).(string) diff --git a/server/docs/docs.go b/server/docs/docs.go index 8bb26744..298a06a0 100644 --- a/server/docs/docs.go +++ b/server/docs/docs.go @@ -974,7 +974,7 @@ const docTemplate = `{ "BearerAuth": [] } ], - "description": "Stream user's notifications", + "description": "Lists user's notifications", "consumes": [ "application/json" ], @@ -984,7 +984,7 @@ const docTemplate = `{ "tags": [ "Notification" ], - "summary": "Stream user's notifications", + "summary": "Lists user's notifications", "responses": { "200": { "description": "OK", @@ -999,6 +999,10 @@ const docTemplate = `{ "description": "Unauthorized", "schema": {} }, + "404": { + "description": "Not Found", + "schema": {} + }, "500": { "description": "Internal Server Error", "schema": {} @@ -1042,6 +1046,45 @@ const docTemplate = `{ } } }, + "/notification/stream": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Stream user's notifications", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Notification" + ], + "summary": "Stream user's notifications", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/models.Notification" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": {} + }, + "500": { + "description": "Internal Server Error", + "schema": {} + } + } + } + }, "/notification/{id}": { "put": { "security": [ @@ -3330,7 +3373,7 @@ const docTemplate = `{ "type": "boolean" }, "type": { - "description": "to allow redirecting from notifications to the right pages", + "description": "to allow redirecting from notifications to the right pages\nfor example if the type is ` + "`" + `vm` + "`" + ` it will be redirected to the vm page", "type": "string" }, "user_id": { diff --git a/server/docs/swagger.yaml b/server/docs/swagger.yaml index e02399a0..28816e6e 100644 --- a/server/docs/swagger.yaml +++ b/server/docs/swagger.yaml @@ -466,7 +466,9 @@ definitions: seen: type: boolean type: - description: to allow redirecting from notifications to the right pages + description: |- + to allow redirecting from notifications to the right pages + for example if the type is `vm` it will be redirected to the vm page type: string user_id: type: string @@ -1275,7 +1277,7 @@ paths: get: consumes: - application/json - description: Stream user's notifications + description: Lists user's notifications produces: - application/json responses: @@ -1288,12 +1290,15 @@ paths: "401": description: Unauthorized schema: {} + "404": + description: Not Found + schema: {} "500": description: Internal Server Error schema: {} security: - BearerAuth: [] - summary: Stream user's notifications + summary: Lists user's notifications tags: - Notification put: @@ -1351,6 +1356,31 @@ paths: summary: Set user's notifications as seen tags: - Notification + /notification/stream: + get: + consumes: + - application/json + description: Stream user's notifications + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/models.Notification' + type: array + "401": + description: Unauthorized + schema: {} + "500": + description: Internal Server Error + schema: {} + security: + - BearerAuth: [] + summary: Stream user's notifications + tags: + - Notification /region: get: consumes: