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 }