diff --git a/api/pkg/model/userdata.go b/api/pkg/model/userdata.go index 892b7ad..d06095e 100644 --- a/api/pkg/model/userdata.go +++ b/api/pkg/model/userdata.go @@ -11,17 +11,15 @@ type LoginData struct { } type AccountData struct { - LoginData `bson:",inline" json:",inline"` - Name string `bson:"name" json:"name"` + LoginData `bson:",inline" json:",inline"` + Describable `bson:",inline" json:",inline"` } func (ad *AccountData) ToAccount() *Account { return &Account{ AccountPublic: AccountPublic{ AccountBase: AccountBase{ - Describable: Describable{ - Name: ad.Name, - }, + Describable: ad.Describable, }, UserDataBase: ad.UserDataBase, }, diff --git a/api/server/config.yml b/api/server/config.yml index 6b401da..80950c8 100755 --- a/api/server/config.yml +++ b/api/server/config.yml @@ -74,6 +74,16 @@ api: settings: root_path: ./storage + chain_gateway: + address_env: CHAIN_GATEWAY_ADDRESS + dial_timeout_seconds: 5 + call_timeout_seconds: 5 + insecure: true + default_asset: + chain: ARBITRUM_ONE + token_symbol: USDT + contract_address: "" + app: database: @@ -94,4 +104,4 @@ database: collection_name_env: PERMISSION_COLLECTION database_name_env: MONGO_DATABASE timeout_seconds_env: PERMISSION_TIMEOUT - is_filtered_env: PERMISSION_IS_FILTERED \ No newline at end of file + is_filtered_env: PERMISSION_IS_FILTERED diff --git a/api/server/go.mod b/api/server/go.mod index 940de09..fd249c5 100644 --- a/api/server/go.mod +++ b/api/server/go.mod @@ -4,6 +4,8 @@ go 1.25.3 replace github.com/tech/sendico/pkg => ../pkg +replace github.com/tech/sendico/chain/gateway => ../chain/gateway + require ( github.com/aws/aws-sdk-go-v2 v1.39.6 github.com/aws/aws-sdk-go-v2/config v1.31.18 @@ -13,8 +15,10 @@ require ( github.com/go-chi/cors v1.2.2 github.com/go-chi/jwtauth/v5 v5.3.3 github.com/go-chi/metrics v0.1.1 + github.com/google/uuid v1.6.0 github.com/mitchellh/mapstructure v1.5.0 github.com/stretchr/testify v1.11.1 + github.com/tech/sendico/chain/gateway v0.1.0 github.com/tech/sendico/pkg v0.1.0 github.com/testcontainers/testcontainers-go v0.33.0 github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0 @@ -27,7 +31,7 @@ require ( require ( github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect - github.com/casbin/casbin/v2 v2.132.0 // indirect + github.com/casbin/casbin/v2 v2.134.0 // indirect github.com/casbin/govaluate v1.10.0 // indirect ) @@ -70,7 +74,6 @@ require ( github.com/goccy/go-json v0.10.5 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/snappy v1.0.0 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/klauspost/compress v1.18.1 // indirect github.com/lestrrat-go/blackmagic v1.0.4 // indirect github.com/lestrrat-go/httpcc v1.0.1 // indirect @@ -107,8 +110,8 @@ require ( github.com/shirou/gopsutil/v3 v3.24.5 // indirect github.com/shoenig/go-m1cpu v0.1.6 // indirect github.com/sirupsen/logrus v1.9.3 // indirect - github.com/tklauser/go-sysconf v0.3.15 // indirect - github.com/tklauser/numcpus v0.10.0 // indirect + github.com/tklauser/go-sysconf v0.3.16 // indirect + github.com/tklauser/numcpus v0.11.0 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/scram v1.1.2 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect diff --git a/api/server/go.sum b/api/server/go.sum index b1939fb..8752498 100644 --- a/api/server/go.sum +++ b/api/server/go.sum @@ -48,8 +48,8 @@ github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6r github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE= github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= -github.com/casbin/casbin/v2 v2.132.0 h1:73hGmOszGSL3hTVquwkAi98XLl3gPJ+BxB6D7G9Fxtk= -github.com/casbin/casbin/v2 v2.132.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18= +github.com/casbin/casbin/v2 v2.134.0 h1:wyO3hZb487GzlGVAI2hUoHQT0ehFD+9B5P+HVG9BVTM= +github.com/casbin/casbin/v2 v2.134.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18= github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A= github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0= github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A= @@ -228,10 +228,10 @@ github.com/testcontainers/testcontainers-go v0.33.0 h1:zJS9PfXYT5O0ZFXM2xxXfk4J5 github.com/testcontainers/testcontainers-go v0.33.0/go.mod h1:W80YpTa8D5C3Yy16icheD01UTDu+LmXIA2Keo+jWtT8= github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0 h1:iXVA84s5hKMS5gn01GWOYHE3ymy/2b+0YkpFeTxB2XY= github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0/go.mod h1:R6tMjTojRiaoo89fh/hf7tOmfzohdqSU17R9DwSVSog= -github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4= -github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4= -github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso= -github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ= +github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= +github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= +github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= +github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= @@ -338,8 +338,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= -golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= -golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= diff --git a/api/server/interface/api/config.go b/api/server/interface/api/config.go index bee2ca9..c1f8850 100644 --- a/api/server/interface/api/config.go +++ b/api/server/interface/api/config.go @@ -6,6 +6,21 @@ import ( ) type Config struct { - Mw *mwa.Config `yaml:"middleware"` - Storage *fsc.Config `yaml:"storage"` + Mw *mwa.Config `yaml:"middleware"` + Storage *fsc.Config `yaml:"storage"` + ChainGateway *ChainGatewayConfig `yaml:"chain_gateway"` +} + +type ChainGatewayConfig struct { + AddressEnv string `yaml:"address_env"` + DialTimeoutSeconds int `yaml:"dial_timeout_seconds"` + CallTimeoutSeconds int `yaml:"call_timeout_seconds"` + Insecure bool `yaml:"insecure"` + DefaultAsset ChainGatewayAssetConfig `yaml:"default_asset"` +} + +type ChainGatewayAssetConfig struct { + Chain string `yaml:"chain"` + TokenSymbol string `yaml:"token_symbol"` + ContractAddress string `yaml:"contract_address"` } diff --git a/api/server/interface/api/srequest/signup.go b/api/server/interface/api/srequest/signup.go index 2f96c04..baac565 100644 --- a/api/server/interface/api/srequest/signup.go +++ b/api/server/interface/api/srequest/signup.go @@ -4,7 +4,7 @@ import "github.com/tech/sendico/pkg/model" type Signup struct { Account model.AccountData `json:"account"` - OrganizationName string `json:"organizationName"` + Organization model.Describable `json:"organization"` OrganizationTimeZone string `json:"organizationTimeZone"` AnonymousUser model.Describable `json:"anonymousUser"` OwnerRole model.Describable `json:"ownerRole"` diff --git a/api/server/interface/api/srequest/signup_test.go b/api/server/interface/api/srequest/signup_test.go index 0b284ef..71107b7 100644 --- a/api/server/interface/api/srequest/signup_test.go +++ b/api/server/interface/api/srequest/signup_test.go @@ -20,9 +20,13 @@ func TestSignupRequest_JSONSerialization(t *testing.T) { }, Password: "TestPassword123!", }, - Name: "Test User", + Describable: model.Describable{ + Name: "Test User", + }, + }, + Organization: model.Describable{ + Name: "Test Organization", }, - OrganizationName: "Test Organization", OrganizationTimeZone: "UTC", AnonymousUser: model.Describable{ Name: "Anonymous User", @@ -49,7 +53,7 @@ func TestSignupRequest_JSONSerialization(t *testing.T) { assert.Equal(t, signup.Account.Name, unmarshaled.Account.Name) assert.Equal(t, signup.Account.Login, unmarshaled.Account.Login) assert.Equal(t, signup.Account.Password, unmarshaled.Account.Password) - assert.Equal(t, signup.OrganizationName, unmarshaled.OrganizationName) + assert.Equal(t, signup.Organization.Name, unmarshaled.Organization.Name) assert.Equal(t, signup.OrganizationTimeZone, unmarshaled.OrganizationTimeZone) assert.Equal(t, signup.AnonymousUser.Name, unmarshaled.AnonymousUser.Name) assert.Equal(t, signup.OwnerRole.Name, unmarshaled.OwnerRole.Name) @@ -65,9 +69,13 @@ func TestSignupRequest_MinimalValidRequest(t *testing.T) { }, Password: "TestPassword123!", }, - Name: "Test User", + Describable: model.Describable{ + Name: "Test User", + }, + }, + Organization: model.Describable{ + Name: "Test Organization", }, - OrganizationName: "Test Organization", OrganizationTimeZone: "UTC", AnonymousUser: model.Describable{ Name: "Anonymous", @@ -93,13 +101,13 @@ func TestSignupRequest_MinimalValidRequest(t *testing.T) { // Verify minimal request is valid assert.Equal(t, signup.Account.Name, unmarshaled.Account.Name) assert.Equal(t, signup.Account.Login, unmarshaled.Account.Login) - assert.Equal(t, signup.OrganizationName, unmarshaled.OrganizationName) + assert.Equal(t, signup.Organization.Name, unmarshaled.Organization.Name) } func TestSignupRequest_InvalidJSON(t *testing.T) { invalidJSONs := []string{ `{"account": invalid}`, - `{"organizationName": 123}`, + `{"organization": 123}`, `{"organizationTimeZone": true}`, `{"defaultPriorityGroup": "not_an_object"}`, `{"anonymousUser": []}`, @@ -125,9 +133,13 @@ func TestSignupRequest_UnicodeCharacters(t *testing.T) { }, Password: "TestPassword123!", }, - Name: "Test 用户 Üser", + Describable: model.Describable{ + Name: "Test 用户 Üser", + }, + }, + Organization: model.Describable{ + Name: "测试 Organization", }, - OrganizationName: "测试 Organization", OrganizationTimeZone: "UTC", AnonymousUser: model.Describable{ Name: "匿名 User", @@ -153,7 +165,7 @@ func TestSignupRequest_UnicodeCharacters(t *testing.T) { // Verify unicode characters are properly handled assert.Equal(t, "测试@example.com", unmarshaled.Account.Login) assert.Equal(t, "Test 用户 Üser", unmarshaled.Account.Name) - assert.Equal(t, "测试 Organization", unmarshaled.OrganizationName) + assert.Equal(t, "测试 Organization", unmarshaled.Organization.Name) assert.Equal(t, "匿名 User", unmarshaled.AnonymousUser.Name) assert.Equal(t, "所有者", unmarshaled.OwnerRole.Name) assert.Equal(t, "匿名", unmarshaled.AnonymousRole.Name) diff --git a/api/server/interface/api/sresponse/signup_availability.go b/api/server/interface/api/sresponse/signup_availability.go new file mode 100644 index 0000000..f5ca161 --- /dev/null +++ b/api/server/interface/api/sresponse/signup_availability.go @@ -0,0 +1,23 @@ +package sresponse + +import ( + "net/http" + + "github.com/tech/sendico/pkg/api/http/response" + "github.com/tech/sendico/pkg/mlogger" +) + +type SignupAvailability struct { + Login string `json:"login"` + Available bool `json:"available"` +} + +func SignUpAvailability(logger mlogger.Logger, login string, available bool) http.HandlerFunc { + return response.Ok( + logger, + SignupAvailability{ + Login: login, + Available: available, + }, + ) +} diff --git a/api/server/internal/server/accountapiimp/service.go b/api/server/internal/server/accountapiimp/service.go index 7425b03..169d464 100644 --- a/api/server/internal/server/accountapiimp/service.go +++ b/api/server/internal/server/accountapiimp/service.go @@ -2,7 +2,12 @@ package accountapiimp import ( "context" + "fmt" + "os" + "strings" + "time" + chaingatewayclient "github.com/tech/sendico/chain/gateway/client" api "github.com/tech/sendico/pkg/api/http" "github.com/tech/sendico/pkg/auth" "github.com/tech/sendico/pkg/db/account" @@ -14,6 +19,7 @@ import ( "github.com/tech/sendico/pkg/messaging" "github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mservice" + gatewayv1 "github.com/tech/sendico/pkg/proto/chain/gateway/v1" "github.com/tech/sendico/server/interface/accountservice" eapi "github.com/tech/sendico/server/interface/api" "github.com/tech/sendico/server/interface/services/fileservice" @@ -39,6 +45,13 @@ type AccountAPI struct { tph mutil.ParamHelper accountsPermissionRef primitive.ObjectID accService accountservice.AccountService + chainGateway chainWalletClient + chainAsset *gatewayv1.Asset +} + +type chainWalletClient interface { + CreateManagedWallet(ctx context.Context, req *gatewayv1.CreateManagedWalletRequest) (*gatewayv1.CreateManagedWalletResponse, error) + Close() error } func (a *AccountAPI) Name() mservice.Type { @@ -46,7 +59,15 @@ func (a *AccountAPI) Name() mservice.Type { } func (a *AccountAPI) Finish(ctx context.Context) error { - return a.avatars.Finish(ctx) + if err := a.avatars.Finish(ctx); err != nil { + return err + } + if a.chainGateway != nil { + if err := a.chainGateway.Close(); err != nil { + a.logger.Warn("Failed to close chain gateway client", zap.Error(err)) + } + } + return nil } func CreateAPI(a eapi.API) (*AccountAPI, error) { @@ -86,6 +107,7 @@ func CreateAPI(a eapi.API) (*AccountAPI, error) { // Account related api endpoints a.Register().Handler(mservice.Accounts, "/signup", api.Post, p.signup) + a.Register().Handler(mservice.Accounts, "/signup/availability", api.Get, p.signupAvailability) a.Register().AccountHandler(mservice.Accounts, "", api.Put, p.updateProfile) a.Register().AccountHandler(mservice.Accounts, "", api.Get, p.getProfile) @@ -120,5 +142,74 @@ func CreateAPI(a eapi.API) (*AccountAPI, error) { } p.accountsPermissionRef = accountsPolicy.ID + if err := p.initChainGateway(a.Config()); err != nil { + p.logger.Error("Failed to initialize chain gateway client", zap.Error(err)) + return nil, err + } + return p, nil } + +func (a *AccountAPI) initChainGateway(cfg *eapi.Config) error { + if cfg == nil || cfg.ChainGateway == nil { + return fmt.Errorf("chain gateway configuration is not provided") + } + + address := strings.TrimSpace(os.Getenv(cfg.ChainGateway.AddressEnv)) + if address == "" { + return fmt.Errorf("chain gateway address env %s is empty", cfg.ChainGateway.AddressEnv) + } + + clientCfg := chaingatewayclient.Config{ + Address: address, + DialTimeout: time.Duration(cfg.ChainGateway.DialTimeoutSeconds) * time.Second, + CallTimeout: time.Duration(cfg.ChainGateway.CallTimeoutSeconds) * time.Second, + Insecure: cfg.ChainGateway.Insecure, + } + + client, err := chaingatewayclient.New(context.Background(), clientCfg) + if err != nil { + return err + } + + asset, err := buildGatewayAsset(cfg.ChainGateway.DefaultAsset) + if err != nil { + _ = client.Close() + return err + } + + a.chainGateway = client + a.chainAsset = asset + return nil +} + +func buildGatewayAsset(cfg eapi.ChainGatewayAssetConfig) (*gatewayv1.Asset, error) { + chain, err := parseChainNetwork(cfg.Chain) + if err != nil { + return nil, err + } + tokenSymbol := strings.TrimSpace(cfg.TokenSymbol) + if tokenSymbol == "" { + return nil, fmt.Errorf("chain gateway token symbol is required") + } + return &gatewayv1.Asset{ + Chain: chain, + TokenSymbol: strings.ToUpper(tokenSymbol), + ContractAddress: strings.ToLower(strings.TrimSpace(cfg.ContractAddress)), + }, nil +} + +func parseChainNetwork(value string) (gatewayv1.ChainNetwork, error) { + switch strings.ToUpper(strings.TrimSpace(value)) { + case "ETHEREUM_MAINNET", "CHAIN_NETWORK_ETHEREUM_MAINNET": + return gatewayv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET, nil + case "ARBITRUM_ONE", "CHAIN_NETWORK_ARBITRUM_ONE": + return gatewayv1.ChainNetwork_CHAIN_NETWORK_ARBITRUM_ONE, nil + case "OTHER_EVM", "CHAIN_NETWORK_OTHER_EVM": + return gatewayv1.ChainNetwork_CHAIN_NETWORK_OTHER_EVM, nil + case "", "CHAIN_NETWORK_UNSPECIFIED": + return gatewayv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED, fmt.Errorf("chain network must be specified") + default: + return gatewayv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED, fmt.Errorf("unsupported chain network %s", value) + } +} diff --git a/api/server/internal/server/accountapiimp/signup.go b/api/server/internal/server/accountapiimp/signup.go index a770d12..c57df70 100644 --- a/api/server/internal/server/accountapiimp/signup.go +++ b/api/server/internal/server/accountapiimp/signup.go @@ -6,13 +6,16 @@ import ( "errors" "fmt" "net/http" + "strings" "time" + "github.com/google/uuid" "github.com/tech/sendico/pkg/api/http/response" "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" + gatewayv1 "github.com/tech/sendico/pkg/proto/chain/gateway/v1" "github.com/tech/sendico/server/interface/api/srequest" "github.com/tech/sendico/server/interface/api/sresponse" "go.mongodb.org/mongo-driver/bson/primitive" @@ -41,6 +44,10 @@ func (a *AccountAPI) createAnonymousAccount(ctx context.Context, org *model.Orga } 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())) } @@ -51,7 +58,8 @@ func (a *AccountAPI) createOrg(ctx context.Context, sr *srequest.Signup, permiss PermissionRef: permissionRef, }, Describable: model.Describable{ - Name: sr.OrganizationName, + Name: name, + Description: sr.Organization.Description, }, TimeZone: sr.OrganizationTimeZone, }, @@ -74,6 +82,18 @@ func (a *AccountAPI) signup(r *http.Request) http.HandlerFunc { 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)) @@ -96,6 +116,26 @@ func (a *AccountAPI) signup(r *http.Request) http.HandlerFunc { 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) @@ -116,6 +156,10 @@ func (a *AccountAPI) signupTransactionBody(ctx context.Context, sr *srequest.Sig return nil, err } + if err := a.openOrgWallet(ctx, org, sr); err != nil { + return nil, err + } + 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)) @@ -146,8 +190,21 @@ func (a *AccountAPI) grantAllPermissions(ctx context.Context, organizationRef pr 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)) @@ -172,5 +229,55 @@ func (a *AccountAPI) grantAllPermissions(ctx context.Context, organizationRef pr 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 := &gatewayv1.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 } diff --git a/api/server/internal/server/accountapiimp/signup_integration_test.go b/api/server/internal/server/accountapiimp/signup_integration_test.go index 54baae2..3337eb8 100644 --- a/api/server/internal/server/accountapiimp/signup_integration_test.go +++ b/api/server/internal/server/accountapiimp/signup_integration_test.go @@ -65,9 +65,13 @@ func TestSignupRequestSerialization(t *testing.T) { }, Password: "TestPassword123!", }, - Name: "Test User", + Describable: model.Describable{ + Name: "Test User", + }, + }, + Organization: model.Describable{ + Name: "Test Organization", }, - OrganizationName: "Test Organization", OrganizationTimeZone: "UTC", AnonymousUser: model.Describable{ Name: "Anonymous User", @@ -93,7 +97,7 @@ func TestSignupRequestSerialization(t *testing.T) { // Verify data integrity assert.Equal(t, signupRequest.Account.Login, retrieved.Account.Login) assert.Equal(t, signupRequest.Account.Name, retrieved.Account.Name) - assert.Equal(t, signupRequest.OrganizationName, retrieved.OrganizationName) + assert.Equal(t, signupRequest.Organization.Name, retrieved.Organization.Name) assert.Equal(t, signupRequest.OrganizationTimeZone, retrieved.OrganizationTimeZone) }) @@ -109,9 +113,13 @@ func TestSignupHTTPSerialization(t *testing.T) { }, Password: "TestPassword123!", }, - Name: "Test User", + Describable: model.Describable{ + Name: "Test User", + }, + }, + Organization: model.Describable{ + Name: "Test Organization", }, - OrganizationName: "Test Organization", OrganizationTimeZone: "UTC", AnonymousUser: model.Describable{ Name: "Anonymous User", @@ -141,13 +149,13 @@ func TestSignupHTTPSerialization(t *testing.T) { // Verify parsing assert.Equal(t, signupRequest.Account.Login, parsedRequest.Account.Login) assert.Equal(t, signupRequest.Account.Name, parsedRequest.Account.Name) - assert.Equal(t, signupRequest.OrganizationName, parsedRequest.OrganizationName) + assert.Equal(t, signupRequest.Organization.Name, parsedRequest.Organization.Name) }) t.Run("UnicodeCharacters", func(t *testing.T) { unicodeRequest := signupRequest unicodeRequest.Account.Name = "Test 用户 Üser" - unicodeRequest.OrganizationName = "测试 Organization" + unicodeRequest.Organization.Name = "测试 Organization" // Serialize to JSON reqBody, err := json.Marshal(unicodeRequest) @@ -160,7 +168,7 @@ func TestSignupHTTPSerialization(t *testing.T) { // Verify unicode characters are preserved assert.Equal(t, "Test 用户 Üser", parsedRequest.Account.Name) - assert.Equal(t, "测试 Organization", parsedRequest.OrganizationName) + assert.Equal(t, "测试 Organization", parsedRequest.Organization.Name) }) t.Run("InvalidJSONRequest", func(t *testing.T) { @@ -184,7 +192,9 @@ func TestAccountDataConversion(t *testing.T) { }, Password: "TestPassword123!", }, - Name: "Test User", + Describable: model.Describable{ + Name: "Test User", + }, } t.Run("ToAccount", func(t *testing.T) { diff --git a/api/server/internal/server/accountapiimp/signup_test.go b/api/server/internal/server/accountapiimp/signup_test.go index 276ecac..87687ce 100644 --- a/api/server/internal/server/accountapiimp/signup_test.go +++ b/api/server/internal/server/accountapiimp/signup_test.go @@ -1,12 +1,18 @@ package accountapiimp import ( + "context" + "errors" "testing" "time" "github.com/stretchr/testify/assert" + "github.com/tech/sendico/pkg/db/repository/builder" + "github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/model" "github.com/tech/sendico/server/interface/api/srequest" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.uber.org/zap" ) // Helper function to create string pointers @@ -60,9 +66,13 @@ func TestCreateValidSignupRequest(t *testing.T) { }, Password: "TestPassword123!", }, - Name: "Test User", + Describable: model.Describable{ + Name: "Test User", + }, + }, + Organization: model.Describable{ + Name: "Test Organization", }, - OrganizationName: "Test Organization", OrganizationTimeZone: "UTC", AnonymousUser: model.Describable{ Name: "Anonymous User", @@ -79,7 +89,7 @@ func TestCreateValidSignupRequest(t *testing.T) { assert.Equal(t, "test@example.com", request.Account.Login) assert.Equal(t, "TestPassword123!", request.Account.Password) assert.Equal(t, "Test User", request.Account.Name) - assert.Equal(t, "Test Organization", request.OrganizationName) + assert.Equal(t, "Test Organization", request.Organization.Name) assert.Equal(t, "UTC", request.OrganizationTimeZone) } @@ -94,9 +104,13 @@ func TestSignupRequestValidation(t *testing.T) { }, Password: "TestPassword123!", }, - Name: "Test User", + Describable: model.Describable{ + Name: "Test User", + }, + }, + Organization: model.Describable{ + Name: "Test Organization", }, - OrganizationName: "Test Organization", OrganizationTimeZone: "UTC", } @@ -104,7 +118,7 @@ func TestSignupRequestValidation(t *testing.T) { assert.NotEmpty(t, request.Account.Login) assert.NotEmpty(t, request.Account.Password) assert.NotEmpty(t, request.Account.Name) - assert.NotEmpty(t, request.OrganizationName) + assert.NotEmpty(t, request.Organization.Name) assert.NotEmpty(t, request.OrganizationTimeZone) }) @@ -205,7 +219,9 @@ func TestAccountDataToAccount(t *testing.T) { }, Password: "TestPassword123!", }, - Name: "Test User", + Describable: model.Describable{ + Name: "Test User", + }, } account := accountData.ToAccount() @@ -240,3 +256,106 @@ func TestColorValidation(t *testing.T) { }) } } + +type stubAccountDB struct { + result *model.Account + err error +} + +func (s *stubAccountDB) GetByEmail(ctx context.Context, email string) (*model.Account, error) { + return s.result, s.err +} + +func (s *stubAccountDB) GetByToken(ctx context.Context, email string) (*model.Account, error) { + return nil, merrors.NotImplemented("stub") +} + +func (s *stubAccountDB) GetAccountsByRefs(ctx context.Context, orgRef primitive.ObjectID, refs []primitive.ObjectID) ([]model.Account, error) { + return nil, merrors.NotImplemented("stub") +} + +func (s *stubAccountDB) Create(ctx context.Context, object *model.Account) error { + return merrors.NotImplemented("stub") +} + +func (s *stubAccountDB) InsertMany(ctx context.Context, objects []*model.Account) error { + return merrors.NotImplemented("stub") +} + +func (s *stubAccountDB) Get(ctx context.Context, objectRef primitive.ObjectID, result *model.Account) error { + return merrors.NotImplemented("stub") +} + +func (s *stubAccountDB) Update(ctx context.Context, object *model.Account) error { + return merrors.NotImplemented("stub") +} + +func (s *stubAccountDB) Patch(ctx context.Context, objectRef primitive.ObjectID, patch builder.Patch) error { + return merrors.NotImplemented("stub") +} + +func (s *stubAccountDB) Delete(ctx context.Context, objectRef primitive.ObjectID) error { + return merrors.NotImplemented("stub") +} + +func (s *stubAccountDB) DeleteMany(ctx context.Context, query builder.Query) error { + return merrors.NotImplemented("stub") +} + +func (s *stubAccountDB) DeleteCascade(ctx context.Context, objectRef primitive.ObjectID) error { + return merrors.NotImplemented("stub") +} + +func (s *stubAccountDB) FindOne(ctx context.Context, query builder.Query, result *model.Account) error { + return merrors.NotImplemented("stub") +} + +func TestEnsureLoginAvailable(t *testing.T) { + ctx := context.Background() + logger := zap.NewNop() + + t.Run("available", func(t *testing.T) { + api := &AccountAPI{ + logger: logger, + db: &stubAccountDB{ + err: merrors.ErrNoData, + }, + } + assert.NoError(t, api.ensureLoginAvailable(ctx, "new@example.com")) + }) + + t.Run("taken", func(t *testing.T) { + api := &AccountAPI{ + logger: logger, + db: &stubAccountDB{ + result: &model.Account{}, + }, + } + err := api.ensureLoginAvailable(ctx, "used@example.com") + assert.Error(t, err) + assert.True(t, errors.Is(err, merrors.ErrDataConflict)) + }) + + t.Run("invalid login", func(t *testing.T) { + api := &AccountAPI{ + logger: logger, + db: &stubAccountDB{ + err: merrors.ErrNoData, + }, + } + err := api.ensureLoginAvailable(ctx, " ") + assert.Error(t, err) + assert.True(t, errors.Is(err, merrors.ErrInvalidArg)) + }) + + t.Run("db error", func(t *testing.T) { + api := &AccountAPI{ + logger: logger, + db: &stubAccountDB{ + err: errors.New("boom"), + }, + } + err := api.ensureLoginAvailable(ctx, "err@example.com") + assert.EqualError(t, err, "boom") + }) +} diff --git a/frontend/pshared/lib/api/requests/signup.dart b/frontend/pshared/lib/api/requests/signup.dart index 98a817a..6af1cc8 100644 --- a/frontend/pshared/lib/api/requests/signup.dart +++ b/frontend/pshared/lib/api/requests/signup.dart @@ -5,20 +5,20 @@ part 'signup.g.dart'; @JsonSerializable(explicitToJson: true) class SignupRequest { - final String name; - final String login; - final String password; - final String locale; - final String organizationName; + final SignupAccount account; + final DescribableRequest organization; final String organizationTimeZone; + final DescribableRequest anonymousUser; + final DescribableRequest ownerRole; + final DescribableRequest anonymousRole; const SignupRequest({ - required this.name, - required this.login, - required this.password, - required this.locale, - required this.organizationName, + required this.account, + required this.organization, required this.organizationTimeZone, + required this.anonymousUser, + required this.ownerRole, + required this.anonymousRole, }); factory SignupRequest.build({ @@ -28,15 +28,55 @@ class SignupRequest { required String locale, required String organizationName, required String organizationTimeZone, - }) => SignupRequest( - name: name, - login: login, - password: password, - locale: locale, - organizationName: organizationName, - organizationTimeZone: organizationTimeZone, - ); + }) => + SignupRequest( + account: SignupAccount( + name: name, + login: login, + password: password, + locale: locale, + ), + organization: DescribableRequest(name: organizationName), + organizationTimeZone: organizationTimeZone, + anonymousUser: const DescribableRequest(name: 'Anonymous'), + ownerRole: const DescribableRequest(name: 'Owner'), + anonymousRole: const DescribableRequest(name: 'Anonymous'), + ); - factory SignupRequest.fromJson(Map json) => _$SignupRequestFromJson(json); + factory SignupRequest.fromJson(Map json) => + _$SignupRequestFromJson(json); Map toJson() => _$SignupRequestToJson(this); } + +@JsonSerializable() +class SignupAccount { + final String name; + final String login; + final String password; + final String locale; + final String? description; + + const SignupAccount({ + required this.name, + required this.login, + required this.password, + required this.locale, + this.description, + }); + + factory SignupAccount.fromJson(Map json) => + _$SignupAccountFromJson(json); + Map toJson() => _$SignupAccountToJson(this); +} + +@JsonSerializable() +class DescribableRequest { + final String name; + final String? description; + + const DescribableRequest({required this.name, this.description}); + + factory DescribableRequest.fromJson(Map json) => + _$DescribableRequestFromJson(json); + Map toJson() => _$DescribableRequestToJson(this); +}