Files
sendico/api/edge/bff/internal/server/accountapiimp/signup.go
2026-02-28 00:39:20 +01:00

383 lines
14 KiB
Go

package accountapiimp
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/google/uuid"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/db/storable"
"github.com/tech/sendico/pkg/merrors"
snotifications "github.com/tech/sendico/pkg/messaging/notifications/site"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/pkg/mutil/mzap"
describablev1 "github.com/tech/sendico/pkg/proto/common/describable/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
"github.com/tech/sendico/server/interface/api/srequest"
"github.com/tech/sendico/server/interface/api/sresponse"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
func (a *AccountAPI) createOrg(ctx context.Context, sr *srequest.Signup, permissionRef bson.ObjectID) (*model.Organization, error) {
name := strings.TrimSpace(sr.Organization.Name)
if name == "" {
return nil, merrors.InvalidArgument("organization name must not be empty")
}
if _, err := time.LoadLocation(sr.OrganizationTimeZone); err != nil {
return nil, merrors.DataConflict(fmt.Sprintf("invalid time zone '%s' provided, error %s", sr.OrganizationTimeZone, err.Error()))
}
// explicitly set org ref for permission related checks as unprotected template implementation
// is not aware of permisssions and won't set org
orgRef := bson.NewObjectID()
org := &model.Organization{
OrganizationBase: model.OrganizationBase{
PermissionBound: model.PermissionBound{
Base: storable.Base{
ID: orgRef,
CreatedAt: time.Now(),
},
PermissionRef: permissionRef,
OrganizationBoundBase: model.OrganizationBoundBase{
OrganizationRef: orgRef,
},
},
Describable: model.Describable{
Name: name,
Description: sr.Organization.Description,
},
TimeZone: sr.OrganizationTimeZone,
},
Members: []bson.ObjectID{},
}
if err := a.odb.Unprotected().Create(ctx, org); err != nil {
a.logger.Warn("Failed to create organization", zap.Error(err))
return nil, err
}
return org, nil
}
// signupHandler handles user sign up
func (a *AccountAPI) signup(r *http.Request) http.HandlerFunc {
// Validate user input
var sr srequest.Signup
if err := json.NewDecoder(r.Body).Decode(&sr); err != nil {
a.logger.Warn("Failed to decode signup request", zap.Error(err))
return response.BadPayload(a.logger, a.Name(), err)
}
sr.Account.Login = strings.ToLower(strings.TrimSpace(sr.Account.Login))
if err := a.ensureLoginAvailable(r.Context(), sr.Account.Login); err != nil {
if errors.Is(err, merrors.ErrDataConflict) {
return response.DataConflict(a.logger, "user_already_registered", "User has already been registered")
}
if errors.Is(err, merrors.ErrInvalidArg) {
return response.BadPayload(a.logger, a.Name(), err)
}
a.logger.Warn("Failed to validate login availability", zap.Error(err), zap.String("login", sr.Account.Login))
return response.Internal(a.logger, a.Name(), err)
}
newAccount := sr.Account.ToAccount()
if res := a.accService.ValidateAccount(newAccount); res != nil {
a.logger.Warn("Invalid signup account received", zap.Error(res), zap.String("account", newAccount.Login))
return response.BadPayload(a.logger, a.Name(), res)
}
verificationToken, err := a.executeSignupTransaction(r.Context(), &sr, newAccount)
if err != nil {
if errors.Is(err, merrors.ErrDataConflict) {
a.logger.Warn("Failed to register account", zap.Error(err), zap.String("login", newAccount.Login))
return response.DataConflict(a.logger, "user_already_registered", "User has already been registered")
}
a.logger.Warn("Failed to create new user", zap.Error(err), zap.String("login", newAccount.Login))
return response.Internal(a.logger, a.Name(), err)
}
if err := a.sendWelcomeEmail(newAccount, verificationToken); err != nil {
a.logger.Warn("Failed to send welcome email", zap.Error(err), mzap.StorableRef(newAccount))
}
if err := a.sendSignupNotification(newAccount, &sr); err != nil {
a.logger.Warn("Failed to enqueue signup notification", zap.Error(err), zap.String("login", newAccount.Login))
}
return sresponse.SignUp(a.logger, newAccount)
}
func (a *AccountAPI) sendSignupNotification(account *model.Account, request *srequest.Signup) error {
if account == nil || request == nil {
return merrors.InvalidArgument("signup notification payload is empty")
}
signupNotification := &model.ContactRequest{
Name: accountNotificationName(account),
Email: strings.TrimSpace(account.Login),
Company: strings.TrimSpace(request.Organization.Name),
Topic: model.ContactRequestTopicSignupCompleted,
}
return a.producer.SendMessage(snotifications.ContactRequest(a.Name(), signupNotification))
}
func (a *AccountAPI) sendAccountVerificationCompletedNotification(account *model.Account) error {
if account == nil {
return merrors.InvalidArgument("account verification notification payload is empty", "account")
}
notification := &model.ContactRequest{
Name: accountNotificationName(account),
Email: strings.TrimSpace(account.Login),
Topic: model.ContactRequestTopicAccountVerificationCompleted,
}
return a.producer.SendMessage(snotifications.ContactRequest(a.Name(), notification))
}
func accountNotificationName(account *model.Account) string {
if account == nil {
return ""
}
return strings.TrimSpace(strings.Join([]string{
strings.TrimSpace(account.Name),
strings.TrimSpace(account.LastName),
}, " "))
}
func (a *AccountAPI) signupAvailability(r *http.Request) http.HandlerFunc {
login := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("login")))
if login == "" {
return response.BadRequest(a.logger, a.Name(), "missing_login", "login query parameter is required")
}
err := a.ensureLoginAvailable(r.Context(), login)
switch {
case err == nil:
return sresponse.SignUpAvailability(a.logger, login, true)
case errors.Is(err, merrors.ErrDataConflict):
return sresponse.SignUpAvailability(a.logger, login, false)
case errors.Is(err, merrors.ErrInvalidArg):
return response.BadPayload(a.logger, a.Name(), err)
default:
a.logger.Warn("Failed to check login availability", zap.Error(err), zap.String("login", login))
return response.Internal(a.logger, a.Name(), err)
}
}
func (a *AccountAPI) executeSignupTransaction(ctxt context.Context, sr *srequest.Signup, newAccount *model.Account) (string, error) {
res, err := a.tf.CreateTransaction().Execute(ctxt, func(ctx context.Context) (any, error) {
return a.signupTransactionBody(ctx, sr, newAccount)
})
if token, ok := res.(string); token != "" || ok {
return token, err
}
return "", err
}
func (a *AccountAPI) signupTransactionBody(ctx context.Context, sr *srequest.Signup, newAccount *model.Account) (any, error) {
var orgPolicy model.PolicyDescription
if err := a.plcdb.GetBuiltInPolicy(ctx, mservice.Organizations, &orgPolicy); err != nil {
a.logger.Warn("Failed to fetch built-in organization policy", zap.Error(err), zap.String("login", newAccount.Login))
return nil, err
}
org, err := a.createOrg(ctx, sr, orgPolicy.ID)
if err != nil {
a.logger.Warn("Failed to create organization", zap.Error(err))
return nil, err
}
a.logger.Info("Organization created", mzap.StorableRef(org), zap.String("account", sr.Account.Login))
if err := a.openOrgWallet(ctx, org, sr); err != nil {
return nil, err
}
a.logger.Info("Organization wallet created", mzap.StorableRef(org), zap.String("account", sr.Account.Login))
if err := a.openOrgLedgerAccount(ctx, org, sr); err != nil {
return nil, err
}
a.logger.Info("Organization ledger account created", mzap.StorableRef(org), zap.String("account", sr.Account.Login))
roleDescription, err := a.pmanager.Role().Create(ctx, org.ID, &sr.OwnerRole)
if err != nil {
a.logger.Warn("Failed to create owner role", zap.Error(err), zap.String("login", newAccount.Login))
return nil, err
}
a.logger.Info("Organization owner role created", mzap.StorableRef(org), zap.String("account", sr.Account.Login))
if err := a.grantAllPermissions(ctx, org.ID, roleDescription.ID, newAccount); err != nil {
return nil, err
}
a.logger.Info("Organization owner role permissions granted", mzap.StorableRef(org), zap.String("account", sr.Account.Login))
token, err := a.accService.CreateAccount(ctx, org, newAccount, roleDescription.ID)
if err != nil {
a.logger.Warn("Failed to create account", zap.Error(err), zap.String("login", newAccount.Login))
return nil, err
}
a.logger.Info("Organization owner account registered", mzap.StorableRef(org), zap.String("account", sr.Account.Login))
return token, nil
}
func (a *AccountAPI) grantAllPermissions(ctx context.Context, organizationRef bson.ObjectID, roleID bson.ObjectID, newAccount *model.Account) error {
om := a.pmanager.Permission()
policies, err := a.plcdb.All(ctx, organizationRef)
if err != nil {
a.logger.Warn("Failed to fetch permissions", zap.Error(err), mzap.StorableRef(newAccount))
return err
}
required := map[mservice.Type]bool{
mservice.Organizations: false,
mservice.Accounts: false,
mservice.LedgerAccounts: false,
}
actions := []model.Action{model.ActionCreate, model.ActionRead, model.ActionUpdate, model.ActionDelete}
for _, policy := range policies {
if policy.ResourceTypes != nil {
for _, resource := range *policy.ResourceTypes {
if _, ok := required[resource]; ok {
required[resource] = true
}
}
}
for _, action := range actions {
a.logger.Debug("Adding permission", mzap.StorableRef(&policy), zap.String("action", string(action)),
mzap.ObjRef("role_ref", roleID), mzap.ObjRef("policy_ref", policy.ID), mzap.ObjRef("organization_ref", organizationRef))
policy := model.RolePolicy{
Policy: model.Policy{
OrganizationRef: organizationRef,
DescriptionRef: policy.ID,
ObjectRef: nil, // all objects are affected
Effect: model.ActionEffect{Action: action, Effect: model.EffectAllow},
},
RoleDescriptionRef: roleID,
}
if err := om.GrantToRole(ctx, &policy); err != nil {
a.logger.Warn("Failed to grant permission", zap.Error(err), mzap.StorableRef(newAccount))
return err
}
}
}
if err := om.Save(); err != nil {
a.logger.Warn("Failed to save permissions", zap.Error(err), mzap.StorableRef(newAccount))
return err
}
for resource, granted := range required {
if !granted {
a.logger.Warn("Required policy description not found for signup permissions", zap.String("resource", string(resource)))
}
}
return nil
}
func (a *AccountAPI) ensureLoginAvailable(ctx context.Context, login string) error {
if strings.TrimSpace(login) == "" {
return merrors.InvalidArgument("login must not be empty")
}
if _, err := a.db.GetByEmail(ctx, login); err != nil {
if errors.Is(err, merrors.ErrNoData) {
return nil
}
a.logger.Warn("Failed to lookup account by login", zap.Error(err), zap.String("login", login))
return err
}
return merrors.DataConflict("account already exists")
}
func (a *AccountAPI) openOrgWallet(ctx context.Context, org *model.Organization, sr *srequest.Signup) error {
if a.chainGateway == nil || a.chainAsset == nil {
a.logger.Warn("Chain gateway client not configured, skipping wallet creation", mzap.StorableRef(org))
return merrors.Internal("chain gateway client is not configured")
}
req := &chainv1.CreateManagedWalletRequest{
IdempotencyKey: uuid.NewString(),
OrganizationRef: org.ID.Hex(),
Describable: &describablev1.Describable{
Name: sr.CryptoWallet.Name,
Description: sr.CryptoWallet.Description,
},
Asset: a.chainAsset,
Metadata: map[string]string{
"source": "signup",
"login": sr.Account.Login,
},
}
resp, err := a.chainGateway.CreateManagedWallet(ctx, req)
if err != nil {
a.logger.Warn("Failed to create managed wallet for organization", zap.Error(err), mzap.StorableRef(org))
return err
}
if resp == nil || resp.Wallet == nil || strings.TrimSpace(resp.Wallet.WalletRef) == "" {
return merrors.Internal("chain gateway returned empty wallet reference")
}
a.logger.Info("Managed wallet created for organization", mzap.StorableRef(org), zap.String("wallet_ref", resp.Wallet.WalletRef))
return nil
}
func (a *AccountAPI) openOrgLedgerAccount(ctx context.Context, org *model.Organization, sr *srequest.Signup) error {
if a.ledgerClient == nil {
a.logger.Warn("Ledger client not configured, skipping ledger account creation", mzap.StorableRef(org))
return merrors.Internal("ledger client is not configured")
}
if a.chainAsset == nil {
return merrors.Internal("chain gateway default asset is not configured")
}
// TODO: remove hardcode
currency := "RUB"
var describable *describablev1.Describable
name := strings.TrimSpace(sr.LedgerWallet.Name)
var description *string
if sr.LedgerWallet.Description != nil {
trimmed := strings.TrimSpace(*sr.LedgerWallet.Description)
if trimmed != "" {
description = &trimmed
}
}
if name != "" || description != nil {
describable = &describablev1.Describable{
Name: name,
Description: description,
}
}
resp, err := a.ledgerClient.CreateAccount(ctx, &ledgerv1.CreateAccountRequest{
OrganizationRef: org.ID.Hex(),
AccountType: ledgerv1.AccountType_ACCOUNT_TYPE_ASSET,
Currency: currency,
Status: ledgerv1.AccountStatus_ACCOUNT_STATUS_ACTIVE,
Role: ledgerv1.AccountRole_ACCOUNT_ROLE_OPERATING,
Metadata: map[string]string{
"source": "signup",
"login": sr.Account.Login,
},
Describable: describable,
})
if err != nil {
a.logger.Warn("Failed to create ledger account for organization", zap.Error(err), mzap.StorableRef(org))
return err
}
if resp == nil || resp.GetAccount() == nil || strings.TrimSpace(resp.GetAccount().GetLedgerAccountRef()) == "" {
return merrors.Internal("ledger returned empty account reference")
}
a.logger.Info("Ledger account created for organization", mzap.StorableRef(org), zap.String("ledger_account_ref", resp.GetAccount().GetLedgerAccountRef()))
return nil
}