├── .github ├── CODEOWNERS └── workflows │ └── CICD.yaml ├── .gitignore ├── .golangci.yml ├── LICENSE.header ├── LICENSE.txt ├── Makefile ├── README.md ├── application.yaml ├── badges ├── .testdata │ └── application.yaml ├── DDL.sql ├── achieve_badges.go ├── achieve_badges_test.go ├── badges.go ├── contract.go ├── fixture │ ├── contract.go │ └── fixture.go ├── get_badges.go ├── get_badges_test.go └── seeding │ └── seeding.go ├── cmd ├── santa-sleigh │ ├── .testdata │ │ ├── application.yaml │ │ ├── expected_swagger.json │ │ ├── localhost.crt │ │ └── localhost.key │ ├── Dockerfile │ ├── api │ │ ├── docs.go │ │ ├── swagger.json │ │ └── swagger.yaml │ ├── contract.go │ ├── santa.go │ ├── santa_sleigh.go │ └── tasks.go ├── santa │ ├── .testdata │ │ ├── application.yaml │ │ ├── expected_swagger.json │ │ ├── localhost.crt │ │ └── localhost.key │ ├── Dockerfile │ ├── api │ │ ├── docs.go │ │ ├── swagger.json │ │ └── swagger.yaml │ ├── badges.go │ ├── contract.go │ ├── levels_and_roles.go │ ├── santa.go │ └── tasks.go └── scripts │ ├── update_santa_badges_levels_and_roles_tasks │ ├── .testdata │ │ └── application.yaml │ └── update_santa_badges_levels_and_roles_tasks.go │ └── update_santa_friends_invited_count │ ├── application.yaml │ └── update_santa_friends_invited_count.go ├── friends-invited ├── .testdata │ └── application.yaml ├── DDL.sql ├── contract.go ├── friends-invited.go └── users.go ├── go.mod ├── go.sum ├── levels-and-roles ├── .testdata │ └── application.yaml ├── DDL.sql ├── completed_levels_and_activated_roles.go ├── completed_levels_and_activated_roles_test.go ├── contacts_source.go ├── contract.go ├── fixture │ ├── contract.go │ └── fixture.go ├── get_summary.go ├── get_summary_test.go ├── levels_and_roles.go ├── levels_and_roles_test.go └── seeding │ └── seeding.go ├── local.go └── tasks ├── .testdata └── application.yaml ├── DDL.sql ├── complete_tasks.go ├── contract.go ├── fixture ├── contract.go └── fixture.go ├── get_tasks.go ├── seeding └── seeding.go ├── tasks.go └── translations ├── callfluent ├── claim_badge_coin.json ├── claim_badge_level.json ├── claim_badge_social.json ├── claim_level.json ├── claim_username.json ├── invite_friends.json ├── join_athene.json ├── join_bearfi.json ├── join_boinkers.json ├── join_bullish_cmc.json ├── join_capybara.json ├── join_catgoldminer.json ├── join_cmc_act.json ├── join_cmc_ada.json ├── join_cmc_bnb.json ├── join_cmc_btc.json ├── join_cmc_doge.json ├── join_cmc_eth.json ├── join_cmc_pnut.json ├── join_cmc_sol.json ├── join_cmc_ton.json ├── join_cmc_xrp.json ├── join_dejendog.json ├── join_ducks.json ├── join_freedogs.json ├── join_goats.json ├── join_hipo.json ├── join_holdcoin.json ├── join_human.json ├── join_iceberg.json ├── join_instagram_ion.json ├── join_ion_cmc.json ├── join_kolo.json ├── join_pigs.json ├── join_portfolio_coingecko.json ├── join_reddit_ion.json ├── join_sidekick.json ├── join_sugar.json ├── join_tapcoins.json ├── join_telegram.json ├── join_telegram_ion.json ├── join_tokyobeast.json ├── join_tonai.json ├── join_tonkombat.json ├── join_twitter.json ├── join_twitter_ion.json ├── join_twitter_pichain.json ├── join_watchlist_cmc.json ├── join_youtube.json ├── mining_streak.json ├── signup_callfluent.json ├── signup_cryptomayors.json ├── signup_doctorx.json ├── signup_sauces.json ├── signup_sealsend.json ├── signup_sunwaves.json ├── start_mining.json ├── upload_profile_picture.json └── watch_video_with_code_confirmation_1.json ├── doctorx ├── claim_badge_coin.json ├── claim_badge_level.json ├── claim_badge_social.json ├── claim_level.json ├── claim_username.json ├── invite_friends.json ├── join_athene.json ├── join_bearfi.json ├── join_boinkers.json ├── join_bullish_cmc.json ├── join_capybara.json ├── join_catgoldminer.json ├── join_cmc_act.json ├── join_cmc_ada.json ├── join_cmc_bnb.json ├── join_cmc_btc.json ├── join_cmc_doge.json ├── join_cmc_eth.json ├── join_cmc_pnut.json ├── join_cmc_sol.json ├── join_cmc_ton.json ├── join_cmc_xrp.json ├── join_dejendog.json ├── join_ducks.json ├── join_freedogs.json ├── join_goats.json ├── join_hipo.json ├── join_holdcoin.json ├── join_human.json ├── join_iceberg.json ├── join_instagram_ion.json ├── join_ion_cmc.json ├── join_kolo.json ├── join_pigs.json ├── join_portfolio_coingecko.json ├── join_reddit_ion.json ├── join_sidekick.json ├── join_sugar.json ├── join_tapcoins.json ├── join_telegram.json ├── join_telegram_ion.json ├── join_telegram_multiversx.json ├── join_tokyobeast.json ├── join_tonai.json ├── join_tonkombat.json ├── join_twitter.json ├── join_twitter_ion.json ├── join_twitter_multiversx.json ├── join_twitter_pichain.json ├── join_twitter_xportal.json ├── join_watchlist_cmc.json ├── join_youtube.json ├── mining_streak.json ├── signup_callfluent.json ├── signup_cryptomayors.json ├── signup_doctorx.json ├── signup_sauces.json ├── signup_sealsend.json ├── signup_sunwaves.json ├── start_mining.json ├── upload_profile_picture.json └── watch_video_with_code_confirmation_1.json ├── sauces ├── claim_badge_coin.json ├── claim_badge_level.json ├── claim_badge_social.json ├── claim_level.json ├── claim_username.json ├── invite_friends.json ├── join_athene.json ├── join_bearfi.json ├── join_boinkers.json ├── join_bullish_cmc.json ├── join_capybara.json ├── join_catgoldminer.json ├── join_cmc_act.json ├── join_cmc_ada.json ├── join_cmc_bnb.json ├── join_cmc_btc.json ├── join_cmc_doge.json ├── join_cmc_eth.json ├── join_cmc_pnut.json ├── join_cmc_sol.json ├── join_cmc_ton.json ├── join_cmc_xrp.json ├── join_dejendog.json ├── join_ducks.json ├── join_freedogs.json ├── join_goats.json ├── join_hipo.json ├── join_holdcoin.json ├── join_human.json ├── join_iceberg.json ├── join_instagram_ion.json ├── join_ion_cmc.json ├── join_kolo.json ├── join_pigs.json ├── join_portfolio_coingecko.json ├── join_reddit_ion.json ├── join_sidekick.json ├── join_sugar.json ├── join_tapcoins.json ├── join_telegram.json ├── join_telegram_ion.json ├── join_tokyobeast.json ├── join_tonai.json ├── join_tonkombat.json ├── join_twitter.json ├── join_twitter_ion.json ├── join_twitter_pichain.json ├── join_watchlist_cmc.json ├── join_youtube.json ├── mining_streak.json ├── signup_callfluent.json ├── signup_cryptomayors.json ├── signup_doctorx.json ├── signup_sauces.json ├── signup_sealsend.json ├── signup_sunwaves.json ├── start_mining.json ├── upload_profile_picture.json └── watch_video_with_code_confirmation_1.json ├── sealsend ├── claim_badge_coin.json ├── claim_badge_level.json ├── claim_badge_social.json ├── claim_level.json ├── claim_username.json ├── invite_friends.json ├── join_athene.json ├── join_bearfi.json ├── join_boinkers.json ├── join_bullish_cmc.json ├── join_capybara.json ├── join_catgoldminer.json ├── join_cmc_act.json ├── join_cmc_ada.json ├── join_cmc_bnb.json ├── join_cmc_btc.json ├── join_cmc_doge.json ├── join_cmc_eth.json ├── join_cmc_pnut.json ├── join_cmc_sol.json ├── join_cmc_ton.json ├── join_cmc_xrp.json ├── join_dejendog.json ├── join_ducks.json ├── join_freedogs.json ├── join_goats.json ├── join_hipo.json ├── join_holdcoin.json ├── join_human.json ├── join_iceberg.json ├── join_instagram_ion.json ├── join_ion_cmc.json ├── join_kolo.json ├── join_pigs.json ├── join_portfolio_coingecko.json ├── join_reddit_ion.json ├── join_sidekick.json ├── join_sugar.json ├── join_tapcoins.json ├── join_telegram.json ├── join_telegram_ion.json ├── join_tokyobeast.json ├── join_tonai.json ├── join_tonkombat.json ├── join_twitter.json ├── join_twitter_ion.json ├── join_twitter_pichain.json ├── join_watchlist_cmc.json ├── join_youtube.json ├── mining_streak.json ├── signup_callfluent.json ├── signup_cryptomayors.json ├── signup_doctorx.json ├── signup_sauces.json ├── signup_sealsend.json ├── signup_sunwaves.json ├── start_mining.json ├── upload_profile_picture.json └── watch_video_with_code_confirmation_1.json ├── sunwaves ├── claim_badge_coin.json ├── claim_badge_level.json ├── claim_badge_social.json ├── claim_level.json ├── claim_username.json ├── invite_friends.json ├── join_athene.json ├── join_bearfi.json ├── join_boinkers.json ├── join_bullish_cmc.json ├── join_capybara.json ├── join_catgoldminer.json ├── join_cmc_act.json ├── join_cmc_ada.json ├── join_cmc_bnb.json ├── join_cmc_btc.json ├── join_cmc_doge.json ├── join_cmc_eth.json ├── join_cmc_pnut.json ├── join_cmc_sol.json ├── join_cmc_ton.json ├── join_cmc_xrp.json ├── join_dejendog.json ├── join_ducks.json ├── join_freedogs.json ├── join_goats.json ├── join_hipo.json ├── join_holdcoin.json ├── join_human.json ├── join_iceberg.json ├── join_instagram_ion.json ├── join_ion_cmc.json ├── join_kolo.json ├── join_pigs.json ├── join_portfolio_coingecko.json ├── join_reddit_ion.json ├── join_sidekick.json ├── join_sugar.json ├── join_tapcoins.json ├── join_telegram.json ├── join_telegram_ion.json ├── join_tokyobeast.json ├── join_tonai.json ├── join_tonkombat.json ├── join_twitter.json ├── join_twitter_ion.json ├── join_twitter_pichain.json ├── join_watchlist_cmc.json ├── join_youtube.json ├── mining_streak.json ├── signup_callfluent.json ├── signup_cryptomayors.json ├── signup_doctorx.json ├── signup_sauces.json ├── signup_sealsend.json ├── signup_sunwaves.json ├── start_mining.json ├── upload_profile_picture.json └── watch_video_with_code_confirmation_1.json └── tokero ├── claim_badge_coin.json ├── claim_badge_level.json ├── claim_badge_social.json ├── claim_level.json ├── claim_username.json ├── invite_friends.json ├── join_athene.json ├── join_bearfi.json ├── join_boinkers.json ├── join_bullish_cmc.json ├── join_capybara.json ├── join_catgoldminer.json ├── join_cmc_act.json ├── join_cmc_ada.json ├── join_cmc_bnb.json ├── join_cmc_btc.json ├── join_cmc_doge.json ├── join_cmc_eth.json ├── join_cmc_pnut.json ├── join_cmc_sol.json ├── join_cmc_ton.json ├── join_cmc_xrp.json ├── join_dejendog.json ├── join_ducks.json ├── join_facebook_tokero.json ├── join_freedogs.json ├── join_goats.json ├── join_hipo.json ├── join_holdcoin.json ├── join_human.json ├── join_iceberg.json ├── join_instagram_ion.json ├── join_instagram_tokero.json ├── join_ion_cmc.json ├── join_kolo.json ├── join_linkedin_tokero.json ├── join_pigs.json ├── join_portfolio_coingecko.json ├── join_reddit_ion.json ├── join_sidekick.json ├── join_sugar.json ├── join_tapcoins.json ├── join_telegram.json ├── join_telegram_ion.json ├── join_tiktok_tokero.json ├── join_tokyobeast.json ├── join_tonai.json ├── join_tonkombat.json ├── join_twitter.json ├── join_twitter_ion.json ├── join_twitter_pichain.json ├── join_watchlist_cmc.json ├── join_x_tokero.json ├── join_youtube.json ├── join_youtube_tokero.json ├── mining_streak.json ├── signup_callfluent.json ├── signup_cryptomayors.json ├── signup_doctorx.json ├── signup_sauces.json ├── signup_sealsend.json ├── signup_sunwaves.json ├── start_mining.json ├── upload_profile_picture.json └── watch_video_with_code_confirmation_1.json /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @ice-blockchain/golang 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.exe 2 | *.BIN 3 | *.bin 4 | *.exe~ 5 | *.dll 6 | *.so 7 | *.dylib 8 | *.test 9 | *.out 10 | *.app 11 | *.bat 12 | *.cgi 13 | *.com 14 | *.gadget 15 | *.jar 16 | *.pif 17 | *.vb 18 | *.wsf 19 | /out 20 | vendor/ 21 | /Godeps 22 | *.iml 23 | *.ipr 24 | /.idea 25 | *.iws 26 | /.vscode 27 | .tmp-* 28 | .env 29 | .envrc 30 | .DS_Store -------------------------------------------------------------------------------- /LICENSE.header: -------------------------------------------------------------------------------- 1 | SPDX-License-Identifier: ice License 1.0 -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | ice License 2 | 3 | Version 1.0, January 2023 4 | 5 | ----------------------------------------------------------------------------- 6 | 7 | 8 | Licensor: ice Labs Limited 9 | 10 | Licensed Work: ice Network 11 | The Licensed Work is (c) 2023 ice Labs Limited 12 | 13 | ----------------------------------------------------------------------------- 14 | 15 | 16 | Permission is hereby granted by the application Software Developer, ice Labs 17 | Limited, free of charge, to any person obtaining a copy of this application, 18 | software, and associated documentation files (the Software), which was 19 | developed by the Software Developer (ice Labs Limited) for use on ice Network 20 | whereby the purpose of this license is to permit the development of 21 | derivative works based on the Software, including the right to use, copy, 22 | modify, merge, publish, distribute, sub-license, and/or sell copies of such 23 | derivative works and any Software components incorporated therein, and to 24 | permit persons to whom such derivative works are furnished to do so, in each 25 | case, solely to develop, use, and market applications for the official ice 26 | Network. 27 | 28 | All Derivative Works developed under this License for use on the ice Network 29 | may only be released after the official launch of the ice Network’s Mainnet. 30 | 31 | For purposes of this license, ice Network shall mean any application, 32 | software, or another present or future platform developed, owned, or managed 33 | by ice Labs Limited, and its parents, affiliates, or subsidiaries. 34 | 35 | Disclaimer of Warranty. Unless required by applicable law or agreed to in 36 | writing, Licensor provides the Software on an "AS IS" BASIS, WITHOUT 37 | WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, 38 | without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, 39 | MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely 40 | responsible for determining the appropriateness of using or redistributing 41 | the Software and assume any risks associated with Your exercise of 42 | permissions under this License. 43 | 44 | Limitation of Liability. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS 45 | BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION 46 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF, OR IN CONNECTION WITH 47 | THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 48 | 49 | The above copyright notice and this permission notice shall be included in 50 | all copies or substantial portions of the Software. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT_GOAL := all 2 | 3 | DOCKER_REGISTRY ?= registry.digitalocean.com/ice-io 4 | DOCKER_TAG ?= latest-locally 5 | GO_VERSION_MANIFEST := https://raw.githubusercontent.com/actions/go-versions/main/versions-manifest.json 6 | REQUIRED_COVERAGE_PERCENT := 0 7 | COVERAGE_FILE := cover.out 8 | REPOSITORY := $(shell basename `pwd`) 9 | 10 | CGO_ENABLED ?= 1 11 | GOOS ?= 12 | GOARCH ?= 13 | SERVICE_NAME ?= 14 | SERVICES := $(wildcard ./cmd/*) 15 | 16 | export CGO_ENABLED GOOS GOARCH SERVICE_NAME 17 | 18 | define getLatestGoPatchVersion 19 | $(shell curl -s $(GO_VERSION_MANIFEST) | jq -r '.[0].version') 20 | endef 21 | 22 | define getLatestGoMinorVersion 23 | $(shell echo $(call getLatestGoPatchVersion) | cut -f1,2 -d'.') 24 | endef 25 | 26 | latestGoVersion: 27 | @echo $(call getLatestGoPatchVersion) 28 | 29 | latestGoMinorVersion: 30 | @echo $(call getLatestGoMinorVersion) 31 | 32 | updateGoModVersion: 33 | go mod edit -go $(call getLatestGoMinorVersion) 34 | 35 | checkModVersion: updateGoModVersion 36 | @if git status --porcelain | grep -q go.mod; then \ 37 | echo "Outdated go version in go.mod. Please update it using 'make updateGoModVersion' and make sure everything works correctly and tests pass then commit the changes."; \ 38 | exit 1; \ 39 | fi; \ 40 | true; 41 | 42 | updateAllDependencies: 43 | go get -t -u ./... 44 | go mod tidy 45 | 46 | checkIfAllDependenciesAreUpToDate: updateAllDependencies 47 | @if git status --porcelain | grep -q go.sum; then \ 48 | echo "Some dependencies are outdated. Please update all dependencies using 'make updateAllDependencies' and make sure everything works correctly and tests pass then commit the changes."; \ 49 | exit 1; \ 50 | fi; \ 51 | true; 52 | 53 | generate-swagger: 54 | swag init --parseDependency --parseInternal -d ${SERVICE} -g $(shell echo "$${SERVICE##*/}" | sed 's/-/_/g').go -o ${SERVICE}/api; 55 | 56 | generate-swaggers: 57 | go install github.com/swaggo/swag/cmd/swag@latest 58 | set -xe; \ 59 | [ -d cmd ] && find ./cmd -mindepth 1 -maxdepth 1 -type d -print | grep -v 'fixture' | grep -v 'scripts' | sed 's/\.\///g' | while read service; do \ 60 | env SERVICE=$${service} $(MAKE) generate-swagger; \ 61 | done; 62 | 63 | format-swagger: 64 | swag fmt -d ${SERVICE} -g $(shell echo "$${SERVICE##*/}" | sed 's/-/_/g').go 65 | 66 | format-swaggers: 67 | set -xe; \ 68 | [ -d cmd ] && find ./cmd -mindepth 1 -maxdepth 1 -type d -print | grep -v 'fixture' | grep -v 'scripts' | sed 's/\.\///g' | while read service; do \ 69 | env SERVICE=$${service} $(MAKE) format-swagger; \ 70 | done; 71 | 72 | generate-mocks: 73 | # go install github.com/golang/mock/mockgen@latest 74 | # mockgen -source=CHANGE_ME.go -destination=CHANGE_ME.go -package=CHANGE_ME 75 | 76 | generate: 77 | $(MAKE) generate-swaggers 78 | $(MAKE) format-swaggers 79 | $(MAKE) generate-mocks 80 | $(MAKE) addLicense 81 | $(MAKE) format-imports 82 | 83 | checkGenerated: generate 84 | @if git status --porcelain | grep -e [.]go -e [.]json -e [.]yaml; then \ 85 | echo "Please commit generated files, using 'make generate'."; \ 86 | git --no-pager diff; \ 87 | exit 1; \ 88 | fi; \ 89 | true; 90 | 91 | build-all@ci/cd: 92 | go build -tags=go_json -a -v -race ./... 93 | 94 | build: build-all@ci/cd 95 | 96 | binary-specific-service: 97 | set -xe; \ 98 | echo "$@: $(SERVICE_NAME) / $(GOOS) / $(GOARCH)" ; \ 99 | go build -tags=go_json -a -v -o ./cmd/$${SERVICE_NAME}/bin ./cmd/$${SERVICE_NAME}; \ 100 | cp ./cmd/$${SERVICE_NAME}/bin ./$${SERVICE_NAME}.$${GOOS}.$${GOARCH}.bin; \ 101 | 102 | test: 103 | set -xe; \ 104 | mf="$$(pwd)/Makefile"; \ 105 | find . -mindepth 1 -maxdepth 4 -type d -print | grep -v '\./\.' | grep -v '/\.' | sed 's/\.\///g' | while read service; do \ 106 | cd $${service} ; \ 107 | if [[ $$(find . -mindepth 1 -maxdepth 1 -type f -print | grep -E '_test.go' | wc -l | sed "s/ //g") -gt 0 ]]; then \ 108 | make -f $$mf test@ci/cd; \ 109 | fi ; \ 110 | for ((i=0;i<$$(echo "$${service}" | grep -o "/" | wc -l | sed "s/ //g");i++)); do \ 111 | cd .. ; \ 112 | done; \ 113 | cd .. ; \ 114 | done; 115 | 116 | # TODO should be improved to a per file check and maybe against a previous value 117 | #(maybe we should use something like SonarQube for this?) 118 | coverage: $(COVERAGE_FILE) 119 | @t=`go tool cover -func=$(COVERAGE_FILE) | grep total | grep -Eo '[0-9]+\.[0-9]+'`;\ 120 | echo "Total coverage: $${t}%"; \ 121 | if [ "$${t%.*}" -lt $(REQUIRED_COVERAGE_PERCENT) ]; then \ 122 | echo "ERROR: It has to be at least $(REQUIRED_COVERAGE_PERCENT)%"; \ 123 | exit 1; \ 124 | fi; 125 | 126 | test@ci/cd: 127 | # TODO make -race work 128 | go test -timeout 20m -tags=go_json,test -v -cover -coverprofile=$(COVERAGE_FILE) -covermode atomic 129 | 130 | benchmark@ci/cd: 131 | # TODO make -race work 132 | go test -timeout 20m -tags=go_json,test -run=^$ -v -bench=. -benchmem -benchtime 10s 133 | 134 | benchmark: 135 | set -xe; \ 136 | mf="$$(pwd)/Makefile"; \ 137 | find . -mindepth 1 -maxdepth 4 -type d -print | grep -v '\./\.' | grep -v '/\.' | sed 's/\.\///g' | while read service; do \ 138 | cd $${service} ; \ 139 | if [[ $$(find . -mindepth 1 -maxdepth 1 -type f -print | grep -E '_test.go' | wc -l | sed "s/ //g") -gt 0 ]]; then \ 140 | make -f $$mf benchmark@ci/cd; \ 141 | fi ; \ 142 | for ((i=0;i<$$(echo "$${service}" | grep -o "/" | wc -l | sed "s/ //g");i++)); do \ 143 | cd .. ; \ 144 | done; \ 145 | cd .. ; \ 146 | done; 147 | 148 | print-all-packages-with-tests: 149 | set -xe; \ 150 | find . -mindepth 1 -maxdepth 4 -type d -print | grep -v '\./\.' | grep -v '/\.' | sed 's/\.\///g' | while read service; do \ 151 | cd $${service} ; \ 152 | if [[ $$(find . -mindepth 1 -maxdepth 1 -type f -print | grep -E '_test.go' | wc -l | sed "s/ //g") -gt 0 ]]; then \ 153 | echo "$${service}"; \ 154 | fi ; \ 155 | for ((i=0;i<$$(echo "$${service}" | grep -o "/" | wc -l | sed "s/ //g");i++)); do \ 156 | cd .. ; \ 157 | done; \ 158 | cd .. ; \ 159 | done; 160 | 161 | clean: 162 | @go clean 163 | @rm -f tmp$(COVERAGE_FILE) $(COVERAGE_FILE) 2>/dev/null || true 164 | @test -d cmd && find ./cmd -mindepth 2 -maxdepth 2 -type f -name bin -exec rm -f {} \; || true; 165 | @test -d cmd && find ./cmd -mindepth 2 -maxdepth 2 -type d -name bins -exec rm -Rf {} \; || true; 166 | @find . -name ".tmp-*" -exec rm -Rf {} \; || true; 167 | @find . -maxdepth 1 -name "*.bin" -exec rm -Rf {} \; || true; 168 | @find . -mindepth 1 -maxdepth 3 -type f -name $(COVERAGE_FILE) -exec rm -Rf {} \; || true; 169 | @find . -mindepth 1 -maxdepth 3 -type f -name tmp$(COVERAGE_FILE) -exec rm -Rf {} \; || true; 170 | 171 | lint: 172 | golangci-lint run 173 | 174 | # run specific service by its name 175 | run-%: 176 | go run -tags=go_json -v ./cmd/$* 177 | 178 | run: 179 | ifeq ($(words $(SERVICES)),1) 180 | $(MAKE) $(subst ./cmd/,run-,$(SERVICES)) 181 | else 182 | @echo "Do not know what to run" 183 | @echo "Targets:" 184 | @for target in $(subst ./cmd/,run-,$(SERVICES)); do \ 185 | echo " $${target}"; \ 186 | done; false; 187 | endif 188 | 189 | # run specific service by its name 190 | binary-run-%: 191 | ./cmd/$*/bin 192 | 193 | binary-run: 194 | ifeq ($(words $(SERVICES)),1) 195 | $(MAKE) $(subst ./cmd/,binary-run-,$(SERVICES)) 196 | else 197 | @echo "Do not know what to run" 198 | @echo "Targets:" 199 | @for target in $(subst ./cmd/,binary-run-,$(SERVICES)); do \ 200 | echo " $${target}"; \ 201 | done; false; 202 | endif 203 | 204 | buildAllBinaries: 205 | set -xe; \ 206 | find ./cmd -mindepth 1 -maxdepth 1 -type d -print | grep -v 'fixture' | grep -v 'scripts' | while read service; do \ 207 | env SERVICE_NAME=$${service##*/} env GOOS=$(GOOS) env GOARCH=$(GOARCH) $(MAKE) dockerfile; \ 208 | done; 209 | 210 | # note: it requires make-4.3+ to run that 211 | buildMultiPlatformDockerImage: 212 | set -xe; \ 213 | find ./cmd -mindepth 1 -maxdepth 1 -type d -print | grep -v 'fixture' | grep -v 'scripts' | while read service; do \ 214 | for arch in amd64 arm64 s390x ppc64le; do \ 215 | docker buildx build \ 216 | --platform linux/$${arch} \ 217 | -f $${service}/Dockerfile \ 218 | --label os=linux \ 219 | --label arch=$${arch} \ 220 | --force-rm \ 221 | --pull -t $(DOCKER_REGISTRY)/$(REPOSITORY)/$${service##*/}:$(DOCKER_TAG) \ 222 | --build-arg SERVICE_NAME=$${service##*/} \ 223 | --build-arg TARGETARCH=$${arch} \ 224 | --build-arg TARGETOS=linux \ 225 | .; \ 226 | done; \ 227 | done; 228 | 229 | start-test-environment: 230 | #go run -race -v local.go 231 | go run -v local.go --type all 232 | 233 | start-test-environment-%: 234 | #go run -race -v local.go 235 | go run -v local.go --type $* 236 | 237 | getAddLicense: 238 | go install github.com/google/addlicense@latest 239 | 240 | addLicense: getAddLicense 241 | `go env GOPATH`/bin/addlicense -f LICENSE.header * .github/* 242 | 243 | checkLicense: getAddLicense 244 | `go env GOPATH`/bin/addlicense -f LICENSE.header -check * .github/* 245 | 246 | fix-field-alignment: 247 | go install golang.org/x/tools/go/analysis/passes/fieldalignment/cmd/fieldalignment@latest 248 | fieldalignment -fix ./... 249 | 250 | format-imports: 251 | go install golang.org/x/tools/cmd/goimports@latest 252 | go install github.com/daixiang0/gci@latest 253 | gci write -s standard -s default -s "prefix(github.com/ice-blockchain)" ./.. 254 | goimports -w -local github.com/ice-blockchain ./.. 255 | 256 | print-token-%: 257 | go run -v local.go --generateAuth $* 258 | 259 | start-seeding: 260 | go run -v local.go --startSeeding true 261 | 262 | all: checkLicense checkModVersion checkIfAllDependenciesAreUpToDate checkGenerated build test coverage benchmark clean 263 | local: addLicense updateGoModVersion updateAllDependencies generate build buildMultiPlatformDockerImage test coverage benchmark lint clean 264 | dockerfile: binary-specific-service 265 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Santa Service 2 | 3 | ``Santa is handling everything related to user's achievements(levels, roles, tasks, badges) and gamification progress.`` 4 | 5 | ### Development 6 | 7 | These are the crucial/critical operations you will need when developing `Santa`: 8 | 9 | 1. If you need to generate a new Authorization Token & UserID for testing locally: 10 | 1. run `make print-token-XXX`, where `XXX` is the role you want for the user. 11 | 2. If you need to seed your local database, or even a remote one: 12 | 1. run `make start-seeding` 13 | 2. it requires an .env entry: `MASTER_DB_INSTANCE_ADDRESS=admin:pass@127.0.0.1:3301` 14 | 3. `make run-santa` 15 | 1. This runs the actual read service. 16 | 2. It will feed off of the properties in `./application.yaml` 17 | 3. By default, https://localhost:6443/achievements/r runs the Open API (Swagger) entrypoint. 18 | 4. `make run-santa-sleigh` 19 | 1. This runs the actual write service. 20 | 2. It will feed off of the properties in `./application.yaml` 21 | 3. By default, https://localhost:7443/achievements/w runs the Open API (Swagger) entrypoint. 22 | 5. `make start-test-environment` 23 | 1. This bootstraps a local test environment with **Santa**'s dependencies using your `docker` and `docker-compose` daemons. 24 | 2. It is a blocking operation, SIGTERM or SIGINT will kill it. 25 | 3. It will feed off of the properties in `./application.yaml` 26 | 1. MessageBroker GUIs 27 | 1. https://www.conduktor.io 28 | 2. https://www.kafkatool.com 29 | 3. (CLI) https://vectorized.io/redpanda 30 | 2. DB GUIs 31 | 1. https://github.com/tarantool/awesome-tarantool#gui-clients 32 | 2. (CLI) `docker exec -t -i mytarantool console` where `mytarantool` is the container name 33 | 6. `make all` 34 | 1. This runs the CI pipeline, locally -- the same pipeline that PR checks run. 35 | 2. Run it before you commit to save time & not wait for PR check to fail remotely. 36 | 7. `make local` 37 | 1. This runs the CI pipeline, in a descriptive/debug mode. Run it before you run the "real" one. 38 | 8. `make lint` 39 | 1. This runs the linters. It is a part of the other pipelines, so you can run this separately to fix lint issues. 40 | 9. `make test` 41 | 1. This runs all tests. 42 | 10. `make benchmark` 43 | 1. This runs all benchmarks. 44 | -------------------------------------------------------------------------------- /badges/.testdata/application.yaml: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ice License 1.0 2 | 3 | development: true 4 | logger: 5 | encoder: console 6 | level: info 7 | badges: &badges 8 | badgesList: 9 | levels: 10 | - name: Wave Starter 11 | toInclusive: 1 12 | - name: Beach Walker 13 | fromInclusive: 2 14 | toInclusive: 3 15 | - name: Rhythm Keeper 16 | fromInclusive: 4 17 | toInclusive: 5 18 | - name: Dance Captain 19 | fromInclusive: 6 20 | toInclusive: 7 21 | - name: Vibe Master 22 | fromInclusive: 8 23 | toInclusive: 9 24 | - name: Festival Guru 25 | fromInclusive: 10 26 | coins: 27 | - name: Rookie Raver 28 | toInclusive: 10 29 | - name: Beats Saver 30 | fromInclusive: 20 31 | toInclusive: 30 32 | - name: Tune Tracker 33 | fromInclusive: 40 34 | toInclusive: 50 35 | - name: Rhythm Collector 36 | fromInclusive: 60 37 | toInclusive: 70 38 | - name: Bass Booster 39 | fromInclusive: 80 40 | toInclusive: 90 41 | - name: Melody Maker 42 | fromInclusive: 100 43 | toInclusive: 110 44 | - name: Harmony Holder 45 | fromInclusive: 120 46 | toInclusive: 130 47 | - name: Sound Seeker 48 | fromInclusive: 140 49 | toInclusive: 150 50 | - name: Vibe Crafter 51 | fromInclusive: 160 52 | toInclusive: 170 53 | - name: Festival Icon 54 | fromInclusive: 180 55 | socials: 56 | - name: Meet Greeter 57 | toInclusive: 1 58 | - name: Friend Finder 59 | fromInclusive: 2 60 | toInclusive: 3 61 | - name: Party Starter 62 | fromInclusive: 4 63 | toInclusive: 5 64 | - name: Crowd Mixer 65 | fromInclusive: 6 66 | toInclusive: 7 67 | - name: Dance Circle King 68 | fromInclusive: 8 69 | toInclusive: 9 70 | - name: Wave Rider 71 | fromInclusive: 10 72 | toInclusive: 11 73 | - name: Night Owl 74 | fromInclusive: 12 75 | toInclusive: 13 76 | - name: Beach Buddy 77 | fromInclusive: 14 78 | toInclusive: 15 79 | - name: Sunset Chaser 80 | fromInclusive: 16 81 | toInclusive: 17 82 | - name: Dance Floor Legend 83 | fromInclusive: 18 84 | db: &badgesDatabase 85 | urls: 86 | - localhost:3304 87 | user: admin 88 | password: pass 89 | messageBroker: &badgesMessageBroker 90 | consumerGroup: badges-testing 91 | createTopics: true 92 | urls: 93 | - localhost:9095 94 | topics: &badgesMessageBrokerTopics 95 | - name: santa-health-check 96 | partitions: 1 97 | replicationFactor: 1 98 | retention: 1000h 99 | - name: try-achieve-badges-commands 100 | partitions: 10 101 | replicationFactor: 1 102 | retention: 1000h 103 | - name: achieved-badges 104 | partitions: 10 105 | replicationFactor: 1 106 | retention: 1000h 107 | ### The next topics are not owned by this service, but are needed to be created for the local/test environment. 108 | - name: users-table 109 | partitions: 10 110 | replicationFactor: 1 111 | retention: 1000h 112 | - name: completed-levels 113 | partitions: 10 114 | replicationFactor: 1 115 | retention: 1000h 116 | - name: balances-table 117 | partitions: 10 118 | replicationFactor: 1 119 | retention: 1000h 120 | - name: global-table 121 | partitions: 10 122 | replicationFactor: 1 123 | retention: 1000h 124 | - name: friends-invited 125 | partitions: 10 126 | replicationFactor: 1 127 | retention: 1000h 128 | consumingTopics: 129 | - name: try-achieve-badges-commands 130 | - name: achieved-badges 131 | - name: users-table 132 | - name: completed-levels 133 | - name: balances-table 134 | - name: global-table 135 | - name: friends-invited 136 | badges_test: 137 | <<: *badges 138 | messageBroker: 139 | <<: *badgesMessageBroker 140 | consumingTopics: *badgesMessageBrokerTopics 141 | consumerGroup: santa-testing-badges 142 | db: 143 | <<: *badgesDatabase 144 | schemaPath: badges/DDL.lua 145 | -------------------------------------------------------------------------------- /badges/DDL.sql: -------------------------------------------------------------------------------- 1 | -- SPDX-License-Identifier: ice License 1.0 2 | --************************************************************************************************************************************ 3 | -- badge_progress 4 | CREATE TABLE IF NOT EXISTS badge_progress ( 5 | balance BIGINT NOT NULL DEFAULT 0, 6 | friends_invited BIGINT NOT NULL DEFAULT 0, 7 | completed_levels BIGINT NOT NULL DEFAULT 0, 8 | hide_badges BOOLEAN DEFAULT FALSE, 9 | achieved_badges TEXT[], 10 | user_id TEXT NOT NULL PRIMARY KEY 11 | ) WITH (fillfactor = 70); 12 | --************************************************************************************************************************************ 13 | -- badge_statistics 14 | CREATE TABLE IF NOT EXISTS badge_statistics ( 15 | achieved_by BIGINT NOT NULL DEFAULT 0, 16 | badge_type TEXT NOT NULL PRIMARY KEY, 17 | badge_group_type TEXT NOT NULL 18 | ) WITH (fillfactor = 70); 19 | CREATE INDEX IF NOT EXISTS badge_statistics_badge_group_type_ix ON badge_statistics (badge_group_type); 20 | INSERT INTO badge_statistics (badge_group_type,badge_type) 21 | VALUES ('level','level'), 22 | ('level','l1'), 23 | ('level','l2'), 24 | ('level','l3'), 25 | ('level','l4'), 26 | ('level','l5'), 27 | ('level','l6'), 28 | ('coin','coin'), 29 | ('coin','c1'), 30 | ('coin','c2'), 31 | ('coin','c3'), 32 | ('coin','c4'), 33 | ('coin','c5'), 34 | ('coin','c6'), 35 | ('coin','c7'), 36 | ('coin','c8'), 37 | ('coin','c9'), 38 | ('coin','c10'), 39 | ('social','social'), 40 | ('social','s1'), 41 | ('social','s2'), 42 | ('social','s3'), 43 | ('social','s4'), 44 | ('social','s5'), 45 | ('social','s6'), 46 | ('social','s7'), 47 | ('social','s8'), 48 | ('social','s9'), 49 | ('social','s10') 50 | ON CONFLICT DO NOTHING; 51 | -------------------------------------------------------------------------------- /badges/achieve_badges_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package badges 4 | 5 | import ( 6 | "math" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | 11 | "github.com/ice-blockchain/eskimo/users" 12 | wintrconfig "github.com/ice-blockchain/wintr/config" 13 | ) 14 | 15 | const ( 16 | Level1Type Type = "l1" 17 | Level2Type Type = "l2" 18 | Level3Type Type = "l3" 19 | Level4Type Type = "l4" 20 | Level5Type Type = "l5" 21 | Level6Type Type = "l6" 22 | Coin1Type Type = "c1" 23 | Coin2Type Type = "c2" 24 | Coin3Type Type = "c3" 25 | Coin4Type Type = "c4" 26 | Coin5Type Type = "c5" 27 | Coin6Type Type = "c6" 28 | Coin7Type Type = "c7" 29 | Coin8Type Type = "c8" 30 | Coin9Type Type = "c9" 31 | Coin10Type Type = "c10" 32 | Social1Type Type = "s1" 33 | Social2Type Type = "s2" 34 | Social3Type Type = "s3" 35 | Social4Type Type = "s4" 36 | Social5Type Type = "s5" 37 | Social6Type Type = "s6" 38 | Social7Type Type = "s7" 39 | Social8Type Type = "s8" 40 | Social9Type Type = "s9" 41 | Social10Type Type = "s10" 42 | ) 43 | 44 | //nolint:funlen,paralleltest,tparallel // A lot of testcases in test. Not needed parallel due to global variables usage. 45 | func Test_Progress_ReevaluateAchievedBadges(t *testing.T) { 46 | defCfg := defaultCfg() 47 | loadBadges(defCfg) 48 | testCases := []*struct { 49 | *progress 50 | cfg *config 51 | expectedNewBadgesState *users.Enum[Type] 52 | name string 53 | }{ 54 | { 55 | name: "1st badges requires zero so they are achieved automatically", 56 | progress: badgeProgress(nil, 0, 0, 0), 57 | cfg: defCfg, 58 | expectedNewBadgesState: &users.Enum[Type]{Social1Type, Level1Type}, 59 | }, 60 | { 61 | name: "No badges with non-zero balance", 62 | progress: badgeProgress(nil, 1, 0, 0), 63 | cfg: defCfg, 64 | expectedNewBadgesState: &users.Enum[Type]{Social1Type, Level1Type, Coin1Type}, 65 | }, 66 | { 67 | name: "Nothing to achieve cuz we already have social1 and level1", 68 | progress: badgeProgress(&users.Enum[Type]{Social1Type, Level1Type}, 0, Milestones[Social1Type].FromInclusive, 0), 69 | cfg: defCfg, 70 | expectedNewBadgesState: &users.Enum[Type]{Social1Type, Level1Type}, 71 | }, 72 | { 73 | name: "Achieve next one for the socials", 74 | progress: badgeProgress(&users.Enum[Type]{Social1Type, Level1Type}, 0, Milestones[Social2Type].FromInclusive, 0), 75 | cfg: defCfg, 76 | expectedNewBadgesState: &users.Enum[Type]{Social1Type, Level1Type, Social2Type}, 77 | }, 78 | { 79 | name: "Achieve a lot of new badges at once", 80 | progress: badgeProgress(&users.Enum[Type]{Social1Type, Level1Type}, 0, math.MaxUint64, 0), 81 | cfg: defCfg, 82 | expectedNewBadgesState: &users.Enum[Type]{ 83 | Social1Type, 84 | Level1Type, 85 | Social2Type, 86 | Social3Type, 87 | Social4Type, 88 | Social5Type, 89 | Social6Type, 90 | Social7Type, 91 | Social8Type, 92 | Social9Type, 93 | Social10Type, 94 | }, 95 | }, 96 | { 97 | name: "Downgrade value for already achieved badge does not change badge state", 98 | progress: badgeProgress(&users.Enum[Type]{ 99 | Level1Type, 100 | Social1Type, 101 | Social2Type, 102 | Social3Type, 103 | Social4Type, 104 | Social5Type, 105 | Social6Type, 106 | Social7Type, 107 | Social8Type, 108 | Social9Type, 109 | Social10Type, 110 | }, 0, 1, 0), 111 | cfg: defCfg, 112 | expectedNewBadgesState: &users.Enum[Type]{ 113 | Level1Type, 114 | Social1Type, 115 | Social2Type, 116 | Social3Type, 117 | Social4Type, 118 | Social5Type, 119 | Social6Type, 120 | Social7Type, 121 | Social8Type, 122 | Social9Type, 123 | Social10Type, 124 | }, 125 | }, 126 | { 127 | name: "Achieve next one for the balances", 128 | progress: badgeProgress(&users.Enum[Type]{Social1Type, Level1Type}, Milestones[Coin1Type].ToInclusive, 0, 0), 129 | cfg: defCfg, 130 | expectedNewBadgesState: &users.Enum[Type]{Social1Type, Level1Type, Coin1Type}, 131 | }, 132 | { 133 | name: "Test inclusive verification for to value", 134 | progress: badgeProgress(&users.Enum[Type]{Social1Type, Level1Type}, 0, 0, 2), 135 | cfg: defCfg, 136 | expectedNewBadgesState: &users.Enum[Type]{Social1Type, Level1Type, Level2Type}, 137 | }, 138 | } 139 | for _, tt := range testCases { 140 | t.Run(tt.name, func(t *testing.T) { 141 | t.Parallel() 142 | actualAchievedBadges := tt.progress.reEvaluateAchievedBadges() 143 | actualBadges := []Type{} 144 | if actualAchievedBadges != nil { 145 | actualBadges = []Type(*actualAchievedBadges) 146 | } 147 | expected := []Type{} 148 | if tt.expectedNewBadgesState != nil { 149 | expected = []Type(*tt.expectedNewBadgesState) 150 | } 151 | assert.ElementsMatch(t, expected, actualBadges) 152 | }) 153 | } 154 | } 155 | 156 | //nolint:funlen,paralleltest,tparallel // A lot of testcases. Not needed parallel due to global variables usage. 157 | func Test_IsBadgeGroupAchieved(t *testing.T) { 158 | loadBadges(defaultCfg()) 159 | testCases := []*struct { 160 | name string 161 | alreadyAchievedBadges *users.Enum[Type] 162 | group GroupType 163 | expected bool 164 | }{ 165 | { 166 | "no badges achieved, group is no achieved as well", 167 | nil, 168 | CoinGroupType, 169 | false, 170 | }, 171 | { 172 | "no badges achieved in certain group, but in another one", 173 | &users.Enum[Type]{Level1Type, Level2Type, Level3Type, Level4Type, Level5Type, Level6Type}, 174 | CoinGroupType, 175 | false, 176 | }, 177 | { 178 | "Badges are, partially achieved, but group itself is not", 179 | &users.Enum[Type]{Coin1Type, Coin2Type, Coin3Type}, 180 | CoinGroupType, 181 | false, 182 | }, 183 | { 184 | "Last badge is required for the group to be achieved", 185 | &users.Enum[Type]{ 186 | Coin1Type, Coin2Type, Coin3Type, Coin4Type, 187 | Coin5Type, Coin6Type, Coin7Type, Coin8Type, Coin9Type, 188 | }, 189 | CoinGroupType, 190 | false, 191 | }, 192 | { 193 | "All badges in the group are achieved", 194 | &users.Enum[Type]{ 195 | Coin1Type, Coin2Type, Coin3Type, Coin4Type, 196 | Coin5Type, Coin6Type, Coin7Type, Coin8Type, Coin9Type, Coin10Type, 197 | }, 198 | CoinGroupType, 199 | true, 200 | }, 201 | { 202 | "All badges in the group are achieved, and partially achieved in another group(Coins)", 203 | &users.Enum[Type]{ 204 | Coin1Type, Coin2Type, Coin3Type, Coin4Type, 205 | Coin5Type, Coin6Type, Coin7Type, Coin8Type, Coin9Type, Coin10Type, 206 | Level1Type, Level2Type, Level3Type, 207 | }, 208 | CoinGroupType, 209 | true, 210 | }, 211 | { 212 | "All badges in the group are achieved, and partially achieved in another group(Levels)", 213 | &users.Enum[Type]{ 214 | Coin1Type, Coin2Type, Coin3Type, Coin4Type, 215 | Coin5Type, Coin6Type, Coin7Type, Coin8Type, Coin9Type, Coin10Type, 216 | Level1Type, Level2Type, Level3Type, 217 | }, 218 | LevelGroupType, 219 | false, 220 | }, 221 | { 222 | "Multiple groups achieved (Levels)", 223 | &users.Enum[Type]{ 224 | Coin1Type, Coin2Type, Coin3Type, Coin4Type, 225 | Coin5Type, Coin6Type, Coin7Type, Coin8Type, Coin9Type, Coin10Type, 226 | Level1Type, Level2Type, Level3Type, Level4Type, Level5Type, Level6Type, 227 | }, 228 | LevelGroupType, 229 | true, 230 | }, 231 | { 232 | "Multiple groups achieved (Coins)", 233 | &users.Enum[Type]{ 234 | Coin1Type, Coin2Type, Coin3Type, Coin4Type, 235 | Coin5Type, Coin6Type, Coin7Type, Coin8Type, Coin9Type, Coin10Type, 236 | Level1Type, Level2Type, Level3Type, Level4Type, Level5Type, Level6Type, 237 | }, 238 | CoinGroupType, 239 | true, 240 | }, 241 | } 242 | for _, tt := range testCases { 243 | t.Run(tt.name, func(t *testing.T) { 244 | t.Parallel() 245 | actualIsGroupAchieved := IsBadgeGroupAchieved(tt.alreadyAchievedBadges, tt.group) 246 | assert.Equal(t, tt.expected, actualIsGroupAchieved) 247 | }) 248 | } 249 | } 250 | 251 | func defaultCfg() *config { 252 | var cfg config 253 | const applicationYamlTestKey = applicationYamlKey + "_test" 254 | wintrconfig.MustLoadFromKey(applicationYamlTestKey, &cfg) 255 | 256 | return &cfg 257 | } 258 | 259 | func badgeProgress(alreadyAchieved *users.Enum[Type], balance, friends, levels uint64) *progress { 260 | return &progress{ 261 | AchievedBadges: alreadyAchieved, 262 | Balance: int64(balance), //nolint:gosec // . 263 | FriendsInvited: int64(friends), //nolint:gosec // . 264 | CompletedLevels: int64(levels), //nolint:gosec // . 265 | } 266 | } 267 | -------------------------------------------------------------------------------- /badges/badges.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package badges 4 | 5 | import ( 6 | "context" 7 | "fmt" 8 | "log" 9 | "sync" 10 | 11 | "github.com/goccy/go-json" 12 | "github.com/hashicorp/go-multierror" 13 | "github.com/pkg/errors" 14 | 15 | "github.com/ice-blockchain/eskimo/users" 16 | appcfg "github.com/ice-blockchain/wintr/config" 17 | messagebroker "github.com/ice-blockchain/wintr/connectors/message_broker" 18 | storage "github.com/ice-blockchain/wintr/connectors/storage/v2" 19 | "github.com/ice-blockchain/wintr/time" 20 | ) 21 | 22 | func New(ctx context.Context, _ context.CancelFunc) Repository { 23 | var cfg config 24 | appcfg.MustLoadFromKey(applicationYamlKey, &cfg) 25 | 26 | db := storage.MustConnect(ctx, ddl, applicationYamlKey) 27 | loadBadges(&cfg) 28 | 29 | return &repository{ 30 | cfg: &cfg, 31 | shutdown: db.Close, 32 | db: db, 33 | } 34 | } 35 | 36 | func StartProcessor(ctx context.Context, cancel context.CancelFunc) Processor { 37 | var cfg config 38 | appcfg.MustLoadFromKey(applicationYamlKey, &cfg) 39 | loadBadges(&cfg) 40 | 41 | var mbConsumer messagebroker.Client 42 | prc := &processor{repository: &repository{ 43 | cfg: &cfg, 44 | db: storage.MustConnect(ctx, ddl, applicationYamlKey), 45 | mb: messagebroker.MustConnect(ctx, applicationYamlKey), 46 | }} 47 | mbConsumer = messagebroker.MustConnectAndStartConsuming(context.Background(), cancel, applicationYamlKey, //nolint:contextcheck // . 48 | &tryAchievedBadgesCommandSource{processor: prc}, 49 | &achievedBadgesSource{processor: prc}, 50 | &userTableSource{processor: prc}, 51 | &completedLevelsSource{processor: prc}, 52 | &balancesTableSource{processor: prc}, 53 | &globalTableSource{processor: prc}, 54 | &friendsInvitedSource{processor: prc}, 55 | ) 56 | prc.shutdown = closeAll(mbConsumer, prc.mb, prc.db) 57 | 58 | return prc 59 | } 60 | 61 | func (r *repository) Close() error { 62 | return errors.Wrap(r.shutdown(), "closing repository failed") 63 | } 64 | 65 | func closeAll(mbConsumer, mbProducer messagebroker.Client, db *storage.DB, otherClosers ...func() error) func() error { 66 | return func() error { 67 | err1 := errors.Wrap(mbConsumer.Close(), "closing message broker consumer connection failed") 68 | err2 := errors.Wrap(db.Close(), "closing db connection failed") 69 | err3 := errors.Wrap(mbProducer.Close(), "closing message broker producer connection failed") 70 | errs := make([]error, 0, 1+1+1+len(otherClosers)) 71 | errs = append(errs, err1, err2, err3) 72 | for _, closeOther := range otherClosers { 73 | if err := closeOther(); err != nil { 74 | errs = append(errs, err) 75 | } 76 | } 77 | 78 | return errors.Wrap(multierror.Append(nil, errs...).ErrorOrNil(), "failed to close resources") 79 | } 80 | } 81 | 82 | func (p *processor) CheckHealth(ctx context.Context) error { 83 | if err := p.db.Ping(ctx); err != nil { 84 | return errors.Wrap(err, "[health-check] failed to ping DB") 85 | } 86 | type ts struct { 87 | TS *time.Time `json:"ts"` 88 | } 89 | now := ts{TS: time.Now()} 90 | bytes, err := json.MarshalContext(ctx, now) 91 | if err != nil { 92 | return errors.Wrapf(err, "[health-check] failed to marshal %#v", now) 93 | } 94 | responder := make(chan error, 1) 95 | p.mb.SendMessage(ctx, &messagebroker.Message{ 96 | Headers: map[string]string{"producer": "santa"}, 97 | Key: p.cfg.MessageBroker.Topics[0].Name, 98 | Topic: p.cfg.MessageBroker.Topics[0].Name, 99 | Value: bytes, 100 | }, responder) 101 | 102 | return errors.Wrapf(<-responder, "[health-check] failed to send health check message to broker") 103 | } 104 | 105 | func runConcurrently[ARG any](ctx context.Context, run func(context.Context, ARG) error, args []ARG) error { 106 | if ctx.Err() != nil { 107 | return errors.Wrap(ctx.Err(), "unexpected deadline") 108 | } 109 | if len(args) == 0 { 110 | return nil 111 | } 112 | wg := new(sync.WaitGroup) 113 | wg.Add(len(args)) 114 | errChan := make(chan error, len(args)) 115 | for i := range args { 116 | go func(ix int) { 117 | defer wg.Done() 118 | errChan <- errors.Wrapf(run(ctx, args[ix]), "failed to run:%#v", args[ix]) 119 | }(i) 120 | } 121 | wg.Wait() 122 | close(errChan) 123 | errs := make([]error, 0, len(args)) 124 | for err := range errChan { 125 | errs = append(errs, err) 126 | } 127 | 128 | return errors.Wrap(multierror.Append(nil, errs...).ErrorOrNil(), "at least one execution failed") 129 | } 130 | 131 | func AreBadgesAchieved(actual *users.Enum[Type], expectedSubset ...Type) bool { 132 | if len(expectedSubset) == 0 { 133 | return actual == nil || len(*actual) == 0 134 | } 135 | if (actual == nil || len(*actual) == 0) && len(expectedSubset) > 0 { 136 | return false 137 | } 138 | for _, expectedType := range expectedSubset { 139 | var achieved bool 140 | for _, achievedType := range *actual { 141 | if achievedType == expectedType { 142 | achieved = true 143 | 144 | break 145 | } 146 | } 147 | if !achieved { 148 | return false 149 | } 150 | } 151 | 152 | return true 153 | } 154 | 155 | func IsBadgeGroupAchieved(actual *users.Enum[Type], expectedGroupType GroupType) bool { 156 | if actual == nil || len(*actual) == 0 { 157 | return false 158 | } 159 | for _, expectedType := range AllGroups[expectedGroupType] { 160 | var achieved bool 161 | for _, achievedType := range *actual { 162 | if achievedType == expectedType { 163 | achieved = true 164 | 165 | break 166 | } 167 | } 168 | if !achieved { 169 | return false 170 | } 171 | } 172 | 173 | return true 174 | } 175 | 176 | func requestingUserID(ctx context.Context) (requestingUserID string) { 177 | requestingUserID, _ = ctx.Value(requestingUserIDCtxValueKey).(string) //nolint:errcheck,revive // Not needed. 178 | 179 | return 180 | } 181 | 182 | func loadBadges(cfg *config) { 183 | LevelTypeOrder = make(map[Type]int, len(cfg.BadgesList.Levels)) 184 | CoinTypeOrder = make(map[Type]int, len(cfg.BadgesList.Coins)) 185 | SocialTypeOrder = make(map[Type]int, len(cfg.BadgesList.Socials)) 186 | AllTypeOrder = make(map[Type]int, len(cfg.BadgesList.Levels)+len(cfg.BadgesList.Coins)+len(cfg.BadgesList.Socials)) 187 | LevelTypeNames = make(map[Type]string, len(cfg.BadgesList.Levels)) 188 | CoinTypeNames = make(map[Type]string, len(cfg.BadgesList.Coins)) 189 | SocialTypeNames = make(map[Type]string, len(cfg.BadgesList.Socials)) 190 | GroupTypeForEachType = make(map[Type]GroupType, len(cfg.BadgesList.Levels)+len(cfg.BadgesList.Coins)+len(cfg.BadgesList.Socials)) 191 | AllNames = make(map[GroupType]map[Type]string, len(GroupsOrderSummaries)) 192 | AllGroups = make(map[GroupType][]Type, len(GroupsOrderSummaries)) 193 | Milestones = make(map[Type]AchievingRange, len(cfg.BadgesList.Levels)+len(cfg.BadgesList.Coins)+len(cfg.BadgesList.Socials)) 194 | 195 | loadBadgesInfo(cfg.BadgesList.Levels, LevelGroupType) 196 | AllNames[LevelGroupType] = make(map[Type]string, len(LevelTypeNames)) 197 | for key, val := range LevelTypeNames { 198 | AllNames[LevelGroupType][key] = val 199 | } 200 | loadBadgesInfo(cfg.BadgesList.Coins, CoinGroupType) 201 | AllNames[CoinGroupType] = make(map[Type]string, len(CoinTypeNames)) 202 | for key, val := range CoinTypeNames { 203 | AllNames[CoinGroupType][key] = val 204 | } 205 | loadBadgesInfo(cfg.BadgesList.Socials, SocialGroupType) 206 | AllNames[SocialGroupType] = make(map[Type]string, len(SocialTypeNames)) 207 | for key, val := range SocialTypeNames { 208 | AllNames[SocialGroupType][key] = val 209 | } 210 | } 211 | 212 | func loadBadgesInfo(badgeInfoList []*AchievingRange, groupType GroupType) { 213 | offset := len(AllTypes) 214 | for idx, val := range badgeInfoList { 215 | typeName := getTypeName(groupType) 216 | tpe := Type(fmt.Sprintf("%v%v", typeName, idx+1)) 217 | AllTypes = append(AllTypes, tpe) 218 | Milestones[tpe] = *val 219 | 220 | switch groupType { 221 | case LevelGroupType: 222 | LevelTypeOrder[tpe] = idx 223 | LevelTypeNames[tpe] = val.Name 224 | case CoinGroupType: 225 | CoinTypeOrder[tpe] = idx 226 | CoinTypeNames[tpe] = val.Name 227 | case SocialGroupType: 228 | SocialTypeOrder[tpe] = idx 229 | SocialTypeNames[tpe] = val.Name 230 | default: 231 | log.Panic("wrong group type") 232 | } 233 | AllTypeOrder[tpe] = idx + offset 234 | GroupTypeForEachType[tpe] = groupType 235 | AllGroups[groupType] = append(AllGroups[groupType], tpe) 236 | } 237 | } 238 | 239 | func getTypeName(groupType GroupType) (typeName string) { 240 | switch groupType { 241 | case LevelGroupType: 242 | typeName = "l" 243 | case CoinGroupType: 244 | typeName = "c" 245 | case SocialGroupType: 246 | typeName = "s" 247 | default: 248 | log.Panic("wrong group type") 249 | } 250 | 251 | return 252 | } 253 | -------------------------------------------------------------------------------- /badges/contract.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package badges 4 | 5 | import ( 6 | "context" 7 | _ "embed" 8 | "io" 9 | 10 | "github.com/pkg/errors" 11 | 12 | "github.com/ice-blockchain/eskimo/users" 13 | messagebroker "github.com/ice-blockchain/wintr/connectors/message_broker" 14 | storage "github.com/ice-blockchain/wintr/connectors/storage/v2" 15 | ) 16 | 17 | // Public API. 18 | 19 | const ( 20 | LevelGroupType GroupType = "level" 21 | CoinGroupType GroupType = "coin" 22 | SocialGroupType GroupType = "social" 23 | ) 24 | 25 | //nolint:gochecknoglobals // . 26 | var ( 27 | ErrRelationNotFound = storage.ErrRelationNotFound 28 | ErrHidden = errors.New("badges are hidden") 29 | ErrRaceCondition = errors.New("race condition") 30 | 31 | AllTypes []Type 32 | LevelTypeOrder map[Type]int 33 | CoinTypeOrder map[Type]int 34 | SocialTypeOrder map[Type]int 35 | AllTypeOrder map[Type]int 36 | LevelTypeNames map[Type]string 37 | CoinTypeNames map[Type]string 38 | SocialTypeNames map[Type]string 39 | GroupTypeForEachType map[Type]GroupType 40 | AllNames map[GroupType]map[Type]string 41 | AllGroups map[GroupType][]Type 42 | GroupsOrderSummaries = [3]GroupType{ 43 | SocialGroupType, 44 | CoinGroupType, 45 | LevelGroupType, 46 | } 47 | Milestones map[Type]AchievingRange 48 | ) 49 | 50 | type ( 51 | Type string 52 | GroupType string 53 | AchievingRange struct { 54 | Name string `json:"-"` 55 | FromInclusive uint64 `json:"fromInclusive,omitempty"` 56 | ToInclusive uint64 `json:"toInclusive,omitempty"` 57 | } 58 | Badge struct { 59 | Name string `json:"name"` 60 | Type Type `json:"-"` 61 | GroupType GroupType `json:"type"` //nolint:tagliatelle // Intended. 62 | AchievingRange AchievingRange `json:"achievingRange"` 63 | PercentageOfUsersInProgress float64 `json:"percentageOfUsersInProgress"` 64 | Achieved bool `json:"achieved"` 65 | } 66 | BadgeSummary struct { 67 | Name string `json:"name"` 68 | GroupType GroupType `json:"type"` //nolint:tagliatelle // Intended. 69 | Index uint64 `json:"index"` 70 | LastIndex uint64 `json:"lastIndex"` 71 | } 72 | AchievedBadge struct { 73 | UserID string `json:"userId" example:"edfd8c02-75e0-4687-9ac2-1ce4723865c4"` 74 | Type Type `json:"type" example:"c1"` 75 | Name string `json:"name" example:"Glacial Polly"` 76 | GroupType GroupType `json:"groupType" example:"coin"` 77 | AchievedBadges uint64 `json:"achievedBadges,omitempty" example:"3"` 78 | } 79 | ReadRepository interface { 80 | GetBadges(ctx context.Context, groupType GroupType, userID string) ([]*Badge, error) 81 | GetSummary(ctx context.Context, userID string) ([]*BadgeSummary, error) 82 | } 83 | WriteRepository interface{} //nolint:revive // . 84 | Repository interface { 85 | io.Closer 86 | 87 | ReadRepository 88 | WriteRepository 89 | } 90 | Processor interface { 91 | Repository 92 | CheckHealth(ctx context.Context) error 93 | } 94 | ) 95 | 96 | // Private API. 97 | 98 | const ( 99 | applicationYamlKey = "badges" 100 | requestingUserIDCtxValueKey = "requestingUserIDCtxValueKey" 101 | percent100 = 100.0 102 | ) 103 | 104 | // . 105 | var ( 106 | //go:embed DDL.sql 107 | ddl string 108 | ) 109 | 110 | type ( 111 | progress struct { 112 | AchievedBadges *users.Enum[Type] `json:"achievedBadges,omitempty" example:"c1,l1,l2,c2"` 113 | UserID string `json:"userId,omitempty" example:"edfd8c02-75e0-4687-9ac2-1ce4723865c4"` 114 | FriendsInvited int64 `json:"friendsInvited,omitempty" example:"3"` 115 | CompletedLevels int64 `json:"completedLevels,omitempty" example:"3"` 116 | Balance int64 `json:"balance,omitempty" example:"1232323232"` 117 | HideBadges bool `json:"hideBadges,omitempty" example:"false"` 118 | } 119 | badgeDistribution struct { 120 | Range string `db:"range"` 121 | Count uint64 `db:"count"` 122 | } 123 | tryAchievedBadgesCommandSource struct { 124 | *processor 125 | } 126 | achievedBadgesSource struct { 127 | *processor 128 | } 129 | userTableSource struct { 130 | *processor 131 | } 132 | 133 | friendsInvitedSource struct { 134 | *processor 135 | } 136 | 137 | completedLevelsSource struct { 138 | *processor 139 | } 140 | balancesTableSource struct { 141 | *processor 142 | } 143 | globalTableSource struct { 144 | *processor 145 | } 146 | repository struct { 147 | cfg *config 148 | shutdown func() error 149 | db *storage.DB 150 | mb messagebroker.Client 151 | } 152 | processor struct { 153 | *repository 154 | } 155 | config struct { 156 | BadgesList struct { 157 | Levels []*AchievingRange `yaml:"levels" mapstructure:"levels"` 158 | Coins []*AchievingRange `yaml:"coins" mapstructure:"coins"` 159 | Socials []*AchievingRange `yaml:"socials" mapstructure:"socials"` 160 | } `yaml:"badgesList" mapstructure:"badgesList"` 161 | messagebroker.Config `mapstructure:",squash"` //nolint:tagliatelle // Nope. 162 | } 163 | ) 164 | -------------------------------------------------------------------------------- /badges/fixture/contract.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package fixture 4 | 5 | // Public API. 6 | 7 | const ( 8 | TestConnectorsOrder = 0 9 | ) 10 | 11 | const ( 12 | All StartLocalTestEnvironmentType = "all" 13 | DB StartLocalTestEnvironmentType = "db" 14 | MB StartLocalTestEnvironmentType = "mb" 15 | ) 16 | 17 | type ( 18 | StartLocalTestEnvironmentType string 19 | ) 20 | 21 | // Private API. 22 | 23 | const ( 24 | applicationYAMLKey = "badges" 25 | ) 26 | -------------------------------------------------------------------------------- /badges/fixture/fixture.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package fixture 4 | 5 | import ( 6 | "testing" 7 | 8 | testcontainers "github.com/testcontainers/testcontainers-go" 9 | 10 | connectorsfixture "github.com/ice-blockchain/wintr/connectors/fixture" 11 | messagebrokerfixture "github.com/ice-blockchain/wintr/connectors/message_broker/fixture" 12 | storagefixture "github.com/ice-blockchain/wintr/connectors/storage/fixture" 13 | ) 14 | 15 | func StartLocalTestEnvironment(tp StartLocalTestEnvironmentType) { 16 | var connectors []connectorsfixture.TestConnector 17 | switch tp { 18 | case DB: 19 | connectors = append(connectors, newDBConnector()) 20 | case MB: 21 | connectors = append(connectors, newMBConnector()) 22 | case All: 23 | connectors = WTestConnectors() 24 | default: 25 | connectors = WTestConnectors() 26 | } 27 | connectorsfixture. 28 | NewTestRunner(applicationYAMLKey, nil, connectors...). 29 | StartConnectorsIndefinitely() 30 | } 31 | 32 | //nolint:gocritic // Because that's exactly what we want. 33 | func RunTests( 34 | m *testing.M, 35 | dbConnector *storagefixture.TestConnector, 36 | mbConnector *messagebrokerfixture.TestConnector, 37 | lifeCycleHooks ...*connectorsfixture.ConnectorLifecycleHooks, 38 | ) { 39 | *dbConnector = newDBConnector() 40 | *mbConnector = newMBConnector() 41 | 42 | var connectorLifecycleHooks *connectorsfixture.ConnectorLifecycleHooks 43 | if len(lifeCycleHooks) == 1 { 44 | connectorLifecycleHooks = lifeCycleHooks[0] 45 | } 46 | 47 | connectorsfixture. 48 | NewTestRunner(applicationYAMLKey, connectorLifecycleHooks, *dbConnector, *mbConnector). 49 | RunTests(m) 50 | } 51 | 52 | func WTestConnectors() []connectorsfixture.TestConnector { 53 | return []connectorsfixture.TestConnector{newDBConnector(), newMBConnector()} 54 | } 55 | 56 | func RTestConnectors() []connectorsfixture.TestConnector { 57 | return []connectorsfixture.TestConnector{newDBConnector()} 58 | } 59 | 60 | func newDBConnector() storagefixture.TestConnector { 61 | return storagefixture.NewTestConnector(applicationYAMLKey, TestConnectorsOrder) 62 | } 63 | 64 | func newMBConnector() messagebrokerfixture.TestConnector { 65 | return messagebrokerfixture.NewTestConnector(applicationYAMLKey, TestConnectorsOrder) 66 | } 67 | 68 | func RContainerMounts() []func(projectRoot string) testcontainers.ContainerMount { 69 | return nil 70 | } 71 | 72 | func WContainerMounts() []func(projectRoot string) testcontainers.ContainerMount { 73 | return nil 74 | } 75 | -------------------------------------------------------------------------------- /badges/get_badges.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package badges 4 | 5 | import ( 6 | "context" 7 | "fmt" 8 | "strings" 9 | 10 | "github.com/pkg/errors" 11 | 12 | storage "github.com/ice-blockchain/wintr/connectors/storage/v2" 13 | ) 14 | 15 | func (r *repository) GetBadges(ctx context.Context, groupType GroupType, userID string) ([]*Badge, error) { 16 | if ctx.Err() != nil { 17 | return nil, errors.Wrap(ctx.Err(), "unexpected deadline") 18 | } 19 | stats, err := r.getBadgesDistributon(ctx, groupType) 20 | if err != nil { 21 | return nil, errors.Wrapf(err, "failed to getStatistics for %v", groupType) 22 | } 23 | userProgress, err := r.getProgress(ctx, userID, true) 24 | if err != nil && !errors.Is(err, ErrRelationNotFound) { 25 | return nil, errors.Wrapf(err, "failed to getProgress for userID:%v", userID) 26 | } 27 | if userProgress != nil && (userProgress.HideBadges && requestingUserID(ctx) != userID) { 28 | return nil, ErrHidden 29 | } 30 | 31 | return userProgress.buildBadges(groupType, stats), nil 32 | } 33 | 34 | func (r *repository) GetSummary(ctx context.Context, userID string) ([]*BadgeSummary, error) { 35 | if ctx.Err() != nil { 36 | return nil, errors.Wrap(ctx.Err(), "unexpected deadline") 37 | } 38 | userProgress, err := r.getProgress(ctx, userID, true) 39 | if err != nil && !errors.Is(err, ErrRelationNotFound) { 40 | return nil, errors.Wrapf(err, "failed to getProgress for userID:%v", userID) 41 | } 42 | if userProgress != nil && (userProgress.HideBadges && requestingUserID(ctx) != userID) { 43 | return nil, ErrHidden 44 | } 45 | 46 | return userProgress.buildBadgeSummaries(), nil 47 | } 48 | 49 | //nolint:revive // . 50 | func (r *repository) getProgress(ctx context.Context, userID string, tolerateOldData bool) (res *progress, err error) { 51 | if ctx.Err() != nil { 52 | return nil, errors.Wrap(ctx.Err(), "unexpected deadline") 53 | } 54 | sql := `SELECT * FROM badge_progress WHERE user_id = $1` 55 | if tolerateOldData { 56 | res, err = storage.Get[progress](ctx, r.db, sql, userID) 57 | } else { 58 | res, err = storage.ExecOne[progress](ctx, r.db, sql, userID) 59 | } 60 | 61 | if res == nil { 62 | return nil, ErrRelationNotFound 63 | } 64 | 65 | return res, errors.Wrapf(err, "can't get badge progress for userID:%v", userID) 66 | } 67 | 68 | func (r *repository) getBadgesDistributon(ctx context.Context, groupType GroupType) (map[Type]float64, error) { 69 | if ctx.Err() != nil { 70 | return nil, errors.Wrap(ctx.Err(), "unexpected deadline") 71 | } 72 | sql := fmt.Sprintf(`SELECT COALESCE(COUNT(user_id), 0) AS count, 73 | (CASE 74 | %v 75 | END) AS range 76 | FROM badge_progress 77 | GROUP BY range 78 | ORDER BY range ASC`, strings.Join(r.preparePercentageDistributionSQL(groupType), "\n")) 79 | resp, err := storage.Select[badgeDistribution](ctx, r.db, sql) 80 | if err != nil { 81 | return nil, errors.Wrapf(err, "failed to get BADGE_STATISTICS for groupType:%v", groupType) 82 | } 83 | 84 | return r.calculatePercentages(resp, groupType), nil 85 | } 86 | 87 | //nolint:funlen,gocognit,revive // . 88 | func (r *repository) preparePercentageDistributionSQL(groupType GroupType) []string { 89 | var whenValues []string 90 | switch groupType { 91 | case CoinGroupType: 92 | whenValues = make([]string, 0, len(r.cfg.BadgesList.Coins)) 93 | for idx, rnge := range r.cfg.BadgesList.Coins { 94 | val := fmt.Sprintf("when balance >= %v", rnge.FromInclusive) 95 | if rnge.ToInclusive != 0 { 96 | val = fmt.Sprintf("%v AND balance <= %v", val, rnge.ToInclusive) 97 | } 98 | whenValues = append(whenValues, fmt.Sprintf("%v THEN 'c%v'", val, idx+1)) 99 | } 100 | case LevelGroupType: 101 | whenValues = make([]string, 0, len(r.cfg.BadgesList.Levels)) 102 | for idx, rnge := range r.cfg.BadgesList.Levels { 103 | val := fmt.Sprintf("when completed_levels >= %v", rnge.FromInclusive) 104 | if rnge.ToInclusive != 0 { 105 | val = fmt.Sprintf("%v AND completed_levels <= %v", val, rnge.ToInclusive) 106 | } 107 | whenValues = append(whenValues, fmt.Sprintf("%v THEN 'l%v'", val, idx+1)) 108 | } 109 | case SocialGroupType: 110 | whenValues = make([]string, 0, len(r.cfg.BadgesList.Socials)) 111 | for idx, rnge := range r.cfg.BadgesList.Socials { 112 | val := fmt.Sprintf("when friends_invited >= %v", rnge.FromInclusive) 113 | if rnge.ToInclusive != 0 { 114 | val = fmt.Sprintf("%v AND friends_invited <= %v", val, rnge.ToInclusive) 115 | } 116 | whenValues = append(whenValues, fmt.Sprintf("%v THEN 's%v'", val, idx+1)) 117 | } 118 | } 119 | 120 | return whenValues 121 | } 122 | 123 | func (r *repository) calculatePercentages(distribution []*badgeDistribution, groupType GroupType) map[Type]float64 { 124 | var length int 125 | switch groupType { 126 | case CoinGroupType: 127 | length = len(r.cfg.BadgesList.Coins) 128 | case LevelGroupType: 129 | length = len(r.cfg.BadgesList.Levels) 130 | case SocialGroupType: 131 | length = len(r.cfg.BadgesList.Socials) 132 | } 133 | var totalUsers uint64 134 | for _, val := range distribution { 135 | totalUsers += val.Count 136 | } 137 | result := make(map[Type]float64, length) 138 | for _, val := range distribution { 139 | result[Type(val.Range)] = float64(val.Count) / float64(totalUsers) * 100 140 | } 141 | 142 | return result 143 | } 144 | 145 | func (p *progress) buildBadges(groupType GroupType, stats map[Type]float64) []*Badge { 146 | resp := make([]*Badge, 0, len(AllGroups[groupType])) 147 | for _, badgeType := range AllGroups[groupType] { 148 | resp = append(resp, &Badge{ 149 | AchievingRange: Milestones[badgeType], 150 | Name: AllNames[groupType][badgeType], 151 | Type: badgeType, 152 | GroupType: groupType, 153 | PercentageOfUsersInProgress: stats[badgeType], 154 | }) 155 | } 156 | if p == nil || p.AchievedBadges == nil || len(*p.AchievedBadges) == 0 { 157 | return resp 158 | } 159 | achievedBadges := make(map[Type]bool, len(resp)) 160 | for _, achievedBadge := range *p.AchievedBadges { 161 | achievedBadges[achievedBadge] = true 162 | } 163 | for _, badge := range resp { 164 | badge.Achieved = achievedBadges[badge.Type] 165 | } 166 | 167 | return resp 168 | } 169 | 170 | func (p *progress) buildBadgeSummaries() []*BadgeSummary { //nolint:gocognit,revive // . 171 | resp := make([]*BadgeSummary, 0, len(AllGroups)) 172 | for _, groupType := range &GroupsOrderSummaries { 173 | types := AllGroups[groupType] 174 | lastAchievedIndex := 0 175 | if p != nil && p.AchievedBadges != nil { 176 | for ix, badgeType := range types { 177 | for _, achievedBadge := range *p.AchievedBadges { 178 | if badgeType == achievedBadge { 179 | lastAchievedIndex = ix 180 | } 181 | } 182 | } 183 | } 184 | resp = append(resp, &BadgeSummary{ 185 | Name: AllNames[groupType][types[lastAchievedIndex]], 186 | GroupType: groupType, 187 | Index: uint64(lastAchievedIndex), //nolint:gosec // . 188 | LastIndex: uint64(len(types) - 1), //nolint:gosec // . 189 | }) 190 | } 191 | 192 | return resp 193 | } 194 | -------------------------------------------------------------------------------- /badges/seeding/seeding.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | //go:build !test 4 | 5 | package seeding 6 | 7 | import ( 8 | "github.com/ice-blockchain/wintr/log" 9 | ) 10 | 11 | func StartSeeding() { 12 | log.Info("TODO: implement seeding") 13 | } 14 | -------------------------------------------------------------------------------- /cmd/santa-sleigh/.testdata/application.yaml: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ice License 1.0 2 | 3 | development: false 4 | logger: 5 | encoder: console 6 | level: info 7 | cmd/santa-sleigh: 8 | host: localhost 9 | version: latest 10 | defaultEndpointTimeout: 5s 11 | httpServer: 12 | port: 44443 13 | certPath: .testdata/localhost.crt 14 | keyPath: .testdata/localhost.key -------------------------------------------------------------------------------- /cmd/santa-sleigh/.testdata/expected_swagger.json: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ice-blockchain/santa/f26235b80c9643c4515f7bf8cb06af188dad5be9/cmd/santa-sleigh/.testdata/expected_swagger.json -------------------------------------------------------------------------------- /cmd/santa-sleigh/.testdata/localhost.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDijCCAnKgAwIBAgIJAMeawIdSd6+8MA0GCSqGSIb3DQEBCwUAMCcxCzAJBgNV 3 | BAYTAlVTMRgwFgYDVQQDDA9FeGFtcGxlLVJvb3QtQ0EwHhcNMjIwMTA0MjEwNDE3 4 | WhcNMjQxMDI0MjEwNDE3WjBtMQswCQYDVQQGEwJVUzESMBAGA1UECAwJWW91clN0 5 | YXRlMREwDwYDVQQHDAhZb3VyQ2l0eTEdMBsGA1UECgwURXhhbXBsZS1DZXJ0aWZp 6 | Y2F0ZXMxGDAWBgNVBAMMD2xvY2FsaG9zdC5sb2NhbDCCASIwDQYJKoZIhvcNAQEB 7 | BQADggEPADCCAQoCggEBAONuA1zntIXbNaEvt/n+/Jisib/8Bjvfm2I9ENMq0TBH 8 | OGlbZgJ9ywiKsrxBYH/O2q6Dsxy9fL5cSfcMmAS0FXPrcXQx/pVNCgNWLEXZyPDk 9 | SzSR+tlPXzuryN2/jbWtgOZc73kfxQVBqUWbLyMiXaxMxVGHgpYMg0w68Ee62d2H 10 | AnA7c0YBllvggDRSaoDRJJZTc8DDGAHm9x5583zdxpCQh/EeV+zIjd2lAGF0ioYu 11 | PV69lwyrTnY/s7WG59nRYwYR50JvbI4G+5bbpf4q2W7Q0BVLqwSdMJfAfG43N5U/ 12 | 4Q1dfyJeXavFfQaZWJtEiVOU9TBiV3QQto0tI28R6J0CAwEAAaNzMHEwQQYDVR0j 13 | BDowOKErpCkwJzELMAkGA1UEBhMCVVMxGDAWBgNVBAMMD0V4YW1wbGUtUm9vdC1D 14 | QYIJANxKhfP/dJTMMAkGA1UdEwQCMAAwCwYDVR0PBAQDAgTwMBQGA1UdEQQNMAuC 15 | CWxvY2FsaG9zdDANBgkqhkiG9w0BAQsFAAOCAQEAjrUp0epptzaaTULvhrFdNJ6e 16 | 2WAeJpYCxMXjms7P+B/ldyIirDqG/WEzpN64Z1gXJhtxnw7IGTsQ8eXqLmBDk045 17 | vHhVbRhjVGADc+EVwX6OzQ+WQEGZzNDPX7DBObLC1ZV5LcfUwQXyACmlARlYgXJN 18 | GZFDkijDcvY3/Hyq9NkV6VGYPKnzxaal3v3cYO8FXQHaOLnu+SLWknT56y2vTa5/ 19 | H4CoX8nrts5Fa0NuOdoyNA1c7IdHjR/dy4g5IUZW+Sbhr1nNgkECBJvJ5QOWZ3M4 20 | 4a8NroD0ikzQDaeS4Tpk54WnJLEjDgQe5fX9RMu9F2sbr+wP+gUTmHuhLg/Ptw== 21 | -----END CERTIFICATE----- 22 | -------------------------------------------------------------------------------- /cmd/santa-sleigh/.testdata/localhost.key: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDjbgNc57SF2zWh 3 | L7f5/vyYrIm//AY735tiPRDTKtEwRzhpW2YCfcsIirK8QWB/ztqug7McvXy+XEn3 4 | DJgEtBVz63F0Mf6VTQoDVixF2cjw5Es0kfrZT187q8jdv421rYDmXO95H8UFQalF 5 | my8jIl2sTMVRh4KWDINMOvBHutndhwJwO3NGAZZb4IA0UmqA0SSWU3PAwxgB5vce 6 | efN83caQkIfxHlfsyI3dpQBhdIqGLj1evZcMq052P7O1hufZ0WMGEedCb2yOBvuW 7 | 26X+Ktlu0NAVS6sEnTCXwHxuNzeVP+ENXX8iXl2rxX0GmVibRIlTlPUwYld0ELaN 8 | LSNvEeidAgMBAAECggEBALHtN6RPgePXA7X+5ygmXOf01C/ms9nTrnTE4YzTSqVC 9 | kteaMcxxLY6ZNAwj+aMD6gHt9wrdE+K5wQQOTkAfw0jVQgVtt4aGpvbFTA25vIL5 10 | l/yg2Gd6uT6tvo/9dJhWDSosOw2/1RuvqwZRyibqk+5ggV6vbXKGh5Hz6lezzw6H 11 | P8xazcT634Tj5YhNhd00XIcr1V+kqEHZGiJP0XzrdXzjAS5NciEdW529gv4Dp4Ni 12 | zpSroznCcP6psLXS99snDg1UdQPFu90IW51i7VOBkF+RhRIMWOywO9FeFHoQ7j0u 13 | SqACHFz8HQnR0uSZ8AwnWrRhWVoBfQ6bwDjJKi/vtQECgYEA8ZxQtliNEd2ojF0s 14 | PbU7YE9vTDEY5AXk6bRPf1rJk/RTDZZwguC4MWjTBpcqawppzur8RLRJAp3WtyP4 15 | zXh7qvgeOFIaVmGUefEfg8OkXAtvwT+ogvl9HHyY3lPWQyF+WV3dN4ILWguDYiCB 16 | myL/4EqBZjSHmqfzKS6sT9x+TYkCgYEA8Pl9uH6wDSReKqmO1kNnyF+dWfP0I7wX 17 | UpSTkRvSrYQIH2VFYH+LSN5OZaku0FHQkIbgjunAT29N8p//E2ZA3L2xNIKDV+hI 18 | M+NV52YwguUROh2mIypGlPT1f7R+tiYzz27jZgctYIF3mzTMQ1TC2TqgXzG5eA2y 19 | /Ojcmj9ncXUCgYEA4y5fOkYjR3RMAsetTMy3awTmGxdjVy0vpIx138NHHYaz/WfC 20 | nV2d9F+jZWQIb6PX/8c2s4dtyzcM6SG61cD/T7CEAeM5fpW8XbjbMDNqvV3HlEc+ 21 | NQFQodOKjir4oiDBRFidJI90CxQeUstL8srDHGwSJj8obsSTQNrxDRq/7DkCgYBR 22 | cLBpmv9a4bClkHqCtXMsyAvA6+7V6Oqk8SvSPen81IN+QNaqn1BuhxtNxljY9N2d 23 | Csh35E4nSoG4fxRQ9Rz0vXNXQMis/Aby6mEM/H9mrY4d6wlMFyyViRgzWcf9PXoD 24 | IAHgaIqQdBD9NmHWW54ilmq+4WpCRbb5PKXZx5XpRQKBgQCCMpaANqren/4aeDdz 25 | F2lkEJweRsTaS13LJKkk/fGWeXo3N/sXuBPocViSzkCNoHGx1yHrG9TyC7Cz7UXj 26 | 4Dpy7gI3cg0i7gaHgC1JfYoPzCSmvnJT62TyL/5SGwF4Xkg8efmF+sVKZqsqgiiT 27 | ATGyCMbfg4XaTw84ubV2rGxvRQ== 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /cmd/santa-sleigh/Dockerfile: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ice License 1.0 2 | 3 | FROM golang:latest AS build 4 | ARG SERVICE_NAME 5 | ARG TARGETOS 6 | ARG TARGETARCH 7 | 8 | WORKDIR /app/ 9 | COPY . /app/ 10 | 11 | ENV CGO_ENABLED=0 12 | ENV GOOS=$TARGETOS 13 | ENV GOARCH=$TARGETARCH 14 | 15 | RUN env SERVICE_NAME=$SERVICE_NAME make dockerfile 16 | RUN cp cmd/$SERVICE_NAME/bin bin 17 | 18 | FROM gcr.io/distroless/base-debian11:latest 19 | ARG TARGETOS 20 | ARG TARGETARCH 21 | ARG PORT=443 22 | LABEL os=$TARGETOS 23 | LABEL arch=$TARGETARCH 24 | COPY --from=build /app/bin app 25 | #You might need to expose more ports. Just add more separated by space 26 | #I.E. EXPOSE 8080 8081 8082 8083 27 | EXPOSE $PORT 28 | ENTRYPOINT ["/app"] 29 | -------------------------------------------------------------------------------- /cmd/santa-sleigh/contract.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package main 4 | 5 | import ( 6 | "github.com/ice-blockchain/santa/badges" 7 | friendsinvited "github.com/ice-blockchain/santa/friends-invited" 8 | levelsandroles "github.com/ice-blockchain/santa/levels-and-roles" 9 | "github.com/ice-blockchain/santa/tasks" 10 | ) 11 | 12 | // Public API. 13 | 14 | type ( 15 | CompleteTaskRequestBody struct { 16 | Data *tasks.Data `json:"data,omitempty"` 17 | UserID string `uri:"userId" example:"edfd8c02-75e0-4687-9ac2-1ce4723865c4" swaggerignore:"true" required:"true"` 18 | TaskType tasks.Type `uri:"taskType" example:"start_mining" swaggerignore:"true" required:"true" enums:"claim_username,start_mining,upload_profile_picture,follow_us_on_twitter,join_twitter,join_telegram,invite_friends,invite_friends_5,invite_friends_10,invite_friends_25,invite_friends_50,invite_friends_100,invite_friends_200,join_youtube,watch_video_with_code_confirmation_1,invite_friends_5,invite_friends_10,claim_badge_l1,claim_badge_l2,claim_badge_l3,claim_badge_l4,claim_badge_l5,claim_badge_l6,claim_badge_c1,claim_badge_c2,claim_badge_c3,claim_badge_c4,claim_badge_c5,claim_badge_c6,claim_badge_c7,claim_badge_c8,claim_badge_c9,claim_badge_c10,claim_badge_s1,claim_badge_s2,claim_badge_s3,claim_badge_s4,claim_badge_s5,claim_badge_s6,claim_badge_s7,claim_badge_s8,claim_badge_s9,claim_badge_s10,claim_level_1,claim_level_2,claim_level_3,claim_level_4,claim_level_5,claim_level_6,claim_level_7,claim_level_8,claim_level_9,claim_level_10,claim_level_11,claim_level_12,claim_level_13,claim_level_14,claim_level_15,claim_level_16,claim_level_17,claim_level_18,claim_level_19,claim_level_20,claim_level_21,mining_streak_7,mining_streak_14,mining_streak_30,join_reddit_ion,join_instagram_ion,join_twitter_ion,join_telegram_ion,signup_sunwaves,signup_callfluent,signup_sauces,signup_sealsend,signup_doctorx,signup_cryptomayors,join_twitter_multiversx,join_twitter_xportal,join_telegram_multiversx,join_bullish_cmc,join_ion_cmc,join_watchlist_cmc,join_portfolio_coingecko,join_holdcoin,join_human,join_hipo,join_freedogs,join_athene,join_kolo,join_ducks,join_cmc_ton,join_cmc_sol,join_cmc_bnb,join_cmc_eth,join_cmc_btc,join_bearfi,join_boinkers,join_dejendog,join_catgoldminer,join_cmc_pnut,join_cmc_ada,join_cmc_doge,join_cmc_xrp,join_cmc_act,join_tonkombat,join_tonai,join_pigs,join_capybara,join_sidekick,join_iceberg,join_goats,join_tapcoins,join_tokyobeast,join_twitter_pichain,join_sugar,join_facebook_tokero,join_instagram_tokero,join_linkedin_tokero,join_tiktok_tokero,join_x_tokero,join_youtube_tokero"` //nolint:lll // . 19 | DryRun bool `form:"dryRun" example:"true" swaggerignore:"true" required:"false"` 20 | } 21 | ) 22 | 23 | // Private API. 24 | 25 | const ( 26 | applicationYamlKey = "cmd/santa-sleigh" 27 | swaggerRootSuffix = "/achievements/w" 28 | ) 29 | 30 | // Values for server.ErrorResponse#Code. 31 | const ( 32 | userNotFoundErrorCode = "USER_NOT_FOUND" 33 | invalidPropertiesErrorCode = "INVALID_PROPERTIES" 34 | taskNotFoundCode = "TASK_NOT_FOUND" 35 | taskNotCompletedCode = "TASK_NOT_COMPLETED" 36 | ) 37 | 38 | type ( 39 | // | service implements server.State and is responsible for managing the state and lifecycle of the package. 40 | service struct { 41 | tasksProcessor tasks.Processor 42 | levelsAndRolesProcessor levelsandroles.Processor 43 | badgesProcessor badges.Processor 44 | friendsProcessor friendsinvited.Processor 45 | } 46 | config struct { 47 | Host string `yaml:"host"` 48 | Version string `yaml:"version"` 49 | Tenant string `yaml:"tenant"` 50 | } 51 | ) 52 | -------------------------------------------------------------------------------- /cmd/santa-sleigh/santa_sleigh.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package main 4 | 5 | import ( 6 | "context" 7 | 8 | "github.com/hashicorp/go-multierror" 9 | "github.com/pkg/errors" 10 | 11 | "github.com/ice-blockchain/santa/badges" 12 | "github.com/ice-blockchain/santa/cmd/santa-sleigh/api" 13 | friendsinvited "github.com/ice-blockchain/santa/friends-invited" 14 | levelsandroles "github.com/ice-blockchain/santa/levels-and-roles" 15 | "github.com/ice-blockchain/santa/tasks" 16 | appcfg "github.com/ice-blockchain/wintr/config" 17 | "github.com/ice-blockchain/wintr/log" 18 | "github.com/ice-blockchain/wintr/server" 19 | ) 20 | 21 | // @title Achievements API 22 | // @version latest 23 | // @description API that handles everything related to user's achievements and gamification progress. 24 | // @query.collection.format multi 25 | // @schemes https 26 | // @contact.name ice.io 27 | // @contact.url https://ice.io 28 | func main() { 29 | ctx, cancel := context.WithCancel(context.Background()) 30 | defer cancel() 31 | 32 | var cfg config 33 | appcfg.MustLoadFromKey(applicationYamlKey, &cfg) 34 | api.SwaggerInfo.Host = cfg.Host 35 | api.SwaggerInfo.Version = cfg.Version 36 | nginxPrefix := "" 37 | if cfg.Tenant != "" { 38 | nginxPrefix = "/" + cfg.Tenant 39 | api.SwaggerInfo.BasePath = nginxPrefix 40 | } 41 | server.New(new(service), applicationYamlKey, swaggerRootSuffix, nginxPrefix).ListenAndServe(ctx, cancel) 42 | } 43 | 44 | func (s *service) RegisterRoutes(router *server.Router) { 45 | s.registerReadRoutes(router) 46 | s.setupTasksRoutes(router) 47 | } 48 | 49 | func (s *service) Init(ctx context.Context, cancel context.CancelFunc) { 50 | s.tasksProcessor = tasks.StartProcessor(ctx, cancel) 51 | s.levelsAndRolesProcessor = levelsandroles.StartProcessor(ctx, cancel) 52 | s.badgesProcessor = badges.StartProcessor(ctx, cancel) 53 | s.friendsProcessor = friendsinvited.StartProcessor(ctx, cancel) 54 | } 55 | 56 | func (s *service) Close(ctx context.Context) error { 57 | if ctx.Err() != nil { 58 | return errors.Wrap(ctx.Err(), "could not close service because context ended") 59 | } 60 | 61 | return errors.Wrap(multierror.Append( 62 | errors.Wrap(s.badgesProcessor.Close(), "could not close badges processor"), 63 | errors.Wrap(s.levelsAndRolesProcessor.Close(), "could not close levels-and-roles processor"), 64 | errors.Wrap(s.tasksProcessor.Close(), "could not close tasks processor"), 65 | errors.Wrap(s.friendsProcessor.Close(), "could not close friends-invited processor"), 66 | ).ErrorOrNil(), "could not close processors") 67 | } 68 | 69 | func (s *service) CheckHealth(ctx context.Context) error { 70 | log.Debug("checking health...", "package", "tasks") 71 | if err := s.tasksProcessor.CheckHealth(ctx); err != nil { 72 | return errors.Wrap(err, "tasks processor health check failed") 73 | } 74 | log.Debug("checking health...", "package", "levels-and-roles") 75 | if err := s.levelsAndRolesProcessor.CheckHealth(ctx); err != nil { 76 | return errors.Wrap(err, "levels-and-roles processor health check failed") 77 | } 78 | log.Debug("checking health...", "package", "badges") 79 | if err := s.badgesProcessor.CheckHealth(ctx); err != nil { 80 | return errors.Wrap(err, "badges processor health check failed") 81 | } 82 | log.Debug("checking health...", "package", "friends-invited") 83 | if err := s.friendsProcessor.CheckHealth(ctx); err != nil { 84 | return errors.Wrap(err, "friends-invited processor health check failed") 85 | } 86 | 87 | return nil 88 | } 89 | -------------------------------------------------------------------------------- /cmd/santa-sleigh/tasks.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package main 4 | 5 | import ( 6 | "context" 7 | 8 | "github.com/pkg/errors" 9 | 10 | "github.com/ice-blockchain/santa/tasks" 11 | "github.com/ice-blockchain/wintr/server" 12 | ) 13 | 14 | func (s *service) setupTasksRoutes(router *server.Router) { 15 | router. 16 | Group("/v1w"). 17 | PUT("/tasks/:taskType/users/:userId", server.RootHandler(s.PseudoCompleteTask)) 18 | } 19 | 20 | // PseudoCompleteTask godoc 21 | // 22 | // @Schemes 23 | // @Description Completes the specific task (identified via task type) for the specified user. 24 | // @Tags Tasks 25 | // @Accept json 26 | // @Produce json 27 | // @Param Authorization header string true "Insert your access token" default(Bearer ) 28 | // @Param taskType path string true "the type of the task" enums(claim_username,start_mining,upload_profile_picture,follow_us_on_twitter,join_twitter,join_telegram,invite_friends,invite_friends_5,invite_friends_10,invite_friends_25,invite_friends_50,invite_friends_100,invite_friends_200,join_youtube,watch_video_with_code_confirmation_1,claim_badge_l1,claim_badge_l2,claim_badge_l3,claim_badge_l4,claim_badge_l5,claim_badge_l6,claim_badge_c1,claim_badge_c2,claim_badge_c3,claim_badge_c4,claim_badge_c5,claim_badge_c6,claim_badge_c7,claim_badge_c8,claim_badge_c9,claim_badge_c10,claim_badge_s1,claim_badge_s2,claim_badge_s3,claim_badge_s4,claim_badge_s5,claim_badge_s6,claim_badge_s7,claim_badge_s8,claim_badge_s9,claim_badge_s10,claim_level_1,claim_level_2,claim_level_3,claim_level_4,claim_level_5,claim_level_6,claim_level_7,claim_level_8,claim_level_9,claim_level_10,claim_level_11,claim_level_12,claim_level_13,claim_level_14,claim_level_15,claim_level_16,claim_level_17,claim_level_18,claim_level_19,claim_level_20,claim_level_21,mining_streak_7,mining_streak_14,mining_streak_30,join_reddit_ion,join_instagram_ion,join_twitter_ion,join_telegram_ion,signup_sunwaves,signup_callfluent,signup_sauces,signup_sealsend,signup_doctorx,signup_cryptomayors,join_twitter_multiversx,join_twitter_xportal,join_telegram_multiversx,join_bullish_cmc,join_ion_cmc,join_watchlist_cmc,join_portfolio_coingecko,join_holdcoin,join_human,join_hipo,join_freedogs,join_athene,join_kolo,join_ducks,join_cmc_ton,join_cmc_sol,join_cmc_bnb,join_cmc_eth,join_cmc_btc,join_bearfi,join_boinkers,join_dejendog,join_catgoldminer,join_cmc_pnut,join_cmc_ada,join_cmc_doge,join_cmc_xrp,join_cmc_act,join_tonkombat,join_tonai,join_pigs,join_capybara,join_sidekick,join_iceberg,join_goats,join_tapcoins,join_tokyobeast,join_twitter_pichain,join_sugar,join_facebook_tokero,join_instagram_tokero,join_linkedin_tokero,join_tiktok_tokero,join_x_tokero,join_youtube_tokero) 29 | // @Param userId path string true "the id of the user that completed the task" 30 | // @Param request body CompleteTaskRequestBody false "Request params. Set it only if task completion requires additional data." 31 | // @Success 200 "ok" 32 | // @Failure 400 {object} server.ErrorResponse "if validations fail" 33 | // @Failure 401 {object} server.ErrorResponse "if not authorized" 34 | // @Failure 403 {object} server.ErrorResponse "if not allowed" 35 | // @Failure 404 {object} server.ErrorResponse "if user not found" 36 | // @Failure 422 {object} server.ErrorResponse "if syntax fails" 37 | // @Failure 500 {object} server.ErrorResponse 38 | // @Failure 504 {object} server.ErrorResponse "if request times out" 39 | // @Router /v1w/tasks/{taskType}/users/{userId} [PUT]. 40 | func (s *service) PseudoCompleteTask( //nolint:gocritic // False negative. 41 | ctx context.Context, 42 | req *server.Request[CompleteTaskRequestBody, any], 43 | ) (*server.Response[any], *server.Response[server.ErrorResponse]) { 44 | task := &tasks.Task{ 45 | Data: req.Data.Data, 46 | Type: req.Data.TaskType, 47 | UserID: req.AuthenticatedUser.UserID, 48 | } 49 | if err := s.tasksProcessor.PseudoCompleteTask(ctx, task, req.Data.DryRun); err != nil { 50 | err = errors.Wrapf(err, "failed to PseudoCompleteTask for %#v, userID:%v", req.Data, req.AuthenticatedUser.UserID) 51 | switch { 52 | case errors.Is(err, tasks.ErrInvalidSocialProperties): 53 | return nil, server.UnprocessableEntity(err, invalidPropertiesErrorCode) 54 | case errors.Is(err, tasks.ErrRelationNotFound): 55 | return nil, server.NotFound(err, userNotFoundErrorCode) 56 | case errors.Is(err, tasks.ErrTaskNotCompleted): 57 | return nil, server.NotFound(err, taskNotCompletedCode) 58 | default: 59 | return nil, server.Unexpected(err) 60 | } 61 | } 62 | 63 | return server.OK[any](), nil 64 | } 65 | -------------------------------------------------------------------------------- /cmd/santa/.testdata/application.yaml: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ice License 1.0 2 | 3 | development: false 4 | logger: 5 | encoder: console 6 | level: info 7 | cmd/santa: 8 | host: localhost 9 | version: latest 10 | defaultEndpointTimeout: 5s 11 | httpServer: 12 | port: 45443 13 | certPath: .testdata/localhost.crt 14 | keyPath: .testdata/localhost.key 15 | 16 | -------------------------------------------------------------------------------- /cmd/santa/.testdata/expected_swagger.json: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ice-blockchain/santa/f26235b80c9643c4515f7bf8cb06af188dad5be9/cmd/santa/.testdata/expected_swagger.json -------------------------------------------------------------------------------- /cmd/santa/.testdata/localhost.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDijCCAnKgAwIBAgIJAMeawIdSd6+8MA0GCSqGSIb3DQEBCwUAMCcxCzAJBgNV 3 | BAYTAlVTMRgwFgYDVQQDDA9FeGFtcGxlLVJvb3QtQ0EwHhcNMjIwMTA0MjEwNDE3 4 | WhcNMjQxMDI0MjEwNDE3WjBtMQswCQYDVQQGEwJVUzESMBAGA1UECAwJWW91clN0 5 | YXRlMREwDwYDVQQHDAhZb3VyQ2l0eTEdMBsGA1UECgwURXhhbXBsZS1DZXJ0aWZp 6 | Y2F0ZXMxGDAWBgNVBAMMD2xvY2FsaG9zdC5sb2NhbDCCASIwDQYJKoZIhvcNAQEB 7 | BQADggEPADCCAQoCggEBAONuA1zntIXbNaEvt/n+/Jisib/8Bjvfm2I9ENMq0TBH 8 | OGlbZgJ9ywiKsrxBYH/O2q6Dsxy9fL5cSfcMmAS0FXPrcXQx/pVNCgNWLEXZyPDk 9 | SzSR+tlPXzuryN2/jbWtgOZc73kfxQVBqUWbLyMiXaxMxVGHgpYMg0w68Ee62d2H 10 | AnA7c0YBllvggDRSaoDRJJZTc8DDGAHm9x5583zdxpCQh/EeV+zIjd2lAGF0ioYu 11 | PV69lwyrTnY/s7WG59nRYwYR50JvbI4G+5bbpf4q2W7Q0BVLqwSdMJfAfG43N5U/ 12 | 4Q1dfyJeXavFfQaZWJtEiVOU9TBiV3QQto0tI28R6J0CAwEAAaNzMHEwQQYDVR0j 13 | BDowOKErpCkwJzELMAkGA1UEBhMCVVMxGDAWBgNVBAMMD0V4YW1wbGUtUm9vdC1D 14 | QYIJANxKhfP/dJTMMAkGA1UdEwQCMAAwCwYDVR0PBAQDAgTwMBQGA1UdEQQNMAuC 15 | CWxvY2FsaG9zdDANBgkqhkiG9w0BAQsFAAOCAQEAjrUp0epptzaaTULvhrFdNJ6e 16 | 2WAeJpYCxMXjms7P+B/ldyIirDqG/WEzpN64Z1gXJhtxnw7IGTsQ8eXqLmBDk045 17 | vHhVbRhjVGADc+EVwX6OzQ+WQEGZzNDPX7DBObLC1ZV5LcfUwQXyACmlARlYgXJN 18 | GZFDkijDcvY3/Hyq9NkV6VGYPKnzxaal3v3cYO8FXQHaOLnu+SLWknT56y2vTa5/ 19 | H4CoX8nrts5Fa0NuOdoyNA1c7IdHjR/dy4g5IUZW+Sbhr1nNgkECBJvJ5QOWZ3M4 20 | 4a8NroD0ikzQDaeS4Tpk54WnJLEjDgQe5fX9RMu9F2sbr+wP+gUTmHuhLg/Ptw== 21 | -----END CERTIFICATE----- 22 | -------------------------------------------------------------------------------- /cmd/santa/.testdata/localhost.key: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDjbgNc57SF2zWh 3 | L7f5/vyYrIm//AY735tiPRDTKtEwRzhpW2YCfcsIirK8QWB/ztqug7McvXy+XEn3 4 | DJgEtBVz63F0Mf6VTQoDVixF2cjw5Es0kfrZT187q8jdv421rYDmXO95H8UFQalF 5 | my8jIl2sTMVRh4KWDINMOvBHutndhwJwO3NGAZZb4IA0UmqA0SSWU3PAwxgB5vce 6 | efN83caQkIfxHlfsyI3dpQBhdIqGLj1evZcMq052P7O1hufZ0WMGEedCb2yOBvuW 7 | 26X+Ktlu0NAVS6sEnTCXwHxuNzeVP+ENXX8iXl2rxX0GmVibRIlTlPUwYld0ELaN 8 | LSNvEeidAgMBAAECggEBALHtN6RPgePXA7X+5ygmXOf01C/ms9nTrnTE4YzTSqVC 9 | kteaMcxxLY6ZNAwj+aMD6gHt9wrdE+K5wQQOTkAfw0jVQgVtt4aGpvbFTA25vIL5 10 | l/yg2Gd6uT6tvo/9dJhWDSosOw2/1RuvqwZRyibqk+5ggV6vbXKGh5Hz6lezzw6H 11 | P8xazcT634Tj5YhNhd00XIcr1V+kqEHZGiJP0XzrdXzjAS5NciEdW529gv4Dp4Ni 12 | zpSroznCcP6psLXS99snDg1UdQPFu90IW51i7VOBkF+RhRIMWOywO9FeFHoQ7j0u 13 | SqACHFz8HQnR0uSZ8AwnWrRhWVoBfQ6bwDjJKi/vtQECgYEA8ZxQtliNEd2ojF0s 14 | PbU7YE9vTDEY5AXk6bRPf1rJk/RTDZZwguC4MWjTBpcqawppzur8RLRJAp3WtyP4 15 | zXh7qvgeOFIaVmGUefEfg8OkXAtvwT+ogvl9HHyY3lPWQyF+WV3dN4ILWguDYiCB 16 | myL/4EqBZjSHmqfzKS6sT9x+TYkCgYEA8Pl9uH6wDSReKqmO1kNnyF+dWfP0I7wX 17 | UpSTkRvSrYQIH2VFYH+LSN5OZaku0FHQkIbgjunAT29N8p//E2ZA3L2xNIKDV+hI 18 | M+NV52YwguUROh2mIypGlPT1f7R+tiYzz27jZgctYIF3mzTMQ1TC2TqgXzG5eA2y 19 | /Ojcmj9ncXUCgYEA4y5fOkYjR3RMAsetTMy3awTmGxdjVy0vpIx138NHHYaz/WfC 20 | nV2d9F+jZWQIb6PX/8c2s4dtyzcM6SG61cD/T7CEAeM5fpW8XbjbMDNqvV3HlEc+ 21 | NQFQodOKjir4oiDBRFidJI90CxQeUstL8srDHGwSJj8obsSTQNrxDRq/7DkCgYBR 22 | cLBpmv9a4bClkHqCtXMsyAvA6+7V6Oqk8SvSPen81IN+QNaqn1BuhxtNxljY9N2d 23 | Csh35E4nSoG4fxRQ9Rz0vXNXQMis/Aby6mEM/H9mrY4d6wlMFyyViRgzWcf9PXoD 24 | IAHgaIqQdBD9NmHWW54ilmq+4WpCRbb5PKXZx5XpRQKBgQCCMpaANqren/4aeDdz 25 | F2lkEJweRsTaS13LJKkk/fGWeXo3N/sXuBPocViSzkCNoHGx1yHrG9TyC7Cz7UXj 26 | 4Dpy7gI3cg0i7gaHgC1JfYoPzCSmvnJT62TyL/5SGwF4Xkg8efmF+sVKZqsqgiiT 27 | ATGyCMbfg4XaTw84ubV2rGxvRQ== 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /cmd/santa/Dockerfile: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ice License 1.0 2 | 3 | FROM golang:latest AS build 4 | ARG SERVICE_NAME 5 | ARG TARGETOS 6 | ARG TARGETARCH 7 | 8 | WORKDIR /app/ 9 | COPY . /app/ 10 | 11 | ENV CGO_ENABLED=0 12 | ENV GOOS=$TARGETOS 13 | ENV GOARCH=$TARGETARCH 14 | 15 | RUN env SERVICE_NAME=$SERVICE_NAME make dockerfile 16 | RUN cp cmd/$SERVICE_NAME/bin bin 17 | 18 | FROM gcr.io/distroless/base-debian11:latest 19 | ARG TARGETOS 20 | ARG TARGETARCH 21 | ARG PORT=443 22 | LABEL os=$TARGETOS 23 | LABEL arch=$TARGETARCH 24 | COPY --from=build /app/bin app 25 | #You might need to expose more ports. Just add more separated by space 26 | #I.E. EXPOSE 8080 8081 8082 8083 27 | EXPOSE $PORT 28 | ENTRYPOINT ["/app"] 29 | -------------------------------------------------------------------------------- /cmd/santa/badges.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package main 4 | 5 | import ( 6 | "context" 7 | 8 | "github.com/pkg/errors" 9 | 10 | "github.com/ice-blockchain/santa/badges" 11 | "github.com/ice-blockchain/wintr/server" 12 | ) 13 | 14 | func (s *service) setupBadgesRoutes(router *server.Router) { 15 | router. 16 | Group("/v1r"). 17 | GET("/badges/:badgeType/users/:userId", server.RootHandler(s.GetBadges)). 18 | GET("/achievement-summaries/badges/users/:userId", server.RootHandler(s.GetBadgeSummary)) 19 | } 20 | 21 | // GetBadges godoc 22 | // 23 | // @Schemes 24 | // @Description Returns all badges of the specific type for the user, with the progress for each of them. 25 | // @Tags Badges 26 | // @Accept json 27 | // @Produce json 28 | // @Param Authorization header string true "Insert your access token" default(Bearer ) 29 | // @Param userId path string true "the id of the user you need progress for" 30 | // @Param badgeType path string true "the type of the badges" enums(level,coin,social) 31 | // @Success 200 {array} badges.Badge 32 | // @Failure 400 {object} server.ErrorResponse "if validations fail" 33 | // @Failure 401 {object} server.ErrorResponse "if not authorized" 34 | // @Failure 403 {object} server.ErrorResponse "if not allowed" 35 | // @Failure 422 {object} server.ErrorResponse "if syntax fails" 36 | // @Failure 500 {object} server.ErrorResponse 37 | // @Failure 504 {object} server.ErrorResponse "if request times out" 38 | // @Router /badges/{badgeType}/users/{userId} [GET]. 39 | func (s *service) GetBadges( //nolint:gocritic // False negative. 40 | ctx context.Context, 41 | req *server.Request[GetBadgesArg, []*badges.Badge], 42 | ) (*server.Response[[]*badges.Badge], *server.Response[server.ErrorResponse]) { 43 | resp, err := s.badgesRepository.GetBadges(ctx, req.Data.GroupType, req.Data.UserID) 44 | if err != nil { 45 | err = errors.Wrapf(err, "failed to GetBadges for data:%#v", req.Data) 46 | if errors.Is(err, badges.ErrHidden) { 47 | return nil, server.ForbiddenWithCode(err, badgesHiddenErrorCode) 48 | } 49 | 50 | return nil, server.Unexpected(err) 51 | } 52 | 53 | return server.OK(&resp), nil 54 | } 55 | 56 | // GetBadgeSummary godoc 57 | // 58 | // @Schemes 59 | // @Description Returns user's summary about badges. 60 | // @Tags Badges 61 | // @Accept json 62 | // @Produce json 63 | // @Param Authorization header string true "Insert your access token" default(Bearer ) 64 | // @Param userId path string true "the id of the user you need summary for" 65 | // @Success 200 {array} badges.BadgeSummary 66 | // @Failure 400 {object} server.ErrorResponse "if validations fail" 67 | // @Failure 401 {object} server.ErrorResponse "if not authorized" 68 | // @Failure 403 {object} server.ErrorResponse "if not allowed" 69 | // @Failure 422 {object} server.ErrorResponse "if syntax fails" 70 | // @Failure 500 {object} server.ErrorResponse 71 | // @Failure 504 {object} server.ErrorResponse "if request times out" 72 | // @Router /achievement-summaries/badges/users/{userId} [GET]. 73 | func (s *service) GetBadgeSummary( //nolint:gocritic // False negative. 74 | ctx context.Context, 75 | req *server.Request[GetBadgeSummaryArg, []*badges.BadgeSummary], 76 | ) (*server.Response[[]*badges.BadgeSummary], *server.Response[server.ErrorResponse]) { 77 | resp, err := s.badgesRepository.GetSummary(ctx, req.Data.UserID) 78 | if err != nil { 79 | err = errors.Wrapf(err, "failed to badges.GetSummary for data:%#v", req.Data) 80 | if errors.Is(err, badges.ErrHidden) { 81 | return nil, server.ForbiddenWithCode(err, badgesHiddenErrorCode) 82 | } 83 | 84 | return nil, server.Unexpected(err) 85 | } 86 | 87 | return server.OK(&resp), nil 88 | } 89 | -------------------------------------------------------------------------------- /cmd/santa/contract.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package main 4 | 5 | import ( 6 | "github.com/ice-blockchain/santa/badges" 7 | levelsandroles "github.com/ice-blockchain/santa/levels-and-roles" 8 | "github.com/ice-blockchain/santa/tasks" 9 | ) 10 | 11 | // Public API. 12 | 13 | type ( 14 | GetTasksArg struct { 15 | UserID string `uri:"userId" example:"edfd8c02-75e0-4687-9ac2-1ce4723865c4" swaggerignore:"true" required:"true"` 16 | Language string `form:"language" example:"en" swaggerignore:"true" required:"false"` 17 | Status tasks.TaskStatus `form:"status" example:"pending" swaggerignore:"true" required:"false" enums:"pending,completed"` 18 | } 19 | GetLevelsAndRolesSummaryArg struct { 20 | UserID string `uri:"userId" example:"edfd8c02-75e0-4687-9ac2-1ce4723865c4" allowForbiddenGet:"true" swaggerignore:"true" required:"true"` 21 | } 22 | GetBadgeSummaryArg struct { 23 | UserID string `uri:"userId" example:"edfd8c02-75e0-4687-9ac2-1ce4723865c4" allowForbiddenGet:"true" swaggerignore:"true" required:"true"` 24 | } 25 | GetBadgesArg struct { 26 | UserID string `uri:"userId" example:"edfd8c02-75e0-4687-9ac2-1ce4723865c4" allowForbiddenGet:"true" swaggerignore:"true" required:"true"` 27 | GroupType badges.GroupType `uri:"badgeType" example:"social" swaggerignore:"true" required:"true" enums:"level,coin,social"` 28 | } 29 | ) 30 | 31 | // Private API. 32 | 33 | const ( 34 | applicationYamlKey = "cmd/santa" 35 | swaggerRoot = "/achievements/r" 36 | ) 37 | 38 | // Values for server.ErrorResponse#Code. 39 | const ( 40 | badgesHiddenErrorCode = "BADGES_HIDDEN" 41 | ) 42 | 43 | type ( 44 | // | service implements server.State and is responsible for managing the state and lifecycle of the package. 45 | service struct { 46 | tasksRepository tasks.Repository 47 | levelsAndRolesRepository levelsandroles.Repository 48 | badgesRepository badges.Repository 49 | } 50 | config struct { 51 | Host string `yaml:"host"` 52 | Version string `yaml:"version"` 53 | } 54 | ) 55 | -------------------------------------------------------------------------------- /cmd/santa/levels_and_roles.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package main 4 | 5 | import ( 6 | "context" 7 | 8 | "github.com/pkg/errors" 9 | 10 | levelsandroles "github.com/ice-blockchain/santa/levels-and-roles" 11 | "github.com/ice-blockchain/wintr/server" 12 | ) 13 | 14 | func (s *service) setupLevelsAndRolesRoutes(router *server.Router) { 15 | router. 16 | Group("/v1r"). 17 | GET("/achievement-summaries/levels-and-roles/users/:userId", server.RootHandler(s.GetLevelsAndRolesSummary)) 18 | } 19 | 20 | // GetLevelsAndRolesSummary godoc 21 | // 22 | // @Schemes 23 | // @Description Returns user's summary about levels & roles. 24 | // @Tags Levels & Roles 25 | // @Accept json 26 | // @Produce json 27 | // @Param Authorization header string true "Insert your access token" default(Bearer ) 28 | // @Param userId path string true "the id of the user you need summary for" 29 | // @Success 200 {object} levelsandroles.Summary 30 | // @Failure 400 {object} server.ErrorResponse "if validations fail" 31 | // @Failure 401 {object} server.ErrorResponse "if not authorized" 32 | // @Failure 422 {object} server.ErrorResponse "if syntax fails" 33 | // @Failure 500 {object} server.ErrorResponse 34 | // @Failure 504 {object} server.ErrorResponse "if request times out" 35 | // @Router /achievement-summaries/levels-and-roles/users/{userId} [GET]. 36 | func (s *service) GetLevelsAndRolesSummary( //nolint:gocritic // False negative. 37 | ctx context.Context, 38 | req *server.Request[GetLevelsAndRolesSummaryArg, levelsandroles.Summary], 39 | ) (*server.Response[levelsandroles.Summary], *server.Response[server.ErrorResponse]) { 40 | resp, err := s.levelsAndRolesRepository.GetSummary(ctx, req.Data.UserID) 41 | if err != nil { 42 | err = errors.Wrapf(err, "failed to levelsandroles.GetSummary for data:%#v", req.Data) 43 | 44 | return nil, server.Unexpected(err) 45 | } 46 | 47 | return server.OK(resp), nil 48 | } 49 | -------------------------------------------------------------------------------- /cmd/santa/santa.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package main 4 | 5 | import ( 6 | "context" 7 | 8 | "github.com/hashicorp/go-multierror" 9 | "github.com/pkg/errors" 10 | 11 | "github.com/ice-blockchain/santa/badges" 12 | "github.com/ice-blockchain/santa/cmd/santa/api" 13 | levelsandroles "github.com/ice-blockchain/santa/levels-and-roles" 14 | "github.com/ice-blockchain/santa/tasks" 15 | appcfg "github.com/ice-blockchain/wintr/config" 16 | "github.com/ice-blockchain/wintr/log" 17 | "github.com/ice-blockchain/wintr/server" 18 | ) 19 | 20 | // @title Achievements API 21 | // @version latest 22 | // @description API that handles everything related to read-only operations for user's achievements and gamification progress. 23 | // @query.collection.format multi 24 | // @schemes https 25 | // @contact.name ice.io 26 | // @contact.url https://ice.io 27 | // @BasePath /v1r 28 | func main() { 29 | ctx, cancel := context.WithCancel(context.Background()) 30 | defer cancel() 31 | 32 | var cfg config 33 | appcfg.MustLoadFromKey(applicationYamlKey, &cfg) 34 | api.SwaggerInfo.Host = cfg.Host 35 | api.SwaggerInfo.Version = cfg.Version 36 | server.New(new(service), applicationYamlKey, swaggerRoot).ListenAndServe(ctx, cancel) 37 | } 38 | 39 | func (s *service) RegisterRoutes(router *server.Router) { 40 | s.setupTasksRoutes(router) 41 | s.setupLevelsAndRolesRoutes(router) 42 | s.setupBadgesRoutes(router) 43 | } 44 | 45 | func (s *service) Init(ctx context.Context, cancel context.CancelFunc) { 46 | s.tasksRepository = tasks.New(ctx, cancel) 47 | s.levelsAndRolesRepository = levelsandroles.New(ctx, cancel) 48 | s.badgesRepository = badges.New(ctx, cancel) 49 | } 50 | 51 | func (s *service) Close(ctx context.Context) error { 52 | if ctx.Err() != nil { 53 | return errors.Wrap(ctx.Err(), "could not close service because context ended") 54 | } 55 | 56 | return errors.Wrap(multierror.Append( 57 | errors.Wrap(s.badgesRepository.Close(), "could not close badges repository"), 58 | errors.Wrap(s.levelsAndRolesRepository.Close(), "could not close levels-and-roles repository"), 59 | errors.Wrap(s.tasksRepository.Close(), "could not close tasks repository"), 60 | ).ErrorOrNil(), "could not close repositories") 61 | } 62 | 63 | func (s *service) CheckHealth(ctx context.Context) error { 64 | log.Debug("checking health...", "package", "badges") 65 | if _, err := s.badgesRepository.GetBadges(ctx, badges.CoinGroupType, "bogus"); err != nil && !errors.Is(err, tasks.ErrRelationNotFound) { 66 | return errors.Wrap(err, "get badges failed") 67 | } 68 | log.Debug("checking health...", "package", "tasks") 69 | if _, err := s.tasksRepository.GetTasks(ctx, "bogus", "", tasks.TaskStatusCompleted); err != nil && !errors.Is(err, tasks.ErrRelationNotFound) { 70 | return errors.Wrap(err, "get tasks failed") 71 | } 72 | log.Debug("checking health...", "package", "levels-and-roles") 73 | if _, err := s.levelsAndRolesRepository.GetSummary(ctx, "bogus"); err != nil { 74 | return errors.Wrap(err, "levels-and-roles.GetSummary failed") 75 | } 76 | 77 | return nil 78 | } 79 | -------------------------------------------------------------------------------- /cmd/santa/tasks.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package main 4 | 5 | import ( 6 | "context" 7 | 8 | "github.com/pkg/errors" 9 | 10 | "github.com/ice-blockchain/santa/tasks" 11 | "github.com/ice-blockchain/wintr/server" 12 | ) 13 | 14 | func (s *service) setupTasksRoutes(router *server.Router) { 15 | router. 16 | Group("/v1r"). 17 | GET("/tasks/x/users/:userId", server.RootHandler(s.GetTasks)) 18 | } 19 | 20 | // GetTasks godoc 21 | // 22 | // @Schemes 23 | // @Description Returns all the tasks and provided user's progress for each of them. 24 | // @Tags Tasks 25 | // @Accept json 26 | // @Produce json 27 | // @Param Authorization header string true "Insert your access token" default(Bearer ) 28 | // @Param userId path string true "the id of the user you need progress for" 29 | // @Param status query string false "pending/completed status filter" enums(pending,completed) 30 | // @Success 200 {array} tasks.Task 31 | // @Failure 400 {object} server.ErrorResponse "if validations fail" 32 | // @Failure 401 {object} server.ErrorResponse "if not authorized" 33 | // @Failure 403 {object} server.ErrorResponse "if not allowed" 34 | // @Failure 422 {object} server.ErrorResponse "if syntax fails" 35 | // @Failure 500 {object} server.ErrorResponse 36 | // @Failure 504 {object} server.ErrorResponse "if request times out" 37 | // @Router /tasks/x/users/{userId} [GET]. 38 | func (s *service) GetTasks( //nolint:gocritic // False negative. 39 | ctx context.Context, 40 | req *server.Request[GetTasksArg, []*tasks.Task], 41 | ) (*server.Response[[]*tasks.Task], *server.Response[server.ErrorResponse]) { 42 | if req.Data.UserID != req.AuthenticatedUser.UserID { 43 | return nil, server.Forbidden(errors.Errorf("not allowed. %v != %v", req.Data.UserID, req.AuthenticatedUser.UserID)) 44 | } 45 | resp, err := s.tasksRepository.GetTasks(ctx, req.Data.UserID, "", req.Data.Status) 46 | if err != nil { 47 | err = errors.Wrapf(err, "failed to GetTasks for data:%#v", req.Data) 48 | 49 | return nil, server.Unexpected(err) 50 | } 51 | 52 | return server.OK(&resp), nil 53 | } 54 | -------------------------------------------------------------------------------- /cmd/scripts/update_santa_badges_levels_and_roles_tasks/.testdata/application.yaml: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ice License 1.0 2 | 3 | development: true 4 | logger: 5 | encoder: console 6 | level: info 7 | santa: &santa 8 | milestones: 9 | c1: 10 | toInclusive: 10 11 | c2: 12 | fromInclusive: 20 13 | toInclusive: 30 14 | c3: 15 | fromInclusive: 40 16 | toInclusive: 50 17 | c4: 18 | fromInclusive: 60 19 | toInclusive: 70 20 | c5: 21 | fromInclusive: 80 22 | toInclusive: 90 23 | c6: 24 | fromInclusive: 100 25 | toInclusive: 110 26 | c7: 27 | fromInclusive: 120 28 | toInclusive: 130 29 | c8: 30 | fromInclusive: 140 31 | toInclusive: 150 32 | c9: 33 | fromInclusive: 160 34 | toInclusive: 170 35 | c10: 36 | fromInclusive: 180 37 | s1: 38 | toInclusive: 1 39 | s2: 40 | fromInclusive: 2 41 | toInclusive: 3 42 | s3: 43 | fromInclusive: 4 44 | toInclusive: 5 45 | s4: 46 | fromInclusive: 6 47 | toInclusive: 7 48 | s5: 49 | fromInclusive: 8 50 | toInclusive: 9 51 | s6: 52 | fromInclusive: 10 53 | toInclusive: 11 54 | s7: 55 | fromInclusive: 12 56 | toInclusive: 13 57 | s8: 58 | fromInclusive: 14 59 | toInclusive: 15 60 | s9: 61 | fromInclusive: 16 62 | toInclusive: 17 63 | s10: 64 | fromInclusive: 18 65 | wintr/connectors/storage/v2: 66 | runDDL: true 67 | primaryURL: postgresql://root:pass@localhost:5436/ice # TODO: Change. 68 | credentials: 69 | user: root # TODO: Change. 70 | password: pass # TODO: Change. 71 | replicaURLs: 72 | - postgresql://root:pass@localhost:5436/ice # TODO: Change. 73 | requiredInvitedFriendsToBecomeAmbassador: 3 74 | requiredFriendsInvited: 5 75 | santa_test: 76 | <<: *santa 77 | users: &users 78 | skipIp2LocationBinary: true 79 | disableConsumer: true 80 | wintr/connectors/storage/v2: 81 | runDDL: true 82 | primaryURL: postgresql://root:pass@localhost:5433/eskimo # TODO: Change. 83 | credentials: # TODO: Change credentials. 84 | user: root 85 | password: pass 86 | replicaURLs: 87 | - postgresql://root:pass@localhost:5433/eskimo # TODO: Change. 88 | users_test: 89 | <<: *users -------------------------------------------------------------------------------- /cmd/scripts/update_santa_friends_invited_count/application.yaml: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ice License 1.0 2 | 3 | development: false 4 | logger: 5 | encoder: console 6 | level: info 7 | santa: &santa 8 | wintr/connectors/storage/v2: 9 | runDDL: true 10 | primaryURL: postgresql://root:pass@localhost:5436/ice # TODO: Change to santa production primary URL. 11 | credentials: 12 | user: root # TODO: Change to production santa user. 13 | password: pass # TODO: Change to production santa password. 14 | replicaURLs: 15 | - postgresql://root:pass@localhost:5436/ice # TODO: Change to santa production replica URL. 16 | santa_test: 17 | <<: *santa 18 | users: &users 19 | skipIp2LocationBinary: true 20 | disableConsumer: true 21 | wintr/connectors/storage/v2: 22 | runDDL: true 23 | primaryURL: postgresql://root:pass@localhost:5433/eskimo # TODO: Change to eskimo production primary URL. 24 | credentials: 25 | user: root # TODO: Change to production eskimo user. 26 | password: pass # TODO: Change to production eskimo password. 27 | replicaURLs: 28 | - postgresql://root:pass@localhost:5433/eskimo # TODO: Change to eskimo production replica URL. 29 | users_test: 30 | <<: *users -------------------------------------------------------------------------------- /cmd/scripts/update_santa_friends_invited_count/update_santa_friends_invited_count.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package main 4 | 5 | import ( 6 | "context" 7 | _ "embed" 8 | "sync" 9 | 10 | "github.com/pkg/errors" 11 | 12 | storagepg "github.com/ice-blockchain/wintr/connectors/storage/v2" 13 | "github.com/ice-blockchain/wintr/log" 14 | ) 15 | 16 | const ( 17 | applicationYamlUsersKey = "users" 18 | applicationYamlKeySanta = "santa" 19 | concurrencyCount = 1000 20 | ) 21 | 22 | type ( 23 | updater struct { 24 | dbSanta *storagepg.DB 25 | dbEskimo *storagepg.DB 26 | } 27 | eskimoUser struct { 28 | ID string `json:"id" example:"did:ethr:0x4B73C58370AEfcEf86A6021afCDe5673511376B2" db:"id"` 29 | FriendsInvited uint64 `json:"friendsInvited" example:"22" db:"friends_invited"` 30 | } 31 | 32 | commonUser struct { 33 | UserID string 34 | } 35 | ) 36 | 37 | func main() { 38 | dbEskimo := storagepg.MustConnect(context.Background(), "", applicationYamlUsersKey) 39 | dbSanta := storagepg.MustConnect(context.Background(), "", applicationYamlKeySanta) 40 | 41 | if err := dbEskimo.Ping(context.Background()); err != nil { 42 | log.Panic("can't ping users db", err) 43 | } 44 | if err := dbSanta.Ping(context.Background()); err != nil { 45 | log.Panic("can't ping santa db", err) 46 | } 47 | upd := &updater{ 48 | dbSanta: dbSanta, 49 | dbEskimo: dbEskimo, 50 | } 51 | defer upd.dbEskimo.Close() 52 | defer upd.dbSanta.Close() 53 | 54 | upd.update(context.Background()) 55 | } 56 | 57 | //nolint:revive,funlen,gocognit // . 58 | func (u *updater) update(ctx context.Context) { 59 | var ( 60 | updatedCount uint64 61 | maxLimit uint64 = 10000 62 | offset uint64 63 | ) 64 | concurrencyGuard := make(chan struct{}, concurrencyCount) 65 | wg := new(sync.WaitGroup) 66 | for { 67 | /****************************************************************************************************************************************************** 68 | 1. Fetching a new batch of users from eskimo. 69 | ******************************************************************************************************************************************************/ 70 | sql := `SELECT 71 | u.id, 72 | COUNT(DISTINCT t1.id) AS friends_invited 73 | FROM users u 74 | LEFT JOIN USERS t1 75 | ON t1.referred_by = u.ID 76 | AND t1.id != u.id 77 | AND t1.username != t1.id 78 | AND t1.referred_by != t1.id 79 | JOIN referral_acquisition_history rah 80 | ON u.id = rah.user_id 81 | GROUP BY u.id, rah.t1 82 | ORDER BY u.created_at ASC 83 | LIMIT $1 84 | OFFSET $2` 85 | usrs, err := storagepg.Select[eskimoUser](ctx, u.dbEskimo, sql, maxLimit, offset) 86 | if err != nil { 87 | log.Panic("error on trying to get actual friends invited values crossed with already updated values", err) 88 | } 89 | if len(usrs) == 0 { 90 | break 91 | } 92 | 93 | /****************************************************************************************************************************************************** 94 | 2. Fetching tasks progress data. 95 | ******************************************************************************************************************************************************/ 96 | var userKeysProgress []string 97 | actualFriendsInvitedCount := make(map[string]uint64, len(usrs)) 98 | for _, usr := range usrs { 99 | if usr.ID == "" { 100 | continue 101 | } 102 | userKeysProgress = append(userKeysProgress, usr.ID) 103 | actualFriendsInvitedCount[usr.ID] = usr.FriendsInvited 104 | } 105 | sql = `SELECT 106 | tp.user_id 107 | FROM task_progress tp 108 | WHERE tp.user_id = ANY($1)` 109 | res, err := storagepg.Select[commonUser](ctx, u.dbSanta, sql, userKeysProgress) 110 | if err != nil { 111 | log.Panic("error on trying to get tasks", userKeysProgress, err) 112 | } 113 | if len(res) == 0 { 114 | offset += maxLimit 115 | 116 | continue 117 | } 118 | 119 | /****************************************************************************************************************************************************** 120 | 3. Updating santa. 121 | ******************************************************************************************************************************************************/ 122 | for _, r := range res { 123 | if r.UserID == "" { 124 | continue 125 | } 126 | usr := r 127 | wg.Add(1) 128 | concurrencyGuard <- struct{}{} 129 | go func() { 130 | defer wg.Done() 131 | if bErr := u.updateBadges(ctx, usr, actualFriendsInvitedCount[usr.UserID]); bErr != nil { 132 | log.Panic("can't update badges, userID:", usr.UserID, bErr) 133 | } 134 | if uErr := u.updateTasks(ctx, usr, actualFriendsInvitedCount[usr.UserID]); uErr != nil { 135 | log.Panic("can't update tasks, userID:", usr.UserID, uErr) 136 | } 137 | if uErr := u.updateFriendsInvited(ctx, usr, actualFriendsInvitedCount[usr.UserID]); uErr != nil { 138 | log.Panic("can't update friends invited, userID:", usr.UserID, uErr) 139 | } 140 | <-concurrencyGuard 141 | }() 142 | } 143 | 144 | updatedCount += uint64(len(res)) 145 | log.Info("updated count: ", updatedCount) 146 | 147 | offset += maxLimit 148 | } 149 | wg.Wait() 150 | } 151 | 152 | func (u *updater) updateBadges(ctx context.Context, usr *commonUser, actualFriendsInvited uint64) error { 153 | sql := `UPDATE badge_progress 154 | SET friends_invited = $2 155 | WHERE user_id = $1 156 | AND friends_invited != $2` 157 | _, err := storagepg.Exec(ctx, u.dbSanta, sql, usr.UserID, actualFriendsInvited) 158 | 159 | return errors.Wrapf(err, "failed to update badge_progress, userID:%v, friendsInvited:%v", usr.UserID, actualFriendsInvited) 160 | } 161 | 162 | func (u *updater) updateTasks(ctx context.Context, usr *commonUser, actualFriendsInvited uint64) error { 163 | sql := `UPDATE task_progress 164 | SET friends_invited = $2 165 | WHERE user_id = $1 166 | AND (friends_invited != $2)` 167 | _, err := storagepg.Exec(ctx, u.dbSanta, sql, usr.UserID, actualFriendsInvited) 168 | 169 | return errors.Wrapf(err, "failed to update task_progress, userID:%v, friendsInvited:%v", usr.UserID, actualFriendsInvited) 170 | } 171 | 172 | func (u *updater) updateFriendsInvited(ctx context.Context, usr *commonUser, actualFriendsInvited uint64) error { 173 | sql := `UPDATE friends_invited 174 | SET invited_count = $2 175 | WHERE user_id = $1 176 | AND friends_invited.invited_count != $2` 177 | _, err := storagepg.Exec(ctx, u.dbSanta, sql, usr.UserID, actualFriendsInvited) 178 | 179 | return errors.Wrapf(err, "failed to update friends invited, userID:%v, friendsInvited:%v", usr.UserID, actualFriendsInvited) 180 | } 181 | -------------------------------------------------------------------------------- /friends-invited/.testdata/application.yaml: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ice License 1.0 2 | 3 | development: true 4 | logger: 5 | encoder: console 6 | level: info 7 | friends-invited: &friends-invited 8 | wintr/connectors/storage/v2: &friendsInvitedDatabase 9 | runDDL: true 10 | primaryURL: postgresql://root:pass@localhost:5432/santa 11 | credentials: 12 | user: root 13 | password: pass 14 | replicaURLs: 15 | - postgresql://root:pass@localhost:5432/santa 16 | messageBroker: &friendsInvitedMessageBroker 17 | consumerGroup: friends-invited-testing 18 | createTopics: true 19 | urls: 20 | - localhost:9092 21 | topics: &friendsInvitedMessageBrokerTopics 22 | - name: santa-health-check 23 | partitions: 1 24 | replicationFactor: 1 25 | - name: friends-invited 26 | partitions: 10 27 | replicationFactor: 1 28 | retention: 1000h 29 | ### The next topics are not owned by this service, but are needed to be created for the local/test environment. 30 | - name: users-table 31 | partitions: 10 32 | replicationFactor: 1 33 | retention: 1000h 34 | consumingTopics: 35 | - name: users-table 36 | friends-invited_test: 37 | <<: *friends-invited 38 | messageBroker: 39 | <<: *friendsInvitedMessageBroker 40 | consumingTopics: *friendsInvitedMessageBrokerTopics 41 | consumerGroup: santa-friends-invited-test 42 | db: 43 | <<: *friendsInvitedDatabase 44 | schemaPath: friends-invited/DDL.lua -------------------------------------------------------------------------------- /friends-invited/DDL.sql: -------------------------------------------------------------------------------- 1 | -- SPDX-License-Identifier: ice License 1.0 2 | 3 | CREATE TABLE IF NOT EXISTS referrals ( 4 | processed_at TIMESTAMP NOT NULL, 5 | deleted BOOLEAN DEFAULT false NOT NULL, 6 | user_id TEXT NOT NULL, 7 | referred_by TEXT NOT NULL, 8 | primary key (user_id, referred_by, deleted) 9 | ); 10 | CREATE INDEX IF NOT EXISTS referrals_processed_at_ix ON referrals (processed_at); 11 | 12 | CREATE TABLE IF NOT EXISTS friends_invited ( 13 | invited_count BIGINT NOT NULL DEFAULT 0, 14 | user_id TEXT NOT NULL PRIMARY KEY 15 | ) WITH (fillfactor = 70); 16 | 17 | 18 | DO $$ BEGIN 19 | ALTER TABLE referrals 20 | ADD COLUMN IF NOT EXISTS deleted BOOLEAN DEFAULT false NOT NULL, 21 | DROP CONSTRAINT IF EXISTS referrals_pkey; 22 | if NOT exists (select constraint_name from information_schema.table_constraints where table_name = 'referrals' and constraint_type = 'PRIMARY KEY') then 23 | ALTER TABLE referrals 24 | ADD CONSTRAINT referrals_id_refby_deleted_pkey PRIMARY KEY(user_id, referred_by, deleted); 25 | end if; 26 | END $$; -------------------------------------------------------------------------------- /friends-invited/contract.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package friendsinvited 4 | 5 | import ( 6 | "context" 7 | _ "embed" 8 | "io" 9 | 10 | messagebroker "github.com/ice-blockchain/wintr/connectors/message_broker" 11 | "github.com/ice-blockchain/wintr/connectors/storage/v2" 12 | ) 13 | 14 | type ( 15 | Processor interface { 16 | io.Closer 17 | CheckHealth(ctx context.Context) error 18 | } 19 | 20 | Count struct { 21 | UserID string `json:"userId" example:"edfd8c02-75e0-4687-9ac2-1ce4723865c4"` 22 | FriendsInvited uint64 `json:"friendsInvited" example:"5" db:"invited_count"` 23 | } 24 | ) 25 | 26 | // Private API. 27 | 28 | const ( 29 | applicationYamlKey = "friends-invited" 30 | ) 31 | 32 | // . 33 | var ( 34 | //go:embed DDL.sql 35 | ddl string 36 | ) 37 | 38 | type ( 39 | repository struct { 40 | cfg *config 41 | shutdown func() error 42 | db *storage.DB 43 | mb messagebroker.Client 44 | } 45 | 46 | processor struct { 47 | *repository 48 | } 49 | 50 | userTableSource struct { 51 | *processor 52 | } 53 | config struct { 54 | messagebroker.Config `mapstructure:",squash"` //nolint:tagliatelle // Nope. 55 | } 56 | ) 57 | -------------------------------------------------------------------------------- /friends-invited/friends-invited.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package friendsinvited 4 | 5 | import ( 6 | "context" 7 | "math/rand" 8 | stdlibtime "time" 9 | 10 | "github.com/goccy/go-json" 11 | "github.com/hashicorp/go-multierror" 12 | "github.com/pkg/errors" 13 | 14 | appcfg "github.com/ice-blockchain/wintr/config" 15 | messagebroker "github.com/ice-blockchain/wintr/connectors/message_broker" 16 | "github.com/ice-blockchain/wintr/connectors/storage/v2" 17 | "github.com/ice-blockchain/wintr/log" 18 | "github.com/ice-blockchain/wintr/time" 19 | ) 20 | 21 | func StartProcessor(ctx context.Context, cancel context.CancelFunc) Processor { 22 | var cfg config 23 | appcfg.MustLoadFromKey(applicationYamlKey, &cfg) 24 | 25 | var mbConsumer messagebroker.Client 26 | prc := &processor{repository: &repository{ 27 | cfg: &cfg, 28 | db: storage.MustConnect(ctx, ddl, applicationYamlKey), 29 | mb: messagebroker.MustConnect(ctx, applicationYamlKey), 30 | }} 31 | //nolint:contextcheck // It's intended. Cuz we want to close everything gracefully. 32 | mbConsumer = messagebroker.MustConnectAndStartConsuming(context.Background(), cancel, applicationYamlKey, 33 | &userTableSource{processor: prc}, 34 | ) 35 | prc.shutdown = closeAll(mbConsumer, prc.mb, prc.db) 36 | go prc.startProcessedReferralsCleaner(ctx) 37 | 38 | return prc 39 | } 40 | 41 | func (r *repository) Close() error { 42 | return errors.Wrap(r.shutdown(), "closing repository failed") 43 | } 44 | 45 | func closeAll(mbConsumer, mbProducer messagebroker.Client, db *storage.DB, otherClosers ...func() error) func() error { 46 | return func() error { 47 | err1 := errors.Wrap(mbConsumer.Close(), "closing message broker consumer connection failed") 48 | err2 := errors.Wrap(db.Close(), "closing db connection failed") 49 | err3 := errors.Wrap(mbProducer.Close(), "closing message broker producer connection failed") 50 | errs := make([]error, 0, 1+1+1+len(otherClosers)) 51 | errs = append(errs, err1, err2, err3) 52 | for _, closeOther := range otherClosers { 53 | if err := closeOther(); err != nil { 54 | errs = append(errs, err) 55 | } 56 | } 57 | 58 | return errors.Wrap(multierror.Append(nil, errs...).ErrorOrNil(), "failed to close resources") 59 | } 60 | } 61 | 62 | func (p *processor) CheckHealth(ctx context.Context) error { 63 | if err := p.db.Ping(ctx); err != nil { 64 | return errors.Wrap(err, "[health-check] failed to ping DB") 65 | } 66 | type ts struct { 67 | TS *time.Time `json:"ts"` 68 | } 69 | now := ts{TS: time.Now()} 70 | bytes, err := json.MarshalContext(ctx, now) 71 | if err != nil { 72 | return errors.Wrapf(err, "[health-check] failed to marshal %#v", now) 73 | } 74 | responder := make(chan error, 1) 75 | p.mb.SendMessage(ctx, &messagebroker.Message{ 76 | Headers: map[string]string{"producer": "santa"}, 77 | Key: p.cfg.MessageBroker.Topics[0].Name, 78 | Topic: p.cfg.MessageBroker.Topics[0].Name, 79 | Value: bytes, 80 | }, responder) 81 | 82 | return errors.Wrapf(<-responder, "[health-check] failed to send health check message to broker") 83 | } 84 | 85 | func (p *processor) startProcessedReferralsCleaner(ctx context.Context) { 86 | ticker := stdlibtime.NewTicker(stdlibtime.Duration(1+rand.Intn(24)) * stdlibtime.Minute) //nolint:gosec // Not an issue. 87 | defer ticker.Stop() 88 | 89 | for { 90 | select { 91 | case <-ticker.C: 92 | const deadline = 30 * stdlibtime.Second 93 | reqCtx, cancel := context.WithTimeout(ctx, deadline) 94 | log.Error(errors.Wrap(p.deleteProcessedReferrals(reqCtx), "failed to deleteOldReferrals")) 95 | cancel() 96 | case <-ctx.Done(): 97 | return 98 | } 99 | } 100 | } 101 | 102 | func (p *processor) deleteProcessedReferrals(ctx context.Context) error { 103 | if ctx.Err() != nil { 104 | return errors.Wrap(ctx.Err(), "unexpected deadline") 105 | } 106 | sql := `DELETE FROM referrals WHERE processed_at < $1` 107 | if _, err := storage.Exec(ctx, p.db, sql, time.New(time.Now().Add(-24*stdlibtime.Hour)).Time); err != nil { 108 | return errors.Wrap(err, "failed to delete old data from referrals") 109 | } 110 | 111 | return nil 112 | } 113 | -------------------------------------------------------------------------------- /friends-invited/users.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package friendsinvited 4 | 5 | import ( 6 | "context" 7 | 8 | "github.com/goccy/go-json" 9 | "github.com/pkg/errors" 10 | 11 | "github.com/ice-blockchain/eskimo/users" 12 | messagebroker "github.com/ice-blockchain/wintr/connectors/message_broker" 13 | "github.com/ice-blockchain/wintr/connectors/storage/v2" 14 | ) 15 | 16 | func (s *userTableSource) Process(ctx context.Context, msg *messagebroker.Message) error { //nolint:gocyclo,revive,cyclop // . 17 | if ctx.Err() != nil { 18 | return errors.Wrap(ctx.Err(), "unexpected deadline while processing message") 19 | } 20 | if len(msg.Value) == 0 { 21 | return nil 22 | } 23 | snapshot := new(users.UserSnapshot) 24 | if err := json.UnmarshalContext(ctx, msg.Value, snapshot); err != nil { 25 | return errors.Wrapf(err, "cannot unmarshal %v into %#v", string(msg.Value), snapshot) 26 | } 27 | referredByChangedOnMofidy := snapshot.Before != nil && snapshot.Before.ID != "" && snapshot.User != nil && snapshot.User.ID != "" && 28 | snapshot.User.ReferredBy != snapshot.User.ID && snapshot.User.ReferredBy != "" && 29 | (snapshot.Before.ReferredBy == snapshot.Before.ID || snapshot.Before.ReferredBy == "") 30 | referredByChangedOnCreate := snapshot.Before == nil && snapshot.User != nil && snapshot.User.ID != "" && 31 | snapshot.User.ReferredBy != snapshot.User.ID && snapshot.User.ReferredBy != "" 32 | if referredByChangedOnCreate || referredByChangedOnMofidy { 33 | return errors.Wrapf(s.insertReferrals(ctx, snapshot), "failed to insertReferrals[friendsinvited] for:%#v", snapshot) 34 | } 35 | if snapshot.Before != nil && snapshot.Before.ID != "" && (snapshot.User == nil || snapshot.User.ID == "") { 36 | return errors.Wrapf(s.deleteFriendsInvited(ctx, snapshot), "failed to delete [friendsinvited] progress for:%#v", snapshot) 37 | } 38 | 39 | return nil 40 | } 41 | 42 | func (s *userTableSource) insertReferrals(ctx context.Context, us *users.UserSnapshot) error { 43 | return errors.Wrapf(storage.DoInTransaction(ctx, s.db, func(tx storage.QueryExecer) error { 44 | sql := `INSERT INTO referrals(user_id,referred_by, processed_at, deleted) VALUES ($1,$2,$3, false)` 45 | params := []any{ 46 | us.User.ID, 47 | us.User.ReferredBy, 48 | us.User.UpdatedAt.Time, 49 | } 50 | if _, err := storage.Exec(ctx, tx, sql, params...); err != nil { 51 | if storage.IsErr(err, storage.ErrDuplicate) { 52 | return nil 53 | } 54 | 55 | return errors.Wrapf(err, "failed to insert referrals, params:%#v", params...) 56 | } 57 | sql = ` 58 | INSERT INTO friends_invited(user_id,invited_count) VALUES ($1, 1) 59 | ON CONFLICT(user_id) DO UPDATE SET 60 | invited_count = friends_invited.invited_count + 1 61 | RETURNING *` 62 | friends, err := storage.ExecOne[Count](ctx, tx, sql, us.User.ReferredBy) 63 | if err != nil { 64 | return errors.Wrapf(err, "failed to increment friends_invited for userID:%v (ref:%v)", us.User.ReferredBy, us.User.ID) 65 | } 66 | 67 | return s.sendFriendsInvitedCountUpdate(ctx, friends) 68 | }), "insertReferrals: transaction failed for %#v", us) 69 | } 70 | 71 | func (s *userTableSource) deleteFriendsInvited(ctx context.Context, us *users.UserSnapshot) error { //nolint:funlen // . 72 | return errors.Wrapf(storage.DoInTransaction(ctx, s.db, func(tx storage.QueryExecer) error { 73 | if _, errDelUser := storage.Exec(ctx, tx, `DELETE FROM friends_invited WHERE user_id = $1`, us.Before.ID); errDelUser != nil { 74 | return errors.Wrapf(errDelUser, "failed to delete friends-invited for:%#v", us) 75 | } 76 | if us.Before.ReferredBy == "" || us.Before.ReferredBy == us.Before.ID { 77 | return nil 78 | } 79 | sql := `INSERT INTO referrals(user_id,referred_by, processed_at, deleted) VALUES ($1,$2,$3, true)` 80 | params := []any{us.Before.ID, us.Before.ReferredBy, us.Before.UpdatedAt.Time} 81 | if _, err := storage.Exec(ctx, tx, sql, params...); err != nil { 82 | if storage.IsErr(err, storage.ErrDuplicate) { 83 | return nil 84 | } 85 | 86 | return errors.Wrapf(err, "failed to insert referrals, params:%#v", params...) 87 | } 88 | updatedFriendsCount, errDecrementT0 := storage.ExecOne[Count](ctx, tx, ` 89 | UPDATE friends_invited SET 90 | invited_count = GREATEST(friends_invited.invited_count - 1, 0) 91 | WHERE user_id = $1 92 | RETURNING *`, us.Before.ReferredBy) 93 | if errDecrementT0 != nil { 94 | if storage.IsErr(errDecrementT0, storage.ErrNotFound) { 95 | return nil 96 | } 97 | 98 | return errors.Wrapf(errDecrementT0, "failed to decrement friends-invited for T0:%v due to user %v deletion", us.Before.ReferredBy, us.Before.ID) 99 | } 100 | 101 | return errors.Wrapf(s.sendFriendsInvitedCountUpdate(ctx, updatedFriendsCount), 102 | "failed to sendFriendsInvitedCountUpdate for T0:%v due to user %v deletion", us.Before.ReferredBy, us.Before.ID) 103 | }), "deleteFriendsInvited: transaction failed for %#v", us) 104 | } 105 | 106 | func (r *repository) sendFriendsInvitedCountUpdate(ctx context.Context, friends *Count) error { 107 | valueBytes, err := json.MarshalContext(ctx, friends) 108 | if err != nil { 109 | return errors.Wrapf(err, "failed to marshal %#v", friends) 110 | } 111 | msg := &messagebroker.Message{ 112 | Headers: map[string]string{"producer": "santa"}, 113 | Key: friends.UserID, 114 | Topic: r.cfg.MessageBroker.Topics[1].Name, 115 | Value: valueBytes, 116 | } 117 | responder := make(chan error, 1) 118 | defer close(responder) 119 | r.mb.SendMessage(ctx, msg, responder) 120 | 121 | return errors.Wrapf(<-responder, "failed to send `%v` message to broker", msg.Topic) 122 | } 123 | -------------------------------------------------------------------------------- /levels-and-roles/.testdata/application.yaml: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ice License 1.0 2 | 3 | development: true 4 | logger: 5 | encoder: console 6 | level: info 7 | levels-and-roles: &levels-and-roles 8 | tasksV2Enabled: false 9 | requiredInvitedFriendsToBecomeAmbassador: 3 10 | roleNames: 11 | - Snowman 12 | - Ambassador 13 | miningStreakMilestones: 14 | 1: 1 15 | 2: 2 16 | 3: 3 17 | 4: 4 18 | 5: 5 19 | completedTasksMilestones: 20 | 6: 1 21 | 7: 2 22 | 8: 3 23 | 9: 4 24 | 10: 5 25 | 11: 6 26 | agendaContactsJoinedMilestones: 27 | 12: 0 28 | 13: 1 29 | 14: 2 30 | 15: 3 31 | pingsSentMilestones: 32 | 16: 1 33 | 17: 2 34 | 18: 3 35 | 19: 4 36 | 20: 5 37 | 21: 6 38 | db: &levels-and-rolesDatabase 39 | urls: 40 | - localhost:3303 41 | user: admin 42 | password: pass 43 | messageBroker: &levels-and-rolesMessageBroker 44 | consumerGroup: levels-and-roles-testing 45 | createTopics: true 46 | urls: 47 | - localhost:9094 48 | topics: &levels-and-rolesMessageBrokerTopics 49 | - name: santa-health-check 50 | partitions: 1 51 | replicationFactor: 1 52 | retention: 1000h 53 | - name: try-complete-levels-commands 54 | partitions: 10 55 | replicationFactor: 1 56 | retention: 1000h 57 | - name: completed-levels 58 | partitions: 10 59 | replicationFactor: 1 60 | retention: 1000h 61 | - name: enabled-roles 62 | partitions: 10 63 | replicationFactor: 1 64 | retention: 1000h 65 | ### The next topics are not owned by this service, but are needed to be created for the local/test environment. 66 | - name: users-table 67 | partitions: 10 68 | replicationFactor: 1 69 | retention: 1000h 70 | - name: mining-sessions-table 71 | partitions: 10 72 | replicationFactor: 1 73 | retention: 1000h 74 | - name: started-days-off 75 | partitions: 10 76 | replicationFactor: 1 77 | retention: 1000h 78 | - name: completed-tasks 79 | partitions: 10 80 | replicationFactor: 1 81 | retention: 1000h 82 | - name: user-pings 83 | partitions: 10 84 | replicationFactor: 1 85 | retention: 1000h 86 | - name: contacts-table 87 | partitions: 10 88 | replicationFactor: 1 89 | retention: 1000h 90 | - name: friends-invited 91 | partitions: 10 92 | replicationFactor: 1 93 | retention: 1000h 94 | consumingTopics: 95 | - name: try-complete-levels-commands 96 | - name: users-table 97 | - name: mining-sessions-table 98 | - name: started-days-off 99 | - name: completed-tasks 100 | - name: user-pings 101 | - name: friends-invited 102 | - name: contacts-table 103 | levels-and-roles_test: 104 | <<: *levels-and-roles 105 | messageBroker: 106 | <<: *levels-and-rolesMessageBroker 107 | consumingTopics: *levels-and-rolesMessageBrokerTopics 108 | consumerGroup: santa-testing-levels-and-roles 109 | db: 110 | <<: *levels-and-rolesDatabase 111 | schemaPath: levels-and-roles/DDL.lua -------------------------------------------------------------------------------- /levels-and-roles/DDL.sql: -------------------------------------------------------------------------------- 1 | -- SPDX-License-Identifier: ice License 1.0 2 | --************************************************************************************************************************************ 3 | -- levels_and_roles_progress 4 | CREATE TABLE IF NOT EXISTS levels_and_roles_progress ( 5 | mining_streak BIGINT NOT NULL DEFAULT 0, 6 | pings_sent BIGINT NOT NULL DEFAULT 0, 7 | friends_invited BIGINT NOT NULL DEFAULT 0, 8 | completed_tasks BIGINT NOT NULL DEFAULT 0, 9 | hide_level BOOLEAN DEFAULT false, 10 | hide_role BOOLEAN DEFAULT false, 11 | agenda_contact_user_ids TEXT[], 12 | enabled_roles TEXT[], 13 | completed_levels TEXT[], 14 | user_id TEXT NOT NULL PRIMARY KEY, 15 | phone_number_hash TEXT 16 | ) WITH (fillfactor = 70); 17 | --************************************************************************************************************************************ 18 | -- pings 19 | CREATE TABLE IF NOT EXISTS pings ( 20 | last_ping_cooldown_ended_at TIMESTAMP NOT NULL, 21 | user_id TEXT NOT NULL, 22 | pinged_by TEXT NOT NULL, 23 | PRIMARY KEY(user_id, pinged_by, last_ping_cooldown_ended_at) 24 | ); 25 | CREATE INDEX IF NOT EXISTS pings_last_ping_cooldown_ended_at_ix ON pings (last_ping_cooldown_ended_at); -------------------------------------------------------------------------------- /levels-and-roles/completed_levels_and_activated_roles_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package levelsandroles 4 | 5 | import ( 6 | "testing" 7 | 8 | "github.com/stretchr/testify/require" 9 | 10 | "github.com/ice-blockchain/eskimo/users" 11 | ) 12 | 13 | func TestReEvaluateCompletedLevels(t *testing.T) { //nolint:funlen // It's a test function 14 | t.Parallel() 15 | 16 | completedLevels := make(users.Enum[LevelType], 0, len(&AllLevelTypes)) 17 | for _, levelType := range &AllLevelTypes { 18 | completedLevels = append(completedLevels, levelType) 19 | } 20 | phoneNumberHash := "bogus" 21 | tenContacts := users.Enum[string]([]string{"1", "2", "3", "4", "5", "7", "8", "9", "10"}) 22 | testCases := []struct { 23 | p *progress 24 | repo *repository 25 | expected *users.Enum[LevelType] 26 | name string 27 | }{ 28 | { 29 | name: "all levels are completed", 30 | p: &progress{ 31 | CompletedLevels: &completedLevels, 32 | }, 33 | expected: &completedLevels, 34 | }, 35 | { 36 | name: "skip already completed levels", 37 | p: &progress{ 38 | CompletedLevels: &users.Enum[LevelType]{Level1Type, Level2Type}, 39 | }, 40 | repo: &repository{cfg: &config{RoleNames: []RoleType{"bogus", AmbassadorRoleType}}}, 41 | expected: &users.Enum[LevelType]{Level1Type, Level2Type}, 42 | }, 43 | { 44 | name: "mining streak milestone is completed, level is completed as well", 45 | p: &progress{ 46 | MiningStreak: 10, 47 | }, 48 | repo: &repository{cfg: &config{ 49 | MiningStreakMilestones: map[LevelType]uint64{Level1Type: 9}, 50 | RoleNames: []RoleType{"bogus", AmbassadorRoleType}, 51 | }}, 52 | expected: &users.Enum[LevelType]{Level1Type}, 53 | }, 54 | { 55 | name: "pings sent milestone is completed, level is completed as well", 56 | p: &progress{ 57 | PingsSent: 10, 58 | }, 59 | repo: &repository{cfg: &config{ 60 | PingsSentMilestones: map[LevelType]uint64{Level1Type: 9}, 61 | }}, 62 | expected: &users.Enum[LevelType]{Level1Type}, 63 | }, 64 | { 65 | name: "agenda contacts joined milestone is completed, level is completed as well", 66 | p: &progress{ 67 | PhoneNumberHash: &phoneNumberHash, 68 | AgendaContactUserIDs: &tenContacts, 69 | }, 70 | repo: &repository{cfg: &config{ 71 | AgendaContactsJoinedMilestones: map[LevelType]uint64{Level1Type: 9}, 72 | RoleNames: []RoleType{"bogus", AmbassadorRoleType}, 73 | }}, 74 | expected: &users.Enum[LevelType]{Level1Type}, 75 | }, 76 | { 77 | name: "completed tasks milestone is completed, level is completed as well", 78 | p: &progress{ 79 | CompletedTasks: 10, 80 | }, 81 | repo: &repository{cfg: &config{ 82 | CompletedTasksMilestones: map[LevelType]uint64{Level1Type: 9}, 83 | RoleNames: []RoleType{"bogus", AmbassadorRoleType}, 84 | }}, 85 | expected: &users.Enum[LevelType]{Level1Type}, 86 | }, 87 | { 88 | name: "several milestones are completed, level is completed as well", 89 | p: &progress{ 90 | MiningStreak: 10, 91 | PingsSent: 10, 92 | }, 93 | repo: &repository{cfg: &config{ 94 | MiningStreakMilestones: map[LevelType]uint64{Level1Type: 9}, 95 | PingsSentMilestones: map[LevelType]uint64{Level1Type: 9}, 96 | RoleNames: []RoleType{"bogus", AmbassadorRoleType}, 97 | }}, 98 | expected: &users.Enum[LevelType]{Level1Type}, 99 | }, 100 | { 101 | name: "several milestones are completed for 2 levels, 2 levels are completed as well", 102 | p: &progress{ 103 | MiningStreak: 20, 104 | PingsSent: 20, 105 | }, 106 | repo: &repository{cfg: &config{ 107 | MiningStreakMilestones: map[LevelType]uint64{Level1Type: 9, Level2Type: 15}, 108 | PingsSentMilestones: map[LevelType]uint64{Level1Type: 9, Level2Type: 14}, 109 | RoleNames: []RoleType{"bogus", AmbassadorRoleType}, 110 | }}, 111 | expected: &users.Enum[LevelType]{Level1Type, Level2Type}, 112 | }, 113 | { 114 | name: "no levels are completed, return nil", 115 | p: &progress{}, 116 | repo: &repository{cfg: &config{RoleNames: []RoleType{"bogus", AmbassadorRoleType}}}, 117 | expected: nil, 118 | }, 119 | } 120 | 121 | for _, tt := range testCases { //nolint:gocritic // it's a test, no need for optimization 122 | t.Run(tt.name, func(t *testing.T) { 123 | t.Parallel() 124 | 125 | got := tt.p.reEvaluateCompletedLevels(tt.repo) 126 | require.EqualValues(t, tt.expected, got) 127 | }) 128 | } 129 | } 130 | 131 | func TestReEvaluateEnabledRoles(t *testing.T) { //nolint:funlen // It's a test function 132 | t.Parallel() 133 | 134 | testCases := []struct { 135 | p *progress 136 | repo *repository 137 | expected *users.Enum[RoleType] 138 | name string 139 | }{ 140 | { 141 | name: "all roles are returned when they are all enabled", 142 | p: &progress{EnabledRoles: &users.Enum[RoleType]{AmbassadorRoleType}}, 143 | repo: &repository{cfg: &config{RoleNames: []RoleType{"bogus", AmbassadorRoleType}}}, 144 | expected: &users.Enum[RoleType]{AmbassadorRoleType}, 145 | }, 146 | { 147 | name: "ambassador role is returned, if friends invited threshold is passed", 148 | p: &progress{FriendsInvited: 5, EnabledRoles: &users.Enum[RoleType]{AmbassadorRoleType}}, 149 | repo: &repository{cfg: &config{RequiredInvitedFriendsToBecomeAmbassador: 4, RoleNames: []RoleType{"bogus", AmbassadorRoleType}}}, 150 | expected: &users.Enum[RoleType]{AmbassadorRoleType}, 151 | }, 152 | { 153 | name: "nil is returned, when no roles are enabled and friends invited threshold isn't reached", 154 | p: &progress{FriendsInvited: 4}, 155 | repo: &repository{cfg: &config{RequiredInvitedFriendsToBecomeAmbassador: 5, RoleNames: []RoleType{"bogus", AmbassadorRoleType}}}, 156 | expected: nil, 157 | }, 158 | } 159 | 160 | for _, tt := range testCases { //nolint:gocritic // it's a test, no need for optimization 161 | t.Run(tt.name, func(t *testing.T) { 162 | t.Parallel() 163 | 164 | got := tt.p.reEvaluateEnabledRoles(tt.repo) 165 | require.EqualValues(t, tt.expected, got) 166 | }) 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /levels-and-roles/contacts_source.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package levelsandroles 4 | 5 | import ( 6 | "context" 7 | 8 | "github.com/goccy/go-json" 9 | "github.com/hashicorp/go-multierror" 10 | "github.com/pkg/errors" 11 | 12 | "github.com/ice-blockchain/eskimo/users" 13 | messagebroker "github.com/ice-blockchain/wintr/connectors/message_broker" 14 | storage "github.com/ice-blockchain/wintr/connectors/storage/v2" 15 | ) 16 | 17 | func (c *agendaContactsSource) Process(ctx context.Context, msg *messagebroker.Message) error { //nolint:funlen // Not worth to break. 18 | if ctx.Err() != nil { 19 | return errors.Wrap(ctx.Err(), "unexpected deadline while processing message") 20 | } 21 | if len(msg.Value) == 0 { 22 | return nil 23 | } 24 | contact := new(users.Contact) 25 | if err := json.UnmarshalContext(ctx, msg.Value, contact); err != nil { 26 | return errors.Wrapf(err, "cannot unmarshal %v into %#v", string(msg.Value), contact) 27 | } 28 | before, err := c.getProgress(ctx, contact.UserID, true) 29 | if err != nil && !storage.IsErr(err, storage.ErrNotFound) { 30 | return errors.Wrapf(err, "can't get contacts for userID:%v", contact.UserID) 31 | } 32 | if before != nil && before.AgendaContactUserIDs != nil { 33 | for _, id := range *before.AgendaContactUserIDs { 34 | if id == contact.ContactUserID { 35 | return ErrDuplicate 36 | } 37 | } 38 | } 39 | toUpsert := make(users.Enum[users.UserID], 0) 40 | if before.AgendaContactUserIDs != nil { 41 | toUpsert = append(toUpsert, *before.AgendaContactUserIDs...) 42 | } 43 | toUpsert = append(toUpsert, contact.ContactUserID) 44 | if uErr := c.upsertAgendaContacts(ctx, contact.UserID, &toUpsert); uErr != nil { 45 | return errors.Wrapf(uErr, "can't upsert agenda contacts for userID:%v", contact.UserID) 46 | } 47 | if sErr := c.sendTryCompleteLevelsCommandMessage(ctx, contact.UserID); sErr != nil { 48 | //nolint:wrapcheck // Not needed. 49 | return multierror.Append(errors.Wrapf(sErr, "failed to sendTryCompleteLevelsCommandMessage, userID:%v", contact.UserID), 50 | errors.Wrapf(c.upsertAgendaContacts(ctx, contact.UserID, before.AgendaContactUserIDs), 51 | "can't rollback agenda contacts joined value for userID:%v", contact.UserID)).ErrorOrNil() 52 | } 53 | 54 | return nil 55 | } 56 | 57 | func (c *agendaContactsSource) upsertAgendaContacts(ctx context.Context, userID string, contacts *users.Enum[users.UserID]) error { 58 | sql := `INSERT INTO levels_and_roles_progress(user_id, agenda_contact_user_ids) VALUES ($1, $2) 59 | ON CONFLICT(user_id) 60 | DO UPDATE 61 | SET agenda_contact_user_ids = EXCLUDED.agenda_contact_user_ids 62 | WHERE COALESCE(levels_and_roles_progress.agenda_contact_user_ids, ARRAY[]::TEXT[]) != COALESCE(EXCLUDED.agenda_contact_user_ids, ARRAY[]::TEXT[])` 63 | _, err := storage.Exec(ctx, c.db, sql, userID, contacts) 64 | 65 | return errors.Wrapf(err, "can't insert/update contact user ids:%#v for userID:%v", contacts, userID) 66 | } 67 | -------------------------------------------------------------------------------- /levels-and-roles/contract.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package levelsandroles 4 | 5 | import ( 6 | "context" 7 | _ "embed" 8 | "io" 9 | 10 | "github.com/pkg/errors" 11 | 12 | "github.com/ice-blockchain/eskimo/users" 13 | messagebroker "github.com/ice-blockchain/wintr/connectors/message_broker" 14 | storage "github.com/ice-blockchain/wintr/connectors/storage/v2" 15 | ) 16 | 17 | // Public API. 18 | 19 | const ( 20 | Level1Type LevelType = "1" 21 | Level2Type LevelType = "2" 22 | Level3Type LevelType = "3" 23 | Level4Type LevelType = "4" 24 | Level5Type LevelType = "5" 25 | Level6Type LevelType = "6" 26 | Level7Type LevelType = "7" 27 | Level8Type LevelType = "8" 28 | Level9Type LevelType = "9" 29 | Level10Type LevelType = "10" 30 | Level11Type LevelType = "11" 31 | Level12Type LevelType = "12" 32 | Level13Type LevelType = "13" 33 | Level14Type LevelType = "14" 34 | Level15Type LevelType = "15" 35 | Level16Type LevelType = "16" 36 | Level17Type LevelType = "17" 37 | Level18Type LevelType = "18" 38 | Level19Type LevelType = "19" 39 | Level20Type LevelType = "20" 40 | Level21Type LevelType = "21" 41 | ) 42 | 43 | const ( 44 | SnowmanRoleIndex int = 0 45 | AmbassadorRoleIndex int = 1 46 | AmbassadorRoleType RoleType = "ambassador" 47 | ) 48 | 49 | var ( 50 | ErrRaceCondition = errors.New("race condition") 51 | ErrDuplicate = errors.New("duplicate") 52 | //nolint:gochecknoglobals // It's just for more descriptive validation messages. 53 | AllLevelTypes = [21]LevelType{ 54 | Level1Type, 55 | Level2Type, 56 | Level3Type, 57 | Level4Type, 58 | Level5Type, 59 | Level6Type, 60 | Level7Type, 61 | Level8Type, 62 | Level9Type, 63 | Level10Type, 64 | Level11Type, 65 | Level12Type, 66 | Level13Type, 67 | Level14Type, 68 | Level15Type, 69 | Level16Type, 70 | Level17Type, 71 | Level18Type, 72 | Level19Type, 73 | Level20Type, 74 | Level21Type, 75 | } 76 | //nolint:gochecknoglobals // It's just for more descriptive validation messages. 77 | AllRoleTypesThatCanBeEnabled = [1]int{ 78 | AmbassadorRoleIndex, 79 | } 80 | ) 81 | 82 | type ( 83 | LevelType string 84 | RoleType string 85 | Role struct { 86 | Type RoleType `json:"type" example:"snowman"` 87 | Enabled bool `json:"enabled" example:"true"` 88 | } 89 | Summary struct { 90 | Roles []*Role `json:"roles,omitempty"` 91 | Level uint64 `json:"level,omitempty" example:"11"` 92 | } 93 | CompletedLevel struct { 94 | UserID string `json:"userId,omitempty" example:"edfd8c02-75e0-4687-9ac2-1ce4723865c4"` 95 | Type LevelType `json:"type,omitempty" example:"1"` 96 | CompletedLevels uint64 `json:"completedLevels,omitempty" example:"3"` 97 | } 98 | EnabledRole struct { 99 | UserID string `json:"userId,omitempty" example:"edfd8c02-75e0-4687-9ac2-1ce4723865c4"` 100 | Type RoleType `json:"type,omitempty" example:"snowman"` 101 | } 102 | ReadRepository interface { 103 | GetSummary(ctx context.Context, userID string) (*Summary, error) 104 | } 105 | WriteRepository interface{} //nolint:revive // . 106 | Repository interface { 107 | io.Closer 108 | 109 | ReadRepository 110 | WriteRepository 111 | } 112 | Processor interface { 113 | Repository 114 | CheckHealth(ctx context.Context) error 115 | } 116 | ) 117 | 118 | // Private API. 119 | 120 | const ( 121 | applicationYamlKey = "levels-and-roles" 122 | requestingUserIDCtxValueKey = "requestingUserIDCtxValueKey" 123 | ) 124 | 125 | // . 126 | var ( 127 | //go:embed DDL.sql 128 | ddl string 129 | ) 130 | 131 | type ( 132 | progress struct { 133 | EnabledRoles *users.Enum[RoleType] `json:"enabledRoles,omitempty" example:"snowman,ambassador"` 134 | CompletedLevels *users.Enum[LevelType] `json:"completedLevels,omitempty" example:"1,2"` 135 | AgendaContactUserIDs *users.Enum[string] `json:"agendaContactUserIDs,omitempty" example:"edfd8c02-75e0-4687-9ac2-1ce4723865c4,edfd8c02-75e0-4687-9ac2-1ce4723865c5" db:"agenda_contact_user_ids"` //nolint:lll // . 136 | PhoneNumberHash *string `json:"phoneNumberHash,omitempty" example:"some hash"` 137 | UserID string `json:"userId,omitempty" example:"edfd8c02-75e0-4687-9ac2-1ce4723865c4"` 138 | MiningStreak uint64 `json:"miningStreak,omitempty" example:"3"` 139 | PingsSent uint64 `json:"pingsSent,omitempty" example:"3"` 140 | FriendsInvited uint64 `json:"friendsInvited,omitempty" example:"3"` 141 | CompletedTasks uint64 `json:"completedTasks,omitempty" example:"3"` 142 | HideLevel bool `json:"hideLevel,omitempty" example:"true"` 143 | HideRole bool `json:"hideRole,omitempty" example:"true"` 144 | } 145 | tryCompleteLevelsCommandSource struct { 146 | *processor 147 | } 148 | userTableSource struct { 149 | *processor 150 | } 151 | friendsInvitedSource struct { 152 | *processor 153 | } 154 | miningSessionSource struct { 155 | *processor 156 | } 157 | startedDaysOffSource struct { 158 | *miningSessionSource 159 | } 160 | completedTasksSource struct { 161 | *processor 162 | } 163 | userPingsSource struct { 164 | *processor 165 | } 166 | agendaContactsSource struct { 167 | *processor 168 | } 169 | repository struct { 170 | cfg *config 171 | shutdown func() error 172 | db *storage.DB 173 | mb messagebroker.Client 174 | } 175 | processor struct { 176 | *repository 177 | } 178 | config struct { 179 | MiningStreakMilestones map[LevelType]uint64 `yaml:"miningStreakMilestones"` 180 | PingsSentMilestones map[LevelType]uint64 `yaml:"pingsSentMilestones"` 181 | AgendaContactsJoinedMilestones map[LevelType]uint64 `yaml:"agendaContactsJoinedMilestones"` 182 | CompletedTasksMilestones map[LevelType]uint64 `yaml:"completedTasksMilestones"` 183 | RoleNames []RoleType `yaml:"roleNames"` 184 | TasksList []struct { 185 | Type string `yaml:"type" mapstructure:"type"` 186 | Icon string `yaml:"icon" mapstructure:"icon"` 187 | Prize float64 `yaml:"prize" mapstructure:"prize"` 188 | } `yaml:"tasksList" mapstructure:"tasksList"` 189 | AdminUsers []string `yaml:"adminUsers" mapstructure:"adminUsers"` 190 | messagebroker.Config `mapstructure:",squash"` //nolint:tagliatelle // Nope. 191 | RequiredInvitedFriendsToBecomeAmbassador uint64 `yaml:"requiredInvitedFriendsToBecomeAmbassador"` 192 | TasksV2Enabled bool `yaml:"tasksV2Enabled" mapstructure:"tasksV2Enabled"` 193 | } 194 | ) 195 | -------------------------------------------------------------------------------- /levels-and-roles/fixture/contract.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package fixture 4 | 5 | // Public API. 6 | 7 | const ( 8 | TestConnectorsOrder = 0 9 | ) 10 | 11 | const ( 12 | All StartLocalTestEnvironmentType = "all" 13 | DB StartLocalTestEnvironmentType = "db" 14 | MB StartLocalTestEnvironmentType = "mb" 15 | ) 16 | 17 | type ( 18 | StartLocalTestEnvironmentType string 19 | ) 20 | 21 | // Private API. 22 | 23 | const ( 24 | applicationYAMLKey = "levels-and-roles" 25 | ) 26 | -------------------------------------------------------------------------------- /levels-and-roles/fixture/fixture.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package fixture 4 | 5 | import ( 6 | "testing" 7 | 8 | testcontainers "github.com/testcontainers/testcontainers-go" 9 | 10 | connectorsfixture "github.com/ice-blockchain/wintr/connectors/fixture" 11 | messagebrokerfixture "github.com/ice-blockchain/wintr/connectors/message_broker/fixture" 12 | storagefixture "github.com/ice-blockchain/wintr/connectors/storage/fixture" 13 | ) 14 | 15 | func StartLocalTestEnvironment(tp StartLocalTestEnvironmentType) { 16 | var connectors []connectorsfixture.TestConnector 17 | switch tp { 18 | case DB: 19 | connectors = append(connectors, newDBConnector()) 20 | case MB: 21 | connectors = append(connectors, newMBConnector()) 22 | case All: 23 | connectors = WTestConnectors() 24 | default: 25 | connectors = WTestConnectors() 26 | } 27 | connectorsfixture. 28 | NewTestRunner(applicationYAMLKey, nil, connectors...). 29 | StartConnectorsIndefinitely() 30 | } 31 | 32 | //nolint:gocritic // Because that's exactly what we want. 33 | func RunTests( 34 | m *testing.M, 35 | dbConnector *storagefixture.TestConnector, 36 | mbConnector *messagebrokerfixture.TestConnector, 37 | lifeCycleHooks ...*connectorsfixture.ConnectorLifecycleHooks, 38 | ) { 39 | *dbConnector = newDBConnector() 40 | *mbConnector = newMBConnector() 41 | 42 | var connectorLifecycleHooks *connectorsfixture.ConnectorLifecycleHooks 43 | if len(lifeCycleHooks) == 1 { 44 | connectorLifecycleHooks = lifeCycleHooks[0] 45 | } 46 | 47 | connectorsfixture. 48 | NewTestRunner(applicationYAMLKey, connectorLifecycleHooks, *dbConnector, *mbConnector). 49 | RunTests(m) 50 | } 51 | 52 | func WTestConnectors() []connectorsfixture.TestConnector { 53 | return []connectorsfixture.TestConnector{newDBConnector(), newMBConnector()} 54 | } 55 | 56 | func RTestConnectors() []connectorsfixture.TestConnector { 57 | return []connectorsfixture.TestConnector{newDBConnector()} 58 | } 59 | 60 | func newDBConnector() storagefixture.TestConnector { 61 | return storagefixture.NewTestConnector(applicationYAMLKey, TestConnectorsOrder) 62 | } 63 | 64 | func newMBConnector() messagebrokerfixture.TestConnector { 65 | return messagebrokerfixture.NewTestConnector(applicationYAMLKey, TestConnectorsOrder) 66 | } 67 | 68 | func RContainerMounts() []func(projectRoot string) testcontainers.ContainerMount { 69 | return nil 70 | } 71 | 72 | func WContainerMounts() []func(projectRoot string) testcontainers.ContainerMount { 73 | return nil 74 | } 75 | -------------------------------------------------------------------------------- /levels-and-roles/get_summary.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package levelsandroles 4 | 5 | import ( 6 | "context" 7 | 8 | "github.com/pkg/errors" 9 | 10 | storage "github.com/ice-blockchain/wintr/connectors/storage/v2" 11 | ) 12 | 13 | func (r *repository) GetSummary(ctx context.Context, userID string) (*Summary, error) { 14 | if ctx.Err() != nil { 15 | return nil, errors.Wrap(ctx.Err(), "unexpected deadline") 16 | } 17 | if res, err := r.getProgress(ctx, userID, true); err != nil && !errors.Is(err, storage.ErrRelationNotFound) { 18 | return nil, errors.Wrapf(err, "failed to getProgress for userID:%v", userID) 19 | } else { //nolint:revive // . 20 | return r.newSummary(res, requestingUserID(ctx)), nil 21 | } 22 | } 23 | 24 | //nolint:revive // . 25 | func (r *repository) getProgress(ctx context.Context, userID string, tolerateOldData bool) (res *progress, err error) { 26 | if ctx.Err() != nil { 27 | return nil, errors.Wrap(ctx.Err(), "unexpected deadline") 28 | } 29 | sql := `SELECT * 30 | FROM levels_and_roles_progress 31 | WHERE user_id = $1` 32 | if tolerateOldData { 33 | res, err = storage.Get[progress](ctx, r.db, sql, userID) 34 | } else { 35 | res, err = storage.ExecOne[progress](ctx, r.db, sql, userID) 36 | } 37 | 38 | if errors.Is(err, storage.ErrNotFound) { 39 | return nil, storage.ErrRelationNotFound 40 | } 41 | 42 | return 43 | } 44 | 45 | func (r *repository) newSummary(pr *progress, requestingUserID string) *Summary { 46 | var level uint64 47 | if pr == nil || !pr.HideLevel || requestingUserID == pr.UserID { 48 | level = pr.level() 49 | } 50 | var roles []*Role 51 | if pr == nil || !pr.HideRole || requestingUserID == pr.UserID { 52 | roles = pr.roles(r) 53 | } 54 | 55 | return &Summary{Roles: roles, Level: level} 56 | } 57 | 58 | func (p *progress) level() uint64 { 59 | if p == nil || p.CompletedLevels == nil || len(*p.CompletedLevels) == 0 { 60 | return 1 61 | } else { //nolint:revive // . 62 | return uint64(len(*p.CompletedLevels)) 63 | } 64 | } 65 | 66 | func (p *progress) roles(repo *repository) []*Role { 67 | if p == nil || p.EnabledRoles == nil || len(*p.EnabledRoles) == 0 { 68 | return []*Role{ 69 | { 70 | Type: repo.cfg.RoleNames[SnowmanRoleIndex], 71 | Enabled: true, 72 | }, 73 | { 74 | Type: repo.cfg.RoleNames[AmbassadorRoleIndex], 75 | Enabled: false, 76 | }, 77 | } 78 | } else { //nolint:revive // . 79 | return []*Role{ 80 | { 81 | Type: repo.cfg.RoleNames[SnowmanRoleIndex], 82 | Enabled: false, 83 | }, 84 | { 85 | Type: repo.cfg.RoleNames[AmbassadorRoleIndex], 86 | Enabled: true, 87 | }, 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /levels-and-roles/get_summary_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package levelsandroles 4 | 5 | import ( 6 | "testing" 7 | 8 | "github.com/stretchr/testify/require" 9 | 10 | "github.com/ice-blockchain/eskimo/users" 11 | appcfg "github.com/ice-blockchain/wintr/config" 12 | ) 13 | 14 | func TestNewSummary(t *testing.T) { //nolint:funlen // It's a test function 15 | t.Parallel() 16 | cfg := defaultCfg() 17 | repo := repository{cfg: cfg} 18 | testCases := []struct { 19 | pr *progress 20 | expected *Summary 21 | requestingUserID string 22 | name string 23 | }{ 24 | { 25 | name: "return 1 level and snowman role, when there is no progress", 26 | expected: &Summary{Level: 1, Roles: []*Role{ 27 | {Type: cfg.RoleNames[SnowmanRoleIndex], Enabled: true}, 28 | {Type: cfg.RoleNames[AmbassadorRoleIndex], Enabled: false}, 29 | }}, 30 | }, 31 | { 32 | name: "return 1 level and snowman role, when roles aren't hidden", 33 | pr: &progress{HideLevel: false, HideRole: false}, 34 | expected: &Summary{Level: 1, Roles: []*Role{ 35 | {Type: cfg.RoleNames[SnowmanRoleIndex], Enabled: true}, 36 | {Type: cfg.RoleNames[AmbassadorRoleIndex], Enabled: false}, 37 | }}, 38 | }, 39 | { 40 | name: "return 1 level and snowman role, when requesting user id is same as progress user id", 41 | pr: &progress{UserID: "bogus"}, 42 | requestingUserID: "bogus", 43 | expected: &Summary{Level: 1, Roles: []*Role{ 44 | {Type: cfg.RoleNames[SnowmanRoleIndex], Enabled: true}, 45 | {Type: cfg.RoleNames[AmbassadorRoleIndex], Enabled: false}, 46 | }}, 47 | }, 48 | { 49 | name: "return 1 level and ambassador role, when roles aren't enabled", 50 | pr: &progress{EnabledRoles: &users.Enum[RoleType]{AmbassadorRoleType}}, 51 | expected: &Summary{Level: 1, Roles: []*Role{ 52 | {Type: cfg.RoleNames[SnowmanRoleIndex], Enabled: false}, 53 | {Type: cfg.RoleNames[AmbassadorRoleIndex], Enabled: true}, 54 | }}, 55 | }, 56 | { 57 | name: "return 2 level and snowman role, when 2 levels are completed", 58 | pr: &progress{CompletedLevels: &users.Enum[LevelType]{Level1Type, Level2Type}}, 59 | expected: &Summary{Level: 2, Roles: []*Role{ 60 | {Type: cfg.RoleNames[SnowmanRoleIndex], Enabled: true}, 61 | {Type: cfg.RoleNames[AmbassadorRoleIndex], Enabled: false}, 62 | }}, 63 | }, 64 | } 65 | 66 | for _, tt := range testCases { //nolint:gocritic // it's a test, no need for optimization 67 | t.Run(tt.name, func(t *testing.T) { 68 | t.Parallel() 69 | 70 | got := repo.newSummary(tt.pr, tt.requestingUserID) 71 | require.EqualValues(t, tt.expected, got) 72 | }) 73 | } 74 | } 75 | 76 | func defaultCfg() *config { 77 | var cfg config 78 | const applicationYamlTestKey = applicationYamlKey + "_test" 79 | appcfg.MustLoadFromKey(applicationYamlTestKey, &cfg) 80 | 81 | return &cfg 82 | } 83 | -------------------------------------------------------------------------------- /levels-and-roles/levels_and_roles.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package levelsandroles 4 | 5 | import ( 6 | "context" 7 | "math/rand" 8 | "sync" 9 | stdlibtime "time" 10 | 11 | "github.com/goccy/go-json" 12 | "github.com/hashicorp/go-multierror" 13 | "github.com/pkg/errors" 14 | 15 | "github.com/ice-blockchain/eskimo/users" 16 | appcfg "github.com/ice-blockchain/wintr/config" 17 | messagebroker "github.com/ice-blockchain/wintr/connectors/message_broker" 18 | storage "github.com/ice-blockchain/wintr/connectors/storage/v2" 19 | "github.com/ice-blockchain/wintr/log" 20 | "github.com/ice-blockchain/wintr/time" 21 | ) 22 | 23 | func New(ctx context.Context, _ context.CancelFunc) Repository { 24 | var cfg config 25 | appcfg.MustLoadFromKey(applicationYamlKey, &cfg) 26 | if len(cfg.RoleNames) < AllRoleTypesThatCanBeEnabled[len(AllRoleTypesThatCanBeEnabled)-1]+1 { 27 | log.Panic(errors.Errorf("insufficient roleNames")) 28 | } 29 | db := storage.MustConnect(ctx, ddl, applicationYamlKey) 30 | 31 | return &repository{ 32 | cfg: &cfg, 33 | shutdown: db.Close, 34 | db: db, 35 | } 36 | } 37 | 38 | func StartProcessor(ctx context.Context, cancel context.CancelFunc) Processor { 39 | var cfg config 40 | appcfg.MustLoadFromKey(applicationYamlKey, &cfg) 41 | if len(cfg.RoleNames) < AllRoleTypesThatCanBeEnabled[len(AllRoleTypesThatCanBeEnabled)-1]+1 { 42 | log.Panic(errors.Errorf("insufficient roleNames")) 43 | } 44 | var mbConsumer messagebroker.Client 45 | prc := &processor{repository: &repository{ 46 | cfg: &cfg, 47 | db: storage.MustConnect(ctx, ddl, applicationYamlKey), 48 | mb: messagebroker.MustConnect(ctx, applicationYamlKey), 49 | }} 50 | mss := &miningSessionSource{processor: prc} 51 | //nolint:contextcheck // It's intended. Cuz we want to close everything gracefully. 52 | mbConsumer = messagebroker.MustConnectAndStartConsuming(context.Background(), cancel, applicationYamlKey, 53 | &tryCompleteLevelsCommandSource{processor: prc}, 54 | &userTableSource{processor: prc}, 55 | mss, 56 | &startedDaysOffSource{miningSessionSource: mss}, 57 | &completedTasksSource{processor: prc}, 58 | &userPingsSource{processor: prc}, 59 | &friendsInvitedSource{processor: prc}, 60 | &agendaContactsSource{processor: prc}, 61 | ) 62 | prc.shutdown = closeAll(mbConsumer, prc.mb, prc.db) 63 | go prc.startProcessedPingsCleaner(ctx) 64 | 65 | return prc 66 | } 67 | 68 | func (r *repository) Close() error { 69 | return errors.Wrap(r.shutdown(), "closing repository failed") 70 | } 71 | 72 | func closeAll(mbConsumer, mbProducer messagebroker.Client, db *storage.DB, otherClosers ...func() error) func() error { 73 | return func() error { 74 | err1 := errors.Wrap(mbConsumer.Close(), "closing message broker consumer connection failed") 75 | err2 := errors.Wrap(db.Close(), "closing db connection failed") 76 | err3 := errors.Wrap(mbProducer.Close(), "closing message broker producer connection failed") 77 | errs := make([]error, 0, 1+1+1+len(otherClosers)) 78 | errs = append(errs, err1, err2, err3) 79 | for _, closeOther := range otherClosers { 80 | if err := closeOther(); err != nil { 81 | errs = append(errs, err) 82 | } 83 | } 84 | 85 | return errors.Wrap(multierror.Append(nil, errs...).ErrorOrNil(), "failed to close resources") 86 | } 87 | } 88 | 89 | func (p *processor) CheckHealth(ctx context.Context) error { 90 | if err := p.db.Ping(ctx); err != nil { 91 | return errors.Wrap(err, "[health-check] failed to ping DB") 92 | } 93 | type ts struct { 94 | TS *time.Time `json:"ts"` 95 | } 96 | now := ts{TS: time.Now()} 97 | bytes, err := json.MarshalContext(ctx, now) 98 | if err != nil { 99 | return errors.Wrapf(err, "[health-check] failed to marshal %#v", now) 100 | } 101 | responder := make(chan error, 1) 102 | p.mb.SendMessage(ctx, &messagebroker.Message{ 103 | Headers: map[string]string{"producer": "santa"}, 104 | Key: p.cfg.MessageBroker.Topics[0].Name, 105 | Topic: p.cfg.MessageBroker.Topics[0].Name, 106 | Value: bytes, 107 | }, responder) 108 | 109 | return errors.Wrapf(<-responder, "[health-check] failed to send health check message to broker") 110 | } 111 | 112 | func AreLevelsCompleted(actual *users.Enum[LevelType], expectedSubset ...LevelType) bool { 113 | if len(expectedSubset) == 0 { 114 | return actual == nil || len(*actual) == 0 115 | } 116 | if (actual == nil || len(*actual) == 0) && len(expectedSubset) > 0 { 117 | return false 118 | } 119 | for _, expectedType := range expectedSubset { 120 | var completed bool 121 | for _, completedType := range *actual { 122 | if completedType == expectedType { 123 | completed = true 124 | 125 | break 126 | } 127 | } 128 | if !completed { 129 | return false 130 | } 131 | } 132 | 133 | return true 134 | } 135 | 136 | func runConcurrently[ARG any](ctx context.Context, run func(context.Context, ARG) error, args []ARG) error { 137 | if ctx.Err() != nil { 138 | return errors.Wrap(ctx.Err(), "unexpected deadline") 139 | } 140 | if len(args) == 0 { 141 | return nil 142 | } 143 | wg := new(sync.WaitGroup) 144 | wg.Add(len(args)) 145 | errChan := make(chan error, len(args)) 146 | for i := range args { 147 | go func(ix int) { 148 | defer wg.Done() 149 | errChan <- errors.Wrapf(run(ctx, args[ix]), "failed to run:%#v", args[ix]) 150 | }(i) 151 | } 152 | wg.Wait() 153 | close(errChan) 154 | errs := make([]error, 0, len(args)) 155 | for err := range errChan { 156 | errs = append(errs, err) 157 | } 158 | 159 | return errors.Wrap(multierror.Append(nil, errs...).ErrorOrNil(), "at least one execution failed") 160 | } 161 | 162 | func requestingUserID(ctx context.Context) (requestingUserID string) { 163 | requestingUserID, _ = ctx.Value(requestingUserIDCtxValueKey).(string) //nolint:errcheck,revive // Not needed. 164 | 165 | return 166 | } 167 | 168 | func (p *processor) startProcessedPingsCleaner(ctx context.Context) { 169 | ticker := stdlibtime.NewTicker(stdlibtime.Duration(1+rand.Intn(24)) * stdlibtime.Minute) //nolint:gosec // Not an issue. 170 | defer ticker.Stop() 171 | 172 | for { 173 | select { 174 | case <-ticker.C: 175 | const deadline = 30 * stdlibtime.Second 176 | reqCtx, cancel := context.WithTimeout(ctx, deadline) 177 | log.Error(errors.Wrap(p.deleteProcessedPings(reqCtx), "failed to deleteOldReferrals")) 178 | cancel() 179 | case <-ctx.Done(): 180 | return 181 | } 182 | } 183 | } 184 | 185 | func (p *processor) deleteProcessedPings(ctx context.Context) error { 186 | if ctx.Err() != nil { 187 | return errors.Wrap(ctx.Err(), "unexpected deadline") 188 | } 189 | sql := `DELETE FROM pings WHERE last_ping_cooldown_ended_at < $1` 190 | if _, err := storage.Exec(ctx, p.db, sql, time.New(time.Now().Add(-24*stdlibtime.Hour)).Time); err != nil { 191 | return errors.Wrap(err, "failed to delete old data from referrals") 192 | } 193 | 194 | return nil 195 | } 196 | -------------------------------------------------------------------------------- /levels-and-roles/levels_and_roles_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package levelsandroles 4 | 5 | import ( 6 | "testing" 7 | 8 | "github.com/stretchr/testify/require" 9 | 10 | "github.com/ice-blockchain/eskimo/users" 11 | ) 12 | 13 | func TestAreLevelsCompleted(t *testing.T) { //nolint:funlen // It's a test function 14 | t.Parallel() 15 | 16 | testCases := []struct { 17 | name string 18 | actual *users.Enum[LevelType] 19 | expectedSubset []LevelType 20 | expected bool 21 | }{ 22 | { 23 | name: "expected levels are empty, but actual aren't", 24 | actual: &users.Enum[LevelType]{Level1Type}, 25 | expected: false, 26 | }, 27 | { 28 | name: "expected levels are empty and actual as well", 29 | expected: true, 30 | }, 31 | { 32 | name: "expected levels aren't empty, but actual are empty", 33 | expectedSubset: []LevelType{Level1Type}, 34 | expected: false, 35 | }, 36 | { 37 | name: "actual levels are all completed", 38 | actual: &users.Enum[LevelType]{Level1Type, Level2Type, Level3Type}, 39 | expectedSubset: []LevelType{Level1Type, Level2Type, Level3Type}, 40 | expected: true, 41 | }, 42 | { 43 | name: "actual levels are completed, except one", 44 | actual: &users.Enum[LevelType]{Level1Type, Level2Type, Level3Type}, 45 | expectedSubset: []LevelType{Level1Type, Level2Type}, 46 | expected: true, 47 | }, 48 | { 49 | name: "expected levels are not in actual", 50 | actual: &users.Enum[LevelType]{Level1Type, Level2Type}, 51 | expectedSubset: []LevelType{Level1Type, Level2Type, Level3Type}, 52 | expected: false, 53 | }, 54 | } 55 | 56 | for _, tt := range testCases { //nolint:gocritic // it's a test, no need for optimization 57 | t.Run(tt.name, func(t *testing.T) { 58 | t.Parallel() 59 | 60 | got := AreLevelsCompleted(tt.actual, tt.expectedSubset...) 61 | require.EqualValues(t, tt.expected, got) 62 | }) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /levels-and-roles/seeding/seeding.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | //go:build !test 4 | 5 | package seeding 6 | 7 | import ( 8 | "github.com/ice-blockchain/wintr/log" 9 | ) 10 | 11 | func StartSeeding() { 12 | log.Info("TODO: implement seeding") 13 | } 14 | -------------------------------------------------------------------------------- /local.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package main 4 | 5 | import ( 6 | "flag" 7 | 8 | "github.com/ice-blockchain/santa/tasks/fixture" 9 | "github.com/ice-blockchain/santa/tasks/seeding" 10 | serverauthfixture "github.com/ice-blockchain/wintr/auth/fixture" 11 | "github.com/ice-blockchain/wintr/log" 12 | ) 13 | 14 | //nolint:gochecknoglobals // Because those are flags 15 | var ( 16 | generateAuth = flag.String("generateAuth", "", "generate a new auth for a random user, with the specified role") 17 | startSeeding = flag.Bool("startSeeding", false, "whether to start seeding a remote database or not") 18 | startLocalType = flag.String("type", "all", "the strategy to use to spin up the local environment") 19 | ) 20 | 21 | func main() { 22 | flag.Parse() 23 | if generateAuth != nil && *generateAuth != "" { 24 | userID, token := testingAuthorization(*generateAuth) 25 | log.Info("UserID") 26 | log.Info("=================================================================================") 27 | log.Info(userID) 28 | log.Info("Authorization Bearer Token") 29 | log.Info("=================================================================================") 30 | log.Info(token) 31 | 32 | return 33 | } 34 | if *startSeeding { 35 | seeding.StartSeeding() 36 | 37 | return 38 | } 39 | 40 | fixture.StartLocalTestEnvironment(fixture.StartLocalTestEnvironmentType(*startLocalType)) 41 | } 42 | 43 | func testingAuthorization(role string) (userID, token string) { 44 | return serverauthfixture.CreateUser(role) 45 | } 46 | -------------------------------------------------------------------------------- /tasks/.testdata/application.yaml: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: ice License 1.0 2 | 3 | development: true 4 | logger: 5 | encoder: console 6 | level: info 7 | tasksList: &tasksList 8 | - type: claim_username 9 | prize: 100 10 | icon: https://app.sunwavestoken.com/web/images/Components/Overview/nikename.svg 11 | - type: start_mining 12 | prize: 200 13 | icon: https://app.sunwavestoken.com/web/images/Components/Overview/mining.svg 14 | - type: upload_profile_picture 15 | prize: 300 16 | icon: https://ice-staging.b-cdn.net/sunwaves/assets/profile-picture.svg 17 | - type: join_twitter 18 | prize: 400 19 | icon: https://app.sunwavestoken.com/web/images/Components/Overview/twitter-follow.svg 20 | url: https://x.com/ice_blockchain 21 | - type: join_telegram 22 | prize: 500 23 | icon: https://ice-staging.b-cdn.net/sunwaves/assets/join-telegram.svg 24 | url: https://t.me/iceblockchain 25 | - type: invite_friends 26 | prize: 600 27 | icon: https://app.sunwavestoken.com/web/images/Components/Overview/invite.svg 28 | tasks: &tasks 29 | tasksV2Enabled: false 30 | requiredFriendsInvited: 5 31 | db: &tasksDatabase 32 | urls: 33 | - localhost:3302 34 | user: admin 35 | password: pass 36 | messageBroker: &tasksMessageBroker 37 | consumerGroup: tasks-testing 38 | createTopics: true 39 | urls: 40 | - localhost:9093 41 | topics: &tasksMessageBrokerTopics 42 | - name: santa-health-check 43 | partitions: 1 44 | replicationFactor: 1 45 | retention: 1000h 46 | - name: try-complete-tasks-commands 47 | partitions: 10 48 | replicationFactor: 1 49 | retention: 1000h 50 | - name: completed-tasks 51 | partitions: 10 52 | replicationFactor: 1 53 | retention: 1000h 54 | ### The next topics are not owned by this service, but are needed to be created for the local/test environment. 55 | - name: users-table 56 | partitions: 10 57 | replicationFactor: 1 58 | retention: 1000h 59 | - name: mining-sessions-table 60 | partitions: 10 61 | replicationFactor: 1 62 | retention: 1000h 63 | - name: friends-invited 64 | partitions: 10 65 | replicationFactor: 1 66 | retention: 1000h 67 | consumingTopics: 68 | - name: try-complete-tasks-commands 69 | - name: users-table 70 | - name: mining-sessions-table 71 | - name: friends-invited 72 | tasksList: *tasksList 73 | tenantName: sunwaves 74 | tasks_test: 75 | <<: *tasks 76 | messageBroker: 77 | <<: *tasksMessageBroker 78 | consumingTopics: *tasksMessageBrokerTopics 79 | consumerGroup: santa-testing-tasks 80 | db: 81 | <<: *tasksDatabase 82 | schemaPath: tasks/DDL.lua 83 | -------------------------------------------------------------------------------- /tasks/DDL.sql: -------------------------------------------------------------------------------- 1 | -- SPDX-License-Identifier: ice License 1.0 2 | --************************************************************************************************************************************ 3 | -- task_progress 4 | CREATE TABLE IF NOT EXISTS task_progress ( 5 | friends_invited BIGINT NOT NULL DEFAULT 0, 6 | mining_started BOOLEAN DEFAULT FALSE, 7 | username_set BOOLEAN DEFAULT FALSE, 8 | profile_picture_set BOOLEAN DEFAULT FALSE, 9 | completed_tasks TEXT[], 10 | pseudo_completed_tasks TEXT[], 11 | user_id TEXT NOT NULL PRIMARY KEY, 12 | twitter_user_handle TEXT, 13 | telegram_user_handle TEXT 14 | ) WITH (fillfactor = 70); -------------------------------------------------------------------------------- /tasks/fixture/contract.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package fixture 4 | 5 | // Public API. 6 | 7 | const ( 8 | TestConnectorsOrder = 0 9 | ) 10 | 11 | const ( 12 | All StartLocalTestEnvironmentType = "all" 13 | DB StartLocalTestEnvironmentType = "db" 14 | MB StartLocalTestEnvironmentType = "mb" 15 | ) 16 | 17 | type ( 18 | StartLocalTestEnvironmentType string 19 | ) 20 | 21 | // Private API. 22 | 23 | const ( 24 | applicationYAMLKey = "tasks" 25 | ) 26 | -------------------------------------------------------------------------------- /tasks/fixture/fixture.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package fixture 4 | 5 | import ( 6 | "testing" 7 | 8 | testcontainers "github.com/testcontainers/testcontainers-go" 9 | 10 | connectorsfixture "github.com/ice-blockchain/wintr/connectors/fixture" 11 | messagebrokerfixture "github.com/ice-blockchain/wintr/connectors/message_broker/fixture" 12 | storagefixture "github.com/ice-blockchain/wintr/connectors/storage/fixture" 13 | ) 14 | 15 | func StartLocalTestEnvironment(tp StartLocalTestEnvironmentType) { 16 | var connectors []connectorsfixture.TestConnector 17 | switch tp { 18 | case DB: 19 | connectors = append(connectors, newDBConnector()) 20 | case MB: 21 | connectors = append(connectors, newMBConnector()) 22 | case All: 23 | connectors = WTestConnectors() 24 | default: 25 | connectors = WTestConnectors() 26 | } 27 | connectorsfixture. 28 | NewTestRunner(applicationYAMLKey, nil, connectors...). 29 | StartConnectorsIndefinitely() 30 | } 31 | 32 | //nolint:gocritic // Because that's exactly what we want. 33 | func RunTests( 34 | m *testing.M, 35 | dbConnector *storagefixture.TestConnector, 36 | mbConnector *messagebrokerfixture.TestConnector, 37 | lifeCycleHooks ...*connectorsfixture.ConnectorLifecycleHooks, 38 | ) { 39 | *dbConnector = newDBConnector() 40 | *mbConnector = newMBConnector() 41 | 42 | var connectorLifecycleHooks *connectorsfixture.ConnectorLifecycleHooks 43 | if len(lifeCycleHooks) == 1 { 44 | connectorLifecycleHooks = lifeCycleHooks[0] 45 | } 46 | 47 | connectorsfixture. 48 | NewTestRunner(applicationYAMLKey, connectorLifecycleHooks, *dbConnector, *mbConnector). 49 | RunTests(m) 50 | } 51 | 52 | func WTestConnectors() []connectorsfixture.TestConnector { 53 | return []connectorsfixture.TestConnector{newDBConnector(), newMBConnector()} 54 | } 55 | 56 | func RTestConnectors() []connectorsfixture.TestConnector { 57 | return []connectorsfixture.TestConnector{newDBConnector()} 58 | } 59 | 60 | func newDBConnector() storagefixture.TestConnector { 61 | return storagefixture.NewTestConnector(applicationYAMLKey, TestConnectorsOrder) 62 | } 63 | 64 | func newMBConnector() messagebrokerfixture.TestConnector { 65 | return messagebrokerfixture.NewTestConnector(applicationYAMLKey, TestConnectorsOrder) 66 | } 67 | 68 | func RContainerMounts() []func(projectRoot string) testcontainers.ContainerMount { 69 | return nil 70 | } 71 | 72 | func WContainerMounts() []func(projectRoot string) testcontainers.ContainerMount { 73 | return nil 74 | } 75 | -------------------------------------------------------------------------------- /tasks/seeding/seeding.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | //go:build !test 4 | 5 | package seeding 6 | 7 | import ( 8 | "github.com/ice-blockchain/wintr/log" 9 | ) 10 | 11 | func StartSeeding() { 12 | log.Info("TODO: implement seeding") 13 | } 14 | -------------------------------------------------------------------------------- /tasks/tasks.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: ice License 1.0 2 | 3 | package tasks 4 | 5 | import ( 6 | "bytes" 7 | "context" 8 | "fmt" 9 | "slices" 10 | "strings" 11 | "sync" 12 | "text/template" 13 | 14 | "github.com/goccy/go-json" 15 | "github.com/hashicorp/go-multierror" 16 | "github.com/pkg/errors" 17 | 18 | "github.com/ice-blockchain/eskimo/users" 19 | appcfg "github.com/ice-blockchain/wintr/config" 20 | messagebroker "github.com/ice-blockchain/wintr/connectors/message_broker" 21 | storage "github.com/ice-blockchain/wintr/connectors/storage/v2" 22 | "github.com/ice-blockchain/wintr/log" 23 | "github.com/ice-blockchain/wintr/time" 24 | ) 25 | 26 | func New(ctx context.Context, _ context.CancelFunc) Repository { 27 | var cfg config 28 | appcfg.MustLoadFromKey(applicationYamlKey, &cfg) 29 | 30 | db := storage.MustConnect(ctx, ddl, applicationYamlKey) 31 | 32 | return &repository{ 33 | cfg: &cfg, 34 | shutdown: db.Close, 35 | db: db, 36 | } 37 | } 38 | 39 | func StartProcessor(ctx context.Context, cancel context.CancelFunc) Processor { 40 | var cfg config 41 | appcfg.MustLoadFromKey(applicationYamlKey, &cfg) 42 | 43 | var mbConsumer messagebroker.Client 44 | prc := &processor{repository: &repository{ 45 | cfg: &cfg, 46 | db: storage.MustConnect(ctx, ddl, applicationYamlKey), 47 | mb: messagebroker.MustConnect(ctx, applicationYamlKey), 48 | }} 49 | //nolint:contextcheck // It's intended. Cuz we want to close everything gracefully. 50 | mbConsumer = messagebroker.MustConnectAndStartConsuming(context.Background(), cancel, applicationYamlKey, 51 | &tryCompleteTasksCommandSource{processor: prc}, 52 | &userTableSource{processor: prc}, 53 | &miningSessionSource{processor: prc}, 54 | &friendsInvitedSource{processor: prc}, 55 | ) 56 | prc.shutdown = closeAll(mbConsumer, prc.mb, prc.db) 57 | prc.repository.loadTaskTranslationTemplates(cfg.TenantName) 58 | 59 | return prc 60 | } 61 | 62 | func (r *repository) Close() error { 63 | return errors.Wrap(r.shutdown(), "closing repository failed") 64 | } 65 | 66 | func closeAll(mbConsumer, mbProducer messagebroker.Client, db *storage.DB, otherClosers ...func() error) func() error { 67 | return func() error { 68 | err1 := errors.Wrap(mbConsumer.Close(), "closing message broker consumer connection failed") 69 | err2 := errors.Wrap(db.Close(), "closing db connection failed") 70 | err3 := errors.Wrap(mbProducer.Close(), "closing message broker producer connection failed") 71 | errs := make([]error, 0, 1+1+1+len(otherClosers)) 72 | errs = append(errs, err1, err2, err3) 73 | for _, closeOther := range otherClosers { 74 | if err := closeOther(); err != nil { 75 | errs = append(errs, err) 76 | } 77 | } 78 | 79 | return errors.Wrap(multierror.Append(nil, errs...).ErrorOrNil(), "failed to close resources") 80 | } 81 | } 82 | 83 | func (p *processor) CheckHealth(ctx context.Context) error { 84 | if err := p.db.Ping(ctx); err != nil { 85 | return errors.Wrap(err, "[health-check] failed to ping DB") 86 | } 87 | type ts struct { 88 | TS *time.Time `json:"ts"` 89 | } 90 | now := ts{TS: time.Now()} 91 | val, err := json.MarshalContext(ctx, now) 92 | if err != nil { 93 | return errors.Wrapf(err, "[health-check] failed to marshal %#v", now) 94 | } 95 | responder := make(chan error, 1) 96 | p.mb.SendMessage(ctx, &messagebroker.Message{ 97 | Headers: map[string]string{"producer": "santa"}, 98 | Key: p.cfg.MessageBroker.Topics[0].Name, 99 | Topic: p.cfg.MessageBroker.Topics[0].Name, 100 | Value: val, 101 | }, responder) 102 | 103 | return errors.Wrapf(<-responder, "[health-check] failed to send health check message to broker") 104 | } 105 | 106 | func runConcurrently[ARG any](ctx context.Context, run func(context.Context, ARG) error, args []ARG) error { 107 | if ctx.Err() != nil { 108 | return errors.Wrap(ctx.Err(), "unexpected deadline") 109 | } 110 | if len(args) == 0 { 111 | return nil 112 | } 113 | wg := new(sync.WaitGroup) 114 | wg.Add(len(args)) 115 | errChan := make(chan error, len(args)) 116 | for i := range args { 117 | go func(ix int) { 118 | defer wg.Done() 119 | errChan <- errors.Wrapf(run(ctx, args[ix]), "failed to run:%#v", args[ix]) 120 | }(i) 121 | } 122 | wg.Wait() 123 | close(errChan) 124 | errs := make([]error, 0, len(args)) 125 | for err := range errChan { 126 | errs = append(errs, err) 127 | } 128 | 129 | return errors.Wrap(multierror.Append(nil, errs...).ErrorOrNil(), "at least one execution failed") 130 | } 131 | 132 | func AreTasksCompleted(actual *users.Enum[Type], expectedSubset ...Type) bool { 133 | if len(expectedSubset) == 0 { 134 | return actual == nil || len(*actual) == 0 135 | } 136 | if (actual == nil || len(*actual) == 0) && len(expectedSubset) > 0 { 137 | return false 138 | } 139 | for _, expectedType := range expectedSubset { 140 | var completed bool 141 | for _, completedType := range *actual { 142 | if completedType == expectedType { 143 | completed = true 144 | 145 | break 146 | } 147 | } 148 | if !completed { 149 | return false 150 | } 151 | } 152 | 153 | return true 154 | } 155 | 156 | //nolint:funlen // . 157 | func (r *repository) loadTaskTranslationTemplates(tenantName string) { 158 | const totalLanguages = 50 159 | allTaskTemplates = make(map[Type]map[languageCode]*taskTemplate, len(r.cfg.TasksList)) 160 | for ix := range r.cfg.TasksList { 161 | var fileName string 162 | switch r.cfg.TasksList[ix].Group { 163 | case TaskGroupBadgeSocial, TaskGroupBadgeCoin, TaskGroupBadgeLevel, TaskGroupLevel, TaskGroupInviteFriends, TaskGroupMiningStreak: 164 | fileName = r.cfg.TasksList[ix].Group 165 | default: 166 | fileName = r.cfg.TasksList[ix].Type 167 | } 168 | content, fErr := translations.ReadFile(fmt.Sprintf("translations/%v/%v.json", strings.ToLower(tenantName), fileName)) 169 | log.Panic(fErr) //nolint:revive // Wrong. 170 | allTaskTemplates[Type(r.cfg.TasksList[ix].Type)] = make(map[languageCode]*taskTemplate, totalLanguages) 171 | var languageData map[string]*struct { 172 | Title string `json:"title"` 173 | ShortDescription string `json:"shortDescription"` 174 | LongDescription string `json:"longDescription"` 175 | ErrorDescription string `json:"errorDescription"` 176 | } 177 | log.Panic(json.Unmarshal(content, &languageData)) 178 | for language, data := range languageData { 179 | var tmpl taskTemplate 180 | tmpl.ShortDescription = data.ShortDescription 181 | tmpl.LongDescription = data.LongDescription 182 | tmpl.ErrorDescription = data.ErrorDescription 183 | tmpl.Title = data.Title 184 | tmpl.title = template.Must(template.New(fmt.Sprintf("task_%v_%v_title", r.cfg.TasksList[ix].Type, language)).Parse(data.Title)) 185 | tmpl.shortDescription = template.Must(template.New(fmt.Sprintf("task_%v_%v_short_description", r.cfg.TasksList[ix].Type, language)).Parse(data.ShortDescription)) //nolint:lll // . 186 | tmpl.longDescription = template.Must(template.New(fmt.Sprintf("task_%v_%v_long_description", r.cfg.TasksList[ix].Type, language)).Parse(data.LongDescription)) //nolint:lll // . 187 | tmpl.errorDescription = template.Must(template.New(fmt.Sprintf("task_%v_%v_error_description", r.cfg.TasksList[ix].Type, language)).Parse(data.ErrorDescription)) //nolint:lll // . 188 | 189 | allTaskTemplates[Type(r.cfg.TasksList[ix].Type)][language] = &tmpl 190 | } 191 | } 192 | } 193 | 194 | func (t *taskTemplate) getTitle(data any) string { 195 | if data == nil { 196 | return t.Title 197 | } 198 | bf := new(bytes.Buffer) 199 | log.Panic(errors.Wrapf(t.title.Execute(bf, data), "failed to execute title template for data:%#v", data)) 200 | 201 | return bf.String() 202 | } 203 | 204 | func (t *taskTemplate) getShortDescription(data any) string { 205 | if data == nil { 206 | return t.ShortDescription 207 | } 208 | bf := new(bytes.Buffer) 209 | log.Panic(errors.Wrapf(t.shortDescription.Execute(bf, data), "failed to execute short description template for data:%#v", data)) 210 | 211 | return bf.String() 212 | } 213 | 214 | func (t *taskTemplate) getLongDescription(data any) string { 215 | if data == nil { 216 | return t.LongDescription 217 | } 218 | bf := new(bytes.Buffer) 219 | log.Panic(errors.Wrapf(t.longDescription.Execute(bf, data), "failed to execute long description template for data:%#v", data)) 220 | 221 | return bf.String() 222 | } 223 | 224 | func (t *taskTemplate) getErrorDescription(data any) string { 225 | if data == nil { 226 | return t.ErrorDescription 227 | } 228 | bf := new(bytes.Buffer) 229 | log.Panic(errors.Wrapf(t.errorDescription.Execute(bf, data), "failed to execute error description template for data:%#v", data)) 230 | 231 | return bf.String() 232 | } 233 | 234 | func (r *repository) tasksV2Enabled(userID string) bool { 235 | return r.cfg.TasksV2Enabled || (len(r.cfg.AdminUsers) > 0 && slices.Contains(r.cfg.AdminUsers, userID)) 236 | } 237 | -------------------------------------------------------------------------------- /tasks/translations/callfluent/join_goats.json: -------------------------------------------------------------------------------- 1 | { 2 | "af": { 3 | "title": "Sluit aan by GOATS", 4 | "shortDescription": "Die grootste van almal.", 5 | "longDescription": "Het jy 'n paar degen-maats? Bring hulle saam!", 6 | "errorDescription": "Verifikasie het misluk. Maak asseblief seker dat jy by GOATS aangesluit het en probeer weer." 7 | }, 8 | "ar": { 9 | "title": "انضم إلى GOATS", 10 | "shortDescription": "الأعظم على الإطلاق.", 11 | "longDescription": "لديك بعض الأصدقاء الجنونيين؟ اجلبهم معك!", 12 | "errorDescription": "فشل التحقق. يرجى التأكد من أنك انضممت إلى GOATS وحاول مرة أخرى." 13 | }, 14 | "az": { 15 | "title": "GOATS-a qoşulun", 16 | "shortDescription": "Hamının ən böyüyü.", 17 | "longDescription": "Degen dostlarınız varmı? Onları da gətirin!", 18 | "errorDescription": "Təsdiqləmə uğursuz oldu. Zəhmət olmasa GOATS-a qoşulduğunuzdan əmin olun və yenidən cəhd edin." 19 | }, 20 | "bg": { 21 | "title": "Присъединете се към GOATS", 22 | "shortDescription": "Най-великият от всички.", 23 | "longDescription": "Имате ли някои приятели, които обичат риска? Доведете ги!", 24 | "errorDescription": "Проверката не бе успешна. Моля, уверете се, че сте се присъединили към GOATS и опитайте отново." 25 | }, 26 | "bn": { 27 | "title": "GOATS-এ যোগ দিন", 28 | "shortDescription": "সবার সেরা।", 29 | "longDescription": "কিছু সাহসী বন্ধু আছে? তাদের নিয়ে আসুন!", 30 | "errorDescription": "যাচাইকরণ ব্যর্থ হয়েছে। অনুগ্রহ করে নিশ্চিত করুন যে আপনি GOATS-এ যোগ দিয়েছেন এবং আবার চেষ্টা করুন।" 31 | }, 32 | "cs": { 33 | "title": "Připojte se k GOATS", 34 | "shortDescription": "Největší ze všech.", 35 | "longDescription": "Máte nějaké odvážné kamarády? Přiveďte je!", 36 | "errorDescription": "Ověření selhalo. Ujistěte se, že jste se připojili k GOATS, a zkuste to znovu." 37 | }, 38 | "de": { 39 | "title": "Tritt GOATS bei", 40 | "shortDescription": "Das Größte von allem.", 41 | "longDescription": "Hast du ein paar wagemutige Freunde? Bring sie mit!", 42 | "errorDescription": "Verifizierung fehlgeschlagen. Bitte stelle sicher, dass du GOATS beigetreten bist, und versuche es erneut." 43 | }, 44 | "el": { 45 | "title": "Γίνετε μέλος του GOATS", 46 | "shortDescription": "Το μεγαλύτερο όλων.", 47 | "longDescription": "Έχετε μερικούς τολμηρούς φίλους; Φέρτε τους μαζί!", 48 | "errorDescription": "Η επαλήθευση απέτυχε. Βεβαιωθείτε ότι έχετε εγγραφεί στο GOATS και δοκιμάστε ξανά." 49 | }, 50 | "en": { 51 | "title": "Join GOATS", 52 | "shortDescription": "The greatest of all.", 53 | "longDescription": "Got some degen buddies? Bring ’em in!", 54 | "errorDescription": "Verification failed. Please ensure you joined GOATS and try again." 55 | }, 56 | "es": { 57 | "title": "Únete a GOATS", 58 | "shortDescription": "El más grande de todos.", 59 | "longDescription": "¿Tienes algunos amigos atrevidos? ¡Tráelos!", 60 | "errorDescription": "La verificación falló. Por favor, asegúrate de haberte unido a GOATS e inténtalo de nuevo." 61 | }, 62 | "fa": { 63 | "title": "به GOATS بپیوندید", 64 | "shortDescription": "بزرگ‌ترین از همه.", 65 | "longDescription": "چند دوست دیوانه دارید؟ آن‌ها را بیاورید!", 66 | "errorDescription": "اعتبارسنجی ناموفق بود. لطفاً مطمئن شوید که به GOATS پیوسته‌اید و دوباره تلاش کنید." 67 | }, 68 | "fr": { 69 | "title": "Rejoignez GOATS", 70 | "shortDescription": "Le plus grand de tous.", 71 | "longDescription": "Vous avez des amis audacieux ? Amenez-les avec vous !", 72 | "errorDescription": "Échec de la vérification. Veuillez vous assurer que vous avez rejoint GOATS et réessayez." 73 | }, 74 | "gu": { 75 | "title": "GOATS માં જોડાઓ", 76 | "shortDescription": "તમામમાં શ્રેષ્ઠ.", 77 | "longDescription": "તમે કેટલાક સાહસિક મિત્રો રાખો છો? તેમને લાવી દો!", 78 | "errorDescription": "ચકાસણી નિષ્ફળ. કૃપા કરીને ખાતરી કરો કે તમે GOATS માં જોડાયા છો અને ફરી પ્રયાસ કરો." 79 | }, 80 | "he": { 81 | "title": "הצטרף ל-GOATS", 82 | "shortDescription": "הגדול מכולם.", 83 | "longDescription": "יש לך כמה חברים משוגעים? תביא אותם!", 84 | "errorDescription": "האימות נכשל. אנא ודא שהצטרפת ל-GOATS ונסה שוב." 85 | }, 86 | "hi": { 87 | "title": "GOATS से जुड़ें", 88 | "shortDescription": "सबसे महान।", 89 | "longDescription": "क्या आपके पास कुछ निडर दोस्त हैं? उन्हें ले आओ!", 90 | "errorDescription": "सत्यापन विफल। कृपया सुनिश्चित करें कि आप GOATS से जुड़े हैं और फिर से प्रयास करें।" 91 | }, 92 | "hu": { 93 | "title": "Csatlakozz a GOATS-hoz", 94 | "shortDescription": "A legnagyobb mind közül.", 95 | "longDescription": "Vannak degen haverjaid? Hozd őket is!", 96 | "errorDescription": "Az ellenőrzés nem sikerült. Győződj meg róla, hogy csatlakoztál a GOATS-hoz, és próbáld újra." 97 | }, 98 | "id": { 99 | "title": "Bergabunglah dengan GOATS", 100 | "shortDescription": "Yang terbaik dari semuanya.", 101 | "longDescription": "Punya teman degen? Ajak mereka masuk!", 102 | "errorDescription": "Verifikasi gagal. Pastikan Anda bergabung dengan GOATS dan coba lagi." 103 | }, 104 | "it": { 105 | "title": "Unisciti a GOATS", 106 | "shortDescription": "Il più grande di tutti.", 107 | "longDescription": "Hai degli amici audaci? Portali!", 108 | "errorDescription": "Verifica fallita. Assicurati di esserti unito a GOATS e riprova." 109 | }, 110 | "ja": { 111 | "title": "GOATSに参加", 112 | "shortDescription": "史上最高。", 113 | "longDescription": "大胆な友達がいる? 連れてきて!", 114 | "errorDescription": "確認に失敗しました。GOATSに参加したことを確認して、もう一度お試しください。" 115 | }, 116 | "jv": { 117 | "title": "Gabung GOATS", 118 | "shortDescription": "Paling gedhe saka kabeh.", 119 | "longDescription": "Duwe kanca degen? Gawa menyang kene!", 120 | "errorDescription": "Verifikasi gagal. Mangga priksa manawa sampeyan wis gabung GOATS lan coba maneh." 121 | }, 122 | "kn": { 123 | "title": "GOATS ಗೆ ಸೇರಿ", 124 | "shortDescription": "ಎಲ್ಲರಲ್ಲಿಯೂ ಶ್ರೇಷ್ಠ.", 125 | "longDescription": "ನೀವು ಕೆಲವು ಸಾಹಸ ದೋಸ್ತರನ್ನು ಹೊಂದಿದ್ದೀರಾ? ಅವರನ್ನು ತೆಗೆದುಕೊಂಡು ಬನ್ನಿ!", 126 | "errorDescription": "ಪರಿಶೀಲನೆ ವಿಫಲವಾಗಿದೆ. ದಯವಿಟ್ಟು ನೀವು GOATS ಗೆ ಸೇರಿದ್ದೀರೆಂದು ಖಚಿತಪಡಿಸಿ ಮತ್ತೊಮ್ಮೆ ಪ್ರಯತ್ನಿಸಿ." 127 | }, 128 | "ko": { 129 | "title": "GOATS에 가입하세요", 130 | "shortDescription": "모든 것 중 최고.", 131 | "longDescription": "대담한 친구가 있습니까? 그들을 데려오세요!", 132 | "errorDescription": "인증에 실패했습니다. GOATS에 가입했는지 확인하고 다시 시도하세요." 133 | }, 134 | "mr": { 135 | "title": "GOATS मध्ये सामील व्हा", 136 | "shortDescription": "सर्वात महान.", 137 | "longDescription": "तुमच्या काही धाडसी मित्रांना आणा!", 138 | "errorDescription": "सत्यापन अयशस्वी झाले. कृपया आपण GOATS मध्ये सामील झालात का ते सुनिश्चित करा आणि पुन्हा प्रयत्न करा." 139 | }, 140 | "ms": { 141 | "title": "Sertai GOATS", 142 | "shortDescription": "Yang terbaik dari semuanya.", 143 | "longDescription": "Ada kawan berani? Bawa mereka masuk!", 144 | "errorDescription": "Pengesahan gagal. Pastikan anda menyertai GOATS dan cuba lagi." 145 | }, 146 | "nb": { 147 | "title": "Bli med i GOATS", 148 | "shortDescription": "Den største av alle.", 149 | "longDescription": "Har du noen vågale venner? Ta dem med!", 150 | "errorDescription": "Verifiseringen mislyktes. Vennligst sørg for at du har blitt med i GOATS, og prøv igjen." 151 | }, 152 | "pa": { 153 | "title": "GOATS ਵਿੱਚ ਸ਼ਾਮਲ ਹੋਵੋ", 154 | "shortDescription": "ਸਭ ਤੋਂ ਵੱਡਾ।", 155 | "longDescription": "ਕੀ ਤੁਹਾਡੇ ਕੋਲ ਕੁਝ ਸਾਹਸੀ ਦੋਸਤ ਹਨ? ਉਨ੍ਹਾਂ ਨੂੰ ਲਿਆਓ!", 156 | "errorDescription": "ਪ੍ਰਮਾਣਕਰਨ ਫੇਲ੍ਹ ਹੋ ਗਿਆ। ਕਿਰਪਾ ਕਰਕੇ ਯਕੀਨੀ ਬਣਾਓ ਕਿ ਤੁਸੀਂ GOATS ਵਿੱਚ ਸ਼ਾਮਲ ਹੋਏ ਹੋ ਅਤੇ ਦੁਬਾਰਾ ਕੋਸ਼ਿਸ਼ ਕਰੋ।" 157 | }, 158 | "pl": { 159 | "title": "Dołącz do GOATS", 160 | "shortDescription": "Największy ze wszystkich.", 161 | "longDescription": "Masz odważnych znajomych? Przyprowadź ich!", 162 | "errorDescription": "Weryfikacja nie powiodła się. Upewnij się, że dołączyłeś do GOATS i spróbuj ponownie." 163 | }, 164 | "pt": { 165 | "title": "Junte-se ao GOATS", 166 | "shortDescription": "O maior de todos.", 167 | "longDescription": "Tem amigos audaciosos? Traga-os!", 168 | "errorDescription": "Falha na verificação. Verifique se você se juntou ao GOATS e tente novamente." 169 | }, 170 | "ro": { 171 | "title": "Alătură-te GOATS", 172 | "shortDescription": "Cel mai mare dintre toți.", 173 | "longDescription": "Ai prieteni curajoși? Adu-i cu tine!", 174 | "errorDescription": "Verificarea a eșuat. Asigură-te că te-ai alăturat GOATS și încearcă din nou." 175 | }, 176 | "ru": { 177 | "title": "Присоединяйтесь к GOATS", 178 | "shortDescription": "Самый великий из всех.", 179 | "longDescription": "Есть друзья-дегенераты? Приводите их!", 180 | "errorDescription": "Не удалось выполнить проверку. Убедитесь, что вы присоединились к GOATS, и попробуйте снова." 181 | }, 182 | "sl": { 183 | "title": "Pridružite se GOATS", 184 | "shortDescription": "Največji od vseh.", 185 | "longDescription": "Imate drzne prijatelje? Pripeljite jih!", 186 | "errorDescription": "Preverjanje ni uspelo. Prepričajte se, da ste se pridružili GOATS, in poskusite znova." 187 | }, 188 | "tr": { 189 | "title": "GOATS'a Katıl", 190 | "shortDescription": "Hepsinden en büyüğü.", 191 | "longDescription": "Bazı deli arkadaşların var mı? Onları getir!", 192 | "errorDescription": "Doğrulama başarısız oldu. Lütfen GOATS'a katıldığınızdan emin olun ve tekrar deneyin." 193 | }, 194 | "vi": { 195 | "title": "Tham gia GOATS", 196 | "shortDescription": "Vĩ đại nhất trong tất cả.", 197 | "longDescription": "Có bạn bè dám mạo hiểm? Đưa họ vào!", 198 | "errorDescription": "Xác minh không thành công. Vui lòng đảm bảo rằng bạn đã tham gia GOATS và thử lại." 199 | }, 200 | "zh": { 201 | "title": "加入 GOATS", 202 | "shortDescription": "最伟大的。", 203 | "longDescription": "有一些勇敢的朋友?带他们来吧!", 204 | "errorDescription": "验证失败。请确保您已加入 GOATS,然后重试。" 205 | } 206 | } 207 | -------------------------------------------------------------------------------- /tasks/translations/doctorx/join_goats.json: -------------------------------------------------------------------------------- 1 | { 2 | "af": { 3 | "title": "Sluit aan by GOATS", 4 | "shortDescription": "Die grootste van almal.", 5 | "longDescription": "Het jy 'n paar degen-maats? Bring hulle saam!", 6 | "errorDescription": "Verifikasie het misluk. Maak asseblief seker dat jy by GOATS aangesluit het en probeer weer." 7 | }, 8 | "ar": { 9 | "title": "انضم إلى GOATS", 10 | "shortDescription": "الأعظم على الإطلاق.", 11 | "longDescription": "لديك بعض الأصدقاء الجنونيين؟ اجلبهم معك!", 12 | "errorDescription": "فشل التحقق. يرجى التأكد من أنك انضممت إلى GOATS وحاول مرة أخرى." 13 | }, 14 | "az": { 15 | "title": "GOATS-a qoşulun", 16 | "shortDescription": "Hamının ən böyüyü.", 17 | "longDescription": "Degen dostlarınız varmı? Onları da gətirin!", 18 | "errorDescription": "Təsdiqləmə uğursuz oldu. Zəhmət olmasa GOATS-a qoşulduğunuzdan əmin olun və yenidən cəhd edin." 19 | }, 20 | "bg": { 21 | "title": "Присъединете се към GOATS", 22 | "shortDescription": "Най-великият от всички.", 23 | "longDescription": "Имате ли някои приятели, които обичат риска? Доведете ги!", 24 | "errorDescription": "Проверката не бе успешна. Моля, уверете се, че сте се присъединили към GOATS и опитайте отново." 25 | }, 26 | "bn": { 27 | "title": "GOATS-এ যোগ দিন", 28 | "shortDescription": "সবার সেরা।", 29 | "longDescription": "কিছু সাহসী বন্ধু আছে? তাদের নিয়ে আসুন!", 30 | "errorDescription": "যাচাইকরণ ব্যর্থ হয়েছে। অনুগ্রহ করে নিশ্চিত করুন যে আপনি GOATS-এ যোগ দিয়েছেন এবং আবার চেষ্টা করুন।" 31 | }, 32 | "cs": { 33 | "title": "Připojte se k GOATS", 34 | "shortDescription": "Největší ze všech.", 35 | "longDescription": "Máte nějaké odvážné kamarády? Přiveďte je!", 36 | "errorDescription": "Ověření selhalo. Ujistěte se, že jste se připojili k GOATS, a zkuste to znovu." 37 | }, 38 | "de": { 39 | "title": "Tritt GOATS bei", 40 | "shortDescription": "Das Größte von allem.", 41 | "longDescription": "Hast du ein paar wagemutige Freunde? Bring sie mit!", 42 | "errorDescription": "Verifizierung fehlgeschlagen. Bitte stelle sicher, dass du GOATS beigetreten bist, und versuche es erneut." 43 | }, 44 | "el": { 45 | "title": "Γίνετε μέλος του GOATS", 46 | "shortDescription": "Το μεγαλύτερο όλων.", 47 | "longDescription": "Έχετε μερικούς τολμηρούς φίλους; Φέρτε τους μαζί!", 48 | "errorDescription": "Η επαλήθευση απέτυχε. Βεβαιωθείτε ότι έχετε εγγραφεί στο GOATS και δοκιμάστε ξανά." 49 | }, 50 | "en": { 51 | "title": "Join GOATS", 52 | "shortDescription": "The greatest of all.", 53 | "longDescription": "Got some degen buddies? Bring ’em in!", 54 | "errorDescription": "Verification failed. Please ensure you joined GOATS and try again." 55 | }, 56 | "es": { 57 | "title": "Únete a GOATS", 58 | "shortDescription": "El más grande de todos.", 59 | "longDescription": "¿Tienes algunos amigos atrevidos? ¡Tráelos!", 60 | "errorDescription": "La verificación falló. Por favor, asegúrate de haberte unido a GOATS e inténtalo de nuevo." 61 | }, 62 | "fa": { 63 | "title": "به GOATS بپیوندید", 64 | "shortDescription": "بزرگ‌ترین از همه.", 65 | "longDescription": "چند دوست دیوانه دارید؟ آن‌ها را بیاورید!", 66 | "errorDescription": "اعتبارسنجی ناموفق بود. لطفاً مطمئن شوید که به GOATS پیوسته‌اید و دوباره تلاش کنید." 67 | }, 68 | "fr": { 69 | "title": "Rejoignez GOATS", 70 | "shortDescription": "Le plus grand de tous.", 71 | "longDescription": "Vous avez des amis audacieux ? Amenez-les avec vous !", 72 | "errorDescription": "Échec de la vérification. Veuillez vous assurer que vous avez rejoint GOATS et réessayez." 73 | }, 74 | "gu": { 75 | "title": "GOATS માં જોડાઓ", 76 | "shortDescription": "તમામમાં શ્રેષ્ઠ.", 77 | "longDescription": "તમે કેટલાક સાહસિક મિત્રો રાખો છો? તેમને લાવી દો!", 78 | "errorDescription": "ચકાસણી નિષ્ફળ. કૃપા કરીને ખાતરી કરો કે તમે GOATS માં જોડાયા છો અને ફરી પ્રયાસ કરો." 79 | }, 80 | "he": { 81 | "title": "הצטרף ל-GOATS", 82 | "shortDescription": "הגדול מכולם.", 83 | "longDescription": "יש לך כמה חברים משוגעים? תביא אותם!", 84 | "errorDescription": "האימות נכשל. אנא ודא שהצטרפת ל-GOATS ונסה שוב." 85 | }, 86 | "hi": { 87 | "title": "GOATS से जुड़ें", 88 | "shortDescription": "सबसे महान।", 89 | "longDescription": "क्या आपके पास कुछ निडर दोस्त हैं? उन्हें ले आओ!", 90 | "errorDescription": "सत्यापन विफल। कृपया सुनिश्चित करें कि आप GOATS से जुड़े हैं और फिर से प्रयास करें।" 91 | }, 92 | "hu": { 93 | "title": "Csatlakozz a GOATS-hoz", 94 | "shortDescription": "A legnagyobb mind közül.", 95 | "longDescription": "Vannak degen haverjaid? Hozd őket is!", 96 | "errorDescription": "Az ellenőrzés nem sikerült. Győződj meg róla, hogy csatlakoztál a GOATS-hoz, és próbáld újra." 97 | }, 98 | "id": { 99 | "title": "Bergabunglah dengan GOATS", 100 | "shortDescription": "Yang terbaik dari semuanya.", 101 | "longDescription": "Punya teman degen? Ajak mereka masuk!", 102 | "errorDescription": "Verifikasi gagal. Pastikan Anda bergabung dengan GOATS dan coba lagi." 103 | }, 104 | "it": { 105 | "title": "Unisciti a GOATS", 106 | "shortDescription": "Il più grande di tutti.", 107 | "longDescription": "Hai degli amici audaci? Portali!", 108 | "errorDescription": "Verifica fallita. Assicurati di esserti unito a GOATS e riprova." 109 | }, 110 | "ja": { 111 | "title": "GOATSに参加", 112 | "shortDescription": "史上最高。", 113 | "longDescription": "大胆な友達がいる? 連れてきて!", 114 | "errorDescription": "確認に失敗しました。GOATSに参加したことを確認して、もう一度お試しください。" 115 | }, 116 | "jv": { 117 | "title": "Gabung GOATS", 118 | "shortDescription": "Paling gedhe saka kabeh.", 119 | "longDescription": "Duwe kanca degen? Gawa menyang kene!", 120 | "errorDescription": "Verifikasi gagal. Mangga priksa manawa sampeyan wis gabung GOATS lan coba maneh." 121 | }, 122 | "kn": { 123 | "title": "GOATS ಗೆ ಸೇರಿ", 124 | "shortDescription": "ಎಲ್ಲರಲ್ಲಿಯೂ ಶ್ರೇಷ್ಠ.", 125 | "longDescription": "ನೀವು ಕೆಲವು ಸಾಹಸ ದೋಸ್ತರನ್ನು ಹೊಂದಿದ್ದೀರಾ? ಅವರನ್ನು ತೆಗೆದುಕೊಂಡು ಬನ್ನಿ!", 126 | "errorDescription": "ಪರಿಶೀಲನೆ ವಿಫಲವಾಗಿದೆ. ದಯವಿಟ್ಟು ನೀವು GOATS ಗೆ ಸೇರಿದ್ದೀರೆಂದು ಖಚಿತಪಡಿಸಿ ಮತ್ತೊಮ್ಮೆ ಪ್ರಯತ್ನಿಸಿ." 127 | }, 128 | "ko": { 129 | "title": "GOATS에 가입하세요", 130 | "shortDescription": "모든 것 중 최고.", 131 | "longDescription": "대담한 친구가 있습니까? 그들을 데려오세요!", 132 | "errorDescription": "인증에 실패했습니다. GOATS에 가입했는지 확인하고 다시 시도하세요." 133 | }, 134 | "mr": { 135 | "title": "GOATS मध्ये सामील व्हा", 136 | "shortDescription": "सर्वात महान.", 137 | "longDescription": "तुमच्या काही धाडसी मित्रांना आणा!", 138 | "errorDescription": "सत्यापन अयशस्वी झाले. कृपया आपण GOATS मध्ये सामील झालात का ते सुनिश्चित करा आणि पुन्हा प्रयत्न करा." 139 | }, 140 | "ms": { 141 | "title": "Sertai GOATS", 142 | "shortDescription": "Yang terbaik dari semuanya.", 143 | "longDescription": "Ada kawan berani? Bawa mereka masuk!", 144 | "errorDescription": "Pengesahan gagal. Pastikan anda menyertai GOATS dan cuba lagi." 145 | }, 146 | "nb": { 147 | "title": "Bli med i GOATS", 148 | "shortDescription": "Den største av alle.", 149 | "longDescription": "Har du noen vågale venner? Ta dem med!", 150 | "errorDescription": "Verifiseringen mislyktes. Vennligst sørg for at du har blitt med i GOATS, og prøv igjen." 151 | }, 152 | "pa": { 153 | "title": "GOATS ਵਿੱਚ ਸ਼ਾਮਲ ਹੋਵੋ", 154 | "shortDescription": "ਸਭ ਤੋਂ ਵੱਡਾ।", 155 | "longDescription": "ਕੀ ਤੁਹਾਡੇ ਕੋਲ ਕੁਝ ਸਾਹਸੀ ਦੋਸਤ ਹਨ? ਉਨ੍ਹਾਂ ਨੂੰ ਲਿਆਓ!", 156 | "errorDescription": "ਪ੍ਰਮਾਣਕਰਨ ਫੇਲ੍ਹ ਹੋ ਗਿਆ। ਕਿਰਪਾ ਕਰਕੇ ਯਕੀਨੀ ਬਣਾਓ ਕਿ ਤੁਸੀਂ GOATS ਵਿੱਚ ਸ਼ਾਮਲ ਹੋਏ ਹੋ ਅਤੇ ਦੁਬਾਰਾ ਕੋਸ਼ਿਸ਼ ਕਰੋ।" 157 | }, 158 | "pl": { 159 | "title": "Dołącz do GOATS", 160 | "shortDescription": "Największy ze wszystkich.", 161 | "longDescription": "Masz odważnych znajomych? Przyprowadź ich!", 162 | "errorDescription": "Weryfikacja nie powiodła się. Upewnij się, że dołączyłeś do GOATS i spróbuj ponownie." 163 | }, 164 | "pt": { 165 | "title": "Junte-se ao GOATS", 166 | "shortDescription": "O maior de todos.", 167 | "longDescription": "Tem amigos audaciosos? Traga-os!", 168 | "errorDescription": "Falha na verificação. Verifique se você se juntou ao GOATS e tente novamente." 169 | }, 170 | "ro": { 171 | "title": "Alătură-te GOATS", 172 | "shortDescription": "Cel mai mare dintre toți.", 173 | "longDescription": "Ai prieteni curajoși? Adu-i cu tine!", 174 | "errorDescription": "Verificarea a eșuat. Asigură-te că te-ai alăturat GOATS și încearcă din nou." 175 | }, 176 | "ru": { 177 | "title": "Присоединяйтесь к GOATS", 178 | "shortDescription": "Самый великий из всех.", 179 | "longDescription": "Есть друзья-дегенераты? Приводите их!", 180 | "errorDescription": "Не удалось выполнить проверку. Убедитесь, что вы присоединились к GOATS, и попробуйте снова." 181 | }, 182 | "sl": { 183 | "title": "Pridružite se GOATS", 184 | "shortDescription": "Največji od vseh.", 185 | "longDescription": "Imate drzne prijatelje? Pripeljite jih!", 186 | "errorDescription": "Preverjanje ni uspelo. Prepričajte se, da ste se pridružili GOATS, in poskusite znova." 187 | }, 188 | "tr": { 189 | "title": "GOATS'a Katıl", 190 | "shortDescription": "Hepsinden en büyüğü.", 191 | "longDescription": "Bazı deli arkadaşların var mı? Onları getir!", 192 | "errorDescription": "Doğrulama başarısız oldu. Lütfen GOATS'a katıldığınızdan emin olun ve tekrar deneyin." 193 | }, 194 | "vi": { 195 | "title": "Tham gia GOATS", 196 | "shortDescription": "Vĩ đại nhất trong tất cả.", 197 | "longDescription": "Có bạn bè dám mạo hiểm? Đưa họ vào!", 198 | "errorDescription": "Xác minh không thành công. Vui lòng đảm bảo rằng bạn đã tham gia GOATS và thử lại." 199 | }, 200 | "zh": { 201 | "title": "加入 GOATS", 202 | "shortDescription": "最伟大的。", 203 | "longDescription": "有一些勇敢的朋友?带他们来吧!", 204 | "errorDescription": "验证失败。请确保您已加入 GOATS,然后重试。" 205 | } 206 | } 207 | -------------------------------------------------------------------------------- /tasks/translations/sauces/join_goats.json: -------------------------------------------------------------------------------- 1 | { 2 | "af": { 3 | "title": "Sluit aan by GOATS", 4 | "shortDescription": "Die grootste van almal.", 5 | "longDescription": "Het jy 'n paar degen-maats? Bring hulle saam!", 6 | "errorDescription": "Verifikasie het misluk. Maak asseblief seker dat jy by GOATS aangesluit het en probeer weer." 7 | }, 8 | "ar": { 9 | "title": "انضم إلى GOATS", 10 | "shortDescription": "الأعظم على الإطلاق.", 11 | "longDescription": "لديك بعض الأصدقاء الجنونيين؟ اجلبهم معك!", 12 | "errorDescription": "فشل التحقق. يرجى التأكد من أنك انضممت إلى GOATS وحاول مرة أخرى." 13 | }, 14 | "az": { 15 | "title": "GOATS-a qoşulun", 16 | "shortDescription": "Hamının ən böyüyü.", 17 | "longDescription": "Degen dostlarınız varmı? Onları da gətirin!", 18 | "errorDescription": "Təsdiqləmə uğursuz oldu. Zəhmət olmasa GOATS-a qoşulduğunuzdan əmin olun və yenidən cəhd edin." 19 | }, 20 | "bg": { 21 | "title": "Присъединете се към GOATS", 22 | "shortDescription": "Най-великият от всички.", 23 | "longDescription": "Имате ли някои приятели, които обичат риска? Доведете ги!", 24 | "errorDescription": "Проверката не бе успешна. Моля, уверете се, че сте се присъединили към GOATS и опитайте отново." 25 | }, 26 | "bn": { 27 | "title": "GOATS-এ যোগ দিন", 28 | "shortDescription": "সবার সেরা।", 29 | "longDescription": "কিছু সাহসী বন্ধু আছে? তাদের নিয়ে আসুন!", 30 | "errorDescription": "যাচাইকরণ ব্যর্থ হয়েছে। অনুগ্রহ করে নিশ্চিত করুন যে আপনি GOATS-এ যোগ দিয়েছেন এবং আবার চেষ্টা করুন।" 31 | }, 32 | "cs": { 33 | "title": "Připojte se k GOATS", 34 | "shortDescription": "Největší ze všech.", 35 | "longDescription": "Máte nějaké odvážné kamarády? Přiveďte je!", 36 | "errorDescription": "Ověření selhalo. Ujistěte se, že jste se připojili k GOATS, a zkuste to znovu." 37 | }, 38 | "de": { 39 | "title": "Tritt GOATS bei", 40 | "shortDescription": "Das Größte von allem.", 41 | "longDescription": "Hast du ein paar wagemutige Freunde? Bring sie mit!", 42 | "errorDescription": "Verifizierung fehlgeschlagen. Bitte stelle sicher, dass du GOATS beigetreten bist, und versuche es erneut." 43 | }, 44 | "el": { 45 | "title": "Γίνετε μέλος του GOATS", 46 | "shortDescription": "Το μεγαλύτερο όλων.", 47 | "longDescription": "Έχετε μερικούς τολμηρούς φίλους; Φέρτε τους μαζί!", 48 | "errorDescription": "Η επαλήθευση απέτυχε. Βεβαιωθείτε ότι έχετε εγγραφεί στο GOATS και δοκιμάστε ξανά." 49 | }, 50 | "en": { 51 | "title": "Join GOATS", 52 | "shortDescription": "The greatest of all.", 53 | "longDescription": "Got some degen buddies? Bring ’em in!", 54 | "errorDescription": "Verification failed. Please ensure you joined GOATS and try again." 55 | }, 56 | "es": { 57 | "title": "Únete a GOATS", 58 | "shortDescription": "El más grande de todos.", 59 | "longDescription": "¿Tienes algunos amigos atrevidos? ¡Tráelos!", 60 | "errorDescription": "La verificación falló. Por favor, asegúrate de haberte unido a GOATS e inténtalo de nuevo." 61 | }, 62 | "fa": { 63 | "title": "به GOATS بپیوندید", 64 | "shortDescription": "بزرگ‌ترین از همه.", 65 | "longDescription": "چند دوست دیوانه دارید؟ آن‌ها را بیاورید!", 66 | "errorDescription": "اعتبارسنجی ناموفق بود. لطفاً مطمئن شوید که به GOATS پیوسته‌اید و دوباره تلاش کنید." 67 | }, 68 | "fr": { 69 | "title": "Rejoignez GOATS", 70 | "shortDescription": "Le plus grand de tous.", 71 | "longDescription": "Vous avez des amis audacieux ? Amenez-les avec vous !", 72 | "errorDescription": "Échec de la vérification. Veuillez vous assurer que vous avez rejoint GOATS et réessayez." 73 | }, 74 | "gu": { 75 | "title": "GOATS માં જોડાઓ", 76 | "shortDescription": "તમામમાં શ્રેષ્ઠ.", 77 | "longDescription": "તમે કેટલાક સાહસિક મિત્રો રાખો છો? તેમને લાવી દો!", 78 | "errorDescription": "ચકાસણી નિષ્ફળ. કૃપા કરીને ખાતરી કરો કે તમે GOATS માં જોડાયા છો અને ફરી પ્રયાસ કરો." 79 | }, 80 | "he": { 81 | "title": "הצטרף ל-GOATS", 82 | "shortDescription": "הגדול מכולם.", 83 | "longDescription": "יש לך כמה חברים משוגעים? תביא אותם!", 84 | "errorDescription": "האימות נכשל. אנא ודא שהצטרפת ל-GOATS ונסה שוב." 85 | }, 86 | "hi": { 87 | "title": "GOATS से जुड़ें", 88 | "shortDescription": "सबसे महान।", 89 | "longDescription": "क्या आपके पास कुछ निडर दोस्त हैं? उन्हें ले आओ!", 90 | "errorDescription": "सत्यापन विफल। कृपया सुनिश्चित करें कि आप GOATS से जुड़े हैं और फिर से प्रयास करें।" 91 | }, 92 | "hu": { 93 | "title": "Csatlakozz a GOATS-hoz", 94 | "shortDescription": "A legnagyobb mind közül.", 95 | "longDescription": "Vannak degen haverjaid? Hozd őket is!", 96 | "errorDescription": "Az ellenőrzés nem sikerült. Győződj meg róla, hogy csatlakoztál a GOATS-hoz, és próbáld újra." 97 | }, 98 | "id": { 99 | "title": "Bergabunglah dengan GOATS", 100 | "shortDescription": "Yang terbaik dari semuanya.", 101 | "longDescription": "Punya teman degen? Ajak mereka masuk!", 102 | "errorDescription": "Verifikasi gagal. Pastikan Anda bergabung dengan GOATS dan coba lagi." 103 | }, 104 | "it": { 105 | "title": "Unisciti a GOATS", 106 | "shortDescription": "Il più grande di tutti.", 107 | "longDescription": "Hai degli amici audaci? Portali!", 108 | "errorDescription": "Verifica fallita. Assicurati di esserti unito a GOATS e riprova." 109 | }, 110 | "ja": { 111 | "title": "GOATSに参加", 112 | "shortDescription": "史上最高。", 113 | "longDescription": "大胆な友達がいる? 連れてきて!", 114 | "errorDescription": "確認に失敗しました。GOATSに参加したことを確認して、もう一度お試しください。" 115 | }, 116 | "jv": { 117 | "title": "Gabung GOATS", 118 | "shortDescription": "Paling gedhe saka kabeh.", 119 | "longDescription": "Duwe kanca degen? Gawa menyang kene!", 120 | "errorDescription": "Verifikasi gagal. Mangga priksa manawa sampeyan wis gabung GOATS lan coba maneh." 121 | }, 122 | "kn": { 123 | "title": "GOATS ಗೆ ಸೇರಿ", 124 | "shortDescription": "ಎಲ್ಲರಲ್ಲಿಯೂ ಶ್ರೇಷ್ಠ.", 125 | "longDescription": "ನೀವು ಕೆಲವು ಸಾಹಸ ದೋಸ್ತರನ್ನು ಹೊಂದಿದ್ದೀರಾ? ಅವರನ್ನು ತೆಗೆದುಕೊಂಡು ಬನ್ನಿ!", 126 | "errorDescription": "ಪರಿಶೀಲನೆ ವಿಫಲವಾಗಿದೆ. ದಯವಿಟ್ಟು ನೀವು GOATS ಗೆ ಸೇರಿದ್ದೀರೆಂದು ಖಚಿತಪಡಿಸಿ ಮತ್ತೊಮ್ಮೆ ಪ್ರಯತ್ನಿಸಿ." 127 | }, 128 | "ko": { 129 | "title": "GOATS에 가입하세요", 130 | "shortDescription": "모든 것 중 최고.", 131 | "longDescription": "대담한 친구가 있습니까? 그들을 데려오세요!", 132 | "errorDescription": "인증에 실패했습니다. GOATS에 가입했는지 확인하고 다시 시도하세요." 133 | }, 134 | "mr": { 135 | "title": "GOATS मध्ये सामील व्हा", 136 | "shortDescription": "सर्वात महान.", 137 | "longDescription": "तुमच्या काही धाडसी मित्रांना आणा!", 138 | "errorDescription": "सत्यापन अयशस्वी झाले. कृपया आपण GOATS मध्ये सामील झालात का ते सुनिश्चित करा आणि पुन्हा प्रयत्न करा." 139 | }, 140 | "ms": { 141 | "title": "Sertai GOATS", 142 | "shortDescription": "Yang terbaik dari semuanya.", 143 | "longDescription": "Ada kawan berani? Bawa mereka masuk!", 144 | "errorDescription": "Pengesahan gagal. Pastikan anda menyertai GOATS dan cuba lagi." 145 | }, 146 | "nb": { 147 | "title": "Bli med i GOATS", 148 | "shortDescription": "Den største av alle.", 149 | "longDescription": "Har du noen vågale venner? Ta dem med!", 150 | "errorDescription": "Verifiseringen mislyktes. Vennligst sørg for at du har blitt med i GOATS, og prøv igjen." 151 | }, 152 | "pa": { 153 | "title": "GOATS ਵਿੱਚ ਸ਼ਾਮਲ ਹੋਵੋ", 154 | "shortDescription": "ਸਭ ਤੋਂ ਵੱਡਾ।", 155 | "longDescription": "ਕੀ ਤੁਹਾਡੇ ਕੋਲ ਕੁਝ ਸਾਹਸੀ ਦੋਸਤ ਹਨ? ਉਨ੍ਹਾਂ ਨੂੰ ਲਿਆਓ!", 156 | "errorDescription": "ਪ੍ਰਮਾਣਕਰਨ ਫੇਲ੍ਹ ਹੋ ਗਿਆ। ਕਿਰਪਾ ਕਰਕੇ ਯਕੀਨੀ ਬਣਾਓ ਕਿ ਤੁਸੀਂ GOATS ਵਿੱਚ ਸ਼ਾਮਲ ਹੋਏ ਹੋ ਅਤੇ ਦੁਬਾਰਾ ਕੋਸ਼ਿਸ਼ ਕਰੋ।" 157 | }, 158 | "pl": { 159 | "title": "Dołącz do GOATS", 160 | "shortDescription": "Największy ze wszystkich.", 161 | "longDescription": "Masz odważnych znajomych? Przyprowadź ich!", 162 | "errorDescription": "Weryfikacja nie powiodła się. Upewnij się, że dołączyłeś do GOATS i spróbuj ponownie." 163 | }, 164 | "pt": { 165 | "title": "Junte-se ao GOATS", 166 | "shortDescription": "O maior de todos.", 167 | "longDescription": "Tem amigos audaciosos? Traga-os!", 168 | "errorDescription": "Falha na verificação. Verifique se você se juntou ao GOATS e tente novamente." 169 | }, 170 | "ro": { 171 | "title": "Alătură-te GOATS", 172 | "shortDescription": "Cel mai mare dintre toți.", 173 | "longDescription": "Ai prieteni curajoși? Adu-i cu tine!", 174 | "errorDescription": "Verificarea a eșuat. Asigură-te că te-ai alăturat GOATS și încearcă din nou." 175 | }, 176 | "ru": { 177 | "title": "Присоединяйтесь к GOATS", 178 | "shortDescription": "Самый великий из всех.", 179 | "longDescription": "Есть друзья-дегенераты? Приводите их!", 180 | "errorDescription": "Не удалось выполнить проверку. Убедитесь, что вы присоединились к GOATS, и попробуйте снова." 181 | }, 182 | "sl": { 183 | "title": "Pridružite se GOATS", 184 | "shortDescription": "Največji od vseh.", 185 | "longDescription": "Imate drzne prijatelje? Pripeljite jih!", 186 | "errorDescription": "Preverjanje ni uspelo. Prepričajte se, da ste se pridružili GOATS, in poskusite znova." 187 | }, 188 | "tr": { 189 | "title": "GOATS'a Katıl", 190 | "shortDescription": "Hepsinden en büyüğü.", 191 | "longDescription": "Bazı deli arkadaşların var mı? Onları getir!", 192 | "errorDescription": "Doğrulama başarısız oldu. Lütfen GOATS'a katıldığınızdan emin olun ve tekrar deneyin." 193 | }, 194 | "vi": { 195 | "title": "Tham gia GOATS", 196 | "shortDescription": "Vĩ đại nhất trong tất cả.", 197 | "longDescription": "Có bạn bè dám mạo hiểm? Đưa họ vào!", 198 | "errorDescription": "Xác minh không thành công. Vui lòng đảm bảo rằng bạn đã tham gia GOATS và thử lại." 199 | }, 200 | "zh": { 201 | "title": "加入 GOATS", 202 | "shortDescription": "最伟大的。", 203 | "longDescription": "有一些勇敢的朋友?带他们来吧!", 204 | "errorDescription": "验证失败。请确保您已加入 GOATS,然后重试。" 205 | } 206 | } 207 | -------------------------------------------------------------------------------- /tasks/translations/sealsend/join_goats.json: -------------------------------------------------------------------------------- 1 | { 2 | "af": { 3 | "title": "Sluit aan by GOATS", 4 | "shortDescription": "Die grootste van almal.", 5 | "longDescription": "Het jy 'n paar degen-maats? Bring hulle saam!", 6 | "errorDescription": "Verifikasie het misluk. Maak asseblief seker dat jy by GOATS aangesluit het en probeer weer." 7 | }, 8 | "ar": { 9 | "title": "انضم إلى GOATS", 10 | "shortDescription": "الأعظم على الإطلاق.", 11 | "longDescription": "لديك بعض الأصدقاء الجنونيين؟ اجلبهم معك!", 12 | "errorDescription": "فشل التحقق. يرجى التأكد من أنك انضممت إلى GOATS وحاول مرة أخرى." 13 | }, 14 | "az": { 15 | "title": "GOATS-a qoşulun", 16 | "shortDescription": "Hamının ən böyüyü.", 17 | "longDescription": "Degen dostlarınız varmı? Onları da gətirin!", 18 | "errorDescription": "Təsdiqləmə uğursuz oldu. Zəhmət olmasa GOATS-a qoşulduğunuzdan əmin olun və yenidən cəhd edin." 19 | }, 20 | "bg": { 21 | "title": "Присъединете се към GOATS", 22 | "shortDescription": "Най-великият от всички.", 23 | "longDescription": "Имате ли някои приятели, които обичат риска? Доведете ги!", 24 | "errorDescription": "Проверката не бе успешна. Моля, уверете се, че сте се присъединили към GOATS и опитайте отново." 25 | }, 26 | "bn": { 27 | "title": "GOATS-এ যোগ দিন", 28 | "shortDescription": "সবার সেরা।", 29 | "longDescription": "কিছু সাহসী বন্ধু আছে? তাদের নিয়ে আসুন!", 30 | "errorDescription": "যাচাইকরণ ব্যর্থ হয়েছে। অনুগ্রহ করে নিশ্চিত করুন যে আপনি GOATS-এ যোগ দিয়েছেন এবং আবার চেষ্টা করুন।" 31 | }, 32 | "cs": { 33 | "title": "Připojte se k GOATS", 34 | "shortDescription": "Největší ze všech.", 35 | "longDescription": "Máte nějaké odvážné kamarády? Přiveďte je!", 36 | "errorDescription": "Ověření selhalo. Ujistěte se, že jste se připojili k GOATS, a zkuste to znovu." 37 | }, 38 | "de": { 39 | "title": "Tritt GOATS bei", 40 | "shortDescription": "Das Größte von allem.", 41 | "longDescription": "Hast du ein paar wagemutige Freunde? Bring sie mit!", 42 | "errorDescription": "Verifizierung fehlgeschlagen. Bitte stelle sicher, dass du GOATS beigetreten bist, und versuche es erneut." 43 | }, 44 | "el": { 45 | "title": "Γίνετε μέλος του GOATS", 46 | "shortDescription": "Το μεγαλύτερο όλων.", 47 | "longDescription": "Έχετε μερικούς τολμηρούς φίλους; Φέρτε τους μαζί!", 48 | "errorDescription": "Η επαλήθευση απέτυχε. Βεβαιωθείτε ότι έχετε εγγραφεί στο GOATS και δοκιμάστε ξανά." 49 | }, 50 | "en": { 51 | "title": "Join GOATS", 52 | "shortDescription": "The greatest of all.", 53 | "longDescription": "Got some degen buddies? Bring ’em in!", 54 | "errorDescription": "Verification failed. Please ensure you joined GOATS and try again." 55 | }, 56 | "es": { 57 | "title": "Únete a GOATS", 58 | "shortDescription": "El más grande de todos.", 59 | "longDescription": "¿Tienes algunos amigos atrevidos? ¡Tráelos!", 60 | "errorDescription": "La verificación falló. Por favor, asegúrate de haberte unido a GOATS e inténtalo de nuevo." 61 | }, 62 | "fa": { 63 | "title": "به GOATS بپیوندید", 64 | "shortDescription": "بزرگ‌ترین از همه.", 65 | "longDescription": "چند دوست دیوانه دارید؟ آن‌ها را بیاورید!", 66 | "errorDescription": "اعتبارسنجی ناموفق بود. لطفاً مطمئن شوید که به GOATS پیوسته‌اید و دوباره تلاش کنید." 67 | }, 68 | "fr": { 69 | "title": "Rejoignez GOATS", 70 | "shortDescription": "Le plus grand de tous.", 71 | "longDescription": "Vous avez des amis audacieux ? Amenez-les avec vous !", 72 | "errorDescription": "Échec de la vérification. Veuillez vous assurer que vous avez rejoint GOATS et réessayez." 73 | }, 74 | "gu": { 75 | "title": "GOATS માં જોડાઓ", 76 | "shortDescription": "તમામમાં શ્રેષ્ઠ.", 77 | "longDescription": "તમે કેટલાક સાહસિક મિત્રો રાખો છો? તેમને લાવી દો!", 78 | "errorDescription": "ચકાસણી નિષ્ફળ. કૃપા કરીને ખાતરી કરો કે તમે GOATS માં જોડાયા છો અને ફરી પ્રયાસ કરો." 79 | }, 80 | "he": { 81 | "title": "הצטרף ל-GOATS", 82 | "shortDescription": "הגדול מכולם.", 83 | "longDescription": "יש לך כמה חברים משוגעים? תביא אותם!", 84 | "errorDescription": "האימות נכשל. אנא ודא שהצטרפת ל-GOATS ונסה שוב." 85 | }, 86 | "hi": { 87 | "title": "GOATS से जुड़ें", 88 | "shortDescription": "सबसे महान।", 89 | "longDescription": "क्या आपके पास कुछ निडर दोस्त हैं? उन्हें ले आओ!", 90 | "errorDescription": "सत्यापन विफल। कृपया सुनिश्चित करें कि आप GOATS से जुड़े हैं और फिर से प्रयास करें।" 91 | }, 92 | "hu": { 93 | "title": "Csatlakozz a GOATS-hoz", 94 | "shortDescription": "A legnagyobb mind közül.", 95 | "longDescription": "Vannak degen haverjaid? Hozd őket is!", 96 | "errorDescription": "Az ellenőrzés nem sikerült. Győződj meg róla, hogy csatlakoztál a GOATS-hoz, és próbáld újra." 97 | }, 98 | "id": { 99 | "title": "Bergabunglah dengan GOATS", 100 | "shortDescription": "Yang terbaik dari semuanya.", 101 | "longDescription": "Punya teman degen? Ajak mereka masuk!", 102 | "errorDescription": "Verifikasi gagal. Pastikan Anda bergabung dengan GOATS dan coba lagi." 103 | }, 104 | "it": { 105 | "title": "Unisciti a GOATS", 106 | "shortDescription": "Il più grande di tutti.", 107 | "longDescription": "Hai degli amici audaci? Portali!", 108 | "errorDescription": "Verifica fallita. Assicurati di esserti unito a GOATS e riprova." 109 | }, 110 | "ja": { 111 | "title": "GOATSに参加", 112 | "shortDescription": "史上最高。", 113 | "longDescription": "大胆な友達がいる? 連れてきて!", 114 | "errorDescription": "確認に失敗しました。GOATSに参加したことを確認して、もう一度お試しください。" 115 | }, 116 | "jv": { 117 | "title": "Gabung GOATS", 118 | "shortDescription": "Paling gedhe saka kabeh.", 119 | "longDescription": "Duwe kanca degen? Gawa menyang kene!", 120 | "errorDescription": "Verifikasi gagal. Mangga priksa manawa sampeyan wis gabung GOATS lan coba maneh." 121 | }, 122 | "kn": { 123 | "title": "GOATS ಗೆ ಸೇರಿ", 124 | "shortDescription": "ಎಲ್ಲರಲ್ಲಿಯೂ ಶ್ರೇಷ್ಠ.", 125 | "longDescription": "ನೀವು ಕೆಲವು ಸಾಹಸ ದೋಸ್ತರನ್ನು ಹೊಂದಿದ್ದೀರಾ? ಅವರನ್ನು ತೆಗೆದುಕೊಂಡು ಬನ್ನಿ!", 126 | "errorDescription": "ಪರಿಶೀಲನೆ ವಿಫಲವಾಗಿದೆ. ದಯವಿಟ್ಟು ನೀವು GOATS ಗೆ ಸೇರಿದ್ದೀರೆಂದು ಖಚಿತಪಡಿಸಿ ಮತ್ತೊಮ್ಮೆ ಪ್ರಯತ್ನಿಸಿ." 127 | }, 128 | "ko": { 129 | "title": "GOATS에 가입하세요", 130 | "shortDescription": "모든 것 중 최고.", 131 | "longDescription": "대담한 친구가 있습니까? 그들을 데려오세요!", 132 | "errorDescription": "인증에 실패했습니다. GOATS에 가입했는지 확인하고 다시 시도하세요." 133 | }, 134 | "mr": { 135 | "title": "GOATS मध्ये सामील व्हा", 136 | "shortDescription": "सर्वात महान.", 137 | "longDescription": "तुमच्या काही धाडसी मित्रांना आणा!", 138 | "errorDescription": "सत्यापन अयशस्वी झाले. कृपया आपण GOATS मध्ये सामील झालात का ते सुनिश्चित करा आणि पुन्हा प्रयत्न करा." 139 | }, 140 | "ms": { 141 | "title": "Sertai GOATS", 142 | "shortDescription": "Yang terbaik dari semuanya.", 143 | "longDescription": "Ada kawan berani? Bawa mereka masuk!", 144 | "errorDescription": "Pengesahan gagal. Pastikan anda menyertai GOATS dan cuba lagi." 145 | }, 146 | "nb": { 147 | "title": "Bli med i GOATS", 148 | "shortDescription": "Den største av alle.", 149 | "longDescription": "Har du noen vågale venner? Ta dem med!", 150 | "errorDescription": "Verifiseringen mislyktes. Vennligst sørg for at du har blitt med i GOATS, og prøv igjen." 151 | }, 152 | "pa": { 153 | "title": "GOATS ਵਿੱਚ ਸ਼ਾਮਲ ਹੋਵੋ", 154 | "shortDescription": "ਸਭ ਤੋਂ ਵੱਡਾ।", 155 | "longDescription": "ਕੀ ਤੁਹਾਡੇ ਕੋਲ ਕੁਝ ਸਾਹਸੀ ਦੋਸਤ ਹਨ? ਉਨ੍ਹਾਂ ਨੂੰ ਲਿਆਓ!", 156 | "errorDescription": "ਪ੍ਰਮਾਣਕਰਨ ਫੇਲ੍ਹ ਹੋ ਗਿਆ। ਕਿਰਪਾ ਕਰਕੇ ਯਕੀਨੀ ਬਣਾਓ ਕਿ ਤੁਸੀਂ GOATS ਵਿੱਚ ਸ਼ਾਮਲ ਹੋਏ ਹੋ ਅਤੇ ਦੁਬਾਰਾ ਕੋਸ਼ਿਸ਼ ਕਰੋ।" 157 | }, 158 | "pl": { 159 | "title": "Dołącz do GOATS", 160 | "shortDescription": "Największy ze wszystkich.", 161 | "longDescription": "Masz odważnych znajomych? Przyprowadź ich!", 162 | "errorDescription": "Weryfikacja nie powiodła się. Upewnij się, że dołączyłeś do GOATS i spróbuj ponownie." 163 | }, 164 | "pt": { 165 | "title": "Junte-se ao GOATS", 166 | "shortDescription": "O maior de todos.", 167 | "longDescription": "Tem amigos audaciosos? Traga-os!", 168 | "errorDescription": "Falha na verificação. Verifique se você se juntou ao GOATS e tente novamente." 169 | }, 170 | "ro": { 171 | "title": "Alătură-te GOATS", 172 | "shortDescription": "Cel mai mare dintre toți.", 173 | "longDescription": "Ai prieteni curajoși? Adu-i cu tine!", 174 | "errorDescription": "Verificarea a eșuat. Asigură-te că te-ai alăturat GOATS și încearcă din nou." 175 | }, 176 | "ru": { 177 | "title": "Присоединяйтесь к GOATS", 178 | "shortDescription": "Самый великий из всех.", 179 | "longDescription": "Есть друзья-дегенераты? Приводите их!", 180 | "errorDescription": "Не удалось выполнить проверку. Убедитесь, что вы присоединились к GOATS, и попробуйте снова." 181 | }, 182 | "sl": { 183 | "title": "Pridružite se GOATS", 184 | "shortDescription": "Največji od vseh.", 185 | "longDescription": "Imate drzne prijatelje? Pripeljite jih!", 186 | "errorDescription": "Preverjanje ni uspelo. Prepričajte se, da ste se pridružili GOATS, in poskusite znova." 187 | }, 188 | "tr": { 189 | "title": "GOATS'a Katıl", 190 | "shortDescription": "Hepsinden en büyüğü.", 191 | "longDescription": "Bazı deli arkadaşların var mı? Onları getir!", 192 | "errorDescription": "Doğrulama başarısız oldu. Lütfen GOATS'a katıldığınızdan emin olun ve tekrar deneyin." 193 | }, 194 | "vi": { 195 | "title": "Tham gia GOATS", 196 | "shortDescription": "Vĩ đại nhất trong tất cả.", 197 | "longDescription": "Có bạn bè dám mạo hiểm? Đưa họ vào!", 198 | "errorDescription": "Xác minh không thành công. Vui lòng đảm bảo rằng bạn đã tham gia GOATS và thử lại." 199 | }, 200 | "zh": { 201 | "title": "加入 GOATS", 202 | "shortDescription": "最伟大的。", 203 | "longDescription": "有一些勇敢的朋友?带他们来吧!", 204 | "errorDescription": "验证失败。请确保您已加入 GOATS,然后重试。" 205 | } 206 | } 207 | -------------------------------------------------------------------------------- /tasks/translations/sunwaves/join_goats.json: -------------------------------------------------------------------------------- 1 | { 2 | "af": { 3 | "title": "Sluit aan by GOATS", 4 | "shortDescription": "Die grootste van almal.", 5 | "longDescription": "Het jy 'n paar degen-maats? Bring hulle saam!", 6 | "errorDescription": "Verifikasie het misluk. Maak asseblief seker dat jy by GOATS aangesluit het en probeer weer." 7 | }, 8 | "ar": { 9 | "title": "انضم إلى GOATS", 10 | "shortDescription": "الأعظم على الإطلاق.", 11 | "longDescription": "لديك بعض الأصدقاء الجنونيين؟ اجلبهم معك!", 12 | "errorDescription": "فشل التحقق. يرجى التأكد من أنك انضممت إلى GOATS وحاول مرة أخرى." 13 | }, 14 | "az": { 15 | "title": "GOATS-a qoşulun", 16 | "shortDescription": "Hamının ən böyüyü.", 17 | "longDescription": "Degen dostlarınız varmı? Onları da gətirin!", 18 | "errorDescription": "Təsdiqləmə uğursuz oldu. Zəhmət olmasa GOATS-a qoşulduğunuzdan əmin olun və yenidən cəhd edin." 19 | }, 20 | "bg": { 21 | "title": "Присъединете се към GOATS", 22 | "shortDescription": "Най-великият от всички.", 23 | "longDescription": "Имате ли някои приятели, които обичат риска? Доведете ги!", 24 | "errorDescription": "Проверката не бе успешна. Моля, уверете се, че сте се присъединили към GOATS и опитайте отново." 25 | }, 26 | "bn": { 27 | "title": "GOATS-এ যোগ দিন", 28 | "shortDescription": "সবার সেরা।", 29 | "longDescription": "কিছু সাহসী বন্ধু আছে? তাদের নিয়ে আসুন!", 30 | "errorDescription": "যাচাইকরণ ব্যর্থ হয়েছে। অনুগ্রহ করে নিশ্চিত করুন যে আপনি GOATS-এ যোগ দিয়েছেন এবং আবার চেষ্টা করুন।" 31 | }, 32 | "cs": { 33 | "title": "Připojte se k GOATS", 34 | "shortDescription": "Největší ze všech.", 35 | "longDescription": "Máte nějaké odvážné kamarády? Přiveďte je!", 36 | "errorDescription": "Ověření selhalo. Ujistěte se, že jste se připojili k GOATS, a zkuste to znovu." 37 | }, 38 | "de": { 39 | "title": "Tritt GOATS bei", 40 | "shortDescription": "Das Größte von allem.", 41 | "longDescription": "Hast du ein paar wagemutige Freunde? Bring sie mit!", 42 | "errorDescription": "Verifizierung fehlgeschlagen. Bitte stelle sicher, dass du GOATS beigetreten bist, und versuche es erneut." 43 | }, 44 | "el": { 45 | "title": "Γίνετε μέλος του GOATS", 46 | "shortDescription": "Το μεγαλύτερο όλων.", 47 | "longDescription": "Έχετε μερικούς τολμηρούς φίλους; Φέρτε τους μαζί!", 48 | "errorDescription": "Η επαλήθευση απέτυχε. Βεβαιωθείτε ότι έχετε εγγραφεί στο GOATS και δοκιμάστε ξανά." 49 | }, 50 | "en": { 51 | "title": "Join GOATS", 52 | "shortDescription": "The greatest of all.", 53 | "longDescription": "Got some degen buddies? Bring ’em in!", 54 | "errorDescription": "Verification failed. Please ensure you joined GOATS and try again." 55 | }, 56 | "es": { 57 | "title": "Únete a GOATS", 58 | "shortDescription": "El más grande de todos.", 59 | "longDescription": "¿Tienes algunos amigos atrevidos? ¡Tráelos!", 60 | "errorDescription": "La verificación falló. Por favor, asegúrate de haberte unido a GOATS e inténtalo de nuevo." 61 | }, 62 | "fa": { 63 | "title": "به GOATS بپیوندید", 64 | "shortDescription": "بزرگ‌ترین از همه.", 65 | "longDescription": "چند دوست دیوانه دارید؟ آن‌ها را بیاورید!", 66 | "errorDescription": "اعتبارسنجی ناموفق بود. لطفاً مطمئن شوید که به GOATS پیوسته‌اید و دوباره تلاش کنید." 67 | }, 68 | "fr": { 69 | "title": "Rejoignez GOATS", 70 | "shortDescription": "Le plus grand de tous.", 71 | "longDescription": "Vous avez des amis audacieux ? Amenez-les avec vous !", 72 | "errorDescription": "Échec de la vérification. Veuillez vous assurer que vous avez rejoint GOATS et réessayez." 73 | }, 74 | "gu": { 75 | "title": "GOATS માં જોડાઓ", 76 | "shortDescription": "તમામમાં શ્રેષ્ઠ.", 77 | "longDescription": "તમે કેટલાક સાહસિક મિત્રો રાખો છો? તેમને લાવી દો!", 78 | "errorDescription": "ચકાસણી નિષ્ફળ. કૃપા કરીને ખાતરી કરો કે તમે GOATS માં જોડાયા છો અને ફરી પ્રયાસ કરો." 79 | }, 80 | "he": { 81 | "title": "הצטרף ל-GOATS", 82 | "shortDescription": "הגדול מכולם.", 83 | "longDescription": "יש לך כמה חברים משוגעים? תביא אותם!", 84 | "errorDescription": "האימות נכשל. אנא ודא שהצטרפת ל-GOATS ונסה שוב." 85 | }, 86 | "hi": { 87 | "title": "GOATS से जुड़ें", 88 | "shortDescription": "सबसे महान।", 89 | "longDescription": "क्या आपके पास कुछ निडर दोस्त हैं? उन्हें ले आओ!", 90 | "errorDescription": "सत्यापन विफल। कृपया सुनिश्चित करें कि आप GOATS से जुड़े हैं और फिर से प्रयास करें।" 91 | }, 92 | "hu": { 93 | "title": "Csatlakozz a GOATS-hoz", 94 | "shortDescription": "A legnagyobb mind közül.", 95 | "longDescription": "Vannak degen haverjaid? Hozd őket is!", 96 | "errorDescription": "Az ellenőrzés nem sikerült. Győződj meg róla, hogy csatlakoztál a GOATS-hoz, és próbáld újra." 97 | }, 98 | "id": { 99 | "title": "Bergabunglah dengan GOATS", 100 | "shortDescription": "Yang terbaik dari semuanya.", 101 | "longDescription": "Punya teman degen? Ajak mereka masuk!", 102 | "errorDescription": "Verifikasi gagal. Pastikan Anda bergabung dengan GOATS dan coba lagi." 103 | }, 104 | "it": { 105 | "title": "Unisciti a GOATS", 106 | "shortDescription": "Il più grande di tutti.", 107 | "longDescription": "Hai degli amici audaci? Portali!", 108 | "errorDescription": "Verifica fallita. Assicurati di esserti unito a GOATS e riprova." 109 | }, 110 | "ja": { 111 | "title": "GOATSに参加", 112 | "shortDescription": "史上最高。", 113 | "longDescription": "大胆な友達がいる? 連れてきて!", 114 | "errorDescription": "確認に失敗しました。GOATSに参加したことを確認して、もう一度お試しください。" 115 | }, 116 | "jv": { 117 | "title": "Gabung GOATS", 118 | "shortDescription": "Paling gedhe saka kabeh.", 119 | "longDescription": "Duwe kanca degen? Gawa menyang kene!", 120 | "errorDescription": "Verifikasi gagal. Mangga priksa manawa sampeyan wis gabung GOATS lan coba maneh." 121 | }, 122 | "kn": { 123 | "title": "GOATS ಗೆ ಸೇರಿ", 124 | "shortDescription": "ಎಲ್ಲರಲ್ಲಿಯೂ ಶ್ರೇಷ್ಠ.", 125 | "longDescription": "ನೀವು ಕೆಲವು ಸಾಹಸ ದೋಸ್ತರನ್ನು ಹೊಂದಿದ್ದೀರಾ? ಅವರನ್ನು ತೆಗೆದುಕೊಂಡು ಬನ್ನಿ!", 126 | "errorDescription": "ಪರಿಶೀಲನೆ ವಿಫಲವಾಗಿದೆ. ದಯವಿಟ್ಟು ನೀವು GOATS ಗೆ ಸೇರಿದ್ದೀರೆಂದು ಖಚಿತಪಡಿಸಿ ಮತ್ತೊಮ್ಮೆ ಪ್ರಯತ್ನಿಸಿ." 127 | }, 128 | "ko": { 129 | "title": "GOATS에 가입하세요", 130 | "shortDescription": "모든 것 중 최고.", 131 | "longDescription": "대담한 친구가 있습니까? 그들을 데려오세요!", 132 | "errorDescription": "인증에 실패했습니다. GOATS에 가입했는지 확인하고 다시 시도하세요." 133 | }, 134 | "mr": { 135 | "title": "GOATS मध्ये सामील व्हा", 136 | "shortDescription": "सर्वात महान.", 137 | "longDescription": "तुमच्या काही धाडसी मित्रांना आणा!", 138 | "errorDescription": "सत्यापन अयशस्वी झाले. कृपया आपण GOATS मध्ये सामील झालात का ते सुनिश्चित करा आणि पुन्हा प्रयत्न करा." 139 | }, 140 | "ms": { 141 | "title": "Sertai GOATS", 142 | "shortDescription": "Yang terbaik dari semuanya.", 143 | "longDescription": "Ada kawan berani? Bawa mereka masuk!", 144 | "errorDescription": "Pengesahan gagal. Pastikan anda menyertai GOATS dan cuba lagi." 145 | }, 146 | "nb": { 147 | "title": "Bli med i GOATS", 148 | "shortDescription": "Den største av alle.", 149 | "longDescription": "Har du noen vågale venner? Ta dem med!", 150 | "errorDescription": "Verifiseringen mislyktes. Vennligst sørg for at du har blitt med i GOATS, og prøv igjen." 151 | }, 152 | "pa": { 153 | "title": "GOATS ਵਿੱਚ ਸ਼ਾਮਲ ਹੋਵੋ", 154 | "shortDescription": "ਸਭ ਤੋਂ ਵੱਡਾ।", 155 | "longDescription": "ਕੀ ਤੁਹਾਡੇ ਕੋਲ ਕੁਝ ਸਾਹਸੀ ਦੋਸਤ ਹਨ? ਉਨ੍ਹਾਂ ਨੂੰ ਲਿਆਓ!", 156 | "errorDescription": "ਪ੍ਰਮਾਣਕਰਨ ਫੇਲ੍ਹ ਹੋ ਗਿਆ। ਕਿਰਪਾ ਕਰਕੇ ਯਕੀਨੀ ਬਣਾਓ ਕਿ ਤੁਸੀਂ GOATS ਵਿੱਚ ਸ਼ਾਮਲ ਹੋਏ ਹੋ ਅਤੇ ਦੁਬਾਰਾ ਕੋਸ਼ਿਸ਼ ਕਰੋ।" 157 | }, 158 | "pl": { 159 | "title": "Dołącz do GOATS", 160 | "shortDescription": "Największy ze wszystkich.", 161 | "longDescription": "Masz odważnych znajomych? Przyprowadź ich!", 162 | "errorDescription": "Weryfikacja nie powiodła się. Upewnij się, że dołączyłeś do GOATS i spróbuj ponownie." 163 | }, 164 | "pt": { 165 | "title": "Junte-se ao GOATS", 166 | "shortDescription": "O maior de todos.", 167 | "longDescription": "Tem amigos audaciosos? Traga-os!", 168 | "errorDescription": "Falha na verificação. Verifique se você se juntou ao GOATS e tente novamente." 169 | }, 170 | "ro": { 171 | "title": "Alătură-te GOATS", 172 | "shortDescription": "Cel mai mare dintre toți.", 173 | "longDescription": "Ai prieteni curajoși? Adu-i cu tine!", 174 | "errorDescription": "Verificarea a eșuat. Asigură-te că te-ai alăturat GOATS și încearcă din nou." 175 | }, 176 | "ru": { 177 | "title": "Присоединяйтесь к GOATS", 178 | "shortDescription": "Самый великий из всех.", 179 | "longDescription": "Есть друзья-дегенераты? Приводите их!", 180 | "errorDescription": "Не удалось выполнить проверку. Убедитесь, что вы присоединились к GOATS, и попробуйте снова." 181 | }, 182 | "sl": { 183 | "title": "Pridružite se GOATS", 184 | "shortDescription": "Največji od vseh.", 185 | "longDescription": "Imate drzne prijatelje? Pripeljite jih!", 186 | "errorDescription": "Preverjanje ni uspelo. Prepričajte se, da ste se pridružili GOATS, in poskusite znova." 187 | }, 188 | "tr": { 189 | "title": "GOATS'a Katıl", 190 | "shortDescription": "Hepsinden en büyüğü.", 191 | "longDescription": "Bazı deli arkadaşların var mı? Onları getir!", 192 | "errorDescription": "Doğrulama başarısız oldu. Lütfen GOATS'a katıldığınızdan emin olun ve tekrar deneyin." 193 | }, 194 | "vi": { 195 | "title": "Tham gia GOATS", 196 | "shortDescription": "Vĩ đại nhất trong tất cả.", 197 | "longDescription": "Có bạn bè dám mạo hiểm? Đưa họ vào!", 198 | "errorDescription": "Xác minh không thành công. Vui lòng đảm bảo rằng bạn đã tham gia GOATS và thử lại." 199 | }, 200 | "zh": { 201 | "title": "加入 GOATS", 202 | "shortDescription": "最伟大的。", 203 | "longDescription": "有一些勇敢的朋友?带他们来吧!", 204 | "errorDescription": "验证失败。请确保您已加入 GOATS,然后重试。" 205 | } 206 | } 207 | --------------------------------------------------------------------------------