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" "github.com/tech/sendico/pkg/model" "github.com/tech/sendico/pkg/mservice" "github.com/tech/sendico/pkg/mutil/mzap" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" "github.com/tech/sendico/server/interface/api/srequest" "github.com/tech/sendico/server/interface/api/sresponse" "go.mongodb.org/mongo-driver/bson/primitive" "go.uber.org/zap" ) func (a *AccountAPI) createOrg(ctx context.Context, sr *srequest.Signup, permissionRef primitive.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 := primitive.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: []primitive.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.BadRequest(a.logger, a.Name(), "", err.Error()) } 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) } if err := a.executeSignupTransaction(r.Context(), &sr, newAccount); err != nil { if errors.Is(err, merrors.ErrDataConflict) { a.logger.Info("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.Info("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); err != nil { a.logger.Warn("Failed to send welcome email", zap.Error(err), mzap.StorableRef(newAccount)) } return sresponse.SignUp(a.logger, newAccount) } 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) error { _, err := a.tf.CreateTransaction().Execute(ctxt, func(ctx context.Context) (any, error) { return a.signupTransactionBody(ctx, sr, newAccount) }) 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)) 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)) if err := a.accService.CreateAccount(ctx, org, newAccount, roleDescription.ID); 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 nil, nil } func (a *AccountAPI) grantAllPermissions(ctx context.Context, organizationRef primitive.ObjectID, roleID primitive.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") } asset := *a.chainAsset req := &chainv1.CreateManagedWalletRequest{ IdempotencyKey: uuid.NewString(), OrganizationRef: org.ID.Hex(), OwnerRef: org.ID.Hex(), Asset: &asset, 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 }