diff --git a/application.yaml b/application.yaml index 3fa50135..b42bf3d0 100644 --- a/application.yaml +++ b/application.yaml @@ -74,10 +74,6 @@ kyc/face: concurrentUsers: 1 kyc/linking: tenant: sunwaves - # If urls does not match hostname>/$tenant/ schema. - #tenantURLs: - # callfluent: https://localhost:1444/ - # doctorx: https://localhost:1445/ wintr/connectors/storage/v2: runDDL: true primaryURL: postgresql://root:pass@localhost:5438/eskimo-global?pool_max_conn_idle_time=1000ms&pool_health_check_period=500ms&pool_max_conns=40 @@ -86,7 +82,18 @@ kyc/linking: password: pass replicaURLs: - postgresql://root:pass@localhost:5438/eskimo-global?pool_max_conn_idle_time=1000ms&pool_health_check_period=500ms&pool_max_conns=20 - + # If urls does not match hostname>/$tenant/ schema. + #tenantURLs: + # callfluent: https://localhost:1444/ + # doctorx: https://localhost:1445/ +kyc/coinDistributionEligibility: + tenant: sunwaves + configJsonUrl1: https://somewhere.com/something/somebogus.json + # If urls does not match hostname>/$tenant/ schema. + # tenantURLs: + # sunwaves: https://localhost:7443/ + # callfluent: https://localhost:7444/ + # doctorx: https://localhost:7445/ kyc/quiz: environment: local enable-alerts: false diff --git a/cmd/eskimo-hut/api/docs.go b/cmd/eskimo-hut/api/docs.go index 64591901..eb3401c7 100644 --- a/cmd/eskimo-hut/api/docs.go +++ b/cmd/eskimo-hut/api/docs.go @@ -20,6 +20,91 @@ const docTemplate = `{ "host": "{{.Host}}", "basePath": "{{.BasePath}}", "paths": { + "/v1r/kyc/verifyCoinDistributionEligibility/users/{userId}": { + "get": { + "description": "Returns the non-completed kyc verification scenarios", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "KYC" + ], + "parameters": [ + { + "type": "string", + "default": "Bearer \u003cAdd access token here\u003e", + "description": "Insert your access token", + "name": "Authorization", + "in": "header", + "required": true + }, + { + "type": "string", + "default": "\u003cAdd metadata token here\u003e", + "description": "Insert your metadata token", + "name": "X-Account-Metadata", + "in": "header" + }, + { + "type": "string", + "description": "ID of the user", + "name": "userId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/verificationscenarios.Scenario" + } + } + }, + "400": { + "description": "if validations fail", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + }, + "401": { + "description": "if not authorized", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + }, + "403": { + "description": "if not allowed", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + }, + "422": { + "description": "if syntax fails", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + }, + "504": { + "description": "if request times out", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + } + } + } + }, "/v1r/user-statistics/top-countries": { "get": { "description": "Returns the paginated view of users per country.", @@ -1600,6 +1685,101 @@ const docTemplate = `{ } } }, + "/v1w/kyc/verifyCoinDistributionEligibility/users/{userId}/scenarios/{scenarioEnum}": { + "post": { + "description": "Verifies if a user is eligible for coin verificationscenarios.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "KYC" + ], + "parameters": [ + { + "type": "string", + "default": "Bearer \u003cAdd access token here\u003e", + "description": "Insert your access token", + "name": "Authorization", + "in": "header", + "required": true + }, + { + "type": "string", + "default": "\u003cAdd metadata token here\u003e", + "description": "Insert your metadata token", + "name": "X-Account-Metadata", + "in": "header" + }, + { + "type": "string", + "description": "ID of the user", + "name": "userId", + "in": "path", + "required": true + }, + { + "enum": [ + "join_cmc", + "join_twitter", + "join_telegram", + "signup_tenants" + ], + "type": "string", + "description": "the scenario", + "name": "scenarioEnum", + "in": "path", + "required": true + }, + { + "description": "Request params", + "name": "request", + "in": "body", + "schema": { + "$ref": "#/definitions/verificationscenarios.VerificationMetadata" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": {} + }, + "400": { + "description": "if validations fail", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + }, + "401": { + "description": "if not authorized", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + }, + "403": { + "description": "not allowed due to various reasons", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + }, + "422": { + "description": "if syntax fails", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + } + } + } + }, "/v1w/kyc/verifySocialKYCStep/users/{userId}": { "post": { "description": "Verifies if the user has posted the expected verification post on their social media account.", @@ -3550,6 +3730,51 @@ const docTemplate = `{ "example": 11 } } + }, + "verificationscenarios.Scenario": { + "type": "string", + "enum": [ + "join_cmc", + "join_twitter", + "join_telegram", + "signup_tenants" + ], + "x-enum-varnames": [ + "CoinDistributionScenarioCmc", + "CoinDistributionScenarioTwitter", + "CoinDistributionScenarioTelegram", + "CoinDistributionScenarioSignUpTenants" + ] + }, + "verificationscenarios.VerificationMetadata": { + "type": "object", + "properties": { + "cmcProfileLink": { + "type": "string", + "example": "some profile" + }, + "telegramUsername": { + "type": "string", + "example": "some telegram username" + }, + "tenantTokens": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "example": { + "signup_callfluent": "sometoken", + "signup_doctorx": "sometoken", + "signup_sauces": "sometoken", + "signup_sealsend": "sometoken", + "signup_sunwaves": "sometoken" + } + }, + "tweetUrl": { + "type": "string", + "example": "some tweet" + } + } } } }` diff --git a/cmd/eskimo-hut/api/swagger.json b/cmd/eskimo-hut/api/swagger.json index 5e2db1d5..5a044080 100644 --- a/cmd/eskimo-hut/api/swagger.json +++ b/cmd/eskimo-hut/api/swagger.json @@ -13,6 +13,91 @@ "version": "latest" }, "paths": { + "/v1r/kyc/verifyCoinDistributionEligibility/users/{userId}": { + "get": { + "description": "Returns the non-completed kyc verification scenarios", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "KYC" + ], + "parameters": [ + { + "type": "string", + "default": "Bearer \u003cAdd access token here\u003e", + "description": "Insert your access token", + "name": "Authorization", + "in": "header", + "required": true + }, + { + "type": "string", + "default": "\u003cAdd metadata token here\u003e", + "description": "Insert your metadata token", + "name": "X-Account-Metadata", + "in": "header" + }, + { + "type": "string", + "description": "ID of the user", + "name": "userId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/verificationscenarios.Scenario" + } + } + }, + "400": { + "description": "if validations fail", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + }, + "401": { + "description": "if not authorized", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + }, + "403": { + "description": "if not allowed", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + }, + "422": { + "description": "if syntax fails", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + }, + "504": { + "description": "if request times out", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + } + } + } + }, "/v1r/user-statistics/top-countries": { "get": { "description": "Returns the paginated view of users per country.", @@ -1593,6 +1678,101 @@ } } }, + "/v1w/kyc/verifyCoinDistributionEligibility/users/{userId}/scenarios/{scenarioEnum}": { + "post": { + "description": "Verifies if a user is eligible for coin verificationscenarios.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "KYC" + ], + "parameters": [ + { + "type": "string", + "default": "Bearer \u003cAdd access token here\u003e", + "description": "Insert your access token", + "name": "Authorization", + "in": "header", + "required": true + }, + { + "type": "string", + "default": "\u003cAdd metadata token here\u003e", + "description": "Insert your metadata token", + "name": "X-Account-Metadata", + "in": "header" + }, + { + "type": "string", + "description": "ID of the user", + "name": "userId", + "in": "path", + "required": true + }, + { + "enum": [ + "join_cmc", + "join_twitter", + "join_telegram", + "signup_tenants" + ], + "type": "string", + "description": "the scenario", + "name": "scenarioEnum", + "in": "path", + "required": true + }, + { + "description": "Request params", + "name": "request", + "in": "body", + "schema": { + "$ref": "#/definitions/verificationscenarios.VerificationMetadata" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": {} + }, + "400": { + "description": "if validations fail", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + }, + "401": { + "description": "if not authorized", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + }, + "403": { + "description": "not allowed due to various reasons", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + }, + "422": { + "description": "if syntax fails", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + } + } + } + }, "/v1w/kyc/verifySocialKYCStep/users/{userId}": { "post": { "description": "Verifies if the user has posted the expected verification post on their social media account.", @@ -3543,6 +3723,51 @@ "example": 11 } } + }, + "verificationscenarios.Scenario": { + "type": "string", + "enum": [ + "join_cmc", + "join_twitter", + "join_telegram", + "signup_tenants" + ], + "x-enum-varnames": [ + "CoinDistributionScenarioCmc", + "CoinDistributionScenarioTwitter", + "CoinDistributionScenarioTelegram", + "CoinDistributionScenarioSignUpTenants" + ] + }, + "verificationscenarios.VerificationMetadata": { + "type": "object", + "properties": { + "cmcProfileLink": { + "type": "string", + "example": "some profile" + }, + "telegramUsername": { + "type": "string", + "example": "some telegram username" + }, + "tenantTokens": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "example": { + "signup_callfluent": "sometoken", + "signup_doctorx": "sometoken", + "signup_sauces": "sometoken", + "signup_sealsend": "sometoken", + "signup_sunwaves": "sometoken" + } + }, + "tweetUrl": { + "type": "string", + "example": "some tweet" + } + } } } } \ No newline at end of file diff --git a/cmd/eskimo-hut/api/swagger.yaml b/cmd/eskimo-hut/api/swagger.yaml index 1b388fc3..6f25dd2b 100644 --- a/cmd/eskimo-hut/api/swagger.yaml +++ b/cmd/eskimo-hut/api/swagger.yaml @@ -837,6 +837,40 @@ definitions: example: 11 type: integer type: object + verificationscenarios.Scenario: + enum: + - join_cmc + - join_twitter + - join_telegram + - signup_tenants + type: string + x-enum-varnames: + - CoinDistributionScenarioCmc + - CoinDistributionScenarioTwitter + - CoinDistributionScenarioTelegram + - CoinDistributionScenarioSignUpTenants + verificationscenarios.VerificationMetadata: + properties: + cmcProfileLink: + example: some profile + type: string + telegramUsername: + example: some telegram username + type: string + tenantTokens: + additionalProperties: + type: string + example: + signup_callfluent: sometoken + signup_doctorx: sometoken + signup_sauces: sometoken + signup_sealsend: sometoken + signup_sunwaves: sometoken + type: object + tweetUrl: + example: some tweet + type: string + type: object info: contact: name: ice.io @@ -846,6 +880,63 @@ info: title: User Accounts, User Devices, User Statistics API version: latest paths: + /v1r/kyc/verifyCoinDistributionEligibility/users/{userId}: + get: + consumes: + - application/json + description: Returns the non-completed kyc verification scenarios + parameters: + - default: Bearer + description: Insert your access token + in: header + name: Authorization + required: true + type: string + - default: + description: Insert your metadata token + in: header + name: X-Account-Metadata + type: string + - description: ID of the user + in: path + name: userId + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/verificationscenarios.Scenario' + type: array + "400": + description: if validations fail + schema: + $ref: '#/definitions/server.ErrorResponse' + "401": + description: if not authorized + schema: + $ref: '#/definitions/server.ErrorResponse' + "403": + description: if not allowed + schema: + $ref: '#/definitions/server.ErrorResponse' + "422": + description: if syntax fails + schema: + $ref: '#/definitions/server.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/server.ErrorResponse' + "504": + description: if request times out + schema: + $ref: '#/definitions/server.ErrorResponse' + tags: + - KYC /v1r/user-statistics/top-countries: get: consumes: @@ -1912,6 +2003,71 @@ paths: $ref: '#/definitions/server.ErrorResponse' tags: - KYC + /v1w/kyc/verifyCoinDistributionEligibility/users/{userId}/scenarios/{scenarioEnum}: + post: + consumes: + - application/json + description: Verifies if a user is eligible for coin verificationscenarios. + parameters: + - default: Bearer + description: Insert your access token + in: header + name: Authorization + required: true + type: string + - default: + description: Insert your metadata token + in: header + name: X-Account-Metadata + type: string + - description: ID of the user + in: path + name: userId + required: true + type: string + - description: the scenario + enum: + - join_cmc + - join_twitter + - join_telegram + - signup_tenants + in: path + name: scenarioEnum + required: true + type: string + - description: Request params + in: body + name: request + schema: + $ref: '#/definitions/verificationscenarios.VerificationMetadata' + produces: + - application/json + responses: + "200": + description: OK + schema: {} + "400": + description: if validations fail + schema: + $ref: '#/definitions/server.ErrorResponse' + "401": + description: if not authorized + schema: + $ref: '#/definitions/server.ErrorResponse' + "403": + description: not allowed due to various reasons + schema: + $ref: '#/definitions/server.ErrorResponse' + "422": + description: if syntax fails + schema: + $ref: '#/definitions/server.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/server.ErrorResponse' + tags: + - KYC /v1w/kyc/verifySocialKYCStep/users/{userId}: post: consumes: diff --git a/cmd/eskimo-hut/contract.go b/cmd/eskimo-hut/contract.go index 5f5d5b4f..84718b9d 100644 --- a/cmd/eskimo-hut/contract.go +++ b/cmd/eskimo-hut/contract.go @@ -15,6 +15,7 @@ import ( linkerkyc "github.com/ice-blockchain/eskimo/kyc/linking" kycquiz "github.com/ice-blockchain/eskimo/kyc/quiz" kycsocial "github.com/ice-blockchain/eskimo/kyc/social" + verificationscenarios "github.com/ice-blockchain/eskimo/kyc/verification_scenarios" "github.com/ice-blockchain/eskimo/users" ) @@ -197,6 +198,10 @@ type ( ForwardToFaceKYCResponse struct { KycFaceAvailable bool `json:"kycFaceAvailable" example:"true"` } + GetRequiredVerificationEligibilityScenariosArg struct { + Authorization string `header:"Authorization" swaggerignore:"true" required:"true" example:"some token"` + UserID string `uri:"userId" required:"true" example:"did:ethr:0x4B73C58370AEfcEf86A6021afCDe5673511376B2"` + } ) // Private API. @@ -241,6 +246,10 @@ const ( linkingNotOwnedProfile = "NOT_OWNER_OF_REMOTE_USER" linkingDuplicate = "DUPLICATE" + kycVerificationScenariosVadidationFailedErrorCode = "VALIDATION_FAILED" + kycVerificationScenariosNoPendingScenariosErrorCode = "NO_PENDING_SCENARIOS" + kycVerificationScenariosNoTenantTokens = "NO_TENANT_TOKENS" //nolint:gosec // . + deviceIDTokenClaim = "deviceUniqueID" //nolint:gosec // . adminRole = "admin" @@ -256,14 +265,15 @@ var ( type ( // | service implements server.State and is responsible for managing the state and lifecycle of the package. service struct { - usersProcessor users.Processor - quizRepository kycquiz.Repository - authEmailLinkClient emaillink.Client - telegramAuthClient telegramauth.Client - tokenRefresher auth.TokenRefresher - socialRepository kycsocial.Repository - faceKycClient facekyc.Client - usersLinker linkerkyc.Linker + usersProcessor users.Processor + quizRepository kycquiz.Repository + authEmailLinkClient emaillink.Client + telegramAuthClient telegramauth.Client + tokenRefresher auth.TokenRefresher + socialRepository kycsocial.Repository + faceKycClient facekyc.Client + usersLinker linkerkyc.Linker + verificationScenariosRepository verificationscenarios.Repository } config struct { APIKey string `yaml:"api-key" mapstructure:"api-key"` //nolint:tagliatelle // Nope. diff --git a/cmd/eskimo-hut/eskimo.go b/cmd/eskimo-hut/eskimo.go index 91418fc7..d72b5ee2 100644 --- a/cmd/eskimo-hut/eskimo.go +++ b/cmd/eskimo-hut/eskimo.go @@ -10,6 +10,7 @@ import ( "github.com/pkg/errors" + verificationscenarios "github.com/ice-blockchain/eskimo/kyc/verification_scenarios" "github.com/ice-blockchain/eskimo/users" "github.com/ice-blockchain/wintr/server" ) @@ -386,3 +387,34 @@ func (s *service) GetUserByUsername( //nolint:gocritic // False negative. return server.OK(&UserProfile{UserProfile: resp}), nil } + +// GetPendingKYCVerificationScenarios godoc +// +// @Schemes +// @Description Returns the non-completed kyc verification scenarios +// @Tags KYC +// @Accept json +// @Produce json +// @Param Authorization header string true "Insert your access token" default(Bearer ) +// @Param X-Account-Metadata header string false "Insert your metadata token" default() +// @Param userId path string true "ID of the user" +// @Success 200 {array} verificationscenarios.Scenario +// @Failure 400 {object} server.ErrorResponse "if validations fail" +// @Failure 401 {object} server.ErrorResponse "if not authorized" +// @Failure 403 {object} server.ErrorResponse "if not allowed" +// @Failure 422 {object} server.ErrorResponse "if syntax fails" +// @Failure 500 {object} server.ErrorResponse +// @Failure 504 {object} server.ErrorResponse "if request times out" +// @Router /v1r/kyc/verifyCoinDistributionEligibility/users/{userId} [GET]. +func (s *service) GetPendingKYCVerificationScenarios( //nolint:gocritic // . + ctx context.Context, + req *server.Request[GetRequiredVerificationEligibilityScenariosArg, []*verificationscenarios.Scenario], +) (*server.Response[[]*verificationscenarios.Scenario], *server.Response[server.ErrorResponse]) { + ctx = users.ContextWithAuthorization(ctx, req.Data.Authorization) //nolint:revive // . + scenarios, err := s.verificationScenariosRepository.GetPendingVerificationScenarios(ctx, req.Data.UserID) + if err = errors.Wrapf(err, "failed to GetRequiredVerificationEligibilityScenarios for userID:%v", req.Data.UserID); err != nil { + return nil, server.Unexpected(err) + } + + return server.OK(&scenarios), nil +} diff --git a/cmd/eskimo-hut/eskimo_hut.go b/cmd/eskimo-hut/eskimo_hut.go index 05538a93..93929f5b 100644 --- a/cmd/eskimo-hut/eskimo_hut.go +++ b/cmd/eskimo-hut/eskimo_hut.go @@ -17,6 +17,7 @@ import ( linkerkyc "github.com/ice-blockchain/eskimo/kyc/linking" kycquiz "github.com/ice-blockchain/eskimo/kyc/quiz" "github.com/ice-blockchain/eskimo/kyc/social" + verificationscenarios "github.com/ice-blockchain/eskimo/kyc/verification_scenarios" "github.com/ice-blockchain/eskimo/users" appcfg "github.com/ice-blockchain/wintr/config" "github.com/ice-blockchain/wintr/log" @@ -55,7 +56,8 @@ func main() { func (s *service) RegisterRoutes(router *server.Router) { s.registerEskimoRoutes(router) - s.setupKYCRoutes(router) + s.setupKYCWriteRoutes(router) + s.setupKYCReadRoutes(router) s.setupUserRoutes(router) s.setupDevicesRoutes(router) s.setupAuthRoutes(router) @@ -70,6 +72,7 @@ func (s *service) Init(ctx context.Context, cancel context.CancelFunc) { s.quizRepository = kycquiz.NewRepository(ctx, s.usersProcessor) s.usersLinker = linkerkyc.NewAccountLinker(ctx, cfg.Host) s.faceKycClient = facekyc.New(ctx, s.usersProcessor, s.usersLinker) + s.verificationScenariosRepository = verificationscenarios.New(ctx, s.usersProcessor, s.usersLinker, cfg.Host) } func (s *service) Close(ctx context.Context) error { diff --git a/cmd/eskimo-hut/kyc.go b/cmd/eskimo-hut/kyc.go index 5b42ff9c..d31811d6 100644 --- a/cmd/eskimo-hut/kyc.go +++ b/cmd/eskimo-hut/kyc.go @@ -14,19 +14,27 @@ import ( "github.com/ice-blockchain/eskimo/kyc/linking" kycquiz "github.com/ice-blockchain/eskimo/kyc/quiz" kycsocial "github.com/ice-blockchain/eskimo/kyc/social" + verificationscenarios "github.com/ice-blockchain/eskimo/kyc/verification_scenarios" "github.com/ice-blockchain/eskimo/users" "github.com/ice-blockchain/wintr/log" "github.com/ice-blockchain/wintr/server" ) -func (s *service) setupKYCRoutes(router *server.Router) { +func (s *service) setupKYCWriteRoutes(router *server.Router) { router. Group("v1w"). POST("kyc/startOrContinueKYCStep4Session/users/:userId", server.RootHandler(s.StartOrContinueKYCStep4Session)). POST("kyc/checkKYCStep4Status/users/:userId", server.RootHandler(s.CheckKYCStep4Status)). POST("kyc/verifySocialKYCStep/users/:userId", server.RootHandler(s.VerifySocialKYCStep)). POST("kyc/tryResetKYCSteps/users/:userId", server.RootHandler(s.TryResetKYCSteps)). - POST("kyc/checkFaceKYCStatus/users/:userId", server.RootHandler(s.ForwardToFaceKYC)) + POST("kyc/checkFaceKYCStatus/users/:userId", server.RootHandler(s.ForwardToFaceKYC)). + POST("kyc/verifyCoinDistributionEligibility/users/:userId/scenarios/:scenarioEnum", server.RootHandler(s.VerifyKYCScenarios)) +} + +func (s *service) setupKYCReadRoutes(router *server.Router) { + router. + Group("v1r"). + GET("kyc/verifyCoinDistributionEligibility/users/:userId", server.RootHandler(s.GetPendingKYCVerificationScenarios)) } func (s *service) startQuizSession(ctx context.Context, userID users.UserID, lang string) (*kycquiz.Quiz, error) { @@ -359,3 +367,103 @@ func (s *service) ForwardToFaceKYC( return server.OK(&ForwardToFaceKYCResponse{KycFaceAvailable: kycFaceAvailable}), nil } + +// VerifyCoinDistributionEligibility godoc +// +// @Schemes +// @Description Verifies if a user is eligible for coin verificationscenarios. +// @Tags KYC +// @Accept json +// @Produce json +// +// @Param Authorization header string true "Insert your access token" default(Bearer ) +// @Param X-Account-Metadata header string false "Insert your metadata token" default() +// @Param userId path string true "ID of the user" +// @Param scenarioEnum path string true "the scenario" enums(join_cmc,join_twitter,join_telegram,signup_tenants) +// @Param request body verificationscenarios.VerificationMetadata false "Request params" +// @Success 200 {object} any +// @Failure 400 {object} server.ErrorResponse "if validations fail" +// @Failure 401 {object} server.ErrorResponse "if not authorized" +// @Failure 403 {object} server.ErrorResponse "not allowed due to various reasons" +// @Failure 422 {object} server.ErrorResponse "if syntax fails" +// @Failure 500 {object} server.ErrorResponse +// @Router /v1w/kyc/verifyCoinDistributionEligibility/users/{userId}/scenarios/{scenarioEnum} [POST]. +func (s *service) VerifyKYCScenarios( //nolint:gocritic // . + ctx context.Context, + req *server.Request[verificationscenarios.VerificationMetadata, any], +) (*server.Response[any], *server.Response[server.ErrorResponse]) { + if err := validateScenariosData(req.Data); err != nil { + return nil, server.UnprocessableEntity(errors.Wrapf(err, "validations failed for %#v", req.Data), invalidPropertiesErrorCode) + } + ctx = users.ContextWithAuthorization(ctx, req.Data.Authorization) //nolint:revive // . + if err := s.verificationScenariosRepository.VerifyScenarios(ctx, req.Data); err != nil { + switch { + case errors.Is(err, verificationscenarios.ErrVerificationNotPassed): + return nil, server.BadRequest(err, kycVerificationScenariosVadidationFailedErrorCode) + case errors.Is(err, users.ErrRelationNotFound): + return nil, server.NotFound(err, userNotFoundErrorCode) + case errors.Is(err, users.ErrNotFound): + return nil, server.NotFound(err, userNotFoundErrorCode) + case errors.Is(err, linking.ErrNotOwnRemoteUser): + return nil, server.BadRequest(err, linkingNotOwnedProfile) + case errors.Is(err, verificationscenarios.ErrNoPendingScenarios): + return nil, server.NotFound(err, kycVerificationScenariosNoPendingScenariosErrorCode) + case errors.Is(err, verificationscenarios.ErrWrongTenantTokens): + return nil, server.BadRequest(err, kycVerificationScenariosNoTenantTokens) + default: + return nil, server.Unexpected(err) + } + } + + return server.OK[any](nil), nil +} + +//nolint:funlen,gocognit,revive // . +func validateScenariosData(data *verificationscenarios.VerificationMetadata) error { + switch data.ScenarioEnum { + case verificationscenarios.CoinDistributionScenarioCmc: + if data.CMCProfileLink == "" { + return errors.Errorf("empty cmc profile link `%v`", data.CMCProfileLink) + } + case verificationscenarios.CoinDistributionScenarioTwitter: + if data.TweetURL == "" { + return errors.Errorf("empty tweet url `%v`", data.TweetURL) + } + if data.Language == "" { + return errors.Errorf("empty language `%v`", data.Language) + } + case verificationscenarios.CoinDistributionScenarioTelegram: + if data.TelegramUsername == "" { + return errors.Errorf("empty telegram username `%v`", data.TelegramUsername) + } + case verificationscenarios.CoinDistributionScenarioSignUpTenants: + if len(data.TenantTokens) == 0 { + return errors.Errorf("empty tenant tokens `%v`", data.TenantTokens) + } + var ( + supportedTenants = []verificationscenarios.TenantScenario{ + verificationscenarios.CoinDistributionScenarioSignUpSunwaves, + verificationscenarios.CoinDistributionScenarioSignUpCallfluent, + verificationscenarios.CoinDistributionScenarioSignUpSealsend, + verificationscenarios.CoinDistributionScenarioSignUpSauces, + verificationscenarios.CoinDistributionScenarioSignUpDoctorx, + } + unsupportedTenants []verificationscenarios.TenantScenario + ) + for tenant, token := range data.TenantTokens { + if token == "" { //nolint:gosec // . + return errors.Errorf("empty token for tenant `%v`", tenant) + } + if !slices.Contains(supportedTenants, tenant) { + unsupportedTenants = append(unsupportedTenants, tenant) + } + } + if len(unsupportedTenants) > 0 { + return errors.Errorf("unsupported tenants `%v`", unsupportedTenants) + } + default: + return errors.Errorf("unsupported scenario `%v`", data.ScenarioEnum) + } + + return nil +} diff --git a/go.mod b/go.mod index 9a3339c3..0c7349f7 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/google/uuid v1.6.0 github.com/hashicorp/go-multierror v1.1.1 github.com/ice-blockchain/go-tarantool-client v0.0.0-20230327200757-4fc71fa3f7bb + github.com/ice-blockchain/santa v1.161.0 github.com/ice-blockchain/wintr v1.154.0 github.com/imroc/req/v3 v3.48.0 github.com/ip2location/ip2location-go/v9 v9.7.0 @@ -30,8 +31,8 @@ require ( require ( cel.dev/expr v0.18.0 // indirect cloud.google.com/go v0.116.0 // indirect - cloud.google.com/go/auth v0.9.9 // indirect - cloud.google.com/go/auth/oauth2adapt v0.2.4 // indirect + cloud.google.com/go/auth v0.10.0 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.5 // indirect cloud.google.com/go/compute/metadata v0.5.2 // indirect cloud.google.com/go/firestore v1.17.0 // indirect cloud.google.com/go/iam v1.2.2 // indirect @@ -46,7 +47,7 @@ require ( github.com/KyleBanks/depth v1.2.1 // indirect github.com/MicahParks/keyfunc v1.9.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect - github.com/Microsoft/hcsshim v0.12.8 // indirect + github.com/Microsoft/hcsshim v0.12.9 // indirect github.com/andybalholm/brotli v1.1.1 // indirect github.com/andybalholm/cascadia v1.3.2 // indirect github.com/beorn7/perks v1.0.1 // indirect @@ -61,7 +62,9 @@ require ( github.com/cncf/xds/go v0.0.0-20240905190251-b4127c9b8d78 // indirect github.com/containerd/cgroups/v3 v3.0.3 // indirect github.com/containerd/containerd v1.7.23 // indirect - github.com/containerd/continuity v0.4.3 // indirect + github.com/containerd/errdefs v0.3.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/typeurl/v2 v2.2.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dennwc/varint v1.0.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect @@ -73,7 +76,7 @@ require ( github.com/envoyproxy/go-control-plane v0.13.1 // indirect github.com/envoyproxy/protoc-gen-validate v1.1.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/fsnotify/fsnotify v1.8.0 // indirect github.com/gabriel-vasile/mimetype v1.4.6 // indirect github.com/georgysavva/scany/v2 v2.1.3 // indirect github.com/gin-contrib/sse v0.1.0 // indirect @@ -126,7 +129,7 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/morikuni/aec v1.0.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/onsi/ginkgo/v2 v2.20.2 // indirect + github.com/onsi/ginkgo/v2 v2.21.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.0 // indirect github.com/opencontainers/runc v1.2.0 // indirect diff --git a/go.sum b/go.sum index 6b88b37e..27d7564e 100644 --- a/go.sum +++ b/go.sum @@ -3,10 +3,10 @@ cel.dev/expr v0.18.0/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE= cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U= -cloud.google.com/go/auth v0.9.9 h1:BmtbpNQozo8ZwW2t7QJjnrQtdganSdmqeIBxHxNkEZQ= -cloud.google.com/go/auth v0.9.9/go.mod h1:xxA5AqpDrvS+Gkmo9RqrGGRh6WSNKKOXhY3zNOr38tI= -cloud.google.com/go/auth/oauth2adapt v0.2.4 h1:0GWE/FUsXhf6C+jAkWgYm7X9tK8cuEIfy19DBn6B6bY= -cloud.google.com/go/auth/oauth2adapt v0.2.4/go.mod h1:jC/jOpwFP6JBxhB3P5Rr0a9HLMC/Pe3eaL4NmdvqPtc= +cloud.google.com/go/auth v0.10.0 h1:tWlkvFAh+wwTOzXIjrwM64karR1iTBZ/GRr0S/DULYo= +cloud.google.com/go/auth v0.10.0/go.mod h1:xxA5AqpDrvS+Gkmo9RqrGGRh6WSNKKOXhY3zNOr38tI= +cloud.google.com/go/auth/oauth2adapt v0.2.5 h1:2p29+dePqsCHPP1bqDJcKj4qxRyYCcbzKpFyKGt3MTk= +cloud.google.com/go/auth/oauth2adapt v0.2.5/go.mod h1:AlmsELtlEBnaNTL7jCj8VQFLy6mbZv0s4Q7NGBeQ5E8= cloud.google.com/go/compute/metadata v0.5.2 h1:UxK4uu/Tn+I3p2dYWTfiX4wva7aYlKixAHn3fyqngqo= cloud.google.com/go/compute/metadata v0.5.2/go.mod h1:C66sj2AluDcIqakBq/M8lw8/ybHgOZqin2obFxa/E5k= cloud.google.com/go/firestore v1.17.0 h1:iEd1LBbkDZTFsLw3sTH50eyg4qe8eoG6CjocmEXO9aQ= @@ -52,8 +52,8 @@ github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/Microsoft/hcsshim v0.12.8 h1:BtDWYlFMcWhorrvSSo2M7z0csPdw6t7no/C3FsSvqiI= -github.com/Microsoft/hcsshim v0.12.8/go.mod h1:cibQ4BqhJ32FXDwPdQhKhwrwophnh3FuT4nwQZF907w= +github.com/Microsoft/hcsshim v0.12.9 h1:2zJy5KA+l0loz1HzEGqyNnjd3fyZA31ZBCGKacp6lLg= +github.com/Microsoft/hcsshim v0.12.9/go.mod h1:fJ0gkFAna6ukt0bLdKB8djt4XIJhF/vEPuoIWYVvZ8Y= github.com/PuerkitoBio/goquery v1.10.0 h1:6fiXdLuUvYs2OJSvNRqlNPoBm6YABE226xrbavY5Wv4= github.com/PuerkitoBio/goquery v1.10.0/go.mod h1:TjZZl68Q3eGHNBA8CWaxAN7rOU1EbDz3CWuolcO5Yu4= github.com/alecthomas/units v0.0.0-20240626203959-61d1e3462e30 h1:t3eaIm0rUkzbrIewtiFmMK5RXHej2XnoXNhxVsAYUfg= @@ -100,8 +100,14 @@ github.com/containerd/cgroups/v3 v3.0.3 h1:S5ByHZ/h9PMe5IOQoN7E+nMc2UcLEM/V48DGD github.com/containerd/cgroups/v3 v3.0.3/go.mod h1:8HBe7V3aWGLFPd/k03swSIsGjZhHI2WzJmticMgVuz0= github.com/containerd/containerd v1.6.19 h1:F0qgQPrG0P2JPgwpxWxYavrVeXAG0ezUIB9Z/4FTUAU= github.com/containerd/containerd v1.6.19/go.mod h1:HZCDMn4v/Xl2579/MvtOC2M206i+JJ6VxFWU/NetrGY= -github.com/containerd/continuity v0.4.3 h1:6HVkalIp+2u1ZLH1J/pYX2oBVXlJZvh1X1A7bEZ9Su8= -github.com/containerd/continuity v0.4.3/go.mod h1:F6PTNCKepoxEaXLQp3wDAjygEnImnZ/7o4JzpodfroQ= +github.com/containerd/continuity v0.4.4 h1:/fNVfTJ7wIl/YPMHjf+5H32uFhl63JucB34PlCpMKII= +github.com/containerd/continuity v0.4.4/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE= +github.com/containerd/errdefs v0.3.0 h1:FSZgGOeK4yuT/+DnF07/Olde/q4KBoMsaamhXxIMDp4= +github.com/containerd/errdefs v0.3.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/typeurl/v2 v2.2.0 h1:6NBDbQzr7I5LHgp34xAXYF5DOTQDn05X58lsPEmzLso= +github.com/containerd/typeurl/v2 v2.2.0/go.mod h1:8XOOxnyatxSWuG8OfsZXVnAF4iZfedjS/8UHSPJnX4g= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= @@ -135,8 +141,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= -github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= +github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/gabriel-vasile/mimetype v1.4.6 h1:3+PzJTKLkvgjeTbts6msPJt4DixhT4YtFNf1gtGe3zc= github.com/gabriel-vasile/mimetype v1.4.6/go.mod h1:JX1qVKqZd40hUPpAfiNTe0Sne7hdfKSbOqqmkq8GCXc= github.com/georgysavva/scany/v2 v2.1.3 h1:Zd4zm/ej79Den7tBSU2kaTDPAH64suq4qlQdhiBeGds= @@ -245,6 +251,8 @@ github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/ice-blockchain/go-tarantool-client v0.0.0-20230327200757-4fc71fa3f7bb h1:8TnFP3mc7O+tc44kv2e0/TpZKnEVUaKH+UstwfBwRkk= github.com/ice-blockchain/go-tarantool-client v0.0.0-20230327200757-4fc71fa3f7bb/go.mod h1:ZsQU7i3mxhgBBu43Oev7WPFbIjP4TniN/b1UPNGbrq8= +github.com/ice-blockchain/santa v1.161.0 h1:OWZ8xz8PFIM9hIGlxZ18xX17B3634/I7aU+Gzd4oRd0= +github.com/ice-blockchain/santa v1.161.0/go.mod h1:QqjfCjiPiB9vAsDFv/RClnC1+FT0abcsznEWg4GxqQ8= github.com/ice-blockchain/wintr v1.154.0 h1:yZSQtAEwGHTSmJ5pXjX0tpui1TNnG615QfpBkhY99a4= github.com/ice-blockchain/wintr v1.154.0/go.mod h1:DoUn66XJGzPzfCZTsHyMjfgj2aVLGvjqDSuKj2pa3KE= github.com/imroc/req/v3 v3.48.0 h1:IYuMGetuwLzOOTzDCquDqs912WNwpsPK0TBXWPIvoqg= @@ -320,10 +328,10 @@ github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+ github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= -github.com/onsi/ginkgo/v2 v2.20.2 h1:7NVCeyIWROIAheY21RLS+3j2bb52W0W82tkberYytp4= -github.com/onsi/ginkgo/v2 v2.20.2/go.mod h1:K9gyxPIlb+aIvnZ8bd9Ak+YP18w3APlR+5coaZoE2ag= -github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= -github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= +github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM= +github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= +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/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= diff --git a/kyc/linking/contract.go b/kyc/linking/contract.go index 0ec03660..58c3ff84 100644 --- a/kyc/linking/contract.go +++ b/kyc/linking/contract.go @@ -24,6 +24,7 @@ type ( Verify(ctx context.Context, now *time.Time, userID UserID, tokens map[Tenant]Token) (allLinkedProfiles LinkedProfiles, verified Tenant, err error) Get(ctx context.Context, userID UserID) (allLinkedProfiles LinkedProfiles, verified Tenant, err error) SetTenantVerified(ctx context.Context, userID UserID, tenant Tenant) error + StoreLinkedAccounts(ctx context.Context, now *time.Time, userID, verifiedTenant string, res map[Tenant]UserID) error } ) @@ -47,7 +48,7 @@ const ( var ( //go:embed global_ddl.sql ddl string - errRemoteUserNotFound = errors.New("remote user not found") + ErrRemoteUserNotFound = errors.New("remote user not found") ErrNotOwnRemoteUser = errors.New("not own remote user") ErrDuplicate = storage.ErrDuplicate ) diff --git a/kyc/linking/linking.go b/kyc/linking/linking.go index 81cd1551..92cbe0a2 100644 --- a/kyc/linking/linking.go +++ b/kyc/linking/linking.go @@ -69,14 +69,14 @@ func (l *linker) Verify(ctx context.Context, now *time.Time, userID UserID, toke } } allProfiles[l.cfg.Tenant] = userID - if err = l.storeLinkedAccounts(ctx, now, userID, verified, allProfiles); err != nil { + if err = l.StoreLinkedAccounts(ctx, now, userID, verified, allProfiles); err != nil { return nil, "", errors.Wrapf(err, "failed to save linked accounts for %v", userID) } return allProfiles, verified, nil } -func (l *linker) storeLinkedAccounts(ctx context.Context, now *time.Time, userID, verifiedTenant string, res map[Tenant]UserID) error { +func (l *linker) StoreLinkedAccounts(ctx context.Context, now *time.Time, userID, verifiedTenant string, res map[Tenant]UserID) error { params := []any{} values := []string{} idx := 1 @@ -88,7 +88,9 @@ func (l *linker) storeLinkedAccounts(ctx context.Context, now *time.Time, userID } sql := fmt.Sprintf(`INSERT INTO linked_user_accounts(linked_at, tenant, user_id, linked_tenant, linked_user_id, has_kyc) - VALUES %v`, strings.Join(values, ",\n")) + VALUES %v + ON CONFLICT(user_id, linked_user_id, tenant, linked_tenant) + DO UPDATE SET has_kyc = EXCLUDED.has_kyc`, strings.Join(values, ",\n")) rows, err := storage.Exec(ctx, l.globalDB, sql, params...) if err != nil { return errors.Wrapf(err, "failed to save linked accounts for usr %v: %#v", userID, res) @@ -138,30 +140,30 @@ func (l *linker) Get(ctx context.Context, userID UserID) (allLinkedProfiles Link } func (l *linker) verifyToken(ctx context.Context, userID, tenant, token string) (remoteID UserID, hasFaceResult bool, err error) { - usr, err := l.fetchTokenData(ctx, tenant, token) + usr, err := FetchTokenData(ctx, tenant, token, l.host, l.cfg.TenantURLs) if err != nil { - if errors.Is(err, errRemoteUserNotFound) { - return "", false, ErrNotOwnRemoteUser + if errors.Is(err, ErrRemoteUserNotFound) { + return "", false, errors.Wrapf(ErrNotOwnRemoteUser, "token is not belong to %v", userID) } - return "", false, errors.Wrapf(err, "failed to fwtch remote user data for %v", userID) + return "", false, errors.Wrapf(err, "failed to fetch remote user data for %v", userID) } if usr.CreatedAt == nil || usr.ReferredBy == "" || usr.Username == "" { - return "", false, ErrNotOwnRemoteUser + return "", false, errors.Wrapf(ErrNotOwnRemoteUser, "token is not belong to %v", userID) } return usr.ID, usr.HasFaceKYCResult(), nil } //nolint:funlen // Single http call. -func (l *linker) fetchTokenData(ctx context.Context, tenant, token string) (*users.User, error) { +func FetchTokenData(ctx context.Context, tenant, token, host string, tenantURLs map[Tenant]string) (*users.User, error) { tok, err := server.Auth(ctx).ParseToken(token, false) if err != nil { return nil, errors.Wrapf(err, "invalid token passed") } var resp *req.Response var usr users.User - getUserURL, err := l.buildGetUserURL(tenant, tok.Subject) + getUserURL, err := buildGetUserURL(tenant, tok.Subject, host, tenantURLs) if err != nil { log.Panic(errors.Wrapf(err, "failed to detect tenant url")) } @@ -197,7 +199,7 @@ func (l *linker) fetchTokenData(ctx context.Context, tenant, token string) (*use return nil, errors.Wrap(err, "failed to link accounts") } else if statusCode := resp.GetStatusCode(); statusCode != http.StatusOK { if statusCode == http.StatusNotFound { - return nil, errRemoteUserNotFound + return nil, errors.Wrapf(ErrRemoteUserNotFound, "wrong status code for fetch token data for user %v", tok.Subject) } return nil, errors.Errorf("[%v]failed to link accounts", statusCode) @@ -210,15 +212,15 @@ func (l *linker) fetchTokenData(ctx context.Context, tenant, token string) (*use return &usr, nil } -func (l *linker) buildGetUserURL(tenant, userID string) (string, error) { +func buildGetUserURL(tenant, userID, host string, tenantURLs map[Tenant]string) (string, error) { var hasURL bool var baseURL string - if len(l.cfg.TenantURLs) > 0 { - baseURL, hasURL = l.cfg.TenantURLs[tenant] + if len(tenantURLs) > 0 { + baseURL, hasURL = tenantURLs[tenant] } if !hasURL { var err error - if baseURL, err = url.JoinPath("https://"+l.host, tenant); err != nil { + if baseURL, err = url.JoinPath("https://"+host, tenant); err != nil { return "", errors.Wrapf(err, "failed to build user url for tenant %v", tenant) } } diff --git a/kyc/social/internal/.testdata/application.yaml b/kyc/scraper/.testdata/application.yaml similarity index 100% rename from kyc/social/internal/.testdata/application.yaml rename to kyc/scraper/.testdata/application.yaml diff --git a/kyc/social/internal/contract.go b/kyc/scraper/contract.go similarity index 99% rename from kyc/social/internal/contract.go rename to kyc/scraper/contract.go index 4033ef4c..3f7cf44c 100644 --- a/kyc/social/internal/contract.go +++ b/kyc/scraper/contract.go @@ -1,6 +1,6 @@ // SPDX-License-Identifier: ice License 1.0 -package social +package scraper import ( "context" diff --git a/kyc/social/internal/facebook.go b/kyc/scraper/facebook.go similarity index 99% rename from kyc/social/internal/facebook.go rename to kyc/scraper/facebook.go index 022975cd..500a95d0 100644 --- a/kyc/social/internal/facebook.go +++ b/kyc/scraper/facebook.go @@ -1,6 +1,6 @@ // SPDX-License-Identifier: ice License 1.0 -package social +package scraper import ( "context" diff --git a/kyc/social/internal/facebook_test.go b/kyc/scraper/facebook_test.go similarity index 99% rename from kyc/social/internal/facebook_test.go rename to kyc/scraper/facebook_test.go index c867f85a..bb1e8e7b 100644 --- a/kyc/social/internal/facebook_test.go +++ b/kyc/scraper/facebook_test.go @@ -1,6 +1,6 @@ // SPDX-License-Identifier: ice License 1.0 -package social +package scraper import ( "context" diff --git a/kyc/social/internal/scraper.go b/kyc/scraper/scraper.go similarity index 99% rename from kyc/social/internal/scraper.go rename to kyc/scraper/scraper.go index 9b8aa58b..7c94754a 100644 --- a/kyc/social/internal/scraper.go +++ b/kyc/scraper/scraper.go @@ -1,6 +1,6 @@ // SPDX-License-Identifier: ice License 1.0 -package social +package scraper import ( "context" diff --git a/kyc/social/internal/scraper_test.go b/kyc/scraper/scraper_test.go similarity index 98% rename from kyc/social/internal/scraper_test.go rename to kyc/scraper/scraper_test.go index a886b47f..3810f805 100644 --- a/kyc/social/internal/scraper_test.go +++ b/kyc/scraper/scraper_test.go @@ -1,6 +1,6 @@ // SPDX-License-Identifier: ice License 1.0 -package social +package scraper import ( "context" diff --git a/kyc/social/internal/social.go b/kyc/scraper/social.go similarity index 98% rename from kyc/social/internal/social.go rename to kyc/scraper/social.go index 9b35e4cb..9ecd1857 100644 --- a/kyc/social/internal/social.go +++ b/kyc/scraper/social.go @@ -1,6 +1,6 @@ // SPDX-License-Identifier: ice License 1.0 -package social +package scraper import ( "os" diff --git a/kyc/social/internal/social_test.go b/kyc/scraper/social_test.go similarity index 99% rename from kyc/social/internal/social_test.go rename to kyc/scraper/social_test.go index a699964b..8c938892 100644 --- a/kyc/social/internal/social_test.go +++ b/kyc/scraper/social_test.go @@ -1,6 +1,6 @@ // SPDX-License-Identifier: ice License 1.0 -package social +package scraper import ( "context" diff --git a/kyc/social/internal/twitter.go b/kyc/scraper/twitter.go similarity index 99% rename from kyc/social/internal/twitter.go rename to kyc/scraper/twitter.go index 48146477..5d5b1529 100644 --- a/kyc/social/internal/twitter.go +++ b/kyc/scraper/twitter.go @@ -1,6 +1,6 @@ // SPDX-License-Identifier: ice License 1.0 -package social +package scraper import ( "bytes" diff --git a/kyc/social/internal/twitter_test.go b/kyc/scraper/twitter_test.go similarity index 99% rename from kyc/social/internal/twitter_test.go rename to kyc/scraper/twitter_test.go index ec274653..4e08d536 100644 --- a/kyc/social/internal/twitter_test.go +++ b/kyc/scraper/twitter_test.go @@ -1,6 +1,6 @@ // SPDX-License-Identifier: ice License 1.0 -package social +package scraper import ( "context" diff --git a/kyc/social/internal/validator.go b/kyc/scraper/validator.go similarity index 97% rename from kyc/social/internal/validator.go rename to kyc/scraper/validator.go index 28a0d46e..90e69385 100644 --- a/kyc/social/internal/validator.go +++ b/kyc/scraper/validator.go @@ -1,6 +1,6 @@ // SPDX-License-Identifier: ice License 1.0 -package social +package scraper import ( "net/url" diff --git a/kyc/social/internal/validator_test.go b/kyc/scraper/validator_test.go similarity index 98% rename from kyc/social/internal/validator_test.go rename to kyc/scraper/validator_test.go index 8fd759cc..65e9fb71 100644 --- a/kyc/social/internal/validator_test.go +++ b/kyc/scraper/validator_test.go @@ -1,6 +1,6 @@ // SPDX-License-Identifier: ice License 1.0 -package social +package scraper import ( "testing" diff --git a/kyc/social/contract.go b/kyc/social/contract.go index ee076c05..873b166c 100644 --- a/kyc/social/contract.go +++ b/kyc/social/contract.go @@ -14,7 +14,7 @@ import ( "github.com/pkg/errors" - social "github.com/ice-blockchain/eskimo/kyc/social/internal" + "github.com/ice-blockchain/eskimo/kyc/scraper" "github.com/ice-blockchain/eskimo/users" "github.com/ice-blockchain/wintr/connectors/storage/v2" ) @@ -22,8 +22,8 @@ import ( // Public API. const ( - FacebookType = social.StrategyFacebook - TwitterType = social.StrategyTwitter + FacebookType = scraper.StrategyFacebook + TwitterType = scraper.StrategyTwitter ) const ( @@ -44,7 +44,8 @@ var ( ) type ( - Type = social.StrategyType + Type = scraper.StrategyType + Metadata = scraper.Metadata VerificationResult string Verification struct { RemainingAttempts *uint8 `json:"remainingAttempts,omitempty" example:"3"` @@ -65,6 +66,12 @@ type ( Social Type `form:"social" required:"true" swaggerignore:"true" example:"twitter"` KYCStep users.KYCStep `form:"kycStep" required:"true" swaggerignore:"true" example:"1"` } + KycConfigJSON struct { + XPostPatternTemplate *template.Template `json:"-"` + XPostPattern string `json:"xPostPattern"` + XPostLink string `json:"xPostLink"` + XPostPatternExactMatch bool `json:"xPostPatternExactMatch"` + } Repository interface { io.Closer VerifyPost(ctx context.Context, metadata *VerificationMetadata) (*Verification, error) @@ -114,22 +121,15 @@ type ( } repository struct { user UserRepository - socialVerifiers map[Type]social.Verifier + socialVerifiers map[Type]scraper.Verifier cfg *config db *storage.DB } - kycConfigJSON struct { - xPostPatternTemplate *template.Template `json:"-"` //nolint:revive // . - XPostPattern string `json:"xPostPattern"` - XPostLink string `json:"xPostLink"` - XPostPatternExactMatch bool `json:"xPostPatternExactMatch"` - } - config struct { alertFrequency *sync.Map // .map[users.KYCStep]stdlibtime.Duration. - kycConfigJSON1 *atomic.Pointer[kycConfigJSON] - kycConfigJSON2 *atomic.Pointer[kycConfigJSON] + kycConfigJSON1 *atomic.Pointer[KycConfigJSON] + kycConfigJSON2 *atomic.Pointer[KycConfigJSON] ConfigJSONURL1 string `yaml:"config-json-url1" mapstructure:"config-json-url1"` //nolint:tagliatelle // . ConfigJSONURL2 string `yaml:"config-json-url2" mapstructure:"config-json-url2"` //nolint:tagliatelle // . Environment string `yaml:"environment" mapstructure:"environment"` diff --git a/kyc/social/remote_kyc_config.go b/kyc/social/remote_kyc_config.go index 881a00d4..72ad485d 100644 --- a/kyc/social/remote_kyc_config.go +++ b/kyc/social/remote_kyc_config.go @@ -26,8 +26,8 @@ func init() { //nolint:gochecknoinits // It's the only way to tweak the client. func (r *repository) startKYCConfigJSONSyncer(ctx context.Context) { ticker := stdlibtime.NewTicker(stdlibtime.Minute) defer ticker.Stop() - r.cfg.kycConfigJSON1 = new(atomic.Pointer[kycConfigJSON]) - r.cfg.kycConfigJSON2 = new(atomic.Pointer[kycConfigJSON]) + r.cfg.kycConfigJSON1 = new(atomic.Pointer[KycConfigJSON]) + r.cfg.kycConfigJSON2 = new(atomic.Pointer[KycConfigJSON]) log.Panic(errors.Wrap(r.syncKYCConfigJSON1(ctx), "failed to syncKYCConfigJSON1")) //nolint:revive // . log.Panic(errors.Wrap(r.syncKYCConfigJSON2(ctx), "failed to syncKYCConfigJSON2")) @@ -80,7 +80,7 @@ func (r *repository) syncKYCConfigJSON1(ctx context.Context) error { } else if data, err2 := resp.ToBytes(); err2 != nil { return errors.Wrapf(err2, "failed to read body of `%v`", r.cfg.ConfigJSONURL1) } else { //nolint:revive // . - var kycConfig kycConfigJSON + var kycConfig KycConfigJSON if err = json.UnmarshalContext(ctx, data, &kycConfig); err != nil { return errors.Wrapf(err, "failed to unmarshal into %#v, data: %v", kycConfig, string(data)) } @@ -88,8 +88,8 @@ func (r *repository) syncKYCConfigJSON1(ctx context.Context) error { return errors.Errorf("there's something wrong with the KYCConfigJSON body: %v", body) } if pattern := kycConfig.XPostPattern; pattern != "" { - if kycConfig.xPostPatternTemplate, err = template.New("kycCfg.Social1KYC.XPostPattern").Parse(pattern); err != nil { - return errors.Wrapf(err, "failed to parse kycCfg.Social1KYC.xPostPatternTemplate `%v`", pattern) + if kycConfig.XPostPatternTemplate, err = template.New("kycCfg.Social1KYC.XPostPattern").Parse(pattern); err != nil { + return errors.Wrapf(err, "failed to parse kycCfg.Social1KYC.XPostPatternTemplate `%v`", pattern) } } r.cfg.kycConfigJSON1.Swap(&kycConfig) @@ -132,7 +132,7 @@ func (r *repository) syncKYCConfigJSON2(ctx context.Context) error { } else if data, err2 := resp.ToBytes(); err2 != nil { return errors.Wrapf(err2, "failed to read body of `%v`", r.cfg.ConfigJSONURL2) } else { //nolint:revive // . - var kycConfig kycConfigJSON + var kycConfig KycConfigJSON if err = json.UnmarshalContext(ctx, data, &kycConfig); err != nil { return errors.Wrapf(err, "failed to unmarshal into %#v, data: %v", kycConfig, string(data)) } @@ -140,8 +140,8 @@ func (r *repository) syncKYCConfigJSON2(ctx context.Context) error { return errors.Errorf("there's something wrong with the KYCConfigJSON body: %v", body) } if pattern := kycConfig.XPostPattern; pattern != "" { - if kycConfig.xPostPatternTemplate, err = template.New("kycCfg.Social2KYC.XPostPattern").Parse(pattern); err != nil { - return errors.Wrapf(err, "failed to parse kycCfg.Social2KYC.xPostPatternTemplate `%v`", pattern) + if kycConfig.XPostPatternTemplate, err = template.New("kycCfg.Social2KYC.XPostPattern").Parse(pattern); err != nil { + return errors.Wrapf(err, "failed to parse kycCfg.Social2KYC.XPostPatternTemplate `%v`", pattern) } } r.cfg.kycConfigJSON2.Swap(&kycConfig) diff --git a/kyc/social/social.go b/kyc/social/social.go index 2ada1d74..8f775c9f 100644 --- a/kyc/social/social.go +++ b/kyc/social/social.go @@ -16,7 +16,7 @@ import ( "github.com/pkg/errors" - social "github.com/ice-blockchain/eskimo/kyc/social/internal" + scraper "github.com/ice-blockchain/eskimo/kyc/scraper" "github.com/ice-blockchain/eskimo/users" appcfg "github.com/ice-blockchain/wintr/config" "github.com/ice-blockchain/wintr/connectors/storage/v2" @@ -50,10 +50,10 @@ func loadTranslations() { //nolint:funlen,gocognit,revive // . tmpl.content = template.Must(template.New(templName).Parse(tmpl.Content)) if _, found := allTemplates[tenantName(tenantFile.Name())]; !found { - allTemplates[tenantName(tenantFile.Name())] = make(map[users.KYCStep]map[social.StrategyType]map[languageTemplateType]map[string][]*languageTemplate) + allTemplates[tenantName(tenantFile.Name())] = make(map[users.KYCStep]map[scraper.StrategyType]map[languageTemplateType]map[string][]*languageTemplate) } if _, found := allTemplates[tenantName(tenantFile.Name())][kycStep]; !found { - allTemplates[tenantName(tenantFile.Name())][kycStep] = make(map[social.StrategyType]map[languageTemplateType]map[string][]*languageTemplate, len(AllTypes)) //nolint:lll // . + allTemplates[tenantName(tenantFile.Name())][kycStep] = make(map[scraper.StrategyType]map[languageTemplateType]map[string][]*languageTemplate, len(AllTypes)) //nolint:lll // . } if _, found := allTemplates[tenantName(tenantFile.Name())][kycStep][socialType]; !found { allTemplates[tenantName(tenantFile.Name())][kycStep][socialType] = make(map[languageTemplateType]map[string][]*languageTemplate, len(&allLanguageTemplateType)) //nolint:lll // . @@ -73,9 +73,9 @@ func New(ctx context.Context, usrRepo UserRepository) Repository { var cfg config appcfg.MustLoadFromKey(applicationYamlKey, &cfg) - socialVerifiers := make(map[Type]social.Verifier, len(AllTypes)) + socialVerifiers := make(map[Type]scraper.Verifier, len(AllTypes)) for _, tp := range AllTypes { - socialVerifiers[tp] = social.New(tp) + socialVerifiers[tp] = scraper.New(tp) } cfg.alertFrequency = new(sync.Map) @@ -187,7 +187,7 @@ func (r *repository) VerifyPost(ctx context.Context, metadata *VerificationMetad if metadata.Twitter.TweetURL == "" && metadata.Facebook.AccessToken == "" { return &Verification{ExpectedPostText: r.expectedPostText(user.User, metadata)}, nil } - pvm := &social.Metadata{ + pvm := &Metadata{ AccessToken: metadata.Facebook.AccessToken, PostURL: metadata.Twitter.TweetURL, ExpectedPostText: r.expectedPostSubtext(user.User, metadata), @@ -197,7 +197,7 @@ func (r *repository) VerifyPost(ctx context.Context, metadata *VerificationMetad if err != nil { //nolint:nestif // . log.Error(errors.Wrapf(err, "social verification failed for KYCStep:%v,Social:%v,Language:%v,userID:%v", metadata.KYCStep, metadata.Social, metadata.Language, metadata.UserID)) - reason := detectReason(err) + reason := DetectReason(err) if userHandle != "" { reason = strings.ToLower(userHandle) + ": " + reason } @@ -224,7 +224,7 @@ func (r *repository) VerifyPost(ctx context.Context, metadata *VerificationMetad if storage.IsErr(err, storage.ErrDuplicate) { log.Error(errors.Wrapf(err, "[duplicate]social verification failed for KYCStep:%v,Social:%v,Language:%v,userID:%v,userHandle:%v", metadata.KYCStep, metadata.Social, metadata.Language, metadata.UserID, userHandle)) - reason := detectReason(terror.New(err, map[string]any{"user_handle": userHandle})) + reason := DetectReason(terror.New(err, map[string]any{"user_handle": userHandle})) if err = r.saveUnsuccessfulAttempt(ctx, now, reason, metadata); err != nil { return nil, errors.Wrapf(err, "[2]failed to saveUnsuccessfulAttempt reason:%v,metadata:%#v", reason, metadata) } @@ -351,23 +351,23 @@ func (r *repository) saveSocial(ctx context.Context, socialType Type, userID, us return errors.Wrapf(err, "failed to `%v`; userID:%v, social:%v, userHandle:%v", sql, userID, socialType, userHandle) } -func detectReason(err error) string { +func DetectReason(err error) string { switch { - case errors.Is(err, social.ErrInvalidPageContent): + case errors.Is(err, scraper.ErrInvalidPageContent): return "invalid page content" - case errors.Is(err, social.ErrTextNotFound): + case errors.Is(err, scraper.ErrTextNotFound): return "expected text not found" - case errors.Is(err, social.ErrUsernameNotFound): + case errors.Is(err, scraper.ErrUsernameNotFound): return "username not found" - case errors.Is(err, social.ErrPostNotFound): + case errors.Is(err, scraper.ErrPostNotFound): return "post not found" - case errors.Is(err, social.ErrInvalidURL): + case errors.Is(err, scraper.ErrInvalidURL): return "invalid URL" case errors.Is(err, context.DeadlineExceeded): return "timeout" case errors.Is(err, context.Canceled): return "cancellation" - case errors.Is(err, social.ErrFetchFailed): + case errors.Is(err, scraper.ErrFetchFailed): return "post fetch failed" case storage.IsErr(err, storage.ErrDuplicate): if tErr := terror.As(err); tErr != nil { @@ -421,9 +421,9 @@ func (r *repository) expectedPostTextIsExactMatch(metadata *VerificationMetadata if metadata.Social == TwitterType { switch metadata.KYCStep { //nolint:exhaustive // Not needed. Everything else is validated before this. case users.Social1KYCStep: - return r.cfg.kycConfigJSON1.Load().xPostPatternTemplate != nil && r.cfg.kycConfigJSON1.Load().XPostPatternExactMatch + return r.cfg.kycConfigJSON1.Load().XPostPatternTemplate != nil && r.cfg.kycConfigJSON1.Load().XPostPatternExactMatch case users.Social2KYCStep: - return r.cfg.kycConfigJSON2.Load().xPostPatternTemplate != nil && r.cfg.kycConfigJSON2.Load().XPostPatternExactMatch + return r.cfg.kycConfigJSON2.Load().XPostPatternTemplate != nil && r.cfg.kycConfigJSON2.Load().XPostPatternExactMatch default: panic(fmt.Sprintf("social step `%v` not implemented ", metadata.KYCStep)) } @@ -437,9 +437,9 @@ func (r *repository) expectedPostSubtext(user *users.User, metadata *Verificatio var tmpl *template.Template switch metadata.KYCStep { //nolint:exhaustive // Not needed. Everything else is validated before this. case users.Social1KYCStep: - tmpl = r.cfg.kycConfigJSON1.Load().xPostPatternTemplate + tmpl = r.cfg.kycConfigJSON1.Load().XPostPatternTemplate case users.Social2KYCStep: - tmpl = r.cfg.kycConfigJSON2.Load().xPostPatternTemplate + tmpl = r.cfg.kycConfigJSON2.Load().XPostPatternTemplate default: panic(fmt.Sprintf("social step `%v` not implemented ", metadata.KYCStep)) } diff --git a/kyc/social/social_test.go b/kyc/social/social_test.go index cc91b30e..7e193f7e 100644 --- a/kyc/social/social_test.go +++ b/kyc/social/social_test.go @@ -45,7 +45,7 @@ func TestSocialSave(t *testing.T) { err := repo.saveSocial(ctx, TwitterType, userName, "foo") require.ErrorIs(t, err, storage.ErrDuplicate) - reason := detectReason(terror.New(err, map[string]any{"user_handle": "foo"})) + reason := DetectReason(terror.New(err, map[string]any{"user_handle": "foo"})) require.Equal(t, `duplicate userhandle 'foo'`, reason) }) diff --git a/kyc/verification_scenarios/contract.go b/kyc/verification_scenarios/contract.go new file mode 100644 index 00000000..88367f9f --- /dev/null +++ b/kyc/verification_scenarios/contract.go @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: ice License 1.0 + +package verificationscenarios + +import ( + "context" + "errors" + "io" + "mime/multipart" + "sync/atomic" + stdlibtime "time" + + "github.com/ice-blockchain/eskimo/kyc/linking" + "github.com/ice-blockchain/eskimo/kyc/scraper" + "github.com/ice-blockchain/eskimo/kyc/social" + "github.com/ice-blockchain/eskimo/users" +) + +// Public API. + +const ( + // Scenarios. + CoinDistributionScenarioCmc Scenario = "join_cmc" + CoinDistributionScenarioTwitter Scenario = "join_twitter" + CoinDistributionScenarioTelegram Scenario = "join_telegram" + CoinDistributionScenarioSignUpTenants Scenario = "signup_tenants" + + // Tenant scenarios. + CoinDistributionScenarioSignUpSunwaves TenantScenario = "signup_sunwaves" + CoinDistributionScenarioSignUpSealsend TenantScenario = "signup_sealsend" + CoinDistributionScenarioSignUpCallfluent TenantScenario = "signup_callfluent" + CoinDistributionScenarioSignUpSauces TenantScenario = "signup_sauces" + CoinDistributionScenarioSignUpDoctorx TenantScenario = "signup_doctorx" +) + +// . +var ( + ErrVerificationNotPassed = errors.New("not passed") + ErrNoPendingScenarios = errors.New("not pending scenarios") + ErrWrongTenantTokens = errors.New("wrong tenant tokens") +) + +type ( + Tenant string + Token string + Scenario string + TenantScenario string + Repository interface { + VerifyScenarios(ctx context.Context, metadata *VerificationMetadata) error + GetPendingVerificationScenarios(ctx context.Context, userID string) ([]*Scenario, error) + } + UserRepository interface { + io.Closer + GetUserByID(ctx context.Context, userID string) (*users.UserProfile, error) + ModifyUser(ctx context.Context, usr *users.User, profilePicture *multipart.FileHeader) (*users.UserProfile, error) + } + VerificationMetadata struct { + Authorization string `header:"Authorization" swaggerignore:"true" required:"true" example:"some token"` + UserID string `uri:"userId" required:"true" allowForbiddenWriteOperation:"true" swaggerignore:"true" example:"did:ethr:0x4B73C58370AEfcEf86A6021afCDe5673511376B2"` //nolint:lll // . + ScenarioEnum Scenario `uri:"scenarioEnum" example:"join_cmc" swaggerignore:"true" required:"true" enums:"join_cmc,join_twitter,join_telegram,signup_tenants"` //nolint:lll // . + Language string `json:"language" required:"false" swaggerignore:"true" example:"en"` + TenantTokens map[TenantScenario]Token `json:"tenantTokens" required:"false" example:"signup_sunwaves:sometoken,signup_sealsend:sometoken,signup_callfluent:sometoken,signup_doctorx:sometoken,signup_sauces:sometoken"` //nolint:lll // . + CMCProfileLink string `json:"cmcProfileLink" required:"false" example:"some profile"` + TweetURL string `json:"tweetUrl" required:"false" example:"some tweet"` + TelegramUsername string `json:"telegramUsername" required:"false" example:"some telegram username"` + } +) + +// Private API. + +const ( + applicationYamlKey = "kyc/coinDistributionEligibility" + authorizationCtxValueKey = "authorizationCtxValueKey" + + requestDeadline = 25 * stdlibtime.Second +) + +type ( + repository struct { + cfg *config + userRepo UserRepository + twitterVerifier scraper.Verifier + linkerRepo linking.Linker + host string + } + config struct { + TenantURLs map[string]string `yaml:"tenantURLs" mapstructure:"tenantURLs"` //nolint:tagliatelle // . + kycConfigJSON1 *atomic.Pointer[social.KycConfigJSON] + Tenant string `yaml:"tenant" mapstructure:"tenant"` + ConfigJSONURL1 string `yaml:"configJsonUrl1" mapstructure:"configJsonUrl1"` //nolint:tagliatelle // . + } +) diff --git a/kyc/verification_scenarios/verification_scenarios.go b/kyc/verification_scenarios/verification_scenarios.go new file mode 100644 index 00000000..694188f1 --- /dev/null +++ b/kyc/verification_scenarios/verification_scenarios.go @@ -0,0 +1,420 @@ +// SPDX-License-Identifier: ice License 1.0 + +package verificationscenarios + +import ( + "bytes" + "context" + "fmt" + "net/http" + "net/url" + "strings" + "sync/atomic" + "text/template" + stdlibtime "time" + + "github.com/goccy/go-json" + "github.com/imroc/req/v3" + "github.com/pkg/errors" + + "github.com/ice-blockchain/eskimo/kyc/linking" + "github.com/ice-blockchain/eskimo/kyc/scraper" + "github.com/ice-blockchain/eskimo/kyc/social" + "github.com/ice-blockchain/eskimo/users" + "github.com/ice-blockchain/santa/tasks" + appcfg "github.com/ice-blockchain/wintr/config" + "github.com/ice-blockchain/wintr/log" + "github.com/ice-blockchain/wintr/time" +) + +func New(ctx context.Context, usrRepo UserRepository, linker linking.Linker, host string) Repository { + var cfg config + appcfg.MustLoadFromKey(applicationYamlKey, &cfg) + repo := &repository{ + userRepo: usrRepo, + cfg: &cfg, + host: host, + twitterVerifier: scraper.New(scraper.StrategyTwitter), + linkerRepo: linker, + } + go repo.startKYCConfigJSONSyncer(ctx) + + return repo +} + +//nolint:funlen,gocognit,gocyclo,revive,cyclop // . +func (r *repository) VerifyScenarios(ctx context.Context, metadata *VerificationMetadata) error { + now := time.Now() + userIDScenarioMap := make(map[TenantScenario]users.UserID, 0) + usr, err := r.userRepo.GetUserByID(ctx, metadata.UserID) + if err != nil { + return errors.Wrapf(err, "failed to get user by id: %v", metadata.UserID) + } + completedSantaTasks, err := r.getCompletedSantaTasks(ctx, usr.ID) + if err != nil { + return errors.Wrapf(err, "failed to getCompletedSantaTasks for userID: %v", usr.ID) + } + pendingScenarios := r.getPendingScenarios(usr.User, completedSantaTasks) + if len(pendingScenarios) == 0 || !isScenarioPending(pendingScenarios, string(metadata.ScenarioEnum)) { + return errors.Wrapf(ErrNoPendingScenarios, "no pending scenarios for user: %v", metadata.UserID) + } + switch metadata.ScenarioEnum { + case CoinDistributionScenarioCmc: + if false { + return errors.Wrapf(ErrVerificationNotPassed, "haven't passed the CMC verification for userID:%v", metadata.UserID) + } + case CoinDistributionScenarioTwitter: + if sErr := r.VerifyTwitterPost(ctx, metadata); sErr != nil { + return errors.Wrapf(sErr, "failed to call VerifyPostForDistibutionVerification for userID:%v", metadata.UserID) + } + case CoinDistributionScenarioTelegram: + case CoinDistributionScenarioSignUpTenants: + skippedTokenCount := 0 + linkedUserIDs := make(map[linking.Tenant]users.UserID, 0) + for tenantScenario, token := range metadata.TenantTokens { + if !isScenarioPending(pendingScenarios, string(tenantScenario)) { + skippedTokenCount++ + + continue + } + splitted := strings.Split(string(tenantScenario), "_") + tenantUsr, fErr := linking.FetchTokenData(ctx, splitted[1], string(token), r.host, r.cfg.TenantURLs) + if fErr != nil { + if errors.Is(fErr, linking.ErrRemoteUserNotFound) { + return errors.Wrapf(linking.ErrNotOwnRemoteUser, "foreign token of userID:%v for the tenant: %v", metadata.UserID, tenantScenario) + } + + return errors.Wrapf(fErr, "failed to fetch remote user data for %v", metadata.UserID) + } + if tenantUsr.CreatedAt == nil || tenantUsr.ReferredBy == "" || tenantUsr.Username == "" { + return errors.Wrapf(linking.ErrNotOwnRemoteUser, "foreign token of userID:%v for the tenant: %v", metadata.UserID, tenantScenario) + } + userIDScenarioMap[tenantScenario] = tenantUsr.ID + linkedUserIDs[splitted[1]] = tenantUsr.ID + } + if skippedTokenCount == len(metadata.TenantTokens) { + return errors.Wrapf(ErrWrongTenantTokens, "no pending tenant tokens for userID:%v", metadata.UserID) + } + if sErr := r.linkerRepo.StoreLinkedAccounts(ctx, now, usr.ID, "", linkedUserIDs); sErr != nil { + return errors.Wrap(sErr, "failed to store linked accounts") + } + } + + return errors.Wrapf(r.setCompletedDistributionScenario(ctx, usr.User, metadata.ScenarioEnum, userIDScenarioMap), + "failed to setCompletedDistributionScenario for userID:%v", metadata.UserID) +} + +func (r *repository) GetPendingVerificationScenarios(ctx context.Context, userID string) ([]*Scenario, error) { + usr, err := r.userRepo.GetUserByID(ctx, userID) + if err != nil { + return nil, errors.Wrapf(err, "failed to get user by id: %v", userID) + } + completedSantaTasks, err := r.getCompletedSantaTasks(ctx, usr.ID) + if err != nil { + return nil, errors.Wrapf(err, "failed to getCompletedSantaTasks for userID: %v", usr.ID) + } + + return r.getPendingScenarios(usr.User, completedSantaTasks), nil +} + +//nolint:funlen,gocognit,gocyclo,revive,cyclop // . +func (r *repository) getPendingScenarios(usr *users.User, completedSantaTasks []*tasks.Task) []*Scenario { + var ( + joinBulllishCMCTaskCompleted, joinIONCMCTaskCompleted, joinWatchlistCMCTaskCompleted, cmcScenarioCompleted = false, false, false, false + joinTwitterTaskCompleted, twitterScenarioCompleted = false, false + joinTelegramTaskCompleted, telegramScenarioCompleted = false, false + tenantsScenariosCompleted = map[TenantScenario]bool{ + CoinDistributionScenarioSignUpSunwaves: false, + CoinDistributionScenarioSignUpSealsend: false, + CoinDistributionScenarioSignUpCallfluent: false, + CoinDistributionScenarioSignUpSauces: false, + CoinDistributionScenarioSignUpDoctorx: false, + } + ) + const singUpPrefix = "signup" + if usr.DistributionScenariosCompleted != nil { + for _, completedScenario := range *usr.DistributionScenariosCompleted { + switch completedScenario { + case string(CoinDistributionScenarioCmc): + cmcScenarioCompleted = true + case string(CoinDistributionScenarioTwitter): + twitterScenarioCompleted = true + case string(CoinDistributionScenarioTelegram): + telegramScenarioCompleted = true + default: + splitted := strings.Split(completedScenario, "_") + if splitted[0] == singUpPrefix { + tenantsScenariosCompleted[TenantScenario(completedScenario)] = true + } + } + } + } + scenarios := make([]*Scenario, 0) + for _, task := range completedSantaTasks { + switch task.Type { //nolint:exhaustive // We handle only tasks related to distribution verification. + case tasks.JoinBullishCMCType: + joinBulllishCMCTaskCompleted = true + case tasks.JoinIONCMCType: + joinIONCMCTaskCompleted = true + case tasks.JoinWatchListCMCType: + joinWatchlistCMCTaskCompleted = true + case tasks.JoinTwitterType: + joinTwitterTaskCompleted = true + case tasks.JoinTelegramType: + joinTelegramTaskCompleted = true + default: + if splitted := strings.Split(string(task.Type), "_"); len(splitted) > 1 && splitted[0] == singUpPrefix { + if completed, ok := tenantsScenariosCompleted[TenantScenario(task.Type)]; !ok || !completed { + scenario := Scenario(task.Type) + scenarios = append(scenarios, &scenario) + } + } + } + } + if joinBulllishCMCTaskCompleted && joinIONCMCTaskCompleted && joinWatchlistCMCTaskCompleted && !cmcScenarioCompleted { + val := CoinDistributionScenarioCmc + scenarios = append(scenarios, &val) + } + if joinTwitterTaskCompleted && !twitterScenarioCompleted { + val := CoinDistributionScenarioTwitter + scenarios = append(scenarios, &val) + } + if joinTelegramTaskCompleted && !telegramScenarioCompleted { + val := CoinDistributionScenarioTelegram + scenarios = append(scenarios, &val) + } + + return scenarios +} + +func isScenarioPending(pendingScenarios []*Scenario, scenario string) bool { + if scenario == string(CoinDistributionScenarioSignUpTenants) { + return true + } + for _, pending := range pendingScenarios { + if string(*pending) == scenario { + return true + } + } + + return false +} + +func (r *repository) setCompletedDistributionScenario( + ctx context.Context, usr *users.User, scenario Scenario, userIDMap map[TenantScenario]users.UserID, +) error { + var lenScenarios int + if usr.DistributionScenariosCompleted != nil { + lenScenarios = len(*usr.DistributionScenariosCompleted) + } + scenarios := make(users.Enum[string], 0, lenScenarios+1) + if usr.DistributionScenariosCompleted != nil { + scenarios = append(scenarios, *usr.DistributionScenariosCompleted...) + } + if scenario != CoinDistributionScenarioSignUpTenants { + scenarios = append(scenarios, string(scenario)) + } else { + for tenant := range userIDMap { + scenarios = append(scenarios, string(tenant)) + } + } + updUsr := new(users.User) + updUsr.ID = usr.ID + updUsr.DistributionScenariosCompleted = &scenarios + _, err := r.userRepo.ModifyUser(ctx, updUsr, nil) + + return errors.Wrapf(err, "failed to set completed distribution scenarios:%v", scenarios) +} + +//nolint:funlen // . +func (r *repository) getCompletedSantaTasks(ctx context.Context, userID string) (res []*tasks.Task, err error) { + getCompletedTasksURL, err := buildGetCompletedTasksURL(r.cfg.Tenant, userID, r.host, r.cfg.TenantURLs) + if err != nil { + log.Panic(errors.Wrapf(err, "failed to detect completed santa task url")) + } + resp, err := req. //nolint:dupl // . + SetContext(ctx). + SetRetryCount(25). //nolint:gomnd,mnd // . + SetRetryInterval(func(_ *req.Response, attempt int) stdlibtime.Duration { + switch { + case attempt <= 1: + return 100 * stdlibtime.Millisecond //nolint:gomnd // . + case attempt == 2: //nolint:gomnd // . + return 1 * stdlibtime.Second + default: + return 10 * stdlibtime.Second //nolint:gomnd // . + } + }). + SetRetryHook(func(resp *req.Response, err error) { + if err != nil { + log.Error(errors.Wrap(err, "failed to fetch completed santa tasks, retrying...")) //nolint:revive // . + } else { + log.Error(errors.Errorf("failed to fetch completed santa tasks with status code:%v, retrying...", resp.GetStatusCode())) //nolint:revive // . + } + }). + SetRetryCondition(func(resp *req.Response, err error) bool { + return err != nil || resp.GetStatusCode() != http.StatusOK + }). + SetHeader("Accept", "application/json"). + SetHeader("Accept", "application/json"). + SetHeader("Authorization", authorization(ctx)). + SetHeader("Cache-Control", "no-cache, no-store, must-revalidate"). + SetHeader("Pragma", "no-cache"). + SetHeader("Expires", "0"). + Get(fmt.Sprintf("%v?language=en&status=completed", getCompletedTasksURL)) + if err != nil { + return nil, errors.Wrapf(err, "failed to get fetch `%v`", getCompletedTasksURL) + } + data, err2 := resp.ToBytes() + if err2 != nil { + return nil, errors.Wrapf(err2, "failed to read body of `%v`", getCompletedTasksURL) + } + var tasksResp []*tasks.Task + if err = json.UnmarshalContext(ctx, data, &tasksResp); err != nil { + return nil, errors.Wrapf(err, "failed to unmarshal into %#v, data: %v", tasksResp, string(data)) + } + + return tasksResp, nil +} + +func authorization(ctx context.Context) (authorization string) { + authorization, _ = ctx.Value(authorizationCtxValueKey).(string) //nolint:errcheck // Not needed. + + return +} + +func buildGetCompletedTasksURL(tenant, userID, host string, tenantURLs map[string]string) (string, error) { + var hasURL bool + var baseURL string + if len(tenantURLs) > 0 { + baseURL, hasURL = tenantURLs[tenant] + } + if !hasURL { + var err error + if baseURL, err = url.JoinPath("https://"+host, tenant); err != nil { + return "", errors.Wrapf(err, "failed to build user url for get completed tasks %v", tenant) + } + } + userURL, err := url.JoinPath(baseURL, "/v1r/tasks/x/users/", userID) + if err != nil { + return "", errors.Wrapf(err, "failed to build user url for tenant %v", tenant) + } + + return userURL, nil +} + +func (r *repository) VerifyTwitterPost(ctx context.Context, metadata *VerificationMetadata) error { + user, err := r.userRepo.GetUserByID(ctx, metadata.UserID) + if err != nil { + return errors.Wrapf(err, "failed to GetUserByID: %v", metadata.UserID) + } + pvm := &social.Metadata{ + PostURL: metadata.TweetURL, + ExpectedPostText: r.expectedPostSubtext(user.User, metadata), + ExpectedPostURL: r.expectedPostURL(), + } + userHandle, err := r.twitterVerifier.VerifyPost(ctx, pvm) + if err != nil { + return errors.Wrapf(ErrVerificationNotPassed, + "can't verify post for twitter verifier userID:%v,Language:%v,reason:%v", metadata.UserID, metadata.Language, social.DetectReason(err)) + } + if userHandle == "" { + return errors.Wrapf(ErrVerificationNotPassed, + "user handle is empty after the verifyPost call for twitter verifier,Language:%v,userID:%v", metadata.Language, metadata.UserID) + } + + return nil +} + +func (r *repository) expectedPostSubtext(user *users.User, metadata *VerificationMetadata) string { + if tmpl := r.cfg.kycConfigJSON1.Load().XPostPatternTemplate; tmpl != nil { + bf := new(bytes.Buffer) + cpy := *user + cpy.Username = strings.ReplaceAll(cpy.Username, ".", "-") + log.Panic(errors.Wrapf(tmpl.Execute(bf, cpy), "failed to execute expectedPostSubtext template for metadata:%+v user:%+v", metadata, user)) + + return bf.String() + } + + return "" +} + +func (r *repository) expectedPostURL() (resp string) { + resp = r.cfg.kycConfigJSON1.Load().XPostLink + resp = strings.Replace(resp, `https://x.com`, "", 1) + if paramsIndex := strings.IndexRune(resp, '?'); resp != "" && paramsIndex > 0 { + resp = resp[:paramsIndex] + } + + return resp +} + +func (r *repository) startKYCConfigJSONSyncer(ctx context.Context) { + ticker := stdlibtime.NewTicker(stdlibtime.Minute) + defer ticker.Stop() + r.cfg.kycConfigJSON1 = new(atomic.Pointer[social.KycConfigJSON]) + log.Panic(errors.Wrap(r.syncKYCConfigJSON1(ctx), "failed to syncKYCConfigJSON1")) //nolint:revive // . + + for { + select { + case <-ticker.C: + reqCtx, cancel := context.WithTimeout(ctx, requestDeadline) + log.Error(errors.Wrap(r.syncKYCConfigJSON1(reqCtx), "failed to syncKYCConfigJSON1")) + cancel() + case <-ctx.Done(): + return + } + } +} + +//nolint:funlen,gomnd,nestif,dupl,revive // . +func (r *repository) syncKYCConfigJSON1(ctx context.Context) error { + if resp, err := req. + SetContext(ctx). + SetRetryCount(25). + SetRetryInterval(func(_ *req.Response, attempt int) stdlibtime.Duration { + switch { + case attempt <= 1: + return 100 * stdlibtime.Millisecond //nolint:gomnd // . + case attempt == 2: //nolint:gomnd // . + return 1 * stdlibtime.Second + default: + return 10 * stdlibtime.Second //nolint:gomnd // . + } + }). + SetRetryHook(func(resp *req.Response, err error) { + if err != nil { + log.Error(errors.Wrap(err, "failed to fetch KYCConfigJSON, retrying...")) //nolint:revive // . + } else { + log.Error(errors.Errorf("failed to fetch KYCConfigJSON with status code:%v, retrying...", resp.GetStatusCode())) //nolint:revive // . + } + }). + SetRetryCondition(func(resp *req.Response, err error) bool { + return err != nil || resp.GetStatusCode() != http.StatusOK + }). + SetHeader("Accept", "application/json"). + SetHeader("Cache-Control", "no-cache, no-store, must-revalidate"). + SetHeader("Pragma", "no-cache"). + SetHeader("Expires", "0"). + Get(r.cfg.ConfigJSONURL1); err != nil { + return errors.Wrapf(err, "failed to get fetch `%v`", r.cfg.ConfigJSONURL1) + } else if data, err2 := resp.ToBytes(); err2 != nil { + return errors.Wrapf(err2, "failed to read body of `%v`", r.cfg.ConfigJSONURL1) + } else { //nolint:revive // . + var kycConfig social.KycConfigJSON + if err = json.UnmarshalContext(ctx, data, &kycConfig); err != nil { + return errors.Wrapf(err, "failed to unmarshal into %#v, data: %v", kycConfig, string(data)) + } + if body := string(data); !strings.Contains(body, "xPostPattern") && !strings.Contains(body, "xPostLink") { + return errors.Errorf("there's something wrong with the KYCConfigJSON body: %v", body) + } + if pattern := kycConfig.XPostPattern; pattern != "" { + if kycConfig.XPostPatternTemplate, err = template.New("kycCfg.Social1KYC.XPostPattern").Parse(pattern); err != nil { + return errors.Wrapf(err, "failed to parse kycCfg.Social1KYC.xPostPatternTemplate `%v`", pattern) + } + } + r.cfg.kycConfigJSON1.Swap(&kycConfig) + + return nil + } +} diff --git a/kyc/verification_scenarios/verification_scenarios_test.go b/kyc/verification_scenarios/verification_scenarios_test.go new file mode 100644 index 00000000..a5146c52 --- /dev/null +++ b/kyc/verification_scenarios/verification_scenarios_test.go @@ -0,0 +1,158 @@ +// SPDX-License-Identifier: ice License 1.0 + +package verificationscenarios + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/ice-blockchain/eskimo/users" + "github.com/ice-blockchain/santa/tasks" +) + +func TestGetPendingKYCVerificationScenarios(t *testing.T) { + tests := []struct { + name string + completedSantaTasks []string + distributionScenariosCompleted []string + expectedPendingScenarios []string + }{ + { + name: "All tasks and scenarios completed", + completedSantaTasks: []string{ + "signup_sunwaves", "signup_sealsend", "signup_callfluent", "signup_sauces", "signup_doctorx", + "join_twitter", "join_telegram", "join_bullish_cmc", "join_ion_cmc", "join_watchlist_cmc", + }, + distributionScenariosCompleted: []string{ + "signup_sunwaves", "signup_sealsend", "signup_callfluent", "signup_sauces", "signup_doctorx", + "join_twitter", "join_telegram", "join_cmc", + }, + expectedPendingScenarios: nil, + }, + { + name: "Some tasks and scenarios completed, but sauces task copmpleted, distribution scenario not completed", + completedSantaTasks: []string{ + "signup_sunwaves", "signup_sealsend", "signup_callfluent", "signup_sauces", + }, + distributionScenariosCompleted: []string{ + "signup_sunwaves", "signup_sealsend", "signup_callfluent", + }, + expectedPendingScenarios: []string{ + "signup_sauces", + }, + }, + { + name: "No completed santa tasks, but all scenarios completed", + completedSantaTasks: []string{}, + distributionScenariosCompleted: []string{ + "signup_sunwaves", "signup_sealsend", "signup_callfluent", "signup_sauces", "signup_doctorx", + "join_twitter", "join_telegram", "join_cmc", + }, + expectedPendingScenarios: nil, + }, + { + name: "Has all completed santa tasks and no scenarios completed", + completedSantaTasks: []string{ + "signup_sunwaves", "signup_sealsend", "signup_callfluent", "signup_sauces", "signup_doctorx", + "join_twitter", "join_telegram", "join_bullish_cmc", "join_ion_cmc", "join_watchlist_cmc", + }, + distributionScenariosCompleted: []string{}, + expectedPendingScenarios: []string{ + "signup_sunwaves", "signup_sealsend", "signup_callfluent", "signup_sauces", "signup_doctorx", + "join_twitter", "join_telegram", "join_cmc", + }, + }, + { + name: "Has other santa completed tasks that are not related to distribution scenarios", + completedSantaTasks: []string{ + "signup_sunwaves", "signup_sealsend", "signup_callfluent", "signup_sauces", "signup_doctorx", + "join_twitter", "join_telegram", "join_bullish_cmc", "join_ion_cmc", "join_watchlist_cmc", + "join_reddit_ion", "join_instagram_ion", "join_youtube", "join_portfolio_coingecko", + }, + distributionScenariosCompleted: []string{}, + expectedPendingScenarios: []string{ + "signup_sunwaves", "signup_sealsend", "signup_callfluent", "signup_sauces", "signup_doctorx", + "join_twitter", "join_telegram", "join_cmc", + }, + }, + { + name: "CMC", + completedSantaTasks: []string{ + "join_bullish_cmc", "join_ion_cmc", "join_watchlist_cmc", + }, + distributionScenariosCompleted: []string{}, + expectedPendingScenarios: []string{"join_cmc"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + enums := make(users.Enum[string], 0) + if tt.distributionScenariosCompleted != nil { + for _, scenario := range tt.distributionScenariosCompleted { + enums = append(enums, scenario) + } + } + usr := &users.User{ + DistributionScenariosCompleted: &enums, + } + tsks := make([]*tasks.Task, len(tt.completedSantaTasks)) + for i, taskType := range tt.completedSantaTasks { + tsks[i] = &tasks.Task{ + Type: tasks.Type(taskType), + } + } + repo := &repository{} + pendingScenarios := repo.getPendingScenarios(usr, tsks) + require.NotNil(t, pendingScenarios) + require.Len(t, pendingScenarios, len(tt.expectedPendingScenarios)) + for _, expectedScenario := range tt.expectedPendingScenarios { + require.True(t, isScenarioPending(pendingScenarios, expectedScenario)) + } + }) + } +} + +func TestIsScenarioPending(t *testing.T) { + cmcScenario := CoinDistributionScenarioCmc + signUpTenantsScenario := CoinDistributionScenarioSignUpTenants + tests := []struct { + name string + pendingScenarios []*Scenario + scenario Scenario + expectedIsPending bool + }{ + { + name: "Scenario is pending", + pendingScenarios: []*Scenario{&cmcScenario, &signUpTenantsScenario}, + scenario: CoinDistributionScenarioCmc, + expectedIsPending: true, + }, + { + name: "Scenario is not pending", + pendingScenarios: []*Scenario{&cmcScenario}, + scenario: CoinDistributionScenarioTelegram, + expectedIsPending: false, + }, + { + name: "Scenario is CoinDistributionScenarioSignUpTenants, we return true to check tenants tokens later", + pendingScenarios: []*Scenario{&cmcScenario}, + scenario: CoinDistributionScenarioSignUpTenants, + expectedIsPending: true, + }, + { + name: "No pending scenarios", + pendingScenarios: []*Scenario{}, + scenario: CoinDistributionScenarioCmc, + expectedIsPending: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + isPending := isScenarioPending(tt.pendingScenarios, string(tt.scenario)) + require.Equal(t, tt.expectedIsPending, isPending) + }) + } +} diff --git a/users/DDL.sql b/users/DDL.sql index b5207412..bd54f3ad 100644 --- a/users/DDL.sql +++ b/users/DDL.sql @@ -32,6 +32,7 @@ CREATE TABLE IF NOT EXISTS users ( agenda_contact_user_ids text[], kyc_steps_last_updated_at timestamp[], kyc_steps_created_at timestamp[], + distribution_scenarios_completed text[], mining_blockchain_account_address text NOT NULL UNIQUE, blockchain_account_address text NOT NULL UNIQUE, telegram_user_id text NOT NULL UNIQUE, @@ -119,6 +120,7 @@ ALTER TABLE users ADD COLUMN IF NOT EXISTS kyc_step_blocked smallint NOT NULL DE ALTER TABLE users ADD COLUMN IF NOT EXISTS kyc_steps_last_updated_at timestamp[]; ALTER TABLE users ADD COLUMN IF NOT EXISTS kyc_steps_created_at timestamp[]; ALTER TABLE users ADD COLUMN IF NOT EXISTS telegram_user_id text; +ALTER TABLE users ADD COLUMN IF NOT EXISTS distribution_scenarios_completed text[]; DO $$ BEGIN if NOT exists (select constraint_name from information_schema.table_constraints where table_name = 'users' and constraint_name = 'users_telegram_user_id_key') then ALTER TABLE users ADD CONSTRAINT users_telegram_user_id_key UNIQUE (telegram_user_id); diff --git a/users/contract.go b/users/contract.go index f5fceb9b..715c3fd9 100644 --- a/users/contract.go +++ b/users/contract.go @@ -104,23 +104,24 @@ type ( ProfilePictureURL string `json:"profilePictureUrl,omitempty" example:"https://somecdn.com/p1.jpg" db:"profile_picture_name"` } User struct { - CreatedAt *time.Time `json:"createdAt,omitempty" example:"2022-01-03T16:20:52.156534Z" db:"created_at"` - UpdatedAt *time.Time `json:"updatedAt,omitempty" example:"2022-01-03T16:20:52.156534Z" db:"updated_at"` - LastMiningStartedAt *time.Time `json:"lastMiningStartedAt,omitempty" example:"2022-01-03T16:20:52.156534Z" swaggerignore:"true" db:"last_mining_started_at"` //nolint:lll // . - LastMiningEndedAt *time.Time `json:"lastMiningEndedAt,omitempty" example:"2022-01-03T16:20:52.156534Z" swaggerignore:"true" db:"last_mining_ended_at"` //nolint:lll // . - LastPingCooldownEndedAt *time.Time `json:"lastPingCooldownEndedAt,omitempty" example:"2022-01-03T16:20:52.156534Z" swaggerignore:"true" db:"last_ping_cooldown_ended_at"` //nolint:lll // . - ClaimedByThirdPartyAt *time.Time `json:"-" example:"2022-01-03T16:20:52.156534Z" swaggerignore:"true" db:"claimed_by_third_party_at"` //nolint:lll // . - HiddenProfileElements *Enum[HiddenProfileElement] `json:"hiddenProfileElements,omitempty" swaggertype:"array,string" example:"level" enums:"globalRank,referralCount,level,role,badges" db:"hidden_profile_elements"` //nolint:lll // . - RandomReferredBy *bool `json:"randomReferredBy,omitempty" example:"true" swaggerignore:"true" db:"random_referred_by"` - Verified *bool `json:"verified" example:"true" db:"verified"` - QuizCompleted *bool `json:"-" db:"quiz_completed"` - T1ReferralsSharingEnabled *bool `json:"t1ReferralsSharingEnabled" example:"true" db:"t1_referrals_sharing_enabled"` - KYCStepsLastUpdatedAt *[]*time.Time `json:"kycStepsLastUpdatedAt,omitempty" swaggertype:"array,string" example:"2022-01-03T16:20:52.156534Z" db:"kyc_steps_last_updated_at"` //nolint:lll // . - KYCStepsCreatedAt *[]*time.Time `json:"kycStepsCreatedAt,omitempty" swaggertype:"array,string" example:"2022-01-03T16:20:52.156534Z" db:"kyc_steps_created_at"` //nolint:lll // . - KYCStepPassed *KYCStep `json:"kycStepPassed,omitempty" example:"0" db:"kyc_step_passed"` - KYCStepBlocked *KYCStep `json:"kycStepBlocked,omitempty" example:"0" db:"kyc_step_blocked"` - ClientData *JSON `json:"clientData,omitempty" db:"client_data"` - RepeatableKYCSteps *map[KYCStep]*time.Time `json:"repeatableKYCSteps,omitempty" db:"-"` //nolint:tagliatelle // Nope. + CreatedAt *time.Time `json:"createdAt,omitempty" example:"2022-01-03T16:20:52.156534Z" db:"created_at"` + UpdatedAt *time.Time `json:"updatedAt,omitempty" example:"2022-01-03T16:20:52.156534Z" db:"updated_at"` + LastMiningStartedAt *time.Time `json:"lastMiningStartedAt,omitempty" example:"2022-01-03T16:20:52.156534Z" swaggerignore:"true" db:"last_mining_started_at"` //nolint:lll // . + LastMiningEndedAt *time.Time `json:"lastMiningEndedAt,omitempty" example:"2022-01-03T16:20:52.156534Z" swaggerignore:"true" db:"last_mining_ended_at"` //nolint:lll // . + LastPingCooldownEndedAt *time.Time `json:"lastPingCooldownEndedAt,omitempty" example:"2022-01-03T16:20:52.156534Z" swaggerignore:"true" db:"last_ping_cooldown_ended_at"` //nolint:lll // . + ClaimedByThirdPartyAt *time.Time `json:"-" example:"2022-01-03T16:20:52.156534Z" swaggerignore:"true" db:"claimed_by_third_party_at"` //nolint:lll // . + HiddenProfileElements *Enum[HiddenProfileElement] `json:"hiddenProfileElements,omitempty" swaggertype:"array,string" example:"level" enums:"globalRank,referralCount,level,role,badges" db:"hidden_profile_elements"` //nolint:lll // . + DistributionScenariosCompleted *Enum[string] `json:"distributionScenariosCompleted,omitempty" swaggerignore:"true" db:"distribution_scenarios_completed" enums:"join_cmc,join_twitter,join_telegram,signup_sunwaves,signup_doctorx,signup_callfluent,signup_sealsend,signup_sauces"` //nolint:lll // . + RandomReferredBy *bool `json:"randomReferredBy,omitempty" example:"true" swaggerignore:"true" db:"random_referred_by"` + Verified *bool `json:"verified" example:"true" db:"verified"` + QuizCompleted *bool `json:"-" db:"quiz_completed"` + T1ReferralsSharingEnabled *bool `json:"t1ReferralsSharingEnabled" example:"true" db:"t1_referrals_sharing_enabled"` + KYCStepsLastUpdatedAt *[]*time.Time `json:"kycStepsLastUpdatedAt,omitempty" swaggertype:"array,string" example:"2022-01-03T16:20:52.156534Z" db:"kyc_steps_last_updated_at"` //nolint:lll // . + KYCStepsCreatedAt *[]*time.Time `json:"kycStepsCreatedAt,omitempty" swaggertype:"array,string" example:"2022-01-03T16:20:52.156534Z" db:"kyc_steps_created_at"` //nolint:lll // . + KYCStepPassed *KYCStep `json:"kycStepPassed,omitempty" example:"0" db:"kyc_step_passed"` + KYCStepBlocked *KYCStep `json:"kycStepBlocked,omitempty" example:"0" db:"kyc_step_blocked"` + ClientData *JSON `json:"clientData,omitempty" db:"client_data"` + RepeatableKYCSteps *map[KYCStep]*time.Time `json:"repeatableKYCSteps,omitempty" db:"-"` //nolint:tagliatelle // Nope. PrivateUserInformation PublicUserInformation ReferredBy UserID `json:"referredBy,omitempty" example:"did:ethr:0x4B73C58370AEfcEf86A6021afCDe5673511376B2" db:"referred_by"` diff --git a/users/users_modify.go b/users/users_modify.go index 76f3440b..23121ee0 100644 --- a/users/users_modify.go +++ b/users/users_modify.go @@ -155,6 +155,7 @@ func (u *User) override(user *User) *User { usr.PhoneNumberHash = mergeStringField(u.PhoneNumberHash, user.PhoneNumberHash) usr.BlockchainAccountAddress = mergeStringField(u.BlockchainAccountAddress, user.BlockchainAccountAddress) usr.MiningBlockchainAccountAddress = mergeStringField(u.MiningBlockchainAccountAddress, user.MiningBlockchainAccountAddress) + usr.DistributionScenariosCompleted = mergePointerToArrayField(u.DistributionScenariosCompleted, user.DistributionScenariosCompleted) usr.TelegramUserID = mergeStringField(u.TelegramUserID, user.TelegramUserID) usr.TelegramBotID = mergeStringField(u.TelegramBotID, user.TelegramBotID) @@ -333,6 +334,11 @@ func (u *User) genSQLUpdate(ctx context.Context, agendaUserIDs []UserID) (sql st sql += fmt.Sprintf(", telegram_bot_id = $%v", nextIndex) nextIndex++ } + if u.DistributionScenariosCompleted != nil { + params = append(params, u.DistributionScenariosCompleted) + sql += fmt.Sprintf(", DISTRIBUTION_SCENARIOS_COMPLETED = $%v", nextIndex) + nextIndex++ + } sql += " WHERE ID = $1"