├── .github ├── dependabot.yml └── workflows │ └── test.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README-email.md ├── README.md ├── cmd ├── es-restore │ └── main.go ├── mccs-alpha-api │ └── main.go └── seed │ └── main.go ├── configs ├── development-example.yaml ├── revive.toml ├── seed.yaml └── test.yaml ├── docker-compose.dev.yml ├── docker-compose.production.yml ├── dockerfile.dev ├── dockerfile.production ├── global ├── app.go ├── constant │ ├── constant.go │ ├── date.go │ ├── entity.go │ ├── transfer.go │ └── unit.go └── init.go ├── go.mod ├── go.sum ├── internal ├── app │ ├── api │ │ ├── api.go │ │ └── errors.go │ ├── http │ │ ├── controller │ │ │ ├── admin_user.go │ │ │ ├── category.go │ │ │ ├── entity.go │ │ │ ├── service_discovery.go │ │ │ ├── tag.go │ │ │ ├── transfer.go │ │ │ ├── user.go │ │ │ └── user_action.go │ │ ├── middleware │ │ │ ├── auth.go │ │ │ ├── cache.go │ │ │ ├── logging.go │ │ │ ├── rate_limiting.go │ │ │ └── recover.go │ │ ├── routes.go │ │ └── server.go │ ├── logic │ │ ├── account.go │ │ ├── admin_user.go │ │ ├── balance_limit.go │ │ ├── balancecheck │ │ │ └── balancecheck.go │ │ ├── category.go │ │ ├── dailyemail │ │ │ ├── dailyemail.go │ │ │ └── work.go │ │ ├── email.go │ │ ├── entity.go │ │ ├── error.go │ │ ├── lostpassword.go │ │ ├── tag.go │ │ ├── transfer.go │ │ ├── user.go │ │ └── user_action.go │ ├── repository │ │ ├── es │ │ │ ├── entity.go │ │ │ ├── es.go │ │ │ ├── helper.go │ │ │ ├── index.go │ │ │ ├── journal.go │ │ │ ├── tag.go │ │ │ ├── user.go │ │ │ └── user_action.go │ │ ├── mongo │ │ │ ├── admin_user.go │ │ │ ├── category.go │ │ │ ├── config.go │ │ │ ├── entity.go │ │ │ ├── helper.go │ │ │ ├── lost_password.go │ │ │ ├── mongo.go │ │ │ ├── tag.go │ │ │ ├── user.go │ │ │ └── user_action.go │ │ ├── pg │ │ │ ├── account.go │ │ │ ├── balance_limit.go │ │ │ ├── journal.go │ │ │ ├── pg.go │ │ │ └── posting.go │ │ └── redis │ │ │ ├── key.go │ │ │ └── redis.go │ └── types │ │ ├── api_request_body.go │ │ ├── api_respond_body.go │ │ ├── es_entity.go │ │ ├── es_journal.go │ │ ├── es_tag.go │ │ ├── es_user.go │ │ ├── es_user_action.go │ │ ├── mongo_admin_user.go │ │ ├── mongo_category.go │ │ ├── mongo_entity.go │ │ ├── mongo_login_info.go │ │ ├── mongo_lost_password.go │ │ ├── mongo_tag.go │ │ ├── mongo_tag_field.go │ │ ├── mongo_user.go │ │ ├── mongo_user_action.go │ │ ├── pg_account.go │ │ ├── pg_balance_limit.go │ │ ├── pg_journal.go │ │ └── pg_posting.go ├── migration │ └── migration.go ├── pkg │ └── email │ │ ├── balance.go │ │ ├── email.go │ │ └── transfer.go └── seed │ ├── data │ ├── admin_user.json │ ├── balance_limit.json │ ├── category.json │ ├── entity.json │ ├── tag.json │ └── user.json │ ├── es.go │ ├── mongodb.go │ ├── pg.go │ └── seed.go ├── openapi-admin.yaml ├── openapi-user.yaml ├── reflex.dev.conf └── util ├── amount.go ├── bcrypt └── bcrypt.go ├── bool.go ├── check_field_diff.go ├── cookie └── cookie.go ├── email.go ├── entity_status.go ├── float.go ├── int.go ├── ip.go ├── jwt ├── jwt.go └── jwt_test.go ├── l └── logger.go ├── mongo.go ├── pagination.go ├── status.go ├── string.go ├── tag.go └── time.go /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | 8 | - package-ecosystem: "github-actions" 9 | directory: "/" 10 | schedule: 11 | interval: "daily" 12 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: [push, pull_request] 3 | jobs: 4 | 5 | test: 6 | name: Test 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | 11 | - name: Set up Go 12 | uses: actions/setup-go@v5 13 | with: 14 | go-version: 1.19.5 15 | 16 | - name: Test 17 | run: make test 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .vscode 3 | .volumes 4 | mccs 5 | 6 | # config files 7 | production.yaml 8 | development.yaml -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2019-2020 IC3 Network 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so, 8 | subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 15 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 16 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | APP=mccs 2 | 3 | GIT_TAG = $(shell if [ "`git describe --tags --abbrev=0 2>/dev/null`" != "" ];then git describe --tags --abbrev=0; else git log --pretty=format:'%h' -n 1; fi) 4 | BUILD_DATE = $(shell TZ=UTC date +%FT%T%z) 5 | GIT_COMMIT = $(shell git log --pretty=format:'%H' -n 1) 6 | GIT_TREE_STATUS = $(shell if git status|grep -q 'clean';then echo clean; else echo dirty; fi) 7 | 8 | production: 9 | @echo "=============starting production server=============" 10 | GIT_TAG=${GIT_TAG} BUILD_DATE=${BUILD_DATE} GIT_COMMIT=${GIT_COMMIT} GIT_TREE_STATUS=${GIT_TREE_STATUS} \ 11 | docker-compose -f docker-compose.production.yml up --build 12 | 13 | clean: 14 | @echo "=============removing app=============" 15 | rm -f ${APP} 16 | 17 | run: 18 | @echo "=============starting server=============" 19 | docker-compose -f docker-compose.dev.yml up --build 20 | 21 | test: 22 | @echo "=============running test=============" 23 | go test ./... 24 | 25 | seed: 26 | @echo "=============generating seed data=============" 27 | go run cmd/seed/main.go -config="seed" 28 | 29 | es-restore: 30 | @echo "=============restoring es data=============" 31 | go run cmd/es-restore/main.go -config="seed" 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MCCS Alpha API 2 | 3 | ## Overview 4 | 5 | The MCCS Alpha API is a prototype API that exposes all of the functionality described in the [MCCS overview of functionality](https://github.com/ic3network/mccs/blob/master/alpha-functionality.md). 6 | 7 | By providing an API, developers who want to create their own front-end user interface for MCCS will have significant flexibility to implement it in whatever way they choose. This means developers can present MCCS in any language, setup their own signup flow, optimize it for whatever devices their users prefer, develop a mobile app, integrate other services such as chat, etc. 8 | 9 | Importantly, an API enables developers to integrate MCCS functionality directly into their own apps (e.g., import transfer data into an accounting application, instruct mutual credit transfers from an e-wallet application, etc.). 10 | 11 | We are making this code public to show our commitment to free and open source software, and to signal our intention to develop mutual credit software that will be freely available to anyone who wishes to implement a mutual credit transfer system. 12 | 13 | ## Main Functions 14 | 15 | There are four main functions that the MCCS API provides to its users: 16 | 17 | 1. **Manage accounts** - create and modify user accounts and their related entity details 18 | 2. **Find entities** - search for and view entities based on what they sell and need 19 | 3. **Transfer mutual credits** - create and complete/cancel mutual credit (MC) transfers between entities 20 | 4. **Review transfer activity** - view pending and completed MC transfers 21 | 22 | See the detailed description of these main functions in the [MCCS overview of functionality](https://github.com/ic3network/mccs/blob/master/alpha-functionality.md). 23 | 24 | ## API Documentation 25 | 26 | ### [User API](https://app.swaggerhub.com/apis-docs/ic3software/mccs-alpha-user-api/1) 27 | 28 | ### [Admin API](https://app.swaggerhub.com/apis-docs/ic3software/mccs-alpha-admin-api/1) 29 | 30 | ## How to Start 31 | 32 | Basic requirements: Go version 1.13+, Docker and Docker Compose ([see all requirements](#requirements)) 33 | 34 | 1. Change the name of the [`development-example.yaml` file](configs/development-example.yaml) to `configs/development.yaml` 35 | 1. Generate JSON Web Token public and private keys 36 | 1. Generate private key 37 | ``` 38 | openssl genrsa -out private.pem 2048 39 | ``` 40 | 1. Extract public key from it 41 | ``` 42 | openssl rsa -in private.pem -pubout > public.pem 43 | ``` 44 | 1. Copy/paste the private and public keys into `configs/development.yaml` 45 | 1. Run the app 46 | ``` 47 | make run 48 | ``` 49 | 1. Seed the DB with some test data 50 | ``` 51 | make seed 52 | ``` 53 | 1. [Make API calls](#api-documentation) 54 | 55 | ## Requirements 56 | 57 | **Software** 58 | 59 | - [Go](https://golang.org/doc/install) 60 | - [Docker](https://docs.docker.com/install/) 61 | - [Docker Compose](https://docs.docker.com/compose/install/) 62 | 63 | The MCCS web app is written in Go, and it uses Docker Compose to orchestrate itself and its dependencies as Docker containers. 64 | 65 | **App Dependencies** 66 | 67 | - [MongoDB](https://en.wikipedia.org/wiki/MongoDB) - the database used to store user and entity directory data 68 | - [PostgreSQL](https://www.postgresql.org/) - the database used to store mutual credit transfer data 69 | - [Elasticsearch](https://en.wikipedia.org/wiki/Elasticsearch) - the search engine for searching user/entity data 70 | - [Redis](https://redis.io/) - in-memory cache used for rate limiting API requests 71 | 72 | These dependencies are installed automatically by Docker Compose. 73 | 74 | **External Dependencies** 75 | 76 | - [Sendgrid](https://sendgrid.com/) - email delivery provider for sending [emails generated by MCCS](README-email.md) (e.g., welcome and password reset emails) 77 | 78 | Sendgrid does not need to be setup to run the app in development mode. 79 | -------------------------------------------------------------------------------- /cmd/mccs-alpha-api/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/ic3network/mccs-alpha-api/global" 5 | "github.com/ic3network/mccs-alpha-api/internal/app/http" 6 | "github.com/ic3network/mccs-alpha-api/internal/app/logic/balancecheck" 7 | "github.com/ic3network/mccs-alpha-api/internal/app/logic/dailyemail" 8 | "github.com/ic3network/mccs-alpha-api/util/l" 9 | "github.com/robfig/cron" 10 | "github.com/spf13/viper" 11 | ) 12 | 13 | func init() { 14 | global.Init() 15 | } 16 | 17 | func main() { 18 | // Flushes log buffer, if any. 19 | defer l.Logger.Sync() 20 | go ServeBackGround() 21 | go RunMigration() 22 | 23 | http.AppServer.Run(viper.GetString("port")) 24 | } 25 | 26 | // ServeBackGround performs the background activities. 27 | func ServeBackGround() { 28 | c := cron.New() 29 | 30 | viper.SetDefault("daily_email_schedule", "0 0 7 * * *") 31 | c.AddFunc(viper.GetString("daily_email_schedule"), func() { 32 | l.Logger.Info("[ServeBackGround] Running daily email schedule. \n") 33 | dailyemail.Run() 34 | }) 35 | 36 | viper.SetDefault("balance_check_schedule", "0 0 * * * *") 37 | c.AddFunc(viper.GetString("balance_check_schedule"), func() { 38 | l.Logger.Info("[ServeBackGround] Running balance check schedule. \n") 39 | balancecheck.Run() 40 | }) 41 | 42 | c.Start() 43 | } 44 | 45 | func RunMigration() { 46 | } 47 | -------------------------------------------------------------------------------- /cmd/seed/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/ic3network/mccs-alpha-api/global" 5 | "github.com/ic3network/mccs-alpha-api/internal/seed" 6 | ) 7 | 8 | func main() { 9 | global.Init() 10 | seed.LoadData() 11 | seed.Run() 12 | } 13 | -------------------------------------------------------------------------------- /configs/development-example.yaml: -------------------------------------------------------------------------------- 1 | # To create a development.yaml file, please replace xxx with actual values. 2 | 3 | env: development 4 | url: http://localhost:8080 5 | port: 8080 6 | reset_password_timeout: 60 # 1 minute, should be at least 60 minutes in production 7 | page_size: 10 8 | tags_limit: 10 9 | email_from: MCCS localhost dev 10 | daily_email_schedule: "* * 1 * * *" 11 | balance_check_schedule: "* * 1 * * *" 12 | concurrency_num: 3 13 | 14 | receive_email: 15 | trade_contact_emails: true 16 | signup_notifications: true 17 | 18 | login_attempts: 19 | limit: 3 # number of attempts before applying login_attempts_timeout 20 | timeout: 60 # 1 minute, should be at least 15 minutes in production 21 | 22 | rate_limiting: 23 | limit: 60 # number of requests within the duration; increase for automated testing scripts 24 | duration: 1 # minute 25 | 26 | validate: 27 | email: 28 | maxLen: 100 29 | password: 30 | minLen: 8 31 | 32 | transaction: 33 | max_neg_bal: 0 34 | max_pos_bal: 500 35 | 36 | psql: 37 | host: postgres 38 | port: 5432 39 | user: postgres 40 | password: 41 | db: mccs 42 | 43 | mongo: 44 | url: mongodb://mongo:27017 45 | database: mccs 46 | 47 | redis: 48 | host: redis 49 | port: 6379 50 | password: sOmE_sEcUrE_pAsS 51 | 52 | es: 53 | url: http://es01:9200 54 | 55 | jwt: 56 | private_key: | 57 | -----BEGIN RSA PRIVATE KEY----- 58 | xxx 59 | -----END RSA PRIVATE KEY----- 60 | public_key: | 61 | -----BEGIN PUBLIC KEY----- 62 | xxx 63 | -----END PUBLIC KEY----- 64 | 65 | sendgrid: 66 | key: xxx 67 | sender_email: xxx 68 | template_id: 69 | welcome_message: xxx 70 | daily_match_notification: xxx 71 | trade_contact: xxx 72 | transfer_initiated: xxx 73 | transfer_accepted: xxx 74 | transfer_rejected: xxx 75 | transfer_cancelled: xxx 76 | transfer_cancelled_by_system: xxx 77 | user_password_reset: xxx 78 | admin_password_reset: xxx 79 | signup_notification: xxx 80 | non_zero_balance_notification: xxx 81 | -------------------------------------------------------------------------------- /configs/revive.toml: -------------------------------------------------------------------------------- 1 | ignoreGeneratedHeader = false 2 | severity = "warning" 3 | confidence = 0.8 4 | errorCode = 0 5 | warningCode = 0 6 | 7 | [rule.blank-imports] 8 | [rule.context-as-argument] 9 | [rule.context-keys-type] 10 | [rule.dot-imports] 11 | [rule.error-return] 12 | [rule.error-strings] 13 | [rule.error-naming] 14 | [rule.exported] 15 | [rule.if-return] 16 | [rule.increment-decrement] 17 | [rule.var-naming] 18 | [rule.var-declaration] 19 | [rule.package-comments] 20 | [rule.range] 21 | [rule.receiver-naming] 22 | [rule.time-naming] 23 | [rule.unexported-return] 24 | [rule.indent-error-flow] 25 | [rule.errorf] 26 | [rule.empty-block] 27 | [rule.superfluous-else] 28 | [rule.unused-parameter] 29 | [rule.unreachable-code] 30 | [rule.redefines-builtin-id] 31 | -------------------------------------------------------------------------------- /configs/seed.yaml: -------------------------------------------------------------------------------- 1 | env: seed 2 | url: http://localhost:8080 3 | port: 8080 4 | reset_password_timeout: 60 5 | page_size: 10 6 | tags_limit: 10 7 | email_from: MCCS 8 | daily_email_schedule: "0 0 7 * * *" 9 | balance_check_schedule: "0 0 * * * *" 10 | concurrency_num: 3 11 | 12 | receive_email: 13 | trade_contact_emails: true 14 | signup_notifications: true 15 | 16 | login_attempts: 17 | limit: 3 18 | timeout: 60 19 | 20 | rate_limiting: 21 | duration: 1 # minute 22 | limit: 60 23 | 24 | validate: 25 | email: 26 | maxLen: 100 27 | password: 28 | minLen: 8 29 | 30 | transaction: 31 | max_neg_bal: 0 32 | max_pos_bal: 500 33 | 34 | psql: 35 | host: localhost 36 | port: 5432 37 | user: postgres 38 | password: 39 | db: mccs 40 | 41 | mongo: 42 | url: mongodb://localhost:27017 43 | database: mccs 44 | 45 | redis: 46 | host: localhost 47 | port: 6379 48 | password: sOmE_sEcUrE_pAsS 49 | 50 | es: 51 | url: http://localhost:9200 52 | 53 | jwt: 54 | private_key: | 55 | -----BEGIN RSA PRIVATE KEY----- 56 | xxx 57 | -----END RSA PRIVATE KEY----- 58 | public_key: | 59 | -----BEGIN PUBLIC KEY----- 60 | xxx 61 | -----END PUBLIC KEY----- 62 | 63 | sendgrid: 64 | key: xxx 65 | sender_email: xxx 66 | template_id: 67 | welcome_message: xxx 68 | daily_match_notification: xxx 69 | trade_contact: xxx 70 | transfer_initiated: xxx 71 | transfer_accepted: xxx 72 | transfer_rejected: xxx 73 | transfer_cancelled: xxx 74 | transfer_cancelled_by_system: xxx 75 | user_password_reset: xxx 76 | admin_password_reset: xxx 77 | signup_notification: xxx 78 | non_zero_balance_notification: xxx 79 | -------------------------------------------------------------------------------- /configs/test.yaml: -------------------------------------------------------------------------------- 1 | env: test 2 | url: http://localhost:8080 3 | port: 8080 4 | reset_password_timeout: 60 5 | page_size: 10 6 | tags_limit: 10 7 | email_from: MCCS 8 | daily_email_schedule: "0 0 7 * * *" 9 | balance_check_schedule: "0 0 * * * *" 10 | concurrency_num: 3 11 | 12 | receive_email: 13 | trade_contact_emails: true 14 | signup_notifications: true 15 | 16 | login_attempts: 17 | limit: 3 18 | timeout: 60 19 | 20 | rate_limiting: 21 | duration: 1 # minute 22 | limit: 1000 23 | 24 | validate: 25 | email: 26 | maxLen: 100 27 | password: 28 | minLen: 8 29 | 30 | transaction: 31 | max_neg_bal: 0 32 | max_pos_bal: 500 33 | 34 | psql: 35 | host: postgres 36 | port: 5432 37 | user: postgres 38 | password: 39 | db: mccs 40 | 41 | mongo: 42 | url: mongodb://mongo:27017 43 | database: mccs 44 | 45 | redis: 46 | host: redis 47 | port: 6379 48 | password: sOmE_sEcUrE_pAsS 49 | 50 | es: 51 | url: http://es01:9200 52 | 53 | jwt: 54 | private_key: | 55 | -----BEGIN RSA PRIVATE KEY----- 56 | xxx 57 | -----END RSA PRIVATE KEY----- 58 | public_key: | 59 | -----BEGIN PUBLIC KEY----- 60 | xxx 61 | -----END PUBLIC KEY----- 62 | 63 | sendgrid: 64 | key: xxx 65 | sender_email: xxx 66 | template_id: 67 | welcome_message: xxx 68 | daily_match_notification: xxx 69 | trade_contact: xxx 70 | transfer_initiated: xxx 71 | transfer_accepted: xxx 72 | transfer_rejected: xxx 73 | transfer_cancelled: xxx 74 | transfer_cancelled_by_system: xxx 75 | user_password_reset: xxx 76 | admin_password_reset: xxx 77 | signup_notification: xxx 78 | non_zero_balance_notification: xxx 79 | -------------------------------------------------------------------------------- /docker-compose.dev.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | # Web Service 5 | web: 6 | container_name: mccs 7 | build: 8 | context: . 9 | dockerfile: dockerfile.dev 10 | volumes: 11 | # mounts the current directory to /usr/src/app in the container 12 | - ./:/usr/src/app 13 | ports: 14 | - 8080:8080 15 | depends_on: 16 | - postgres 17 | - mongo 18 | - redis 19 | - es01 20 | 21 | # PostgreSQL Service 22 | postgres: 23 | container_name: postgres 24 | image: postgres:11.4 25 | # Log all statements to the PostgreSQL log. 26 | command: ["postgres", "-c", "log_statement=all"] 27 | ports: 28 | - 5432:5432 29 | environment: 30 | - POSTGRES_USER=postgres 31 | - POSTGRES_DB=mccs 32 | volumes: 33 | - postgresql:/var/lib/postgresql/data 34 | 35 | # MongoDB Service 36 | mongo: 37 | container_name: mongo 38 | image: mongo:4.0.10 39 | ports: 40 | - 27017:27017 41 | volumes: 42 | - mongodb:/data/db 43 | 44 | # Redis Service 45 | redis: 46 | container_name: redis 47 | image: redis:alpine 48 | command: redis-server --requirepass sOmE_sEcUrE_pAsS 49 | ports: 50 | - 6379:6379 51 | environment: 52 | - REDIS_REPLICATION_MODE=master 53 | volumes: 54 | - redis:/data 55 | 56 | # Elasticsearch Service 57 | es01: 58 | container_name: es01 59 | image: docker.elastic.co/elasticsearch/elasticsearch:7.17.5 60 | environment: 61 | - node.name=es01 62 | # single-node discovery type. 63 | - discovery.type=single-node 64 | # JVM memory: initial and max set to 512MB. 65 | - "ES_JAVA_OPTS=-Xms512m -Xmx512m" 66 | ports: 67 | - 9200:9200 68 | volumes: 69 | - esdata01:/usr/share/elasticsearch/data 70 | healthcheck: 71 | test: ["CMD-SHELL", "curl --silent --fail localhost:9200/_cluster/health || exit 1"] 72 | interval: 30s 73 | timeout: 30s 74 | retries: 3 75 | 76 | # Kibana Service 77 | kibana: 78 | container_name: kibana 79 | image: docker.elastic.co/kibana/kibana:7.1.1 80 | environment: 81 | - ELASTICSEARCH_HOSTS=http://es01:9200 82 | ports: 83 | - 5601:5601 84 | depends_on: 85 | - es01 86 | 87 | # Persistent Volumes 88 | volumes: 89 | postgresql: 90 | mongodb: 91 | redis: 92 | esdata01: 93 | -------------------------------------------------------------------------------- /docker-compose.production.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | # Web Service 5 | web: 6 | container_name: mccs 7 | build: 8 | context: . 9 | dockerfile: dockerfile.production 10 | args: 11 | - GIT_TAG=$GIT_TAG 12 | - BUILD_DATE=$BUILD_DATE 13 | - GIT_COMMIT=$GIT_COMMIT 14 | - GIT_TREE_STATUS=$GIT_TREE_STATUS 15 | restart: always 16 | volumes: 17 | - ./:/usr/src/app 18 | ports: 19 | - 8080:8080 20 | depends_on: 21 | - postgres 22 | - mongo 23 | - es01 24 | 25 | # PostgreSQL Service 26 | postgres: 27 | container_name: postgres 28 | image: postgres:11.4 29 | restart: always 30 | ports: 31 | - 5432:5432 32 | environment: 33 | - POSTGRES_USER=postgres 34 | - POSTGRES_DB=mccs 35 | volumes: 36 | - postgresql:/var/lib/postgresql/data 37 | 38 | # MongoDB Service 39 | mongo: 40 | container_name: mongo 41 | image: mongo:4.0.10 42 | restart: always 43 | ports: 44 | - 27017:27017 45 | volumes: 46 | - mongodb:/data/db 47 | - restore:/data/restore 48 | 49 | # Elasticsearch Service 50 | es01: 51 | container_name: es01 52 | image: docker.elastic.co/elasticsearch/elasticsearch:7.17.5 53 | restart: always 54 | environment: 55 | - node.name=es01 56 | # single-node discovery type. 57 | - discovery.type=single-node 58 | # JVM memory: initial and max set to 512MB. 59 | ports: 60 | - 9200:9200 61 | volumes: 62 | - esdata01:/usr/share/elasticsearch/data 63 | healthcheck: 64 | test: ["CMD-SHELL", "curl --silent --fail localhost:9200/_cluster/health || exit 1"] 65 | interval: 30s 66 | timeout: 30s 67 | retries: 3 68 | 69 | # Kibana Service 70 | kibana: 71 | container_name: kibana 72 | image: docker.elastic.co/kibana/kibana:7.1.1 73 | restart: always 74 | environment: 75 | - ELASTICSEARCH_HOSTS=http://es01:9200 76 | ports: 77 | - 5601:5601 78 | depends_on: 79 | - es01 80 | 81 | # Persistent Volumes 82 | volumes: 83 | mongodb: 84 | driver: local 85 | driver_opts: 86 | type: 'none' 87 | o: 'bind' 88 | device: 'mnt/mccs_data/mongo' 89 | esdata01: 90 | driver: local 91 | driver_opts: 92 | type: 'none' 93 | o: 'bind' 94 | device: 'mnt/mccs_data/es' 95 | restore: 96 | driver: local 97 | driver_opts: 98 | type: 'none' 99 | o: 'bind' 100 | device: 'mnt/mccs_data/restore' 101 | postgresql: 102 | driver: local 103 | driver_opts: 104 | type: 'none' 105 | o: 'bind' 106 | device: 'mnt/mccs_data/postgres' 107 | -------------------------------------------------------------------------------- /dockerfile.dev: -------------------------------------------------------------------------------- 1 | # Base image from which we are building. 2 | FROM golang:1.19.5-alpine 3 | 4 | WORKDIR /usr/src/app 5 | 6 | COPY go.mod go.sum ./ 7 | 8 | # Download the dependencies listed in the go.mod file. 9 | RUN go mod download 10 | 11 | # Install reflex, a tool for hot reloading of Go applications. 12 | RUN go install github.com/cespare/reflex@latest 13 | 14 | # The CMD instruction provides defaults for executing the container. 15 | CMD ["reflex", "-c", "./reflex.dev.conf"] 16 | -------------------------------------------------------------------------------- /dockerfile.production: -------------------------------------------------------------------------------- 1 | # Use the official Golang image as the build environment. 2 | FROM golang:1.19.5-alpine AS builder 3 | 4 | # Set the working directory within the Docker image. 5 | WORKDIR /temp 6 | 7 | # Copy go.mod and go.sum to the /temp directory in the image. 8 | COPY go.mod go.sum ./ 9 | 10 | # Download the dependencies listed in go.mod. 11 | RUN go mod download 12 | 13 | # Copy the rest of the application code to the /temp directory. 14 | COPY . . 15 | 16 | # Start a new build stage. This stage begins with a minimal Alpine Linux image. 17 | FROM alpine:latest 18 | 19 | # Upgrade all the software in the Alpine Linux image, then 20 | # install the ca-certificates package. This package is necessary 21 | # if your application makes HTTPS requests. 22 | RUN apk --no-cache --update upgrade && \ 23 | apk --no-cache add ca-certificates 24 | 25 | # Set the working directory to /app. 26 | WORKDIR /app 27 | 28 | COPY --from=builder /temp . 29 | COPY --from=0 /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt 30 | 31 | # Expose port 8080. This is the port that your application will use. 32 | EXPOSE 8080 33 | 34 | # Specify the command to run when the Docker container starts. 35 | ENTRYPOINT ["./mccs", "-config=production"] 36 | 37 | # CMD is optional; it provides defaults for an executing container. 38 | CMD [] 39 | -------------------------------------------------------------------------------- /global/app.go: -------------------------------------------------------------------------------- 1 | package global 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | 7 | "github.com/spf13/viper" 8 | ) 9 | 10 | func init() { 11 | App.RootDir = "." 12 | if !viper.InConfig("url") { 13 | App.RootDir = inferRootDir() 14 | } 15 | } 16 | 17 | func inferRootDir() string { 18 | cwd, err := os.Getwd() 19 | if err != nil { 20 | panic(err) 21 | } 22 | 23 | var infer func(d string) string 24 | infer = func(d string) string { 25 | if Exist(d + "/configs") { 26 | return d 27 | } 28 | 29 | return infer(filepath.Dir(d)) 30 | } 31 | 32 | return infer(cwd) 33 | } 34 | 35 | var App = &app{} 36 | 37 | type app struct { 38 | RootDir string 39 | } 40 | 41 | func Exist(filename string) bool { 42 | _, err := os.Stat(filename) 43 | return err == nil || os.IsExist(err) 44 | } 45 | -------------------------------------------------------------------------------- /global/constant/constant.go: -------------------------------------------------------------------------------- 1 | package constant 2 | 3 | var ( 4 | ALL = "all" 5 | 6 | OFFERS = "offers" 7 | WANTS = "wants" 8 | ) 9 | -------------------------------------------------------------------------------- /global/constant/date.go: -------------------------------------------------------------------------------- 1 | package constant 2 | 3 | import "time" 4 | 5 | var Date = struct { 6 | DefaultFrom time.Time 7 | DefaultTo time.Time 8 | }{ 9 | DefaultFrom: time.Date(2000, 1, 1, 00, 00, 0, 0, time.UTC), 10 | DefaultTo: time.Date(2100, 1, 1, 00, 00, 0, 0, time.UTC), 11 | } 12 | -------------------------------------------------------------------------------- /global/constant/entity.go: -------------------------------------------------------------------------------- 1 | package constant 2 | 3 | // Entity status 4 | var Entity = struct { 5 | Pending string 6 | Accepted string 7 | Rejected string 8 | }{ 9 | Pending: "pending", 10 | Accepted: "accepted", 11 | Rejected: "rejected", 12 | } 13 | 14 | // Trading Status decides whether a entity can perform transactions (already in accepted status). 15 | var Trading = struct { 16 | Pending string 17 | Accepted string 18 | Rejected string 19 | }{ 20 | Pending: "tradingPending", 21 | Accepted: "tradingAccepted", 22 | Rejected: "tradingRejected", 23 | } 24 | -------------------------------------------------------------------------------- /global/constant/transfer.go: -------------------------------------------------------------------------------- 1 | package constant 2 | 3 | func MapTransferType(name string) string { 4 | if name == "initiated" { 5 | return Transfer.Initiated 6 | } else if name == "completed" { 7 | return Transfer.Completed 8 | } else if name == "cancelled" { 9 | return Transfer.Cancelled 10 | } 11 | return "unknown" 12 | } 13 | 14 | var Transfer = struct { 15 | Initiated string 16 | Completed string 17 | Cancelled string 18 | }{ 19 | Initiated: "transferInitiated", 20 | Completed: "transferCompleted", 21 | Cancelled: "transferCancelled", 22 | } 23 | 24 | var TransferDirection = struct { 25 | In string 26 | Out string 27 | }{ 28 | In: "in", 29 | Out: "out", 30 | } 31 | 32 | var TransferType = struct { 33 | Transfer string 34 | AdminTransfer string 35 | }{ 36 | Transfer: "transfer", 37 | AdminTransfer: "adminTransfer", 38 | } 39 | -------------------------------------------------------------------------------- /global/constant/unit.go: -------------------------------------------------------------------------------- 1 | package constant 2 | 3 | var Unit = struct { 4 | UK string 5 | }{ 6 | UK: "ocn-uk", 7 | } 8 | -------------------------------------------------------------------------------- /global/init.go: -------------------------------------------------------------------------------- 1 | package global 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "log" 7 | "strings" 8 | "sync" 9 | 10 | "github.com/fsnotify/fsnotify" 11 | "github.com/ic3network/mccs-alpha-api/util/l" 12 | "github.com/spf13/viper" 13 | ) 14 | 15 | var ( 16 | once = new(sync.Once) 17 | configName = flag.String("config", "development", "config file name, default is development") 18 | ShowVersionInfo = flag.Bool("v", false, "show version info or not") 19 | ) 20 | 21 | func Init() { 22 | once.Do(func() { 23 | if !flag.Parsed() { 24 | flag.Parse() 25 | } 26 | if err := initConfig(); err != nil { 27 | panic(fmt.Errorf("initconfig failed: %s \n", err)) 28 | } 29 | watchConfig() 30 | 31 | l.Init(viper.GetString("env")) 32 | }) 33 | } 34 | 35 | func initConfig() error { 36 | viper.SetConfigName(*configName) 37 | viper.AddConfigPath("configs") 38 | viper.AddConfigPath(App.RootDir + "/configs") 39 | viper.SetConfigType("yaml") 40 | viper.AutomaticEnv() 41 | replacer := strings.NewReplacer(".", "_") 42 | viper.SetEnvKeyReplacer(replacer) 43 | if err := viper.ReadInConfig(); err != nil { 44 | return err 45 | } 46 | return nil 47 | } 48 | 49 | func watchConfig() { 50 | viper.WatchConfig() 51 | viper.OnConfigChange(func(e fsnotify.Event) { 52 | log.Printf("Config file changed: %s", e.Name) 53 | }) 54 | } 55 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/ic3network/mccs-alpha-api 2 | 3 | go 1.19 4 | 5 | require ( 6 | github.com/ShiraazMoollatjie/goluhn v0.0.0-20200114194008-196cda0245b9 7 | github.com/fsnotify/fsnotify v1.7.0 8 | github.com/gofrs/uuid/v5 v5.0.0 9 | github.com/golang-jwt/jwt/v5 v5.2.0 10 | github.com/gorilla/handlers v1.5.2 11 | github.com/gorilla/mux v1.8.1 12 | github.com/jinzhu/gorm v1.9.16 13 | github.com/jinzhu/now v1.1.5 14 | github.com/olivere/elastic/v7 v7.0.32 15 | github.com/redis/go-redis/v9 v9.4.0 16 | github.com/robfig/cron v1.2.0 17 | github.com/segmentio/ksuid v1.0.4 18 | github.com/sendgrid/sendgrid-go v3.5.0+incompatible 19 | github.com/shirou/gopsutil v3.21.11+incompatible 20 | github.com/spf13/viper v1.18.2 21 | github.com/stretchr/testify v1.8.4 22 | go.mongodb.org/mongo-driver v1.13.1 23 | go.uber.org/zap v1.26.0 24 | golang.org/x/crypto v0.18.0 25 | gopkg.in/oleiade/reflections.v1 v1.0.0 26 | ) 27 | 28 | require ( 29 | github.com/cespare/xxhash/v2 v2.2.0 // indirect 30 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 31 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 32 | github.com/felixge/httpsnoop v1.0.3 // indirect 33 | github.com/go-ole/go-ole v1.2.6 // indirect 34 | github.com/golang/snappy v0.0.1 // indirect 35 | github.com/hashicorp/hcl v1.0.0 // indirect 36 | github.com/jinzhu/inflection v1.0.0 // indirect 37 | github.com/josharian/intern v1.0.0 // indirect 38 | github.com/klauspost/compress v1.17.0 // indirect 39 | github.com/lib/pq v1.1.1 // indirect 40 | github.com/magiconair/properties v1.8.7 // indirect 41 | github.com/mailru/easyjson v0.7.7 // indirect 42 | github.com/mitchellh/mapstructure v1.5.0 // indirect 43 | github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe // indirect 44 | github.com/oleiade/reflections v1.0.0 // indirect 45 | github.com/pelletier/go-toml/v2 v2.1.0 // indirect 46 | github.com/pkg/errors v0.9.1 // indirect 47 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 48 | github.com/sagikazarmark/locafero v0.4.0 // indirect 49 | github.com/sagikazarmark/slog-shim v0.1.0 // indirect 50 | github.com/sendgrid/rest v2.4.1+incompatible // indirect 51 | github.com/sourcegraph/conc v0.3.0 // indirect 52 | github.com/spf13/afero v1.11.0 // indirect 53 | github.com/spf13/cast v1.6.0 // indirect 54 | github.com/spf13/pflag v1.0.5 // indirect 55 | github.com/subosito/gotenv v1.6.0 // indirect 56 | github.com/tklauser/go-sysconf v0.3.12 // indirect 57 | github.com/tklauser/numcpus v0.6.1 // indirect 58 | github.com/xdg-go/pbkdf2 v1.0.0 // indirect 59 | github.com/xdg-go/scram v1.1.2 // indirect 60 | github.com/xdg-go/stringprep v1.0.4 // indirect 61 | github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d // indirect 62 | github.com/yusufpapurcu/wmi v1.2.3 // indirect 63 | go.uber.org/multierr v1.10.0 // indirect 64 | golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect 65 | golang.org/x/sync v0.5.0 // indirect 66 | golang.org/x/sys v0.16.0 // indirect 67 | golang.org/x/text v0.14.0 // indirect 68 | gopkg.in/ini.v1 v1.67.0 // indirect 69 | gopkg.in/yaml.v3 v3.0.1 // indirect 70 | ) 71 | -------------------------------------------------------------------------------- /internal/app/api/api.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | 7 | "github.com/ic3network/mccs-alpha-api/util/l" 8 | "go.uber.org/zap" 9 | ) 10 | 11 | // Respond return an object with specific status as JSON. 12 | func Respond(w http.ResponseWriter, r *http.Request, status int, data ...interface{}) { 13 | if len(data) == 0 { 14 | w.Header().Set("Content-Type", "application/json") 15 | w.WriteHeader(status) 16 | return 17 | } 18 | 19 | d := evaluateData(data[0]) 20 | 21 | if d == nil { 22 | w.WriteHeader(status) 23 | return 24 | } 25 | 26 | js, err := json.Marshal(d) 27 | if err != nil { 28 | http.Error(w, err.Error(), http.StatusInternalServerError) 29 | l.Logger.Error("[ERROR] Marshaling data error:", zap.Error(err)) 30 | return 31 | } 32 | 33 | w.Header().Set("Content-Type", "application/json") 34 | w.WriteHeader(status) 35 | w.Write(js) 36 | } 37 | 38 | func evaluateData(data interface{}) interface{} { 39 | var value interface{} 40 | 41 | if errors, ok := isMultipleErrors(data); ok { 42 | value = getErrors(errors) 43 | } else if e, ok := data.(error); ok { 44 | value = httpErrors{Errors: []httpError{{Message: e.Error()}}} 45 | } else { 46 | value = data 47 | } 48 | 49 | return value 50 | } 51 | 52 | func isMultipleErrors(data interface{}) ([]error, bool) { 53 | errors, ok := data.([]error) 54 | if ok { 55 | return errors, true 56 | } 57 | return nil, false 58 | } 59 | 60 | func getErrors(data []error) httpErrors { 61 | errs := httpErrors{} 62 | 63 | for _, err := range data { 64 | errs.Errors = append(errs.Errors, httpError{Message: err.Error()}) 65 | } 66 | 67 | return errs 68 | } 69 | -------------------------------------------------------------------------------- /internal/app/api/errors.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import "errors" 4 | 5 | type httpError struct { 6 | Message string `json:"message"` 7 | } 8 | 9 | type httpErrors struct { 10 | Errors []httpError `json:"errors"` 11 | } 12 | 13 | var ( 14 | // ErrUnauthorized occurs when the user is unauthorized. 15 | ErrUnauthorized = errors.New("Could not authenticate you.") 16 | // ErrPermissionDenied occurs when the user does not have permission to perform the action. 17 | ErrPermissionDenied = errors.New("Permission denied.") 18 | ) 19 | -------------------------------------------------------------------------------- /internal/app/http/controller/category.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | 7 | "sync" 8 | 9 | "github.com/gorilla/mux" 10 | "github.com/ic3network/mccs-alpha-api/internal/app/api" 11 | "github.com/ic3network/mccs-alpha-api/internal/app/logic" 12 | "github.com/ic3network/mccs-alpha-api/internal/app/types" 13 | "github.com/ic3network/mccs-alpha-api/util/l" 14 | "go.uber.org/zap" 15 | ) 16 | 17 | type categoryHandler struct { 18 | once *sync.Once 19 | } 20 | 21 | var CategoryHandler = newCategoryHandler() 22 | 23 | func newCategoryHandler() *categoryHandler { 24 | return &categoryHandler{ 25 | once: new(sync.Once), 26 | } 27 | } 28 | 29 | func (handler *categoryHandler) RegisterRoutes( 30 | public *mux.Router, 31 | private *mux.Router, 32 | adminPublic *mux.Router, 33 | adminPrivate *mux.Router, 34 | ) { 35 | handler.once.Do(func() { 36 | public.Path("/categories").HandlerFunc(handler.search()).Methods("GET") 37 | 38 | adminPrivate.Path("/categories").HandlerFunc(handler.create()).Methods("POST") 39 | adminPrivate.Path("/categories/{id}").HandlerFunc(handler.update()).Methods("PATCH") 40 | adminPrivate.Path("/categories/{id}").HandlerFunc(handler.delete()).Methods("DELETE") 41 | }) 42 | } 43 | 44 | func (handler *categoryHandler) Update(categories []string) { 45 | err := logic.Category.Create(categories...) 46 | if err != nil { 47 | l.Logger.Error("[Error] CategoryHandler.Update failed:", zap.Error(err)) 48 | } 49 | } 50 | 51 | // GET /categories 52 | 53 | func (handler *categoryHandler) search() func(http.ResponseWriter, *http.Request) { 54 | type meta struct { 55 | NumberOfResults int `json:"numberOfResults"` 56 | TotalPages int `json:"totalPages"` 57 | } 58 | type respond struct { 59 | Data []*types.CategoryRespond `json:"data"` 60 | Meta meta `json:"meta"` 61 | } 62 | return func(w http.ResponseWriter, r *http.Request) { 63 | req, err := types.NewSearchCategoryReq(r.URL.Query()) 64 | if err != nil { 65 | l.Logger.Info("[Info] CategoryHandler.search failed:", zap.Error(err)) 66 | api.Respond(w, r, http.StatusBadRequest, err) 67 | return 68 | } 69 | 70 | errs := req.Validate() 71 | if len(errs) > 0 { 72 | api.Respond(w, r, http.StatusBadRequest, errs) 73 | return 74 | } 75 | 76 | found, err := logic.Category.Search(req) 77 | if err != nil { 78 | l.Logger.Error("[Error] CategoryHandler.search failed:", zap.Error(err)) 79 | api.Respond(w, r, http.StatusInternalServerError, err) 80 | return 81 | } 82 | 83 | api.Respond(w, r, http.StatusOK, respond{ 84 | Data: types.NewSearchCategoryRespond(found.Categories), 85 | Meta: meta{ 86 | TotalPages: found.TotalPages, 87 | NumberOfResults: found.NumberOfResults, 88 | }, 89 | }) 90 | } 91 | } 92 | 93 | // POST /admin/categories 94 | 95 | func (handler *categoryHandler) create() func(http.ResponseWriter, *http.Request) { 96 | type respond struct { 97 | Data *types.CategoryRespond `json:"data"` 98 | } 99 | return func(w http.ResponseWriter, r *http.Request) { 100 | req, errs := types.NewAdminCreateCategoryReq(r) 101 | if len(errs) > 0 { 102 | api.Respond(w, r, http.StatusBadRequest, errs) 103 | return 104 | } 105 | 106 | _, err := logic.Category.FindByName(req.Name) 107 | if err == nil { 108 | api.Respond(w, r, http.StatusBadRequest, errors.New("Category already exists.")) 109 | return 110 | } 111 | 112 | created, err := logic.Category.CreateOne(req.Name) 113 | if err != nil { 114 | l.Logger.Error("[Error] CategoryHandler.create failed:", zap.Error(err)) 115 | api.Respond(w, r, http.StatusInternalServerError, err) 116 | return 117 | } 118 | 119 | go logic.UserAction.AdminCreateCategory(r.Header.Get("userID"), req.Name) 120 | 121 | api.Respond(w, r, http.StatusOK, respond{Data: types.NewCategoryRespond(created)}) 122 | } 123 | } 124 | 125 | // PATCH /admin/categories/{id} 126 | 127 | func (handler *categoryHandler) update() func(http.ResponseWriter, *http.Request) { 128 | type respond struct { 129 | Data *types.CategoryRespond `json:"data"` 130 | } 131 | return func(w http.ResponseWriter, r *http.Request) { 132 | req, errs := types.NewAdminUpdateCategoryReq(r) 133 | if len(errs) > 0 { 134 | api.Respond(w, r, http.StatusBadRequest, errs) 135 | return 136 | } 137 | 138 | _, err := logic.Category.FindByName(req.Name) 139 | if err == nil { 140 | api.Respond(w, r, http.StatusBadRequest, errors.New("Category already exists.")) 141 | return 142 | } 143 | 144 | old, err := logic.Category.FindByIDString(req.ID) 145 | if err != nil { 146 | l.Logger.Error("[Error] CategoryHandler.update failed:", zap.Error(err)) 147 | api.Respond(w, r, http.StatusInternalServerError, err) 148 | return 149 | } 150 | 151 | updated, err := logic.Category.FindOneAndUpdate(old.ID, &types.Category{Name: req.Name}) 152 | if err != nil { 153 | l.Logger.Error("[Error] CategoryHandler.update failed:", zap.Error(err)) 154 | api.Respond(w, r, http.StatusInternalServerError, err) 155 | return 156 | } 157 | 158 | go func() { 159 | err := logic.Entity.RenameCategory(old.Name, updated.Name) 160 | if err != nil { 161 | l.Logger.Error("[Error] CategoryHandler.update failed:", zap.Error(err)) 162 | return 163 | } 164 | }() 165 | 166 | go logic.UserAction.AdminModifyCategory(r.Header.Get("userID"), old.Name, updated.Name) 167 | 168 | api.Respond(w, r, http.StatusOK, respond{Data: types.NewCategoryRespond(updated)}) 169 | } 170 | } 171 | 172 | // DELETE /admin/categories/{id} 173 | 174 | func (handler *categoryHandler) delete() func(http.ResponseWriter, *http.Request) { 175 | type respond struct { 176 | Data *types.CategoryRespond `json:"data"` 177 | } 178 | return func(w http.ResponseWriter, r *http.Request) { 179 | req, errs := types.NewAdminDeleteCategoryReq(r) 180 | if len(errs) > 0 { 181 | api.Respond(w, r, http.StatusBadRequest, errs) 182 | return 183 | } 184 | 185 | deleted, err := logic.Category.FindOneAndDelete(req.ID) 186 | if err != nil { 187 | l.Logger.Error("[Error] CategoryHandler.delete failed:", zap.Error(err)) 188 | api.Respond(w, r, http.StatusBadRequest, err) 189 | return 190 | } 191 | 192 | go func() { 193 | err := logic.Entity.DeleteCategory(deleted.Name) 194 | if err != nil { 195 | l.Logger.Error("[Error] logic.Entity.delete failed:", zap.Error(err)) 196 | } 197 | }() 198 | 199 | go logic.UserAction.AdminDeleteCategory(r.Header.Get("userID"), deleted.Name) 200 | 201 | api.Respond(w, r, http.StatusOK, respond{Data: types.NewCategoryRespond(deleted)}) 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /internal/app/http/controller/service_discovery.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "sync" 7 | 8 | "github.com/gorilla/mux" 9 | "github.com/shirou/gopsutil/cpu" 10 | "github.com/shirou/gopsutil/disk" 11 | "github.com/shirou/gopsutil/load" 12 | "github.com/shirou/gopsutil/mem" 13 | ) 14 | 15 | type serviceDiscovery struct { 16 | once *sync.Once 17 | } 18 | 19 | var ServiceDiscovery = newServiceDiscovery() 20 | 21 | func newServiceDiscovery() *serviceDiscovery { 22 | return &serviceDiscovery{ 23 | once: new(sync.Once), 24 | } 25 | } 26 | 27 | func (s *serviceDiscovery) RegisterRoutes( 28 | public *mux.Router, 29 | private *mux.Router, 30 | ) { 31 | s.once.Do(func() { 32 | public.Path("/health").HandlerFunc(s.healthCheck).Methods("GET") 33 | public.Path("/disk").HandlerFunc(s.diskCheck).Methods("GET") 34 | public.Path("/cpu").HandlerFunc(s.cpuCheck).Methods("GET") 35 | public.Path("/ram").HandlerFunc(s.ramCheck).Methods("GET") 36 | }) 37 | } 38 | 39 | const ( 40 | B = 1 41 | KB = 1024 * B 42 | MB = 1024 * KB 43 | GB = 1024 * MB 44 | ) 45 | 46 | // HealthCheck shows `OK` as the ping-pong result. 47 | func (s *serviceDiscovery) healthCheck(w http.ResponseWriter, r *http.Request) { 48 | message := "OK" 49 | w.WriteHeader(http.StatusOK) 50 | w.Write([]byte("\n" + message)) 51 | } 52 | 53 | // DiskCheck checks the disk usage. 54 | func (s *serviceDiscovery) diskCheck(w http.ResponseWriter, r *http.Request) { 55 | u, _ := disk.Usage("/") 56 | 57 | usedMB := int(u.Used) / MB 58 | usedGB := int(u.Used) / GB 59 | totalMB := int(u.Total) / MB 60 | totalGB := int(u.Total) / GB 61 | usedPercent := int(u.UsedPercent) 62 | 63 | status := http.StatusOK 64 | text := "OK" 65 | 66 | if usedPercent >= 95 { 67 | status = http.StatusOK 68 | text = "CRITICAL" 69 | } else if usedPercent >= 90 { 70 | status = http.StatusTooManyRequests 71 | text = "WARNING" 72 | } 73 | 74 | message := fmt.Sprintf("%s - Free space: %dMB (%dGB) / %dMB (%dGB) | Used: %d%%", text, usedMB, usedGB, totalMB, totalGB, usedPercent) 75 | w.WriteHeader(status) 76 | w.Write([]byte("\n" + message)) 77 | } 78 | 79 | // CPUCheck checks the cpu usage. 80 | func (s *serviceDiscovery) cpuCheck(w http.ResponseWriter, r *http.Request) { 81 | cores, _ := cpu.Counts(false) 82 | 83 | a, _ := load.Avg() 84 | l1 := a.Load1 85 | l5 := a.Load5 86 | l15 := a.Load15 87 | 88 | status := http.StatusOK 89 | text := "OK" 90 | 91 | if l5 >= float64(cores-1) { 92 | status = http.StatusInternalServerError 93 | text = "CRITICAL" 94 | } else if l5 >= float64(cores-2) { 95 | status = http.StatusTooManyRequests 96 | text = "WARNING" 97 | } 98 | 99 | message := fmt.Sprintf("%s - Load average: %.2f, %.2f, %.2f | Cores: %d", text, l1, l5, l15, cores) 100 | w.WriteHeader(status) 101 | w.Write([]byte("\n" + message)) 102 | } 103 | 104 | // RAMCheck checks the disk usage. 105 | func (s *serviceDiscovery) ramCheck(w http.ResponseWriter, r *http.Request) { 106 | u, _ := mem.VirtualMemory() 107 | 108 | usedMB := int(u.Used) / MB 109 | usedGB := int(u.Used) / GB 110 | totalMB := int(u.Total) / MB 111 | totalGB := int(u.Total) / GB 112 | usedPercent := int(u.UsedPercent) 113 | 114 | status := http.StatusOK 115 | text := "OK" 116 | 117 | if usedPercent >= 95 { 118 | status = http.StatusInternalServerError 119 | text = "CRITICAL" 120 | } else if usedPercent >= 90 { 121 | status = http.StatusTooManyRequests 122 | text = "WARNING" 123 | } 124 | 125 | message := fmt.Sprintf("%s - Free space: %dMB (%dGB) / %dMB (%dGB) | Used: %d%%", text, usedMB, usedGB, totalMB, totalGB, usedPercent) 126 | w.WriteHeader(status) 127 | w.Write([]byte("\n" + message)) 128 | } 129 | -------------------------------------------------------------------------------- /internal/app/http/controller/tag.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | "sync" 7 | 8 | "github.com/ic3network/mccs-alpha-api/internal/app/logic" 9 | "github.com/ic3network/mccs-alpha-api/internal/app/types" 10 | 11 | "github.com/gorilla/mux" 12 | "github.com/ic3network/mccs-alpha-api/internal/app/api" 13 | "github.com/ic3network/mccs-alpha-api/util/l" 14 | "go.uber.org/zap" 15 | ) 16 | 17 | type tagHandler struct { 18 | once *sync.Once 19 | } 20 | 21 | var TagHandler = newTagHandler() 22 | 23 | func newTagHandler() *tagHandler { 24 | return &tagHandler{ 25 | once: new(sync.Once), 26 | } 27 | } 28 | 29 | func (handler *tagHandler) RegisterRoutes( 30 | public *mux.Router, 31 | private *mux.Router, 32 | adminPublic *mux.Router, 33 | adminPrivate *mux.Router, 34 | ) { 35 | handler.once.Do(func() { 36 | public.Path("/tags").HandlerFunc(handler.searchTag()).Methods("GET") 37 | 38 | adminPrivate.Path("/tags").HandlerFunc(handler.adminCreate()).Methods("POST") 39 | adminPrivate.Path("/tags/{id}").HandlerFunc(handler.adminUpdate()).Methods("PATCH") 40 | adminPrivate.Path("/tags/{id}").HandlerFunc(handler.adminDelete()).Methods("DELETE") 41 | }) 42 | } 43 | 44 | func (h *tagHandler) UpdateOffers(added []string) error { 45 | for _, tagName := range added { 46 | // TODO: UpdateOffers 47 | err := logic.Tag.UpdateOffer(tagName) 48 | if err != nil { 49 | return err 50 | } 51 | } 52 | return nil 53 | } 54 | 55 | func (h *tagHandler) UpdateWants(added []string) error { 56 | for _, tagName := range added { 57 | // TODO: UpdateWants 58 | err := logic.Tag.UpdateWant(tagName) 59 | if err != nil { 60 | return err 61 | } 62 | } 63 | return nil 64 | } 65 | 66 | // GET /tags 67 | // GET /admin/tags 68 | 69 | func (handler *tagHandler) searchTag() func(http.ResponseWriter, *http.Request) { 70 | type meta struct { 71 | NumberOfResults int `json:"numberOfResults"` 72 | TotalPages int `json:"totalPages"` 73 | } 74 | type respond struct { 75 | Data []*types.TagRespond `json:"data"` 76 | Meta meta `json:"meta"` 77 | } 78 | return func(w http.ResponseWriter, r *http.Request) { 79 | query, err := types.NewSearchTagReq(r.URL.Query()) 80 | if err != nil { 81 | l.Logger.Info("[Info] TagHandler.searchTag failed:", zap.Error(err)) 82 | api.Respond(w, r, http.StatusBadRequest, err) 83 | return 84 | } 85 | 86 | errs := query.Validate() 87 | if len(errs) > 0 { 88 | api.Respond(w, r, http.StatusBadRequest, errs) 89 | return 90 | } 91 | 92 | found, err := logic.Tag.Search(query) 93 | if err != nil { 94 | l.Logger.Error("[Error] TagHandler.searchTag failed:", zap.Error(err)) 95 | w.WriteHeader(http.StatusInternalServerError) 96 | return 97 | } 98 | 99 | api.Respond(w, r, http.StatusOK, respond{ 100 | Data: types.NewSearchTagRespond(found.Tags), 101 | Meta: meta{ 102 | TotalPages: found.TotalPages, 103 | NumberOfResults: found.NumberOfResults, 104 | }, 105 | }) 106 | } 107 | } 108 | 109 | // POST /admin/tags 110 | 111 | func (h *tagHandler) adminCreate() func(http.ResponseWriter, *http.Request) { 112 | type respond struct { 113 | Data *types.TagRespond `json:"data"` 114 | } 115 | return func(w http.ResponseWriter, r *http.Request) { 116 | req, errs := types.NewAdminCreateTagReq(r) 117 | if len(errs) > 0 { 118 | api.Respond(w, r, http.StatusBadRequest, errs) 119 | return 120 | } 121 | 122 | _, err := logic.Tag.FindByName(req.Name) 123 | if err == nil { 124 | api.Respond(w, r, http.StatusBadRequest, errors.New("Tag already exists.")) 125 | return 126 | } 127 | 128 | created, err := logic.Tag.Create(req.Name) 129 | if err != nil { 130 | l.Logger.Error("[Error] AdminTagHandler.create failed:", zap.Error(err)) 131 | api.Respond(w, r, http.StatusInternalServerError, err) 132 | return 133 | } 134 | 135 | go logic.UserAction.AdminCreateTag(r.Header.Get("userID"), req.Name) 136 | 137 | api.Respond(w, r, http.StatusOK, respond{Data: types.NewTagRespond(created)}) 138 | } 139 | } 140 | 141 | // PATCH /admin/tags/{id} 142 | 143 | func (h *tagHandler) adminUpdate() func(http.ResponseWriter, *http.Request) { 144 | type respond struct { 145 | Data *types.TagRespond `json:"data"` 146 | } 147 | return func(w http.ResponseWriter, r *http.Request) { 148 | req, errs := types.NewAdminUpdateTagReq(r) 149 | if len(errs) > 0 { 150 | api.Respond(w, r, http.StatusBadRequest, errs) 151 | return 152 | } 153 | 154 | _, err := logic.Tag.FindByName(req.Name) 155 | if err == nil { 156 | api.Respond(w, r, http.StatusBadRequest, errors.New("Tag already exists.")) 157 | return 158 | } 159 | 160 | old, err := logic.Tag.FindByIDString(req.ID) 161 | if err != nil { 162 | l.Logger.Error("[Error] AdminTagHandler.update failed:", zap.Error(err)) 163 | api.Respond(w, r, http.StatusInternalServerError, err) 164 | return 165 | } 166 | 167 | updated, err := logic.Tag.FindOneAndUpdate(old.ID, &types.Tag{Name: req.Name}) 168 | if err != nil { 169 | l.Logger.Error("[Error] AdminTagHandler.update failed:", zap.Error(err)) 170 | api.Respond(w, r, http.StatusInternalServerError, err) 171 | return 172 | } 173 | 174 | go func() { 175 | err := logic.Entity.RenameTag(old.Name, updated.Name) 176 | if err != nil { 177 | l.Logger.Error("[Error] AdminTagHandler.update failed:", zap.Error(err)) 178 | return 179 | } 180 | }() 181 | 182 | go logic.UserAction.AdminModifyTag(r.Header.Get("userID"), old.Name, updated.Name) 183 | 184 | api.Respond(w, r, http.StatusOK, respond{Data: &types.TagRespond{ 185 | ID: updated.ID.Hex(), 186 | Name: updated.Name, 187 | }}) 188 | } 189 | } 190 | 191 | // DELETE /admin/tags/{id} 192 | 193 | func (h *tagHandler) adminDelete() func(http.ResponseWriter, *http.Request) { 194 | type respond struct { 195 | Data *types.TagRespond `json:"data"` 196 | } 197 | return func(w http.ResponseWriter, r *http.Request) { 198 | req, errs := types.NewAdminDeleteTagReq(r) 199 | if len(errs) > 0 { 200 | api.Respond(w, r, http.StatusBadRequest, errs) 201 | return 202 | } 203 | 204 | deleted, err := logic.Tag.FindOneAndDelete(req.ID) 205 | if err != nil { 206 | l.Logger.Error("[Error] CategoryHandler.delete failed:", zap.Error(err)) 207 | api.Respond(w, r, http.StatusBadRequest, err) 208 | return 209 | } 210 | 211 | go func() { 212 | err := logic.Entity.DeleteTag(deleted.Name) 213 | if err != nil { 214 | l.Logger.Error("[Error] logic.Entity.delete failed:", zap.Error(err)) 215 | } 216 | }() 217 | 218 | go logic.UserAction.AdminDeleteTag(r.Header.Get("userID"), deleted.Name) 219 | 220 | api.Respond(w, r, http.StatusOK, respond{Data: &types.TagRespond{ 221 | ID: deleted.ID.Hex(), 222 | Name: deleted.Name, 223 | }}) 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /internal/app/http/controller/user_action.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "net/http" 5 | "sync" 6 | 7 | "github.com/gorilla/mux" 8 | "github.com/ic3network/mccs-alpha-api/internal/app/api" 9 | "github.com/ic3network/mccs-alpha-api/internal/app/logic" 10 | "github.com/ic3network/mccs-alpha-api/internal/app/types" 11 | "github.com/ic3network/mccs-alpha-api/util/l" 12 | "go.uber.org/zap" 13 | ) 14 | 15 | var UserAction = newUserAction() 16 | 17 | type userAction struct { 18 | once *sync.Once 19 | } 20 | 21 | func newUserAction() *userAction { 22 | return &userAction{ 23 | once: new(sync.Once), 24 | } 25 | } 26 | 27 | func (ua *userAction) RegisterRoutes(adminPrivate *mux.Router) { 28 | ua.once.Do(func() { 29 | adminPrivate.Path("/logs").HandlerFunc(ua.search()).Methods("GET") 30 | }) 31 | } 32 | 33 | func (ua *userAction) search() func(http.ResponseWriter, *http.Request) { 34 | type meta struct { 35 | NumberOfResults int `json:"numberOfResults"` 36 | TotalPages int `json:"totalPages"` 37 | } 38 | type respond struct { 39 | Data []*types.UserActionESRecord `json:"data"` 40 | Meta meta `json:"meta"` 41 | } 42 | return func(w http.ResponseWriter, r *http.Request) { 43 | req, errs := types.NewAdminSearchLog(r) 44 | if len(errs) > 0 { 45 | api.Respond(w, r, http.StatusBadRequest, errs) 46 | return 47 | } 48 | 49 | found, err := logic.UserAction.Search(req) 50 | if err != nil { 51 | l.Logger.Error("[Error] TransferHandler.searchTransfers failed:", zap.Error(err)) 52 | api.Respond(w, r, http.StatusInternalServerError, err) 53 | return 54 | } 55 | 56 | api.Respond(w, r, http.StatusOK, respond{ 57 | Data: found.UserActions, 58 | Meta: meta{ 59 | TotalPages: found.TotalPages, 60 | NumberOfResults: found.NumberOfResults, 61 | }, 62 | }) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /internal/app/http/middleware/auth.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "net/http" 5 | "strconv" 6 | "strings" 7 | 8 | "github.com/gorilla/mux" 9 | "github.com/ic3network/mccs-alpha-api/internal/app/api" 10 | "github.com/ic3network/mccs-alpha-api/util/jwt" 11 | ) 12 | 13 | const ( 14 | BEARER_SCHEMA string = "Bearer " 15 | ) 16 | 17 | // GetLoggedInUser extracts the auth token from req. 18 | func GetLoggedInUser() mux.MiddlewareFunc { 19 | return func(next http.Handler) http.Handler { 20 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 21 | // Grab the raw Authoirzation header 22 | authHeader := r.Header.Get("Authorization") 23 | if authHeader == "" || !strings.HasPrefix(authHeader, BEARER_SCHEMA) { 24 | next.ServeHTTP(w, r) 25 | return 26 | } 27 | claims, err := jwt.NewJWTManager().Validate(authHeader[len(BEARER_SCHEMA):]) 28 | if err != nil { 29 | next.ServeHTTP(w, r) 30 | return 31 | } 32 | r.Header.Set("userID", claims.UserID) 33 | r.Header.Set("admin", strconv.FormatBool(claims.Admin)) 34 | next.ServeHTTP(w, r) 35 | }) 36 | } 37 | } 38 | 39 | func RequireUser() mux.MiddlewareFunc { 40 | return func(next http.Handler) http.Handler { 41 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 42 | userID := r.Header.Get("userID") 43 | if userID == "" { 44 | api.Respond(w, r, http.StatusUnauthorized, api.ErrUnauthorized) 45 | return 46 | } 47 | admin, _ := strconv.ParseBool(r.Header.Get("admin")) 48 | if admin == true { 49 | // Redirect to admin page if user is an admin. 50 | http.Redirect(w, r, "/admin", http.StatusFound) 51 | return 52 | } 53 | next.ServeHTTP(w, r) 54 | }) 55 | } 56 | } 57 | 58 | func RequireAdmin() mux.MiddlewareFunc { 59 | return func(next http.Handler) http.Handler { 60 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 61 | admin, err := strconv.ParseBool(r.Header.Get("admin")) 62 | if err != nil { 63 | api.Respond(w, r, http.StatusUnauthorized, api.ErrUnauthorized) 64 | return 65 | } 66 | if admin != true { 67 | api.Respond(w, r, http.StatusForbidden, api.ErrPermissionDenied) 68 | return 69 | } 70 | next.ServeHTTP(w, r) 71 | }) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /internal/app/http/middleware/cache.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gorilla/mux" 7 | ) 8 | 9 | func NoCache() mux.MiddlewareFunc { 10 | return func(next http.Handler) http.Handler { 11 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 12 | w.Header().Set("Cache-Control", "no-cache, must-revalidate, no-store") // HTTP 1.1. 13 | w.Header().Set("Pragma", "no-cache") // HTTP 1.0. 14 | w.Header().Set("Expires", "0") // Proxies. 15 | next.ServeHTTP(w, r) 16 | }) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /internal/app/http/middleware/logging.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "net/http" 5 | "strings" 6 | "time" 7 | 8 | "go.uber.org/zap" 9 | 10 | "github.com/gorilla/mux" 11 | 12 | "github.com/ic3network/mccs-alpha-api/util" 13 | "github.com/ic3network/mccs-alpha-api/util/l" 14 | ) 15 | 16 | // Logging middleware logs messages. 17 | func Logging() mux.MiddlewareFunc { 18 | return func(next http.Handler) http.Handler { 19 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 20 | startTime := time.Now() 21 | 22 | defer func() { 23 | uri := r.RequestURI 24 | // Skip for the health check and static requests. 25 | if uri == "/health" || uri == "/ram" || uri == "/cpu" || uri == "/disk" || strings.HasPrefix(uri, "/static") { 26 | return 27 | } 28 | elapse := time.Now().Sub(startTime) 29 | l.Logger.Info("request", 30 | zap.String("ip", util.IPAddress(r)), 31 | zap.String("method", r.Method), 32 | zap.String("uri", uri), 33 | zap.String("userAgent", r.UserAgent()), 34 | zap.Duration("responseTime", elapse)) 35 | }() 36 | 37 | next.ServeHTTP(w, r) 38 | }) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /internal/app/http/middleware/rate_limiting.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gorilla/mux" 7 | "github.com/ic3network/mccs-alpha-api/internal/app/api" 8 | "github.com/ic3network/mccs-alpha-api/internal/app/repository/redis" 9 | "github.com/ic3network/mccs-alpha-api/util" 10 | "github.com/spf13/viper" 11 | ) 12 | 13 | func RateLimiting() mux.MiddlewareFunc { 14 | return func(next http.Handler) http.Handler { 15 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 16 | ip := util.IPAddress(r) 17 | 18 | requestCount := redis.GetRequestCount(ip) 19 | 20 | if requestCount >= viper.GetInt("rate_limiting.limit") { 21 | api.Respond(w, r, http.StatusTooManyRequests) 22 | return 23 | } 24 | 25 | redis.IncRequestCount(ip) 26 | 27 | next.ServeHTTP(w, r) 28 | }) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /internal/app/http/middleware/recover.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "net/http" 5 | "runtime" 6 | 7 | "github.com/gorilla/mux" 8 | "github.com/ic3network/mccs-alpha-api/util/l" 9 | "go.uber.org/zap" 10 | ) 11 | 12 | func Recover() mux.MiddlewareFunc { 13 | return func(next http.Handler) http.Handler { 14 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 15 | defer func() { 16 | if err := recover(); err != nil { 17 | buf := make([]byte, 1024) 18 | runtime.Stack(buf, false) 19 | l.Logger.Error("recover, error", zap.Any("err", err), zap.ByteString("method", buf)) 20 | } 21 | }() 22 | next.ServeHTTP(w, r) 23 | }) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /internal/app/http/routes.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "github.com/gorilla/mux" 5 | "github.com/ic3network/mccs-alpha-api/internal/app/http/controller" 6 | "github.com/ic3network/mccs-alpha-api/internal/app/http/middleware" 7 | ) 8 | 9 | func RegisterRoutes(r *mux.Router) { 10 | public := r.PathPrefix("/api/v1").Subrouter() 11 | public.Use(middleware.Recover(), middleware.RateLimiting(), middleware.NoCache(), middleware.Logging(), middleware.GetLoggedInUser()) 12 | private := r.PathPrefix("/api/v1").Subrouter() 13 | private.Use(middleware.Recover(), middleware.RateLimiting(), middleware.NoCache(), middleware.Logging(), middleware.GetLoggedInUser(), middleware.RequireUser()) 14 | adminPublic := r.PathPrefix("/api/v1/admin").Subrouter() 15 | adminPublic.Use(middleware.Recover(), middleware.RateLimiting(), middleware.NoCache(), middleware.Logging(), middleware.GetLoggedInUser()) 16 | adminPrivate := r.PathPrefix("/api/v1/admin").Subrouter() 17 | adminPrivate.Use(middleware.Recover(), middleware.RateLimiting(), middleware.NoCache(), middleware.Logging(), middleware.GetLoggedInUser(), middleware.RequireAdmin()) 18 | 19 | controller.ServiceDiscovery.RegisterRoutes(public, private) 20 | controller.UserHandler.RegisterRoutes(public, private, adminPublic, adminPrivate) 21 | controller.AdminUserHandler.RegisterRoutes(public, private, adminPublic, adminPrivate) 22 | controller.EntityHandler.RegisterRoutes(public, private, adminPublic, adminPrivate) 23 | controller.TagHandler.RegisterRoutes(public, private, adminPublic, adminPrivate) 24 | controller.CategoryHandler.RegisterRoutes(public, private, adminPublic, adminPrivate) 25 | controller.TransferHandler.RegisterRoutes(public, private, adminPublic, adminPrivate) 26 | controller.UserAction.RegisterRoutes(adminPrivate) 27 | } 28 | -------------------------------------------------------------------------------- /internal/app/http/server.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "time" 7 | 8 | "github.com/gorilla/handlers" 9 | "github.com/gorilla/mux" 10 | "github.com/ic3network/mccs-alpha-api/util/l" 11 | "go.uber.org/zap" 12 | ) 13 | 14 | // AppServer contains the information to run a server. 15 | type appServer struct{} 16 | 17 | var AppServer = &appServer{} 18 | 19 | // Run will start the http server. 20 | func (a *appServer) Run(port string) { 21 | r := mux.NewRouter().StrictSlash(true) 22 | RegisterRoutes(r) 23 | 24 | headersOk := handlers.AllowedHeaders([]string{"Authorization", "Content-Type"}) 25 | 26 | srv := &http.Server{ 27 | Addr: fmt.Sprintf("0.0.0.0:%s", port), 28 | WriteTimeout: time.Second * 15, 29 | ReadTimeout: time.Second * 15, 30 | IdleTimeout: time.Second * 60, 31 | Handler: handlers.CORS(headersOk)(r), 32 | } 33 | 34 | l.Logger.Info("app is running at localhost:" + port) 35 | 36 | if err := srv.ListenAndServe(); err != nil { 37 | l.Logger.Fatal("ListenAndServe failed", zap.Error(err)) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /internal/app/logic/account.go: -------------------------------------------------------------------------------- 1 | package logic 2 | 3 | import ( 4 | "github.com/ic3network/mccs-alpha-api/internal/app/repository/pg" 5 | "github.com/ic3network/mccs-alpha-api/internal/app/types" 6 | ) 7 | 8 | type account struct{} 9 | 10 | var Account = &account{} 11 | 12 | func (a *account) Create() (*types.Account, error) { 13 | account, err := pg.Account.Create() 14 | if err != nil { 15 | return nil, err 16 | } 17 | return account, nil 18 | } 19 | 20 | func (a *account) FindByAccountNumber(accountNumber string) (*types.Account, error) { 21 | account, err := pg.Account.FindByAccountNumber(accountNumber) 22 | if err != nil { 23 | return nil, err 24 | } 25 | return account, nil 26 | } 27 | 28 | func (a *account) IsZeroBalance(accountNumber string) (bool, error) { 29 | account, err := a.FindByAccountNumber(accountNumber) 30 | if err != nil { 31 | return false, err 32 | } 33 | return account.Balance == 0.0, nil 34 | } 35 | 36 | // GET /balance 37 | 38 | func (a *account) FindByEntityID(entityID string) (*types.Account, error) { 39 | entity, err := Entity.FindByStringID(entityID) 40 | if err != nil { 41 | return nil, err 42 | } 43 | 44 | account, err := a.FindByAccountNumber(entity.AccountNumber) 45 | if err != nil { 46 | return nil, err 47 | } 48 | 49 | return account, nil 50 | } 51 | -------------------------------------------------------------------------------- /internal/app/logic/admin_user.go: -------------------------------------------------------------------------------- 1 | package logic 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/ic3network/mccs-alpha-api/internal/app/repository/mongo" 7 | "github.com/ic3network/mccs-alpha-api/internal/app/repository/redis" 8 | "github.com/ic3network/mccs-alpha-api/internal/app/types" 9 | "github.com/ic3network/mccs-alpha-api/util/bcrypt" 10 | "github.com/spf13/viper" 11 | "go.mongodb.org/mongo-driver/bson/primitive" 12 | ) 13 | 14 | type adminUser struct{} 15 | 16 | var AdminUser = &adminUser{} 17 | 18 | func (a *adminUser) FindByID(id primitive.ObjectID) (*types.AdminUser, error) { 19 | adminUser, err := mongo.AdminUser.FindByID(id) 20 | if err != nil { 21 | return nil, err 22 | } 23 | return adminUser, nil 24 | } 25 | 26 | func (a *adminUser) FindByIDString(id string) (*types.AdminUser, error) { 27 | objectID, err := primitive.ObjectIDFromHex(id) 28 | if err != nil { 29 | return nil, err 30 | } 31 | adminUser, err := mongo.AdminUser.FindByID(objectID) 32 | if err != nil { 33 | return nil, err 34 | } 35 | return adminUser, nil 36 | } 37 | 38 | func (a *adminUser) FindByEmail(email string) (*types.AdminUser, error) { 39 | adminUser, err := mongo.AdminUser.FindByEmail(email) 40 | if err != nil { 41 | return nil, err 42 | } 43 | return adminUser, nil 44 | } 45 | 46 | func (a *adminUser) Login(email string, password string) (*types.AdminUser, error) { 47 | user, err := mongo.AdminUser.FindByEmail(email) 48 | if err != nil { 49 | return &types.AdminUser{}, err 50 | } 51 | 52 | attempts := redis.GetLoginAttempts(email) 53 | if attempts >= viper.GetInt("login_attempts.limit") { 54 | return nil, ErrLoginLocked 55 | } 56 | 57 | err = bcrypt.CompareHash(user.Password, password) 58 | if err != nil { 59 | if attempts+1 >= viper.GetInt("login_attempts.limit") { 60 | return nil, ErrLoginLocked 61 | } 62 | return nil, errors.New("Invalid password.") 63 | } 64 | 65 | redis.ResetLoginAttempts(email) 66 | 67 | return user, nil 68 | } 69 | 70 | func (u *adminUser) IncLoginAttempts(email string) error { 71 | err := redis.IncLoginAttempts(email) 72 | if err != nil { 73 | return err 74 | } 75 | return nil 76 | } 77 | 78 | // POST /admin/login 79 | 80 | func (a *adminUser) UpdateLoginInfo(id primitive.ObjectID, ip string) (*types.LoginInfo, error) { 81 | info, err := mongo.AdminUser.UpdateLoginInfo(id, ip) 82 | if err != nil { 83 | return nil, err 84 | } 85 | return info, nil 86 | } 87 | 88 | func (a *adminUser) ResetPassword(email string, newPassword string) error { 89 | user, err := mongo.AdminUser.FindByEmail(email) 90 | if err != nil { 91 | return err 92 | } 93 | 94 | hashedPassword, err := bcrypt.Hash(newPassword) 95 | if err != nil { 96 | return err 97 | } 98 | 99 | user.Password = hashedPassword 100 | err = mongo.AdminUser.UpdatePassword(user) 101 | if err != nil { 102 | return err 103 | } 104 | 105 | return nil 106 | } 107 | -------------------------------------------------------------------------------- /internal/app/logic/balance_limit.go: -------------------------------------------------------------------------------- 1 | package logic 2 | 3 | import ( 4 | "math" 5 | 6 | "github.com/ic3network/mccs-alpha-api/internal/app/repository/pg" 7 | "github.com/ic3network/mccs-alpha-api/internal/app/types" 8 | ) 9 | 10 | type balanceLimit struct{} 11 | 12 | var BalanceLimit = balanceLimit{} 13 | 14 | // IsExceedLimit checks whether or not the account exceeds the max positive or max negative limit. 15 | func (b balanceLimit) IsExceedLimit(accountNumber string, balance float64) (bool, error) { 16 | limit, err := pg.BalanceLimit.FindByAccountNumber(accountNumber) 17 | if err != nil { 18 | return false, err 19 | } 20 | if balance < -(math.Abs(limit.MaxNegBal)) || balance > limit.MaxPosBal { 21 | return true, nil 22 | } 23 | return false, nil 24 | } 25 | 26 | func (b balanceLimit) FindByAccountNumber(accountNumber string) (*types.BalanceLimit, error) { 27 | record, err := pg.BalanceLimit.FindByAccountNumber(accountNumber) 28 | if err != nil { 29 | return nil, err 30 | } 31 | return record, nil 32 | } 33 | 34 | func (b balanceLimit) GetMaxPosBalance(accountNumber string) (float64, error) { 35 | balanceLimitRecord, err := pg.BalanceLimit.FindByAccountNumber(accountNumber) 36 | if err != nil { 37 | return 0, err 38 | } 39 | return balanceLimitRecord.MaxPosBal, nil 40 | } 41 | 42 | func (b balanceLimit) GetMaxNegBalance(accountNumber string) (float64, error) { 43 | balanceLimitRecord, err := pg.BalanceLimit.FindByAccountNumber(accountNumber) 44 | if err != nil { 45 | return 0, err 46 | } 47 | return math.Abs(balanceLimitRecord.MaxNegBal), nil 48 | } 49 | -------------------------------------------------------------------------------- /internal/app/logic/balancecheck/balancecheck.go: -------------------------------------------------------------------------------- 1 | package balancecheck 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/ic3network/mccs-alpha-api/internal/app/repository/pg" 7 | "github.com/ic3network/mccs-alpha-api/internal/pkg/email" 8 | "github.com/ic3network/mccs-alpha-api/util/l" 9 | "go.uber.org/zap" 10 | ) 11 | 12 | // Run will check whether the last past 5 hours the sum of the balance in the posting table is zero. 13 | func Run() { 14 | to := time.Now() 15 | from := to.Add(-5 * time.Hour) 16 | 17 | postings, err := pg.Posting.FindInRange(from, to) 18 | if err != nil { 19 | l.Logger.Error("checking balance failed", zap.Error(err)) 20 | return 21 | } 22 | 23 | var sum float64 24 | for _, p := range postings { 25 | sum += p.Amount 26 | } 27 | 28 | if sum != 0.0 { 29 | email.Balance.SendNonZeroBalanceEmail(&email.NonZeroBalanceEmail{ 30 | From: from, 31 | To: to, 32 | }) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /internal/app/logic/category.go: -------------------------------------------------------------------------------- 1 | package logic 2 | 3 | import ( 4 | "github.com/ic3network/mccs-alpha-api/internal/app/repository/mongo" 5 | "github.com/ic3network/mccs-alpha-api/internal/app/types" 6 | "go.mongodb.org/mongo-driver/bson/primitive" 7 | ) 8 | 9 | type category struct{} 10 | 11 | var Category = &category{} 12 | 13 | func (c *category) Search(req *types.SearchCategoryReq) (*types.FindCategoryResult, error) { 14 | result, err := mongo.Category.Search(req) 15 | if err != nil { 16 | return nil, err 17 | } 18 | return result, nil 19 | } 20 | 21 | func (c *category) FindByIDString(id string) (*types.Category, error) { 22 | objectID, err := primitive.ObjectIDFromHex(id) 23 | if err != nil { 24 | return nil, err 25 | } 26 | category, err := mongo.Category.FindByID(objectID) 27 | if err != nil { 28 | return nil, err 29 | } 30 | return category, nil 31 | } 32 | 33 | func (c *category) FindByName(name string) (*types.Category, error) { 34 | category, err := mongo.Category.FindByName(name) 35 | if err != nil { 36 | return nil, err 37 | } 38 | return category, nil 39 | } 40 | 41 | func (c *category) CreateOne(categoryName string) (*types.Category, error) { 42 | created, err := mongo.Category.Create(categoryName) 43 | if err != nil { 44 | return nil, err 45 | } 46 | return created, nil 47 | } 48 | 49 | func (c *category) Create(categories ...string) error { 50 | if len(categories) == 1 { 51 | _, err := mongo.Category.Create(categories[0]) 52 | if err != nil { 53 | return err 54 | } 55 | return nil 56 | } 57 | for _, category := range categories { 58 | _, err := mongo.Category.Create(category) 59 | if err != nil { 60 | return err 61 | } 62 | } 63 | return nil 64 | } 65 | 66 | func (c *category) FindOneAndUpdate(id primitive.ObjectID, update *types.Category) (*types.Category, error) { 67 | updated, err := mongo.Category.FindOneAndUpdate(id, update) 68 | if err != nil { 69 | return nil, err 70 | } 71 | return updated, nil 72 | } 73 | 74 | func (c *category) FindOneAndDelete(id primitive.ObjectID) (*types.Category, error) { 75 | deleted, err := mongo.Category.FindOneAndDelete(id) 76 | if err != nil { 77 | return nil, err 78 | } 79 | return deleted, nil 80 | } 81 | -------------------------------------------------------------------------------- /internal/app/logic/dailyemail/dailyemail.go: -------------------------------------------------------------------------------- 1 | package dailyemail 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/ic3network/mccs-alpha-api/internal/app/logic" 7 | "github.com/ic3network/mccs-alpha-api/internal/app/types" 8 | "github.com/ic3network/mccs-alpha-api/internal/pkg/email" 9 | "github.com/ic3network/mccs-alpha-api/util" 10 | "github.com/ic3network/mccs-alpha-api/util/l" 11 | "github.com/spf13/viper" 12 | "go.mongodb.org/mongo-driver/bson/primitive" 13 | "go.uber.org/zap" 14 | ) 15 | 16 | // Run performs the daily email notification. 17 | func Run() { 18 | entities, err := logic.Entity.FindByDailyNotification() 19 | if err != nil { 20 | l.Logger.Error("dailyemail failed", zap.Error(err)) 21 | return 22 | } 23 | 24 | viper.SetDefault("concurrency_num", 1) 25 | pool := NewPool(viper.GetInt("concurrency_num")) 26 | 27 | for _, entity := range entities { 28 | worker := createEmailWorker(entity) 29 | pool.Run(worker) 30 | } 31 | 32 | pool.Shutdown() 33 | } 34 | 35 | func createEmailWorker(entity *types.Entity) func() { 36 | return func() { 37 | if !util.IsAcceptedStatus(entity.Status) { 38 | return 39 | } 40 | 41 | matchedTags, err := getMatchTags(entity, entity.LastNotificationSentDate) 42 | if err != nil { 43 | l.Logger.Error("dailyemail failed", zap.Error(err)) 44 | return 45 | } 46 | 47 | if len(matchedTags.MatchedOffers) == 0 && len(matchedTags.MatchedWants) == 0 { 48 | return 49 | } 50 | 51 | email.DailyMatch(&email.DailyMatchNotification{ 52 | Entity: entity, 53 | MatchedTags: matchedTags, 54 | }) 55 | 56 | err = logic.Entity.UpdateLastNotificationSentDate(entity.ID) 57 | if err != nil { 58 | l.Logger.Error("dailyemail failed", zap.Error(err)) 59 | } 60 | } 61 | } 62 | 63 | func getEntity(entityID primitive.ObjectID) (*types.Entity, error) { 64 | entity, err := logic.Entity.FindByID(entityID) 65 | if err != nil { 66 | return nil, err 67 | } 68 | return entity, nil 69 | } 70 | 71 | func getMatchTags(entity *types.Entity, lastNotificationSentDate time.Time) (*types.MatchedTags, error) { 72 | matchedOffers, err := logic.Tag.MatchOffers(types.TagFieldToNames(entity.Offers), lastNotificationSentDate) 73 | if err != nil { 74 | return nil, err 75 | } 76 | matchedWants, err := logic.Tag.MatchWants(types.TagFieldToNames(entity.Wants), lastNotificationSentDate) 77 | if err != nil { 78 | return nil, err 79 | } 80 | return &types.MatchedTags{ 81 | MatchedOffers: matchedOffers, 82 | MatchedWants: matchedWants, 83 | }, nil 84 | } 85 | -------------------------------------------------------------------------------- /internal/app/logic/dailyemail/work.go: -------------------------------------------------------------------------------- 1 | package dailyemail 2 | 3 | import "sync" 4 | 5 | type Pool struct { 6 | worker chan func() 7 | wg sync.WaitGroup 8 | } 9 | 10 | func NewPool(maxGoroutines int) *Pool { 11 | p := Pool{ 12 | worker: make(chan func()), 13 | } 14 | 15 | p.wg.Add(maxGoroutines) 16 | for i := 0; i < maxGoroutines; i++ { 17 | go func() { 18 | for w := range p.worker { 19 | w() 20 | } 21 | p.wg.Done() 22 | }() 23 | } 24 | 25 | return &p 26 | } 27 | 28 | func (p *Pool) Run(w func()) { 29 | p.worker <- w 30 | } 31 | 32 | func (p *Pool) Shutdown() { 33 | close(p.worker) 34 | p.wg.Wait() 35 | } 36 | -------------------------------------------------------------------------------- /internal/app/logic/email.go: -------------------------------------------------------------------------------- 1 | package logic 2 | 3 | import ( 4 | "github.com/ic3network/mccs-alpha-api/internal/app/types" 5 | mail "github.com/ic3network/mccs-alpha-api/internal/pkg/email" 6 | "github.com/ic3network/mccs-alpha-api/util/l" 7 | "go.uber.org/zap" 8 | ) 9 | 10 | var Email = &email{ 11 | Transfer: t{}, 12 | } 13 | 14 | type email struct { 15 | Transfer t 16 | } 17 | 18 | type t struct{} 19 | 20 | func (transfer *t) Initiate(req *types.TransferReq) { 21 | mail.Transfer.Initiate(req) 22 | } 23 | 24 | func (transfer *t) Accept(j *types.Journal) { 25 | info, err := transfer.getTransferEmailInfo(j) 26 | if err != nil { 27 | l.Logger.Error("logic.Email.Transfer.Accept failed", zap.Error(err)) 28 | return 29 | } 30 | mail.Transfer.Accept(info) 31 | } 32 | 33 | func (transfer *t) Reject(j *types.Journal, reason string) { 34 | info, err := transfer.getTransferEmailInfo(j, reason) 35 | if err != nil { 36 | l.Logger.Error("logic.Email.Transfer.Reject failed", zap.Error(err)) 37 | return 38 | } 39 | mail.Transfer.Reject(info) 40 | } 41 | 42 | func (transfer *t) Cancel(j *types.Journal, reason string) { 43 | info, err := transfer.getTransferEmailInfo(j, reason) 44 | if err != nil { 45 | l.Logger.Error("logic.Email.Transfer.Cancel failed", zap.Error(err)) 46 | return 47 | } 48 | mail.Transfer.Cancel(info) 49 | } 50 | 51 | func (transfer *t) CancelBySystem(j *types.Journal, reason string) { 52 | info, err := transfer.getTransferEmailInfo(j, reason) 53 | if err != nil { 54 | l.Logger.Error("logic.Email.Transfer.CancelBySystem failed", zap.Error(err)) 55 | return 56 | } 57 | mail.Transfer.CancelBySystem(info) 58 | } 59 | 60 | func (transfer *t) getTransferEmailInfo(j *types.Journal, reason ...string) (*mail.TransferEmailInfo, error) { 61 | info := &mail.TransferEmailInfo{ 62 | Amount: j.Amount, 63 | } 64 | if len(reason) > 0 { 65 | info.Reason = reason[0] 66 | } 67 | 68 | fromEntity, err := Entity.FindByAccountNumber(j.FromAccountNumber) 69 | if err != nil { 70 | return nil, err 71 | } 72 | toEntity, err := Entity.FindByAccountNumber(j.ToAccountNumber) 73 | if err != nil { 74 | return nil, err 75 | } 76 | 77 | if j.InitiatedBy == j.FromAccountNumber { 78 | info.TransferDirection = "out" 79 | info.InitiatorEmail = fromEntity.Email 80 | info.InitiatorEntityName = fromEntity.Name 81 | info.ReceiverEmail = toEntity.Email 82 | info.ReceiverEntityName = toEntity.Name 83 | } else { 84 | info.TransferDirection = "in" 85 | info.InitiatorEmail = toEntity.Email 86 | info.InitiatorEntityName = toEntity.Name 87 | info.ReceiverEmail = fromEntity.Email 88 | info.ReceiverEntityName = fromEntity.Name 89 | } 90 | 91 | return info, nil 92 | } 93 | -------------------------------------------------------------------------------- /internal/app/logic/error.go: -------------------------------------------------------------------------------- 1 | package logic 2 | 3 | import ( 4 | "errors" 5 | ) 6 | 7 | var ( 8 | ErrLoginLocked = errors.New("Your account has been temporarily locked for 15 minutes. Please try again later.") 9 | ) 10 | -------------------------------------------------------------------------------- /internal/app/logic/lostpassword.go: -------------------------------------------------------------------------------- 1 | package logic 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/ic3network/mccs-alpha-api/internal/app/repository/mongo" 7 | "github.com/ic3network/mccs-alpha-api/internal/app/types" 8 | "github.com/spf13/viper" 9 | ) 10 | 11 | type lostpassword struct{} 12 | 13 | var Lostpassword = &lostpassword{} 14 | 15 | func (s *lostpassword) Create(l *types.LostPassword) error { 16 | err := mongo.LostPassword.Create(l) 17 | if err != nil { 18 | return err 19 | } 20 | return nil 21 | } 22 | 23 | func (s *lostpassword) FindByToken(token string) (*types.LostPassword, error) { 24 | lostPassword, err := mongo.LostPassword.FindByToken(token) 25 | if err != nil { 26 | return nil, err 27 | } 28 | return lostPassword, nil 29 | } 30 | 31 | func (s *lostpassword) FindByEmail(email string) (*types.LostPassword, error) { 32 | lostPassword, err := mongo.LostPassword.FindByEmail(email) 33 | if err != nil { 34 | return nil, err 35 | } 36 | return lostPassword, nil 37 | } 38 | 39 | func (s *lostpassword) SetTokenUsed(token string) error { 40 | err := mongo.LostPassword.SetTokenUsed(token) 41 | if err != nil { 42 | return err 43 | } 44 | return nil 45 | } 46 | 47 | func (s *lostpassword) IsTokenValid(l *types.LostPassword) bool { 48 | if time.Now().Sub(l.CreatedAt).Seconds() >= viper.GetFloat64("reset_password_timeout") || l.TokenUsed == true { 49 | return false 50 | } 51 | return true 52 | } 53 | 54 | func (s *lostpassword) IsTokenInvalid(l *types.LostPassword) bool { 55 | if time.Now().Sub(l.CreatedAt).Seconds() >= viper.GetFloat64("reset_password_timeout") || l.TokenUsed == true { 56 | return true 57 | } 58 | return false 59 | } 60 | -------------------------------------------------------------------------------- /internal/app/logic/tag.go: -------------------------------------------------------------------------------- 1 | package logic 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/ic3network/mccs-alpha-api/internal/app/repository/es" 7 | "github.com/ic3network/mccs-alpha-api/internal/app/repository/mongo" 8 | "github.com/ic3network/mccs-alpha-api/internal/app/types" 9 | "go.mongodb.org/mongo-driver/bson/primitive" 10 | ) 11 | 12 | type tag struct{} 13 | 14 | var Tag = &tag{} 15 | 16 | func (t *tag) Create(name string) (*types.Tag, error) { 17 | created, err := mongo.Tag.Create(name) 18 | if err != nil { 19 | return nil, err 20 | } 21 | err = es.Tag.Create(created.ID, name) 22 | if err != nil { 23 | return nil, err 24 | } 25 | return created, nil 26 | } 27 | 28 | func (t *tag) Search(req *types.SearchTagReq) (*types.FindTagResult, error) { 29 | found, err := mongo.Tag.Search(req) 30 | if err != nil { 31 | return nil, err 32 | } 33 | return found, nil 34 | } 35 | 36 | func (t *tag) FindByName(name string) (*types.Tag, error) { 37 | tag, err := mongo.Tag.FindByName(name) 38 | if err != nil { 39 | return nil, err 40 | } 41 | return tag, nil 42 | } 43 | 44 | func (t *tag) FindByID(objectID primitive.ObjectID) (*types.Tag, error) { 45 | tag, err := mongo.Tag.FindByID(objectID) 46 | if err != nil { 47 | return nil, err 48 | } 49 | return tag, nil 50 | } 51 | 52 | func (t *tag) FindByIDString(id string) (*types.Tag, error) { 53 | objectID, err := primitive.ObjectIDFromHex(id) 54 | if err != nil { 55 | return nil, err 56 | } 57 | tag, err := mongo.Tag.FindByID(objectID) 58 | if err != nil { 59 | return nil, err 60 | } 61 | return tag, nil 62 | } 63 | 64 | func (t *tag) FindOneAndUpdate(id primitive.ObjectID, update *types.Tag) (*types.Tag, error) { 65 | err := es.Tag.Update(id, update) 66 | if err != nil { 67 | return nil, err 68 | } 69 | updated, err := mongo.Tag.FindOneAndUpdate(id, update) 70 | if err != nil { 71 | return nil, err 72 | } 73 | return updated, nil 74 | } 75 | 76 | func (t *tag) FindOneAndDelete(id primitive.ObjectID) (*types.Tag, error) { 77 | err := es.Tag.DeleteByID(id.Hex()) 78 | if err != nil { 79 | return nil, err 80 | } 81 | deleted, err := mongo.Tag.FindOneAndDelete(id) 82 | if err != nil { 83 | return nil, err 84 | } 85 | return deleted, nil 86 | } 87 | 88 | // UpdateOffer will add/modify the offer tag. 89 | func (t *tag) UpdateOffer(name string) error { 90 | id, err := mongo.Tag.UpdateOffer(name) 91 | if err != nil { 92 | return err 93 | } 94 | err = es.Tag.UpdateOffer(id.Hex(), name) 95 | if err != nil { 96 | return err 97 | } 98 | return nil 99 | } 100 | 101 | // UpdateWant will add/modify the want tag. 102 | func (t *tag) UpdateWant(name string) error { 103 | id, err := mongo.Tag.UpdateWant(name) 104 | if err != nil { 105 | return err 106 | } 107 | err = es.Tag.UpdateWant(id.Hex(), name) 108 | if err != nil { 109 | return err 110 | } 111 | return nil 112 | } 113 | 114 | // MatchOffers loops through user's offers and finds out the matched wants. 115 | // Only add to the result when matches more than one tag. 116 | func (t *tag) MatchOffers(offers []string, lastNotificationSentDate time.Time) (map[string][]string, error) { 117 | resultMap := map[string][]string{} 118 | 119 | for _, offer := range offers { 120 | matches, err := es.Tag.MatchOffer(offer, lastNotificationSentDate) 121 | if err != nil { 122 | return nil, err 123 | } 124 | if len(matches) > 0 { 125 | resultMap[offer] = matches 126 | } 127 | } 128 | 129 | return resultMap, nil 130 | } 131 | 132 | // MatchWants loops through user's wants and finds out the matched offers. 133 | // Only add to the result when matches more than one tag. 134 | func (t *tag) MatchWants(wants []string, lastNotificationSentDate time.Time) (map[string][]string, error) { 135 | resultMap := map[string][]string{} 136 | 137 | for _, want := range wants { 138 | matches, err := es.Tag.MatchWant(want, lastNotificationSentDate) 139 | if err != nil { 140 | return nil, err 141 | } 142 | if len(matches) > 0 { 143 | resultMap[want] = matches 144 | } 145 | } 146 | 147 | return resultMap, nil 148 | } 149 | -------------------------------------------------------------------------------- /internal/app/logic/transfer.go: -------------------------------------------------------------------------------- 1 | package logic 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "math" 7 | 8 | "github.com/ic3network/mccs-alpha-api/internal/app/repository/es" 9 | "github.com/ic3network/mccs-alpha-api/internal/app/repository/pg" 10 | "github.com/ic3network/mccs-alpha-api/internal/app/types" 11 | ) 12 | 13 | type transfer struct{} 14 | 15 | var Transfer = &transfer{} 16 | 17 | func (t *transfer) Search(req *types.SearchTransferReq) (*types.SearchTransferRespond, error) { 18 | transfers, err := pg.Journal.Search(req) 19 | if err != nil { 20 | return nil, err 21 | } 22 | return transfers, nil 23 | } 24 | 25 | // POST /transfers 26 | // POST /admin/transfers 27 | 28 | func (t *transfer) CheckBalance(payer, payee string, amount float64) error { 29 | from, err := pg.Account.FindByAccountNumber(payer) 30 | if err != nil { 31 | return err 32 | } 33 | to, err := pg.Account.FindByAccountNumber(payee) 34 | if err != nil { 35 | return err 36 | } 37 | 38 | exceed, err := BalanceLimit.IsExceedLimit(from.AccountNumber, from.Balance-amount) 39 | if err != nil { 40 | return err 41 | } 42 | if exceed { 43 | amount, err := t.maxNegativeBalanceCanBeTransferred(from) 44 | if err != nil { 45 | return err 46 | } 47 | return errors.New("Sender will exceed its credit limit." + " The maximum amount that can be sent is: " + fmt.Sprintf("%.2f", amount)) 48 | } 49 | 50 | exceed, err = BalanceLimit.IsExceedLimit(to.AccountNumber, to.Balance+amount) 51 | if err != nil { 52 | return err 53 | } 54 | if exceed { 55 | amount, err := t.maxPositiveBalanceCanBeTransferred(to) 56 | if err != nil { 57 | return err 58 | } 59 | return errors.New("Receiver will exceed its maximum balance limit." + " The maximum amount that can be received is: " + fmt.Sprintf("%.2f", amount)) 60 | } 61 | 62 | return nil 63 | } 64 | 65 | // PATCH /transfers/{transferID} 66 | 67 | func (t *transfer) FindByID(transferID string) (*types.Journal, error) { 68 | journal, err := pg.Journal.FindByID(transferID) 69 | if err != nil { 70 | return nil, err 71 | } 72 | return journal, nil 73 | } 74 | 75 | // POST /transfers 76 | 77 | func (t *transfer) Propose(req *types.TransferReq) (*types.Journal, error) { 78 | journal, err := pg.Journal.Propose(req) 79 | if err != nil { 80 | return nil, err 81 | } 82 | err = es.Journal.Create(journal) 83 | if err != nil { 84 | return nil, err 85 | } 86 | return journal, nil 87 | } 88 | 89 | func (t *transfer) maxPositiveBalanceCanBeTransferred(a *types.Account) (float64, error) { 90 | maxPosBal, err := BalanceLimit.GetMaxPosBalance(a.AccountNumber) 91 | if err != nil { 92 | return 0, err 93 | } 94 | if a.Balance >= 0 { 95 | return maxPosBal - a.Balance, nil 96 | } 97 | return math.Abs(a.Balance) + maxPosBal, nil 98 | } 99 | 100 | func (t *transfer) maxNegativeBalanceCanBeTransferred(a *types.Account) (float64, error) { 101 | maxNegBal, err := BalanceLimit.GetMaxNegBalance(a.AccountNumber) 102 | if err != nil { 103 | return 0, err 104 | } 105 | if a.Balance >= 0 { 106 | return a.Balance + maxNegBal, nil 107 | } 108 | return maxNegBal - math.Abs(a.Balance), nil 109 | } 110 | 111 | // PATCH /transfers/{transferID} 112 | 113 | func (t *transfer) Accept(j *types.Journal) (*types.Journal, error) { 114 | updated, err := pg.Journal.Accept(j) 115 | if err != nil { 116 | return nil, err 117 | } 118 | err = es.Journal.Update(updated) 119 | if err != nil { 120 | return nil, err 121 | } 122 | err = t.updateESEntityBalances(updated) 123 | if err != nil { 124 | return nil, err 125 | } 126 | return updated, nil 127 | } 128 | 129 | func (t *transfer) updateESEntityBalances(j *types.Journal) error { 130 | from, err := pg.Account.FindByAccountNumber(j.FromAccountNumber) 131 | if err != nil { 132 | return err 133 | } 134 | err = es.Entity.UpdateBalance(from.AccountNumber, from.Balance) 135 | if err != nil { 136 | return err 137 | } 138 | to, err := pg.Account.FindByAccountNumber(j.ToAccountNumber) 139 | if err != nil { 140 | return err 141 | } 142 | err = es.Entity.UpdateBalance(to.AccountNumber, to.Balance) 143 | if err != nil { 144 | return err 145 | } 146 | return nil 147 | } 148 | 149 | func (t *transfer) Cancel(transferID string, reason string) (*types.Journal, error) { 150 | canceled, err := pg.Journal.Cancel(transferID, reason) 151 | if err != nil { 152 | return nil, err 153 | } 154 | err = es.Journal.Update(canceled) 155 | if err != nil { 156 | return nil, err 157 | } 158 | return canceled, nil 159 | } 160 | 161 | // GET /user/entities 162 | 163 | func (t *transfer) GetPendingTransfers(accountNumber string) ([]*types.TransferRespond, error) { 164 | journals, err := pg.Journal.GetPending(accountNumber) 165 | if err != nil { 166 | return nil, err 167 | } 168 | return types.NewJournalsToTransfersRespond(journals, accountNumber), nil 169 | } 170 | 171 | // GET /admin/transfers/{transferID} 172 | 173 | func (t *transfer) AdminGetTransfer(transferID string) (*types.Journal, error) { 174 | journal, err := pg.Journal.FindByID(transferID) 175 | if err != nil { 176 | return nil, err 177 | } 178 | return journal, nil 179 | } 180 | 181 | // POST /admin/transfers 182 | 183 | func (t *transfer) Create(req *types.AdminTransferReq) (*types.Journal, error) { 184 | created, err := pg.Journal.Create(req) 185 | if err != nil { 186 | return nil, err 187 | } 188 | err = es.Journal.Create(created) 189 | if err != nil { 190 | return nil, err 191 | } 192 | err = t.updateESEntityBalances(created) 193 | if err != nil { 194 | return nil, err 195 | } 196 | return created, nil 197 | } 198 | 199 | // GET /admin/transfers 200 | 201 | func (t *transfer) AdminSearch(req *types.AdminSearchTransferReq) (*types.AdminSearchTransferRespond, error) { 202 | result, err := es.Journal.AdminSearch(req) 203 | if err != nil { 204 | return nil, err 205 | } 206 | journals, err := pg.Journal.FindByIDs(result.IDs) 207 | if err != nil { 208 | return nil, err 209 | } 210 | return &types.AdminSearchTransferRespond{ 211 | Transfers: types.NewJournalsToAdminTransfersRespond(journals), 212 | NumberOfResults: result.NumberOfResults, 213 | TotalPages: result.TotalPages, 214 | }, nil 215 | } 216 | 217 | // GET /admin/entities/{entityID} 218 | 219 | func (t *transfer) AdminGetPendingTransfers(accountNumber string) ([]*types.AdminTransferRespond, error) { 220 | journals, err := pg.Journal.GetPending(accountNumber) 221 | if err != nil { 222 | return nil, err 223 | } 224 | return types.NewJournalsToAdminTransfersRespond(journals), nil 225 | } 226 | -------------------------------------------------------------------------------- /internal/app/logic/user.go: -------------------------------------------------------------------------------- 1 | package logic 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/ic3network/mccs-alpha-api/internal/app/repository/es" 7 | "github.com/ic3network/mccs-alpha-api/internal/app/repository/mongo" 8 | "github.com/ic3network/mccs-alpha-api/internal/app/repository/redis" 9 | "github.com/ic3network/mccs-alpha-api/internal/app/types" 10 | "github.com/ic3network/mccs-alpha-api/util" 11 | "github.com/ic3network/mccs-alpha-api/util/bcrypt" 12 | "github.com/spf13/viper" 13 | "go.mongodb.org/mongo-driver/bson/primitive" 14 | ) 15 | 16 | type user struct{} 17 | 18 | var User = &user{} 19 | 20 | func (u *user) Create(user *types.User) (*types.User, error) { 21 | _, err := mongo.User.FindByEmail(user.Email) 22 | if err == nil { 23 | return nil, err 24 | } 25 | 26 | hashedPassword, err := bcrypt.Hash(user.Password) 27 | if err != nil { 28 | return nil, err 29 | } 30 | user.Password = hashedPassword 31 | 32 | created, err := mongo.User.Create(user) 33 | if err != nil { 34 | return nil, err 35 | } 36 | 37 | err = es.User.Create(created.ID, user) 38 | if err != nil { 39 | return nil, err 40 | } 41 | 42 | return created, nil 43 | } 44 | 45 | // POST /signup 46 | 47 | func (u *user) AssociateEntity(userID, entityID primitive.ObjectID) error { 48 | err := mongo.User.AssociateEntity([]primitive.ObjectID{userID}, entityID) 49 | if err != nil { 50 | return err 51 | } 52 | return nil 53 | } 54 | 55 | func (u *user) EmailExists(email string) bool { 56 | _, err := mongo.User.FindByEmail(email) 57 | if err != nil { 58 | return false 59 | } 60 | return true 61 | } 62 | 63 | // POST /login 64 | 65 | func (u *user) Login(email string, password string) (*types.User, error) { 66 | user, err := mongo.User.FindByEmail(email) 67 | if err != nil { 68 | return nil, err 69 | } 70 | 71 | attempts := redis.GetLoginAttempts(email) 72 | 73 | if attempts >= viper.GetInt("login_attempts.limit") { 74 | return nil, ErrLoginLocked 75 | } 76 | 77 | err = bcrypt.CompareHash(user.Password, password) 78 | if err != nil { 79 | if attempts+1 >= viper.GetInt("login_attempts.limit") { 80 | return nil, ErrLoginLocked 81 | } 82 | return nil, errors.New("Invalid password.") 83 | } 84 | 85 | redis.ResetLoginAttempts(email) 86 | 87 | return user, nil 88 | } 89 | 90 | func (u *user) FindByID(id primitive.ObjectID) (*types.User, error) { 91 | user, err := mongo.User.FindByID(id) 92 | if err != nil { 93 | return nil, err 94 | } 95 | return user, nil 96 | } 97 | 98 | func (u *user) FindByStringID(id string) (*types.User, error) { 99 | objectID, _ := primitive.ObjectIDFromHex(id) 100 | user, err := mongo.User.FindByID(objectID) 101 | if err != nil { 102 | return nil, err 103 | } 104 | return user, nil 105 | } 106 | 107 | func (u *user) FindByIDs(ids []primitive.ObjectID) ([]*types.User, error) { 108 | users, err := mongo.User.FindByIDs(ids) 109 | if err != nil { 110 | return nil, err 111 | } 112 | return users, nil 113 | } 114 | 115 | func (u *user) FindByEmail(email string) (*types.User, error) { 116 | user, err := mongo.User.FindByEmail(email) 117 | if err != nil { 118 | return nil, err 119 | } 120 | return user, nil 121 | } 122 | 123 | func (u *user) FindByEntityID(id primitive.ObjectID) (*types.User, error) { 124 | user, err := mongo.User.FindByEntityID(id) 125 | if err != nil { 126 | return nil, err 127 | } 128 | return user, nil 129 | } 130 | 131 | func (a *user) UpdateLoginInfo(id primitive.ObjectID, ip string) (*types.LoginInfo, error) { 132 | info, err := mongo.User.UpdateLoginInfo(id, ip) 133 | if err != nil { 134 | return nil, err 135 | } 136 | return info, nil 137 | } 138 | 139 | func (u *user) IncLoginAttempts(email string) error { 140 | err := redis.IncLoginAttempts(email) 141 | if err != nil { 142 | return err 143 | } 144 | return nil 145 | } 146 | 147 | func (u *user) ResetPassword(email string, newPassword string) error { 148 | user, err := mongo.User.FindByEmail(email) 149 | if err != nil { 150 | return err 151 | } 152 | 153 | hashedPassword, err := bcrypt.Hash(newPassword) 154 | if err != nil { 155 | return err 156 | } 157 | 158 | user.Password = hashedPassword 159 | err = mongo.User.UpdatePassword(user) 160 | if err != nil { 161 | return err 162 | } 163 | 164 | return nil 165 | } 166 | 167 | func (u *user) FindOneAndUpdate(userID primitive.ObjectID, update *types.User) (*types.User, error) { 168 | err := es.User.Update(userID, update) 169 | if err != nil { 170 | return nil, err 171 | } 172 | updated, err := mongo.User.FindOneAndUpdate(userID, update) 173 | if err != nil { 174 | return nil, err 175 | } 176 | return updated, nil 177 | } 178 | 179 | func (u *user) FindEntities(userID primitive.ObjectID) ([]*types.Entity, error) { 180 | user, err := mongo.User.FindByID(userID) 181 | if err != nil { 182 | return nil, err 183 | } 184 | entities, err := mongo.Entity.FindByStringIDs(util.ToIDStrings(user.Entities)) 185 | if err != nil { 186 | return nil, err 187 | } 188 | return entities, nil 189 | } 190 | 191 | func (u *user) AdminFindOneAndUpdate(req *types.AdminUpdateUserReq) (*types.User, error) { 192 | err := es.User.AdminUpdate(req) 193 | if err != nil { 194 | return nil, err 195 | } 196 | updated, err := mongo.User.AdminFindOneAndUpdate(req) 197 | if err != nil { 198 | return nil, err 199 | } 200 | return updated, nil 201 | } 202 | 203 | // DELETE /admin/users/{userID} 204 | 205 | func (u *user) AdminFindOneAndDelete(id primitive.ObjectID) (*types.User, error) { 206 | err := es.User.Delete(id.Hex()) 207 | if err != nil { 208 | return nil, err 209 | } 210 | deleted, err := mongo.User.AdminFindOneAndDelete(id) 211 | if err != nil { 212 | return nil, err 213 | } 214 | return deleted, nil 215 | } 216 | 217 | func (u *user) AdminSearchUser(req *types.AdminSearchUserReq) (*types.SearchUserResult, error) { 218 | result, err := es.User.AdminSearchUser(req) 219 | if err != nil { 220 | return nil, err 221 | } 222 | users, err := mongo.User.FindByStringIDs(result.UserIDs) 223 | if err != nil { 224 | return nil, err 225 | } 226 | return &types.SearchUserResult{ 227 | Users: users, 228 | NumberOfResults: result.NumberOfResults, 229 | TotalPages: result.TotalPages, 230 | }, nil 231 | } 232 | -------------------------------------------------------------------------------- /internal/app/repository/es/es.go: -------------------------------------------------------------------------------- 1 | package es 2 | 3 | import ( 4 | "log" 5 | "time" 6 | 7 | "github.com/ic3network/mccs-alpha-api/global" 8 | "github.com/olivere/elastic/v7" 9 | "github.com/spf13/viper" 10 | ) 11 | 12 | var client *elastic.Client 13 | 14 | func init() { 15 | global.Init() 16 | client = New() 17 | registerCollections(client) 18 | } 19 | 20 | func registerCollections(client *elastic.Client) { 21 | Entity.Register(client) 22 | User.Register(client) 23 | Tag.Register(client) 24 | Journal.Register(client) 25 | UserAction.Register(client) 26 | } 27 | 28 | // New returns an initialized ES instance. 29 | func New() *elastic.Client { 30 | var client *elastic.Client 31 | var err error 32 | 33 | for { 34 | client, err = elastic.NewClient( 35 | elastic.SetURL(viper.GetString("es.url")), 36 | elastic.SetSniff(false), 37 | ) 38 | if err != nil { 39 | log.Printf("ElasticSearch connection error: %+v \n", err) 40 | time.Sleep(5 * time.Second) 41 | } else { 42 | break 43 | } 44 | } 45 | 46 | checkIndexes(client) 47 | return client 48 | } 49 | 50 | // Client is for seed/restore data 51 | func Client() *elastic.Client { 52 | return client 53 | } 54 | -------------------------------------------------------------------------------- /internal/app/repository/es/helper.go: -------------------------------------------------------------------------------- 1 | package es 2 | 3 | import ( 4 | "strings" 5 | "time" 6 | 7 | "github.com/olivere/elastic/v7" 8 | ) 9 | 10 | func newWildcardQuery(name, text string) *elastic.BoolQuery { 11 | q := elastic.NewBoolQuery() 12 | q.Should(elastic.NewWildcardQuery(name, strings.ToLower(text)+"*").Boost(2)) 13 | q.Should(elastic.NewRegexpQuery(name, ".*"+strings.ToLower(text)+".*")) 14 | return q 15 | } 16 | 17 | func newFuzzyWildcardQuery(name, text string) *elastic.BoolQuery { 18 | q := elastic.NewBoolQuery() 19 | q.Should(elastic.NewMatchQuery(name, text).Fuzziness("auto").Boost(3)) 20 | q.Should(elastic.NewWildcardQuery(name, strings.ToLower(text)+"*").Boost(2)) 21 | q.Should(elastic.NewRegexpQuery(name, ".*"+strings.ToLower(text)+".*")) 22 | return q 23 | } 24 | 25 | // Should match one of the three queries. (MatchQuery, WildcardQuery, RegexpQuery) 26 | func newFuzzyWildcardTimeQueryForTag(tagField, tagName string, createdOnOrAfter time.Time) *elastic.NestedQuery { 27 | q := elastic.NewBoolQuery() 28 | 29 | qq := elastic.NewBoolQuery() 30 | qq.Must((elastic.NewMatchQuery(tagField+".name", tagName).Fuzziness("auto").Boost(2))) 31 | qq.Must(elastic.NewRangeQuery(tagField + ".createdAt").Gte(createdOnOrAfter)) 32 | q.Should(qq) 33 | 34 | qq = elastic.NewBoolQuery() 35 | qq.Must(elastic.NewWildcardQuery(tagField+".name", strings.ToLower(tagName)+"*").Boost(1.5)) 36 | qq.Must(elastic.NewRangeQuery(tagField + ".createdAt").Gte(createdOnOrAfter)) 37 | q.Should(qq) 38 | 39 | qq = elastic.NewBoolQuery() 40 | qq.Must(elastic.NewRegexpQuery(tagField+".name", ".*"+strings.ToLower(tagName)+".*")) 41 | qq.Must(elastic.NewRangeQuery(tagField + ".createdAt").Gte(createdOnOrAfter)) 42 | q.Should(qq) 43 | 44 | nestedQ := elastic.NewNestedQuery(tagField, q) 45 | 46 | return nestedQ 47 | } 48 | 49 | // Should match one of the three queries. (MatchQuery, WildcardQuery, RegexpQuery) 50 | func newTagQuery(tag string, lastLoginDate time.Time, timeField string) *elastic.BoolQuery { 51 | q := elastic.NewBoolQuery() 52 | 53 | // The default value for both offerAddedAt and wantAddedAt is 0001-01-01T00:00:00.000+0000. 54 | // If the user never login before and his lastLoginDate will be 0001-01-01T00:00:00.000+0000. 55 | // And we will match the user's own tags. 56 | // Added this filter to solve the problem. 57 | q.MustNot(elastic.NewRangeQuery(timeField).Lte(time.Time{})) 58 | 59 | qq := elastic.NewBoolQuery() 60 | qq.Must(elastic.NewMatchQuery("name", tag).Fuzziness("auto")) 61 | qq.Must(elastic.NewRangeQuery(timeField).Gte(lastLoginDate)) 62 | q.Should(qq) 63 | 64 | qq = elastic.NewBoolQuery() 65 | qq.Must(elastic.NewWildcardQuery("name", strings.ToLower(tag)+"*")) 66 | qq.Must(elastic.NewRangeQuery(timeField).Gte(lastLoginDate)) 67 | q.Should(qq) 68 | 69 | qq = elastic.NewBoolQuery() 70 | qq.Must(elastic.NewRegexpQuery("name", ".*"+strings.ToLower(tag)+".*")) 71 | qq.Must(elastic.NewRangeQuery(timeField).Gte(lastLoginDate)) 72 | q.Should(qq) 73 | 74 | return q 75 | } 76 | -------------------------------------------------------------------------------- /internal/app/repository/es/index.go: -------------------------------------------------------------------------------- 1 | package es 2 | 3 | import ( 4 | "context" 5 | "log" 6 | 7 | "github.com/olivere/elastic/v7" 8 | ) 9 | 10 | func checkIndexes(client *elastic.Client) { 11 | for _, indexName := range indexes { 12 | checkIndex(client, indexName) 13 | } 14 | } 15 | 16 | func checkIndex(client *elastic.Client, index string) { 17 | ctx := context.Background() 18 | 19 | exists, err := client.IndexExists(index).Do(ctx) 20 | if err != nil { 21 | panic(err) 22 | } 23 | 24 | if exists { 25 | return 26 | } 27 | 28 | createIndex, err := client.CreateIndex(index).BodyString(indexMappings[index]).Do(ctx) 29 | if err != nil { 30 | panic(err) 31 | } 32 | if !createIndex.Acknowledged { 33 | panic("CreateIndex " + index + " was not acknowledged.") 34 | } else { 35 | log.Println("Successfully created " + index + " index") 36 | } 37 | } 38 | 39 | var indexes = []string{"entities", "users", "tags", "journals", "user_actions"} 40 | 41 | // Notes: 42 | // 1. Using nested fields for arrays of objects. 43 | var indexMappings = map[string]string{ 44 | "entities": ` 45 | { 46 | "settings": { 47 | "analysis": { 48 | "analyzer": { 49 | "tag_analyzer": { 50 | "type": "custom", 51 | "tokenizer": "whitespace", 52 | "filter": [ 53 | "lowercase", 54 | "asciifolding" 55 | ] 56 | } 57 | } 58 | } 59 | }, 60 | "mappings": { 61 | "properties": { 62 | "entityID": { 63 | "type": "text", 64 | "fields": { 65 | "keyword": { 66 | "type": "keyword", 67 | "ignore_above": 256 68 | } 69 | } 70 | }, 71 | "name": { 72 | "type": "text", 73 | "fields": { 74 | "keyword": { 75 | "type": "keyword", 76 | "ignore_above": 256 77 | } 78 | } 79 | }, 80 | "email": { 81 | "type": "keyword" 82 | }, 83 | "status": { 84 | "type": "keyword" 85 | }, 86 | "offers": { 87 | "type" : "nested", 88 | "properties": { 89 | "createdAt": { 90 | "type": "date" 91 | }, 92 | "name": { 93 | "type": "text", 94 | "analyzer": "tag_analyzer", 95 | "fields": { 96 | "keyword": { 97 | "type": "keyword", 98 | "ignore_above": 256 99 | } 100 | } 101 | } 102 | } 103 | }, 104 | "wants": { 105 | "type" : "nested", 106 | "properties": { 107 | "createdAt": { 108 | "type": "date" 109 | }, 110 | "name": { 111 | "type": "text", 112 | "analyzer": "tag_analyzer", 113 | "fields": { 114 | "keyword": { 115 | "type": "keyword", 116 | "ignore_above": 256 117 | } 118 | } 119 | } 120 | } 121 | }, 122 | "categories": { 123 | "type": "text", 124 | "analyzer": "tag_analyzer", 125 | "fields": { 126 | "keyword": { 127 | "type": "keyword", 128 | "ignore_above": 256 129 | } 130 | } 131 | }, 132 | "city": { 133 | "type": "text", 134 | "fields": { 135 | "keyword": { 136 | "type": "keyword", 137 | "ignore_above": 256 138 | } 139 | } 140 | }, 141 | "region": { 142 | "type": "text", 143 | "fields": { 144 | "keyword": { 145 | "type": "keyword", 146 | "ignore_above": 256 147 | } 148 | } 149 | }, 150 | "country": { 151 | "type": "text", 152 | "fields": { 153 | "keyword": { 154 | "type": "keyword", 155 | "ignore_above": 256 156 | } 157 | } 158 | }, 159 | "accountNumber": { 160 | "type": "keyword" 161 | }, 162 | "balance": { 163 | "type" : "float" 164 | }, 165 | "maxNegBal": { 166 | "type" : "float" 167 | }, 168 | "maxPosBal": { 169 | "type" : "float" 170 | } 171 | } 172 | } 173 | }`, 174 | "users": ` 175 | { 176 | "mappings": { 177 | "properties": { 178 | "email": { 179 | "type": "keyword" 180 | }, 181 | "firstName": { 182 | "type": "text", 183 | "fields": { 184 | "keyword": { 185 | "type": "keyword", 186 | "ignore_above": 256 187 | } 188 | } 189 | }, 190 | "lastName": { 191 | "type": "text", 192 | "fields": { 193 | "keyword": { 194 | "type": "keyword", 195 | "ignore_above": 256 196 | } 197 | } 198 | }, 199 | "userID": { 200 | "type": "text", 201 | "fields": { 202 | "keyword": { 203 | "type": "keyword", 204 | "ignore_above": 256 205 | } 206 | } 207 | } 208 | } 209 | } 210 | }`, 211 | "tags": ` 212 | { 213 | "settings": { 214 | "analysis": { 215 | "analyzer": { 216 | "tag_analyzer": { 217 | "type": "custom", 218 | "tokenizer": "whitespace", 219 | "filter": [ 220 | "lowercase", 221 | "asciifolding" 222 | ] 223 | } 224 | } 225 | } 226 | }, 227 | "mappings": { 228 | "properties": { 229 | "name": { 230 | "type": "text", 231 | "analyzer": "tag_analyzer", 232 | "fields": { 233 | "keyword": { 234 | "type": "keyword", 235 | "ignore_above": 256 236 | } 237 | } 238 | }, 239 | "offerAddedAt": { 240 | "type": "date" 241 | }, 242 | "tagID": { 243 | "type": "text", 244 | "fields": { 245 | "keyword": { 246 | "type": "keyword", 247 | "ignore_above": 256 248 | } 249 | } 250 | }, 251 | "wantAddedAt": { 252 | "type": "date" 253 | } 254 | } 255 | } 256 | }`, 257 | "journals": ` 258 | { 259 | "mappings": { 260 | "properties": { 261 | "transferID": { 262 | "type": "keyword" 263 | }, 264 | "fromAccountNumber": { 265 | "type": "keyword" 266 | }, 267 | "toAccountNumber": { 268 | "type": "keyword" 269 | }, 270 | "status": { 271 | "type": "keyword" 272 | }, 273 | "createdAt": { 274 | "type": "date" 275 | } 276 | } 277 | } 278 | }`, 279 | "user_actions": ` 280 | { 281 | "mappings": { 282 | "properties": { 283 | "userID": { 284 | "type": "keyword" 285 | }, 286 | "email": { 287 | "type": "keyword" 288 | }, 289 | "action": { 290 | "type": "text", 291 | "fields": { 292 | "keyword": { 293 | "type": "keyword", 294 | "ignore_above": 256 295 | } 296 | } 297 | }, 298 | "detail": { 299 | "type": "text", 300 | "fields": { 301 | "keyword": { 302 | "type": "keyword" 303 | } 304 | } 305 | }, 306 | "category": { 307 | "type": "keyword" 308 | }, 309 | "createdAt": { 310 | "type": "date" 311 | } 312 | } 313 | } 314 | }`, 315 | } 316 | -------------------------------------------------------------------------------- /internal/app/repository/es/journal.go: -------------------------------------------------------------------------------- 1 | package es 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "time" 7 | 8 | "github.com/ic3network/mccs-alpha-api/global/constant" 9 | "github.com/ic3network/mccs-alpha-api/internal/app/types" 10 | "github.com/ic3network/mccs-alpha-api/util" 11 | "github.com/olivere/elastic/v7" 12 | ) 13 | 14 | var Journal = &journal{} 15 | 16 | type journal struct { 17 | c *elastic.Client 18 | index string 19 | } 20 | 21 | func (es *journal) Register(client *elastic.Client) { 22 | es.c = client 23 | es.index = "journals" 24 | } 25 | 26 | // POST /transfers 27 | // POST /admin/transfers 28 | 29 | func (es *journal) Create(j *types.Journal) error { 30 | body := types.JournalESRecord{ 31 | TransferID: j.TransferID, 32 | FromAccountNumber: j.FromAccountNumber, 33 | ToAccountNumber: j.ToAccountNumber, 34 | Status: j.Status, 35 | CreatedAt: j.CreatedAt, 36 | } 37 | _, err := es.c.Index(). 38 | Index(es.index). 39 | Id(j.TransferID). 40 | BodyJson(body). 41 | Do(context.Background()) 42 | if err != nil { 43 | return err 44 | } 45 | return nil 46 | } 47 | 48 | // PATCH /transfers/{transferID} 49 | 50 | func (es *journal) Update(j *types.Journal) error { 51 | doc := map[string]interface{}{ 52 | "status": j.Status, 53 | } 54 | _, err := es.c.Update(). 55 | Index(es.index). 56 | Id(j.TransferID). 57 | Doc(doc). 58 | Do(context.Background()) 59 | if err != nil { 60 | return err 61 | } 62 | return nil 63 | } 64 | 65 | // GET /admin/transfers 66 | 67 | func (es *journal) AdminSearch(req *types.AdminSearchTransferReq) (*types.ESSearchJournalResult, error) { 68 | var ids []string 69 | 70 | q := elastic.NewBoolQuery() 71 | 72 | es.seachByAccountNumber(q, req.AccountNumber) 73 | es.seachByStatus(q, req.Status) 74 | es.seachByTime(q, req.DateFrom, req.DateTo) 75 | 76 | from := req.PageSize * (req.Page - 1) 77 | res, err := es.c.Search(). 78 | Index(es.index). 79 | From(from). 80 | Size(req.PageSize). 81 | Query(q). 82 | Do(context.Background()) 83 | if err != nil { 84 | return nil, err 85 | } 86 | 87 | for _, hit := range res.Hits.Hits { 88 | var record types.JournalESRecord 89 | err := json.Unmarshal(hit.Source, &record) 90 | if err != nil { 91 | return nil, err 92 | } 93 | ids = append(ids, record.TransferID) 94 | } 95 | 96 | numberOfResults := int(res.Hits.TotalHits.Value) 97 | totalPages := util.GetNumberOfPages(numberOfResults, req.PageSize) 98 | 99 | return &types.ESSearchJournalResult{ 100 | IDs: ids, 101 | NumberOfResults: int(numberOfResults), 102 | TotalPages: totalPages, 103 | }, nil 104 | } 105 | 106 | func (es *journal) seachByAccountNumber(q *elastic.BoolQuery, accountNumber string) { 107 | if accountNumber != "" { 108 | qq := elastic.NewBoolQuery() 109 | qq.Should(elastic.NewMatchQuery("fromAccountNumber", accountNumber)) 110 | qq.Should(elastic.NewMatchQuery("toAccountNumber", accountNumber)) 111 | q.Must(qq) 112 | } 113 | } 114 | 115 | func (es *journal) seachByStatus(q *elastic.BoolQuery, status []string) { 116 | if len(status) != 0 { 117 | qq := elastic.NewBoolQuery() 118 | for _, status := range status { 119 | qq.Should(elastic.NewMatchQuery("status", constant.MapTransferType(status))) 120 | } 121 | q.Must(qq) 122 | } 123 | } 124 | 125 | func (es *journal) seachByTime(q *elastic.BoolQuery, dateFrom time.Time, dateTo time.Time) { 126 | if !dateFrom.IsZero() { 127 | rangeQ := elastic.NewRangeQuery("createdAt").From(dateFrom) 128 | if !dateTo.IsZero() { 129 | rangeQ.To(dateTo) 130 | } 131 | qq := elastic.NewBoolQuery().Filter(rangeQ) 132 | q.Must(qq) 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /internal/app/repository/es/tag.go: -------------------------------------------------------------------------------- 1 | package es 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "time" 8 | 9 | "github.com/ic3network/mccs-alpha-api/internal/app/types" 10 | "github.com/olivere/elastic/v7" 11 | "go.mongodb.org/mongo-driver/bson/primitive" 12 | ) 13 | 14 | type tag struct { 15 | c *elastic.Client 16 | index string 17 | } 18 | 19 | var Tag = &tag{} 20 | 21 | func (es *tag) Register(client *elastic.Client) { 22 | es.c = client 23 | es.index = "tags" 24 | } 25 | 26 | func (es *tag) Create(id primitive.ObjectID, name string) error { 27 | body := types.TagESRecord{ 28 | TagID: id.Hex(), 29 | Name: name, 30 | } 31 | _, err := es.c.Index(). 32 | Index(es.index). 33 | Id(id.Hex()). 34 | BodyJson(body). 35 | Do(context.Background()) 36 | if err != nil { 37 | return err 38 | } 39 | return nil 40 | } 41 | 42 | func (es *tag) UpdateOffer(id string, name string) error { 43 | exists, err := es.c.Exists().Index(es.index).Id(id).Do(context.TODO()) 44 | if err != nil { 45 | return err 46 | } 47 | if !exists { 48 | body := types.TagESRecord{ 49 | TagID: id, 50 | Name: name, 51 | OfferAddedAt: time.Now(), 52 | } 53 | _, err = es.c.Index(). 54 | Index(es.index). 55 | Id(id). 56 | BodyJson(body). 57 | Do(context.Background()) 58 | if err != nil { 59 | return err 60 | } 61 | return nil 62 | } 63 | 64 | params := map[string]interface{}{ 65 | "offerAddedAt": time.Now(), 66 | } 67 | script := elastic. 68 | NewScript(` 69 | ctx._source.offerAddedAt = params.offerAddedAt; 70 | `). 71 | Params(params) 72 | 73 | _, err = es.c.Update(). 74 | Index(es.index). 75 | Id(id). 76 | Script(script). 77 | Do(context.Background()) 78 | if err != nil { 79 | return err 80 | } 81 | return nil 82 | } 83 | 84 | func (es *tag) UpdateWant(id string, name string) error { 85 | exists, err := es.c.Exists().Index(es.index).Id(id).Do(context.TODO()) 86 | if err != nil { 87 | return err 88 | } 89 | if !exists { 90 | body := types.TagESRecord{ 91 | TagID: id, 92 | Name: name, 93 | WantAddedAt: time.Now(), 94 | } 95 | _, err = es.c.Index(). 96 | Index(es.index). 97 | Id(id). 98 | BodyJson(body). 99 | Do(context.Background()) 100 | if err != nil { 101 | return err 102 | } 103 | return nil 104 | } 105 | 106 | params := map[string]interface{}{ 107 | "wantAddedAt": time.Now(), 108 | } 109 | script := elastic. 110 | NewScript(` 111 | ctx._source.wantAddedAt = params.wantAddedAt; 112 | `). 113 | Params(params) 114 | 115 | _, err = es.c.Update(). 116 | Index(es.index). 117 | Id(id). 118 | Script(script). 119 | Do(context.Background()) 120 | if err != nil { 121 | return err 122 | } 123 | return nil 124 | } 125 | 126 | func (es *tag) Update(id primitive.ObjectID, update *types.Tag) error { 127 | params := map[string]interface{}{ 128 | "name": update.Name, 129 | } 130 | script := elastic. 131 | NewScript(` 132 | ctx._source.name = params.name; 133 | `). 134 | Params(params) 135 | 136 | _, err := es.c.Update(). 137 | Index(es.index). 138 | Id(id.Hex()). 139 | Script(script). 140 | Do(context.Background()) 141 | if err != nil { 142 | return err 143 | } 144 | return nil 145 | } 146 | 147 | func (es *tag) DeleteByID(id string) error { 148 | _, err := es.c.Delete(). 149 | Index(es.index). 150 | Id(id). 151 | Do(context.Background()) 152 | if err != nil { 153 | if elastic.IsNotFound(err) { 154 | return errors.New("Tag does not exist.") 155 | } 156 | return err 157 | } 158 | return nil 159 | } 160 | 161 | // MatchOffer matches wants for the given offer. 162 | func (es *tag) MatchOffer(offer string, lastNotificationSentDate time.Time) ([]string, error) { 163 | q := newTagQuery(offer, lastNotificationSentDate, "wantAddedAt") 164 | res, err := es.c.Search(). 165 | Index(es.index). 166 | Query(q). 167 | Do(context.Background()) 168 | 169 | if err != nil { 170 | return nil, err 171 | } 172 | 173 | matchTags := []string{} 174 | for _, hit := range res.Hits.Hits { 175 | var record types.TagESRecord 176 | err := json.Unmarshal(hit.Source, &record) 177 | if err != nil { 178 | return nil, err 179 | } 180 | matchTags = append(matchTags, record.Name) 181 | } 182 | 183 | return matchTags, nil 184 | } 185 | 186 | // MatchWant matches offers for the given want. 187 | func (es *tag) MatchWant(want string, lastNotificationSentDate time.Time) ([]string, error) { 188 | q := newTagQuery(want, lastNotificationSentDate, "offerAddedAt") 189 | res, err := es.c.Search(). 190 | Index(es.index). 191 | Query(q). 192 | Do(context.Background()) 193 | 194 | if err != nil { 195 | return nil, err 196 | } 197 | 198 | matchTags := []string{} 199 | for _, hit := range res.Hits.Hits { 200 | var record types.TagESRecord 201 | err := json.Unmarshal(hit.Source, &record) 202 | if err != nil { 203 | return nil, err 204 | } 205 | matchTags = append(matchTags, record.Name) 206 | } 207 | 208 | return matchTags, nil 209 | } 210 | -------------------------------------------------------------------------------- /internal/app/repository/es/user.go: -------------------------------------------------------------------------------- 1 | package es 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | 8 | "github.com/ic3network/mccs-alpha-api/internal/app/types" 9 | "github.com/ic3network/mccs-alpha-api/util" 10 | "github.com/olivere/elastic/v7" 11 | "go.mongodb.org/mongo-driver/bson/primitive" 12 | ) 13 | 14 | type user struct { 15 | c *elastic.Client 16 | index string 17 | } 18 | 19 | var User = &user{} 20 | 21 | func (es *user) Register(client *elastic.Client) { 22 | es.c = client 23 | es.index = "users" 24 | } 25 | 26 | func (es *user) Create(userID primitive.ObjectID, user *types.User) error { 27 | body := types.UserESRecord{ 28 | UserID: userID.Hex(), 29 | FirstName: user.FirstName, 30 | LastName: user.LastName, 31 | Email: user.Email, 32 | } 33 | _, err := es.c.Index(). 34 | Index(es.index). 35 | Id(userID.Hex()). 36 | BodyJson(body). 37 | Do(context.Background()) 38 | if err != nil { 39 | return err 40 | } 41 | 42 | return nil 43 | } 44 | 45 | func (es *user) Update(userID primitive.ObjectID, update *types.User) error { 46 | doc := map[string]interface{}{ 47 | "email": update.Email, 48 | "firstName": update.FirstName, 49 | "lastName": update.LastName, 50 | } 51 | 52 | _, err := es.c.Update(). 53 | Index(es.index). 54 | Id(userID.Hex()). 55 | Doc(doc). 56 | Do(context.Background()) 57 | if err != nil { 58 | return err 59 | } 60 | return nil 61 | } 62 | 63 | func (es *user) Delete(id string) error { 64 | _, err := es.c.Delete(). 65 | Index(es.index). 66 | Id(id). 67 | Do(context.Background()) 68 | if err != nil { 69 | if elastic.IsNotFound(err) { 70 | return errors.New("User does not exist.") 71 | } 72 | } 73 | return nil 74 | } 75 | 76 | func (es *user) AdminSearchUser(req *types.AdminSearchUserReq) (*types.ESSearchUserResult, error) { 77 | var ids []string 78 | pageSize := req.PageSize 79 | from := pageSize * (req.Page - 1) 80 | 81 | q := elastic.NewBoolQuery() 82 | 83 | if req.LastName != "" { 84 | q.Must(newFuzzyWildcardQuery("lastName", req.LastName)) 85 | } 86 | if req.Email != "" { 87 | q.Must(newFuzzyWildcardQuery("email", req.Email)) 88 | } 89 | 90 | res, err := es.c.Search(). 91 | Index(es.index). 92 | From(from). 93 | Size(pageSize). 94 | Query(q). 95 | Do(context.Background()) 96 | 97 | if err != nil { 98 | return nil, err 99 | } 100 | 101 | for _, hit := range res.Hits.Hits { 102 | var record types.UserESRecord 103 | err := json.Unmarshal(hit.Source, &record) 104 | if err != nil { 105 | return nil, err 106 | } 107 | ids = append(ids, record.UserID) 108 | } 109 | 110 | numberOfResults := int(res.Hits.TotalHits.Value) 111 | totalPages := util.GetNumberOfPages(numberOfResults, pageSize) 112 | 113 | return &types.ESSearchUserResult{ 114 | UserIDs: ids, 115 | NumberOfResults: numberOfResults, 116 | TotalPages: totalPages, 117 | }, nil 118 | } 119 | 120 | // PATCH /admin/entities/{entityID} 121 | 122 | func (es *user) AdminUpdate(req *types.AdminUpdateUserReq) error { 123 | doc := map[string]interface{}{ 124 | "email": req.Email, 125 | "firstName": req.FirstName, 126 | "lastName": req.LastName, 127 | } 128 | 129 | _, err := es.c.Update(). 130 | Index(es.index). 131 | Id(req.OriginUser.ID.Hex()). 132 | Doc(doc). 133 | Do(context.Background()) 134 | if err != nil { 135 | return err 136 | } 137 | return nil 138 | } 139 | -------------------------------------------------------------------------------- /internal/app/repository/es/user_action.go: -------------------------------------------------------------------------------- 1 | package es 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "time" 7 | 8 | "github.com/ic3network/mccs-alpha-api/internal/app/types" 9 | "github.com/ic3network/mccs-alpha-api/util" 10 | "github.com/olivere/elastic/v7" 11 | ) 12 | 13 | var UserAction = &userAction{} 14 | 15 | type userAction struct { 16 | c *elastic.Client 17 | index string 18 | } 19 | 20 | func (es *userAction) Register(client *elastic.Client) { 21 | es.c = client 22 | es.index = "user_actions" 23 | } 24 | 25 | func (es *userAction) Create(ua *types.UserAction) error { 26 | body := types.UserActionESRecord{ 27 | UserID: ua.UserID.Hex(), 28 | Email: ua.Email, 29 | Action: ua.Action, 30 | Category: ua.Category, 31 | Detail: ua.Detail, 32 | CreatedAt: ua.CreatedAt, 33 | } 34 | _, err := es.c.Index(). 35 | Index(es.index). 36 | Id(ua.ID.Hex()). 37 | BodyJson(body). 38 | Do(context.Background()) 39 | if err != nil { 40 | return err 41 | } 42 | return nil 43 | } 44 | 45 | // GET /admin/logs 46 | 47 | func (es *userAction) Search(req *types.AdminSearchLogReq) (*types.ESSearchUserActionResult, error) { 48 | var userActions []*types.UserActionESRecord 49 | 50 | q := elastic.NewBoolQuery() 51 | 52 | if req.Email != "" { 53 | q.Must(elastic.NewTermQuery("email", req.Email)) 54 | } 55 | if req.Action != "" { 56 | q.Must(newFuzzyWildcardQuery("action", req.Action)) 57 | } 58 | if req.Detail != "" { 59 | q.Must(newFuzzyWildcardQuery("detail", req.Detail)) 60 | } 61 | seachByCateogry(q, req.Categories) 62 | es.seachByTime(q, req.DateFrom, req.DateTo) 63 | 64 | res, err := es.c.Search(). 65 | Index(es.index). 66 | From(req.Offset). 67 | Size(req.PageSize). 68 | Query(q). 69 | Do(context.Background()) 70 | if err != nil { 71 | return nil, err 72 | } 73 | 74 | for _, hit := range res.Hits.Hits { 75 | var record types.UserActionESRecord 76 | err := json.Unmarshal(hit.Source, &record) 77 | if err != nil { 78 | return nil, err 79 | } 80 | userActions = append(userActions, &record) 81 | } 82 | 83 | numberOfResults := int(res.Hits.TotalHits.Value) 84 | totalPages := util.GetNumberOfPages(numberOfResults, req.PageSize) 85 | 86 | return &types.ESSearchUserActionResult{ 87 | UserActions: userActions, 88 | NumberOfResults: int(numberOfResults), 89 | TotalPages: totalPages, 90 | }, nil 91 | } 92 | 93 | func seachByCateogry(q *elastic.BoolQuery, categories []string) *elastic.BoolQuery { 94 | if len(categories) != 0 { 95 | qq := elastic.NewBoolQuery() 96 | for _, category := range categories { 97 | qq.Should(elastic.NewMatchQuery("category", category)) 98 | } 99 | q.Must(qq) 100 | } 101 | return q 102 | } 103 | 104 | func (es *userAction) seachByTime(q *elastic.BoolQuery, dateFrom time.Time, dateTo time.Time) { 105 | if !dateFrom.IsZero() { 106 | rangeQ := elastic.NewRangeQuery("createdAt").From(dateFrom) 107 | if !dateTo.IsZero() { 108 | rangeQ.To(dateTo) 109 | } 110 | qq := elastic.NewBoolQuery().Filter(rangeQ) 111 | q.Must(qq) 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /internal/app/repository/mongo/admin_user.go: -------------------------------------------------------------------------------- 1 | package mongo 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "strings" 7 | "time" 8 | 9 | "github.com/ic3network/mccs-alpha-api/internal/app/types" 10 | "go.mongodb.org/mongo-driver/bson" 11 | "go.mongodb.org/mongo-driver/bson/primitive" 12 | "go.mongodb.org/mongo-driver/mongo" 13 | ) 14 | 15 | type adminUser struct { 16 | c *mongo.Collection 17 | } 18 | 19 | var AdminUser = &adminUser{} 20 | 21 | func (u *adminUser) Register(db *mongo.Database) { 22 | u.c = db.Collection("adminUsers") 23 | } 24 | 25 | func (u *adminUser) FindByID(id primitive.ObjectID) (*types.AdminUser, error) { 26 | adminUser := types.AdminUser{} 27 | filter := bson.M{ 28 | "_id": id, 29 | "deletedAt": bson.M{"$exists": false}, 30 | } 31 | err := u.c.FindOne(context.Background(), filter).Decode(&adminUser) 32 | if err != nil { 33 | return nil, errors.New("Email address not found.") 34 | } 35 | return &adminUser, nil 36 | } 37 | 38 | func (u *adminUser) FindByEmail(email string) (*types.AdminUser, error) { 39 | email = strings.ToLower(email) 40 | 41 | if email == "" { 42 | return &types.AdminUser{}, errors.New("Please specify an email address.") 43 | } 44 | user := types.AdminUser{} 45 | filter := bson.M{ 46 | "email": email, 47 | "deletedAt": bson.M{"$exists": false}, 48 | } 49 | err := u.c.FindOne(context.Background(), filter).Decode(&user) 50 | if err != nil { 51 | return nil, errors.New("The specified admin could not be found.") 52 | } 53 | return &user, nil 54 | } 55 | 56 | // POST /admin/login 57 | 58 | func (u *adminUser) UpdateLoginInfo(id primitive.ObjectID, newLoginIP string) (*types.LoginInfo, error) { 59 | old := &types.LoginInfo{} 60 | filter := bson.M{"_id": id} 61 | err := u.c.FindOne(context.Background(), filter).Decode(&old) 62 | if err != nil { 63 | return nil, err 64 | } 65 | 66 | new := &types.LoginInfo{ 67 | CurrentLoginDate: time.Now(), 68 | CurrentLoginIP: newLoginIP, 69 | LastLoginIP: old.CurrentLoginIP, 70 | LastLoginDate: old.CurrentLoginDate, 71 | } 72 | 73 | filter = bson.M{"_id": id} 74 | update := bson.M{"$set": bson.M{ 75 | "currentLoginIP": new.CurrentLoginIP, 76 | "currentLoginDate": new.CurrentLoginDate, 77 | "lastLoginIP": new.LastLoginIP, 78 | "lastLoginDate": new.LastLoginDate, 79 | "updatedAt": time.Now(), 80 | }} 81 | _, err = u.c.UpdateOne( 82 | context.Background(), 83 | filter, 84 | update, 85 | ) 86 | if err != nil { 87 | return nil, err 88 | } 89 | 90 | return new, nil 91 | } 92 | 93 | func (u *adminUser) UpdatePassword(user *types.AdminUser) error { 94 | filter := bson.M{"_id": user.ID} 95 | update := bson.M{"$set": bson.M{"password": user.Password, "updatedAt": time.Now()}} 96 | _, err := u.c.UpdateOne( 97 | context.Background(), 98 | filter, 99 | update, 100 | ) 101 | if err != nil { 102 | return err 103 | } 104 | return nil 105 | } 106 | -------------------------------------------------------------------------------- /internal/app/repository/mongo/category.go: -------------------------------------------------------------------------------- 1 | package mongo 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "time" 7 | 8 | "github.com/ic3network/mccs-alpha-api/internal/app/types" 9 | "github.com/ic3network/mccs-alpha-api/util" 10 | "go.mongodb.org/mongo-driver/bson" 11 | "go.mongodb.org/mongo-driver/bson/primitive" 12 | "go.mongodb.org/mongo-driver/mongo" 13 | "go.mongodb.org/mongo-driver/mongo/options" 14 | ) 15 | 16 | type category struct { 17 | c *mongo.Collection 18 | } 19 | 20 | var Category = &category{} 21 | 22 | func (c *category) Register(db *mongo.Database) { 23 | c.c = db.Collection("categories") 24 | } 25 | 26 | func (c *category) Create(name string) (*types.Category, error) { 27 | result := c.c.FindOneAndUpdate( 28 | context.Background(), 29 | bson.M{"name": name}, 30 | bson.M{"$setOnInsert": bson.M{"name": name, "createdAt": time.Now()}}, 31 | options.FindOneAndUpdate().SetUpsert(true).SetReturnDocument(options.After), 32 | ) 33 | if result.Err() != nil { 34 | return nil, result.Err() 35 | } 36 | 37 | category := types.Category{} 38 | err := result.Decode(&category) 39 | if err != nil { 40 | return nil, err 41 | } 42 | 43 | return &category, nil 44 | } 45 | 46 | func (c *category) Search(req *types.SearchCategoryReq) (*types.FindCategoryResult, error) { 47 | var results []*types.Category 48 | 49 | findOptions := options.Find() 50 | findOptions.SetSkip(int64(req.PageSize * (req.Page - 1))) 51 | findOptions.SetLimit(int64(req.PageSize)) 52 | 53 | filter := bson.M{ 54 | "name": primitive.Regex{Pattern: "^" + req.Prefix + ".*" + req.Fragment + ".*", Options: "i"}, 55 | "deletedAt": bson.M{"$exists": false}, 56 | } 57 | cur, err := c.c.Find(context.TODO(), filter, findOptions) 58 | if err != nil { 59 | return nil, err 60 | } 61 | 62 | for cur.Next(context.TODO()) { 63 | var elem types.Category 64 | err := cur.Decode(&elem) 65 | if err != nil { 66 | return nil, err 67 | } 68 | results = append(results, &elem) 69 | } 70 | if err := cur.Err(); err != nil { 71 | return nil, err 72 | } 73 | cur.Close(context.TODO()) 74 | 75 | totalCount, err := c.c.CountDocuments(context.TODO(), filter) 76 | if err != nil { 77 | return nil, err 78 | } 79 | 80 | return &types.FindCategoryResult{ 81 | Categories: results, 82 | NumberOfResults: int(totalCount), 83 | TotalPages: util.GetNumberOfPages(int(totalCount), req.PageSize), 84 | }, nil 85 | } 86 | 87 | func (c *category) FindByName(name string) (*types.Category, error) { 88 | category := types.Category{} 89 | filter := bson.M{ 90 | "name": name, 91 | "deletedAt": bson.M{"$exists": false}, 92 | } 93 | err := c.c.FindOne(context.Background(), filter).Decode(&category) 94 | if err != nil { 95 | return nil, err 96 | } 97 | return &category, nil 98 | } 99 | 100 | func (c *category) FindByID(id primitive.ObjectID) (*types.Category, error) { 101 | category := types.Category{} 102 | filter := bson.M{ 103 | "_id": id, 104 | "deletedAt": bson.M{"$exists": false}, 105 | } 106 | err := c.c.FindOne(context.Background(), filter).Decode(&category) 107 | if err != nil { 108 | return nil, errors.New("Entity not found.") 109 | } 110 | return &category, nil 111 | } 112 | 113 | func (c *category) FindOneAndUpdate(id primitive.ObjectID, update *types.Category) (*types.Category, error) { 114 | result := c.c.FindOneAndUpdate( 115 | context.Background(), 116 | bson.M{"_id": id}, 117 | bson.M{ 118 | "$set": bson.M{ 119 | "name": update.Name, 120 | "updatedAt": time.Now(), 121 | }, 122 | }, 123 | options.FindOneAndUpdate().SetReturnDocument(options.After), 124 | ) 125 | if result.Err() != nil { 126 | return nil, result.Err() 127 | } 128 | 129 | category := types.Category{} 130 | err := result.Decode(&category) 131 | if err != nil { 132 | return nil, err 133 | } 134 | 135 | return &category, nil 136 | } 137 | 138 | func (c *category) FindOneAndDelete(id primitive.ObjectID) (*types.Category, error) { 139 | result := c.c.FindOneAndDelete( 140 | context.Background(), 141 | bson.M{"_id": id}, 142 | ) 143 | if result.Err() != nil { 144 | if result.Err() == mongo.ErrNoDocuments { 145 | return nil, errors.New("Category does not exist.") 146 | } 147 | return nil, result.Err() 148 | } 149 | 150 | category := types.Category{} 151 | err := result.Decode(&category) 152 | if err != nil { 153 | return nil, err 154 | } 155 | 156 | return &category, nil 157 | } 158 | -------------------------------------------------------------------------------- /internal/app/repository/mongo/config.go: -------------------------------------------------------------------------------- 1 | package mongo 2 | 3 | // Config contains the environment variables requirements to initialize mongodb. 4 | type Config struct { 5 | URL string 6 | Database string 7 | } 8 | -------------------------------------------------------------------------------- /internal/app/repository/mongo/helper.go: -------------------------------------------------------------------------------- 1 | package mongo 2 | 3 | import ( 4 | "go.mongodb.org/mongo-driver/bson" 5 | "go.mongodb.org/mongo-driver/bson/primitive" 6 | ) 7 | 8 | // A helper function which converts a struct value to a bson.Document. 9 | func toDoc(v interface{}) (doc interface{}, err error) { 10 | data, err := bson.Marshal(v) 11 | if err != nil { 12 | return 13 | } 14 | err = bson.Unmarshal(data, &doc) 15 | return 16 | } 17 | 18 | func toObjectIDs(ids []string) ([]primitive.ObjectID, error) { 19 | objectIDs := make([]primitive.ObjectID, 0, len(ids)) 20 | for _, id := range ids { 21 | objectID, err := primitive.ObjectIDFromHex(id) 22 | if err != nil { 23 | return objectIDs, err 24 | } 25 | objectIDs = append(objectIDs, objectID) 26 | } 27 | return objectIDs, nil 28 | } 29 | 30 | func newFindByIDsPipeline(objectIDs []primitive.ObjectID) []primitive.M { 31 | return []bson.M{ 32 | { 33 | "$match": bson.M{ 34 | "_id": bson.M{"$in": objectIDs}, 35 | "deletedAt": bson.M{"$exists": false}, 36 | }, 37 | }, 38 | { 39 | "$addFields": bson.M{ 40 | "idOrder": bson.M{"$indexOfArray": bson.A{objectIDs, "$_id"}}, 41 | }, 42 | }, 43 | { 44 | "$sort": bson.M{"idOrder": 1}, 45 | }, 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /internal/app/repository/mongo/lost_password.go: -------------------------------------------------------------------------------- 1 | package mongo 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "time" 7 | 8 | "github.com/ic3network/mccs-alpha-api/internal/app/types" 9 | "go.mongodb.org/mongo-driver/bson" 10 | "go.mongodb.org/mongo-driver/mongo" 11 | "go.mongodb.org/mongo-driver/mongo/options" 12 | ) 13 | 14 | type lostPassword struct { 15 | c *mongo.Collection 16 | } 17 | 18 | var LostPassword = &lostPassword{} 19 | 20 | func (l *lostPassword) Register(db *mongo.Database) { 21 | l.c = db.Collection("lostPassword") 22 | } 23 | 24 | // Create creates a lost password record in the table 25 | func (l *lostPassword) Create(lostPassword *types.LostPassword) error { 26 | filter := bson.M{"email": lostPassword.Email} 27 | update := bson.M{"$set": bson.M{ 28 | "email": lostPassword.Email, 29 | "token": lostPassword.Token, 30 | "tokenUsed": false, 31 | "createdAt": time.Now(), 32 | }} 33 | _, err := l.c.UpdateOne( 34 | context.Background(), 35 | filter, 36 | update, 37 | options.Update().SetUpsert(true), 38 | ) 39 | return err 40 | } 41 | 42 | func (l *lostPassword) FindByToken(token string) (*types.LostPassword, error) { 43 | if token == "" { 44 | return nil, errors.New("Invalid token.") 45 | } 46 | lostPassword := types.LostPassword{} 47 | err := l.c.FindOne(context.Background(), types.LostPassword{Token: token}).Decode(&lostPassword) 48 | if err != nil { 49 | return nil, errors.New("Invalid token.") 50 | } 51 | return &lostPassword, nil 52 | } 53 | 54 | func (l *lostPassword) FindByEmail(email string) (*types.LostPassword, error) { 55 | if email == "" { 56 | return nil, errors.New("Invalid token.") 57 | } 58 | lostPassword := types.LostPassword{} 59 | err := l.c.FindOne(context.Background(), types.LostPassword{Email: email}).Decode(&lostPassword) 60 | if err != nil { 61 | return nil, errors.New("Invalid token.") 62 | } 63 | return &lostPassword, nil 64 | } 65 | 66 | func (l *lostPassword) SetTokenUsed(token string) error { 67 | filter := bson.M{"token": token} 68 | update := bson.M{"$set": bson.M{"tokenUsed": true}} 69 | _, err := l.c.UpdateOne(context.Background(), filter, update) 70 | return err 71 | } 72 | -------------------------------------------------------------------------------- /internal/app/repository/mongo/mongo.go: -------------------------------------------------------------------------------- 1 | package mongo 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "time" 7 | 8 | "github.com/ic3network/mccs-alpha-api/global" 9 | "github.com/spf13/viper" 10 | "go.mongodb.org/mongo-driver/mongo" 11 | "go.mongodb.org/mongo-driver/mongo/options" 12 | ) 13 | 14 | var db *mongo.Database 15 | 16 | func init() { 17 | global.Init() 18 | // TODO: set up test docker environment. 19 | if viper.GetString("env") == "test" { 20 | return 21 | } 22 | db = New() 23 | registerCollections(db) 24 | } 25 | 26 | func registerCollections(db *mongo.Database) { 27 | Entity.Register(db) 28 | User.Register(db) 29 | UserAction.Register(db) 30 | AdminUser.Register(db) 31 | Tag.Register(db) 32 | Category.Register(db) 33 | LostPassword.Register(db) 34 | } 35 | 36 | // New returns an initialized JWT instance. 37 | func New() *mongo.Database { 38 | ctx, _ := context.WithTimeout(context.Background(), 10*time.Second) 39 | 40 | client, err := mongo.NewClient(options.Client().ApplyURI(viper.GetString("mongo.url"))) 41 | if err != nil { 42 | log.Fatal(err) 43 | } 44 | 45 | // connect to mongo 46 | if err := client.Connect(ctx); err != nil { 47 | log.Fatal(err) 48 | } 49 | 50 | // check the connection 51 | err = client.Ping(ctx, nil) 52 | if err != nil { 53 | log.Fatal(err) 54 | } 55 | 56 | db := client.Database(viper.GetString("mongo.database")) 57 | return db 58 | } 59 | 60 | // For seed/migration/restore data 61 | func DB() *mongo.Database { 62 | return db 63 | } 64 | -------------------------------------------------------------------------------- /internal/app/repository/mongo/tag.go: -------------------------------------------------------------------------------- 1 | package mongo 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "time" 7 | 8 | "github.com/ic3network/mccs-alpha-api/internal/app/types" 9 | "github.com/ic3network/mccs-alpha-api/util" 10 | "go.mongodb.org/mongo-driver/bson" 11 | "go.mongodb.org/mongo-driver/bson/primitive" 12 | "go.mongodb.org/mongo-driver/mongo" 13 | "go.mongodb.org/mongo-driver/mongo/options" 14 | ) 15 | 16 | type tag struct { 17 | c *mongo.Collection 18 | } 19 | 20 | var Tag = &tag{} 21 | 22 | func (t *tag) Register(db *mongo.Database) { 23 | t.c = db.Collection("tags") 24 | } 25 | 26 | func (t *tag) Create(name string) (*types.Tag, error) { 27 | result := t.c.FindOneAndUpdate( 28 | context.Background(), 29 | bson.M{"name": name}, 30 | bson.M{"$setOnInsert": bson.M{"name": name, "createdAt": time.Now()}}, 31 | options.FindOneAndUpdate().SetUpsert(true).SetReturnDocument(options.After), 32 | ) 33 | if result.Err() != nil { 34 | return nil, result.Err() 35 | } 36 | 37 | tag := types.Tag{} 38 | err := result.Decode(&tag) 39 | if err != nil { 40 | return nil, err 41 | } 42 | 43 | return &tag, nil 44 | } 45 | 46 | func (t *tag) Search(req *types.SearchTagReq) (*types.FindTagResult, error) { 47 | var results []*types.Tag 48 | 49 | findOptions := options.Find() 50 | findOptions.SetSkip(int64(req.PageSize * (req.Page - 1))) 51 | findOptions.SetLimit(int64(req.PageSize)) 52 | 53 | filter := bson.M{ 54 | "name": primitive.Regex{Pattern: req.Fragment, Options: "i"}, 55 | "deletedAt": bson.M{"$exists": false}, 56 | } 57 | cur, err := t.c.Find(context.TODO(), filter, findOptions) 58 | if err != nil { 59 | return nil, err 60 | } 61 | 62 | for cur.Next(context.TODO()) { 63 | var elem types.Tag 64 | err := cur.Decode(&elem) 65 | if err != nil { 66 | return nil, err 67 | } 68 | results = append(results, &elem) 69 | } 70 | if err := cur.Err(); err != nil { 71 | return nil, err 72 | } 73 | cur.Close(context.TODO()) 74 | 75 | totalCount, err := t.c.CountDocuments(context.TODO(), filter) 76 | if err != nil { 77 | return nil, err 78 | } 79 | 80 | return &types.FindTagResult{ 81 | Tags: results, 82 | NumberOfResults: int(totalCount), 83 | TotalPages: util.GetNumberOfPages(int(totalCount), int(req.PageSize)), 84 | }, nil 85 | } 86 | 87 | func (t *tag) FindByID(id primitive.ObjectID) (*types.Tag, error) { 88 | tag := types.Tag{} 89 | filter := bson.M{ 90 | "_id": id, 91 | "deletedAt": bson.M{"$exists": false}, 92 | } 93 | err := t.c.FindOne(context.Background(), filter).Decode(&tag) 94 | if err != nil { 95 | return nil, err 96 | } 97 | return &tag, nil 98 | } 99 | 100 | func (t *tag) FindByName(name string) (*types.Tag, error) { 101 | tag := types.Tag{} 102 | filter := bson.M{ 103 | "name": name, 104 | "deletedAt": bson.M{"$exists": false}, 105 | } 106 | err := t.c.FindOne(context.Background(), filter).Decode(&tag) 107 | if err != nil { 108 | return nil, err 109 | } 110 | return &tag, nil 111 | } 112 | 113 | func (t *tag) UpdateOffer(name string) (primitive.ObjectID, error) { 114 | filter := bson.M{"name": name} 115 | update := bson.M{ 116 | "$set": bson.M{ 117 | "offerAddedAt": time.Now(), 118 | "updatedAt": time.Now(), 119 | }, 120 | "$setOnInsert": bson.M{ 121 | "name": name, 122 | "createdAt": time.Now(), 123 | }, 124 | } 125 | res := t.c.FindOneAndUpdate( 126 | context.Background(), 127 | filter, 128 | update, 129 | options.FindOneAndUpdate().SetUpsert(true).SetReturnDocument(options.After), 130 | ) 131 | if res.Err() != nil { 132 | return primitive.ObjectID{}, res.Err() 133 | } 134 | 135 | tag := types.Tag{} 136 | err := res.Decode(&tag) 137 | if err != nil { 138 | return primitive.ObjectID{}, err 139 | } 140 | return tag.ID, nil 141 | } 142 | 143 | func (t *tag) UpdateWant(name string) (primitive.ObjectID, error) { 144 | filter := bson.M{"name": name} 145 | update := bson.M{ 146 | "$set": bson.M{ 147 | "wantAddedAt": time.Now(), 148 | "updatedAt": time.Now(), 149 | }, 150 | "$setOnInsert": bson.M{ 151 | "name": name, 152 | "createdAt": time.Now(), 153 | }, 154 | } 155 | res := t.c.FindOneAndUpdate( 156 | context.Background(), 157 | filter, 158 | update, 159 | options.FindOneAndUpdate().SetUpsert(true).SetReturnDocument(options.After), 160 | ) 161 | if res.Err() != nil { 162 | return primitive.ObjectID{}, res.Err() 163 | } 164 | 165 | tag := types.Tag{} 166 | err := res.Decode(&tag) 167 | if err != nil { 168 | return primitive.ObjectID{}, err 169 | } 170 | return tag.ID, nil 171 | } 172 | 173 | func (t *tag) FindOneAndUpdate(id primitive.ObjectID, update *types.Tag) (*types.Tag, error) { 174 | result := t.c.FindOneAndUpdate( 175 | context.Background(), 176 | bson.M{"_id": id}, 177 | bson.M{ 178 | "$set": bson.M{ 179 | "name": update.Name, 180 | "updatedAt": time.Now(), 181 | }, 182 | }, 183 | options.FindOneAndUpdate().SetReturnDocument(options.After), 184 | ) 185 | if result.Err() != nil { 186 | return nil, result.Err() 187 | } 188 | 189 | tag := types.Tag{} 190 | err := result.Decode(&tag) 191 | if err != nil { 192 | return nil, err 193 | } 194 | 195 | return &tag, nil 196 | } 197 | 198 | func (t *tag) FindOneAndDelete(id primitive.ObjectID) (*types.Tag, error) { 199 | result := t.c.FindOneAndDelete( 200 | context.Background(), 201 | bson.M{"_id": id}, 202 | ) 203 | if result.Err() != nil { 204 | if result.Err() == mongo.ErrNoDocuments { 205 | return nil, errors.New("Tag does not exists.") 206 | } 207 | return nil, result.Err() 208 | } 209 | 210 | tag := types.Tag{} 211 | err := result.Decode(&tag) 212 | if err != nil { 213 | return nil, err 214 | } 215 | 216 | return &tag, nil 217 | } 218 | -------------------------------------------------------------------------------- /internal/app/repository/mongo/user_action.go: -------------------------------------------------------------------------------- 1 | package mongo 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/ic3network/mccs-alpha-api/internal/app/types" 8 | "go.mongodb.org/mongo-driver/bson" 9 | "go.mongodb.org/mongo-driver/mongo" 10 | "go.mongodb.org/mongo-driver/mongo/options" 11 | ) 12 | 13 | var UserAction = &userAction{} 14 | 15 | type userAction struct { 16 | c *mongo.Collection 17 | } 18 | 19 | func (u *userAction) Register(db *mongo.Database) { 20 | u.c = db.Collection("userActions") 21 | } 22 | 23 | func (u *userAction) Create(a *types.UserAction) (*types.UserAction, error) { 24 | filter := bson.M{"_id": bson.M{"$exists": false}} 25 | update := bson.M{ 26 | "userID": a.UserID, 27 | "email": a.Email, 28 | "action": a.Action, 29 | "detail": a.Detail, 30 | "category": a.Category, 31 | "createdAt": time.Now(), 32 | } 33 | 34 | result := u.c.FindOneAndUpdate( 35 | context.Background(), 36 | filter, 37 | bson.M{"$set": update}, 38 | options.FindOneAndUpdate().SetUpsert(true).SetReturnDocument(options.After), 39 | ) 40 | if result.Err() != nil { 41 | return nil, result.Err() 42 | } 43 | 44 | created := types.UserAction{} 45 | err := result.Decode(&created) 46 | if err != nil { 47 | return nil, err 48 | } 49 | 50 | return &created, nil 51 | } 52 | -------------------------------------------------------------------------------- /internal/app/repository/pg/account.go: -------------------------------------------------------------------------------- 1 | package pg 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/ShiraazMoollatjie/goluhn" 7 | "github.com/ic3network/mccs-alpha-api/internal/app/types" 8 | "github.com/jinzhu/gorm" 9 | ) 10 | 11 | type account struct{} 12 | 13 | var Account = &account{} 14 | 15 | func (a *account) FindByID(accountID uint) (*types.Account, error) { 16 | var result types.Account 17 | err := db.Raw(` 18 | SELECT id, account_number, balance 19 | FROM accounts 20 | WHERE deleted_at IS NULL AND id = ? 21 | LIMIT 1 22 | `, accountID).Scan(&result).Error 23 | if err != nil { 24 | return nil, err 25 | } 26 | return &result, nil 27 | } 28 | 29 | func (a *account) FindByAccountNumber(accountNumber string) (*types.Account, error) { 30 | var result types.Account 31 | err := db.Raw(` 32 | SELECT id, account_number, balance 33 | FROM accounts 34 | WHERE deleted_at IS NULL AND account_number = ? 35 | LIMIT 1 36 | `, accountNumber).Scan(&result).Error 37 | if err != nil { 38 | return nil, err 39 | } 40 | return &result, nil 41 | } 42 | 43 | func (a *account) ifAccountExisted(db *gorm.DB, accountNumber string) bool { 44 | var result types.Account 45 | return !db.Raw(` 46 | SELECT id, account_number, balance 47 | FROM accounts 48 | WHERE deleted_at IS NULL AND account_number = ? 49 | LIMIT 1 50 | `, accountNumber).Scan(&result).RecordNotFound() 51 | } 52 | 53 | func (a *account) generateAccountNumber(db *gorm.DB) string { 54 | accountNumber := goluhn.Generate(16) 55 | for a.ifAccountExisted(db, accountNumber) { 56 | accountNumber = goluhn.Generate(16) 57 | } 58 | return accountNumber 59 | } 60 | 61 | func (a *account) Create() (*types.Account, error) { 62 | tx := db.Begin() 63 | 64 | accountNumber := a.generateAccountNumber(tx) 65 | account := &types.Account{AccountNumber: accountNumber, Balance: 0} 66 | 67 | var result types.Account 68 | err := tx.Create(account).Scan(&result).Error 69 | if err != nil { 70 | tx.Rollback() 71 | return nil, err 72 | } 73 | err = BalanceLimit.Create(tx, accountNumber) 74 | if err != nil { 75 | tx.Rollback() 76 | return nil, err 77 | } 78 | 79 | return &result, tx.Commit().Error 80 | } 81 | 82 | // DELETE /admin/entities/{entityID} 83 | 84 | func (a *account) Delete(accountNumber string) error { 85 | tx := db.Begin() 86 | 87 | err := tx.Exec(` 88 | UPDATE accounts 89 | SET deleted_at = ?, updated_at = ? 90 | WHERE deleted_at IS NULL AND account_number = ? 91 | `, time.Now(), time.Now(), accountNumber).Error 92 | if err != nil { 93 | tx.Rollback() 94 | return err 95 | } 96 | 97 | err = BalanceLimit.delete(tx, accountNumber) 98 | if err != nil { 99 | tx.Rollback() 100 | return err 101 | } 102 | 103 | return tx.Commit().Error 104 | } 105 | -------------------------------------------------------------------------------- /internal/app/repository/pg/balance_limit.go: -------------------------------------------------------------------------------- 1 | package pg 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/ic3network/mccs-alpha-api/internal/app/types" 7 | "github.com/jinzhu/gorm" 8 | "github.com/spf13/viper" 9 | ) 10 | 11 | var BalanceLimit = &balanceLimit{} 12 | 13 | type balanceLimit struct{} 14 | 15 | func (b *balanceLimit) Create(tx *gorm.DB, accountNumber string) error { 16 | balance := &types.BalanceLimit{ 17 | AccountNumber: accountNumber, 18 | MaxNegBal: viper.GetFloat64("transaction.max_neg_bal"), 19 | MaxPosBal: viper.GetFloat64("transaction.max_pos_bal"), 20 | } 21 | err := tx.Create(balance).Error 22 | if err != nil { 23 | return err 24 | } 25 | return nil 26 | } 27 | 28 | func (b *balanceLimit) FindByAccountNumber(accountNumber string) (*types.BalanceLimit, error) { 29 | var result types.BalanceLimit 30 | 31 | err := db.Raw(` 32 | SELECT account_number, max_pos_bal, max_neg_bal 33 | FROM balance_limits 34 | WHERE deleted_at IS NULL AND account_number = ? 35 | LIMIT 1 36 | `, accountNumber).Scan(&result).Error 37 | if err != nil { 38 | return nil, err 39 | } 40 | 41 | return &result, nil 42 | } 43 | 44 | // PATCH /admin/entities/{entityID} 45 | 46 | func (b *balanceLimit) AdminUpdate(req *types.AdminUpdateEntityReq) error { 47 | update := map[string]interface{}{} 48 | if req.MaxPosBal != nil { 49 | update["max_pos_bal"] = *req.MaxPosBal 50 | } 51 | if req.MaxNegBal != nil { 52 | update["max_neg_bal"] = *req.MaxNegBal 53 | } 54 | err := db.Table("balance_limits").Where("deleted_at IS NULL AND account_number = ?", req.OriginEntity.AccountNumber).Updates(update).Error 55 | if err != nil { 56 | return err 57 | } 58 | return nil 59 | } 60 | 61 | func (b *balanceLimit) delete(tx *gorm.DB, accountNumber string) error { 62 | err := tx.Exec(` 63 | UPDATE balance_limits 64 | SET deleted_at = ?, updated_at = ? 65 | WHERE deleted_at IS NULL AND account_number = ? 66 | `, time.Now(), time.Now(), accountNumber).Error 67 | if err != nil { 68 | tx.Rollback() 69 | return err 70 | } 71 | return nil 72 | } 73 | -------------------------------------------------------------------------------- /internal/app/repository/pg/pg.go: -------------------------------------------------------------------------------- 1 | package pg 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "time" 7 | 8 | "github.com/ic3network/mccs-alpha-api/global" 9 | "github.com/ic3network/mccs-alpha-api/internal/app/types" 10 | "github.com/jinzhu/gorm" 11 | _ "github.com/jinzhu/gorm/dialects/postgres" 12 | "github.com/spf13/viper" 13 | ) 14 | 15 | var db *gorm.DB 16 | 17 | func init() { 18 | global.Init() 19 | // TODO: set up test docker environment. 20 | if viper.GetString("env") == "test" { 21 | return 22 | } 23 | db = New() 24 | } 25 | 26 | // New returns an initialized DB instance. 27 | func New() *gorm.DB { 28 | db, err := gorm.Open("postgres", connectionInfo()) 29 | if err != nil { 30 | panic(err) 31 | } 32 | 33 | for { 34 | err := db.DB().Ping() 35 | if err != nil { 36 | log.Printf("PostgreSQL connection error: %+v \n", err) 37 | time.Sleep(5 * time.Second) 38 | } else { 39 | break 40 | } 41 | } 42 | 43 | autoMigrate(db) 44 | 45 | return db 46 | } 47 | 48 | func connectionInfo() string { 49 | password := viper.GetString("psql.password") 50 | host := viper.GetString("psql.host") 51 | port := viper.GetString("psql.port") 52 | user := viper.GetString("psql.user") 53 | dbName := viper.GetString("psql.db") 54 | 55 | if password == "" { 56 | return fmt.Sprintf("host=%s port=%s user=%s dbname=%s "+ 57 | "sslmode=disable", host, port, user, dbName) 58 | } 59 | return fmt.Sprintf("host=%s port=%s user=%s password=%s "+ 60 | "dbname=%s sslmode=disable", host, port, user, 61 | password, dbName) 62 | } 63 | 64 | // AutoMigrate will attempt to automatically migrate all tables 65 | func autoMigrate(db *gorm.DB) { 66 | err := db.AutoMigrate( 67 | &types.Account{}, 68 | &types.BalanceLimit{}, 69 | &types.Journal{}, 70 | &types.Posting{}, 71 | ).Error 72 | if err != nil { 73 | panic(err) 74 | } 75 | } 76 | 77 | // For seed/migration/restore data 78 | func DB() *gorm.DB { 79 | return db 80 | } 81 | -------------------------------------------------------------------------------- /internal/app/repository/pg/posting.go: -------------------------------------------------------------------------------- 1 | package pg 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/ic3network/mccs-alpha-api/internal/app/types" 7 | ) 8 | 9 | type posting struct{} 10 | 11 | var Posting = &posting{} 12 | 13 | func (t *posting) FindInRange(from time.Time, to time.Time) ([]*types.Posting, error) { 14 | var result []*types.Posting 15 | err := db.Raw(` 16 | SELECT P.amount, P.created_at 17 | FROM postings AS P 18 | WHERE P.created_at BETWEEN ? AND ? 19 | ORDER BY P.created_at DESC 20 | `, from, to).Scan(&result).Error 21 | if err != nil { 22 | return nil, err 23 | } 24 | return result, nil 25 | } 26 | -------------------------------------------------------------------------------- /internal/app/repository/redis/key.go: -------------------------------------------------------------------------------- 1 | package redis 2 | 3 | const ( 4 | Ratelimiting = "ratelimiting" 5 | LoginAttempts = "loginAttempts" 6 | ) 7 | -------------------------------------------------------------------------------- /internal/app/repository/redis/redis.go: -------------------------------------------------------------------------------- 1 | package redis 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "strconv" 7 | "time" 8 | 9 | "github.com/redis/go-redis/v9" 10 | "github.com/spf13/viper" 11 | "go.uber.org/zap" 12 | 13 | "github.com/ic3network/mccs-alpha-api/global" 14 | "github.com/ic3network/mccs-alpha-api/util/l" 15 | ) 16 | 17 | var ( 18 | ctx = context.Background() 19 | client *redis.Client 20 | rateLimitingDuration time.Duration 21 | loginAttemptsTimeout time.Duration 22 | ) 23 | 24 | func init() { 25 | global.Init() 26 | 27 | // Get Redis configuration. 28 | host := viper.GetString("redis.host") 29 | port := viper.GetString("redis.port") 30 | password := viper.GetString("redis.password") 31 | 32 | // Get other configuration values, 33 | rateLimitingDuration = viper.GetDuration( 34 | "rate_limiting.duration", 35 | ) * time.Minute 36 | loginAttemptsTimeout = viper.GetDuration( 37 | "login_attempts.timeout", 38 | ) * time.Second 39 | 40 | client = redis.NewClient(&redis.Options{ 41 | Addr: host + ":" + port, 42 | Password: password, 43 | DB: 0, // use default DB 44 | }) 45 | 46 | waitForConnection() 47 | } 48 | 49 | func waitForConnection() { 50 | for { 51 | _, err := client.Ping(ctx).Result() 52 | if err != nil { 53 | log.Printf("Redis connection error: %+v \n", err) 54 | time.Sleep(3 * time.Second) 55 | } else { 56 | break 57 | } 58 | } 59 | } 60 | 61 | // GetRequestCount returns the request count for a given IP address. 62 | func GetRequestCount(ip string) int { 63 | key := Ratelimiting + ":" + ip 64 | val, err := client.Get(ctx, key).Result() 65 | if err != nil { 66 | return 0 67 | } 68 | count, _ := strconv.Atoi(val) 69 | return count 70 | } 71 | 72 | func IncRequestCount(ip string) error { 73 | key := Ratelimiting + ":" + ip 74 | _, err := client.Get(ctx, key).Result() 75 | if err == redis.Nil { 76 | // If the key does not exist, set it with a value of 1 77 | err = client.Set( 78 | ctx, key, 1, rateLimitingDuration, 79 | ).Err() 80 | } else { 81 | err = client.Incr(ctx, key).Err() 82 | } 83 | 84 | if err != nil { 85 | l.Logger.Error("[ERROR] redis IncRequestCount failed:", zap.Error(err)) 86 | } 87 | return err 88 | } 89 | 90 | // GetLoginAttempts returns the login attempts count for a given email address. 91 | func GetLoginAttempts(email string) int { 92 | key := LoginAttempts + ":" + email 93 | val, err := client.Get(ctx, key).Result() 94 | if err != nil { 95 | return 0 96 | } 97 | count, _ := strconv.Atoi(val) 98 | return count 99 | } 100 | 101 | func IncLoginAttempts(email string) error { 102 | key := LoginAttempts + ":" + email 103 | _, err := client.Get(ctx, key).Result() 104 | if err == redis.Nil { 105 | // If the key does not exist, set it with a value of 1. 106 | err = client.Set( 107 | ctx, key, 1, loginAttemptsTimeout, 108 | ).Err() 109 | } else { 110 | err = client.Incr(ctx, key).Err() 111 | } 112 | 113 | if err != nil { 114 | l.Logger.Error("[ERROR] redis IncLoginAttempts failed:", zap.Error(err)) 115 | } 116 | return err 117 | } 118 | 119 | // ResetLoginAttempts resets the login attempts count for a given email address. 120 | func ResetLoginAttempts(email string) { 121 | key := LoginAttempts + ":" + email 122 | _, err := client.Del(ctx, key).Result() 123 | if err != nil { 124 | l.Logger.Error( 125 | "[ERROR] redis ResetLoginAttempts failed:", 126 | zap.Error(err), 127 | ) 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /internal/app/types/es_entity.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | // EntityESRecord is the data that will store into the elastic search. 4 | type EntityESRecord struct { 5 | ID string `json:"id,omitempty"` 6 | Name string `json:"name,omitempty"` 7 | Email string `json:"email,omitempty"` 8 | Status string `json:"status,omitempty"` 9 | // Tags 10 | Offers []*TagField `json:"offers,omitempty"` 11 | Wants []*TagField `json:"wants,omitempty"` 12 | Categories []string `json:"categories,omitempty"` 13 | // Address 14 | City string `json:"city,omitempty"` 15 | Region string `json:"region,omitempty"` 16 | Country string `json:"country,omitempty"` 17 | // Account 18 | AccountNumber string `json:"accountNumber,omitempty"` 19 | Balance *float64 `json:"balance,omitempty"` 20 | MaxNegBal *float64 `json:"maxNegBal,omitempty"` 21 | MaxPosBal *float64 `json:"maxPosBal,omitempty"` 22 | } 23 | 24 | type ESSearchEntityResult struct { 25 | IDs []string 26 | NumberOfResults int 27 | TotalPages int 28 | } 29 | -------------------------------------------------------------------------------- /internal/app/types/es_journal.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import "time" 4 | 5 | type JournalESRecord struct { 6 | TransferID string `json:"transferID,omitempty"` 7 | FromAccountNumber string `json:"fromAccountNumber,omitempty"` 8 | ToAccountNumber string `json:"toAccountNumber,omitempty"` 9 | Status string `json:"status,omitempty"` 10 | CreatedAt time.Time `json:"createdAt,omitempty"` 11 | } 12 | 13 | type ESSearchJournalResult struct { 14 | IDs []string 15 | NumberOfResults int 16 | TotalPages int 17 | } 18 | -------------------------------------------------------------------------------- /internal/app/types/es_tag.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import "time" 4 | 5 | type TagESRecord struct { 6 | TagID string `json:"tagID,omitempty"` 7 | Name string `json:"name,omitempty"` 8 | OfferAddedAt time.Time `json:"offerAddedAt,omitempty"` 9 | WantAddedAt time.Time `json:"wantAddedAt,omitempty"` 10 | } 11 | -------------------------------------------------------------------------------- /internal/app/types/es_user.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | // UserESRecord is the data that will store into the elastic search. 4 | type UserESRecord struct { 5 | UserID string `json:"userID"` 6 | FirstName string `json:"firstName,omitempty"` 7 | LastName string `json:"lastName,omitempty"` 8 | Email string `json:"email,omitempty"` 9 | } 10 | 11 | type ESSearchUserResult struct { 12 | UserIDs []string 13 | NumberOfResults int 14 | TotalPages int 15 | } 16 | -------------------------------------------------------------------------------- /internal/app/types/es_user_action.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type UserActionESRecord struct { 8 | UserID string `json:"userID,omitempty"` 9 | Email string `json:"email,omitempty"` 10 | Action string `json:"action,omitempty"` 11 | Detail string `json:"detail,omitempty"` 12 | Category string `json:"category,omitempty"` 13 | CreatedAt time.Time `json:"createdAt,omitempty"` 14 | } 15 | 16 | type ESSearchUserActionResult struct { 17 | UserActions []*UserActionESRecord 18 | NumberOfResults int 19 | TotalPages int 20 | } 21 | -------------------------------------------------------------------------------- /internal/app/types/mongo_admin_user.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "time" 5 | 6 | "go.mongodb.org/mongo-driver/bson/primitive" 7 | ) 8 | 9 | // AdminUser is the model representation of an admin user in the data model. 10 | type AdminUser struct { 11 | ID primitive.ObjectID `json:"_id,omitempty" bson:"_id,omitempty"` 12 | CreatedAt time.Time `json:"createdAt,omitempty" bson:"createdAt,omitempty"` 13 | UpdatedAt time.Time `json:"updatedAt,omitempty" bson:"updatedAt,omitempty"` 14 | DeletedAt time.Time `json:"deletedAt,omitempty" bson:"deletedAt,omitempty"` 15 | 16 | Email string `json:"email,omitempty" bson:"email,omitempty"` 17 | Name string `json:"name,omitempty" bson:"name,omitempty"` 18 | Password string `json:"password,omitempty" bson:"password,omitempty"` 19 | Roles []string `json:"roles,omitempty" bson:"roles,omitempty"` 20 | 21 | CurrentLoginIP string `json:"currentLoginIP,omitempty" bson:"currentLoginIP,omitempty"` 22 | CurrentLoginDate time.Time `json:"currentLoginDate,omitempty" bson:"currentLoginDate,omitempty"` 23 | LastLoginIP string `json:"lastLoginIP,omitempty" bson:"lastLoginIP,omitempty"` 24 | LastLoginDate time.Time `json:"lastLoginDate,omitempty" bson:"lastLoginDate,omitempty"` 25 | } 26 | -------------------------------------------------------------------------------- /internal/app/types/mongo_category.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "time" 5 | 6 | "go.mongodb.org/mongo-driver/bson/primitive" 7 | ) 8 | 9 | type Category struct { 10 | ID primitive.ObjectID `json:"_id,omitempty" bson:"_id,omitempty"` 11 | CreatedAt time.Time `json:"createdAt,omitempty" bson:"createdAt,omitempty"` 12 | UpdatedAt time.Time `json:"updatedAt,omitempty" bson:"updatedAt,omitempty"` 13 | DeletedAt time.Time `json:"deletedAt,omitempty" bson:"deletedAt,omitempty"` 14 | 15 | Name string `json:"name,omitempty" bson:"name,omitempty"` 16 | } 17 | 18 | // Helper types 19 | 20 | type FindCategoryResult struct { 21 | Categories []*Category 22 | NumberOfResults int 23 | TotalPages int 24 | } 25 | -------------------------------------------------------------------------------- /internal/app/types/mongo_entity.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "errors" 5 | "time" 6 | 7 | "github.com/ic3network/mccs-alpha-api/util" 8 | "go.mongodb.org/mongo-driver/bson/primitive" 9 | ) 10 | 11 | // Entity is the model representation of a entity in the data model. 12 | type Entity struct { 13 | ID primitive.ObjectID `json:"_id,omitempty" bson:"_id,omitempty"` 14 | CreatedAt time.Time `json:"createdAt,omitempty" bson:"createdAt,omitempty"` 15 | UpdatedAt time.Time `json:"updatedAt,omitempty" bson:"updatedAt,omitempty"` 16 | DeletedAt time.Time `json:"deletedAt,omitempty" bson:"deletedAt,omitempty"` 17 | 18 | Users []primitive.ObjectID `json:"users,omitempty" bson:"users,omitempty"` 19 | 20 | Name string `json:"name,omitempty" bson:"name,omitempty"` 21 | Telephone string `json:"telephone,omitempty" bson:"telephone,omitempty"` 22 | Email string `json:"email,omitempty" bson:"email,omitempty"` 23 | IncType string `json:"incType,omitempty" bson:"incType,omitempty"` 24 | CompanyNumber string `json:"companyNumber,omitempty" bson:"companyNumber,omitempty"` 25 | Website string `json:"website,omitempty" bson:"website,omitempty"` 26 | DeclaredTurnover *int `json:"declaredTurnover,omitempty" bson:"declaredTurnover,omitempty"` 27 | Offers []*TagField `json:"offers,omitempty" bson:"offers,omitempty"` 28 | Wants []*TagField `json:"wants,omitempty" bson:"wants,omitempty"` 29 | Description string `json:"description,omitempty" bson:"description,omitempty"` 30 | Address string `json:"address,omitempty" bson:"address,omitempty"` 31 | City string `json:"city,omitempty" bson:"city,omitempty"` 32 | Region string `json:"region,omitempty" bson:"region,omitempty"` 33 | PostalCode string `json:"postalCode,omitempty" bson:"postalCode,omitempty"` 34 | Country string `json:"country,omitempty" bson:"country,omitempty"` 35 | Status string `json:"status,omitempty" bson:"status,omitempty"` 36 | Categories []string `json:"categories,omitempty" bson:"categories,omitempty"` 37 | // Timestamp when trading status applied 38 | MemberStartedAt time.Time `json:"memberStartedAt,omitempty" bson:"memberStartedAt,omitempty"` 39 | 40 | // flags 41 | ShowTagsMatchedSinceLastLogin *bool `json:"showTagsMatchedSinceLastLogin,omitempty" bson:"showTagsMatchedSinceLastLogin,omitempty"` 42 | ReceiveDailyMatchNotificationEmail *bool `json:"receiveDailyMatchNotificationEmail,omitempty" bson:"receiveDailyMatchNotificationEmail,omitempty"` 43 | 44 | LastNotificationSentDate time.Time `json:"lastNotificationSentDate,omitempty" bson:"lastNotificationSentDate,omitempty"` 45 | 46 | AccountNumber string `json:"accountNumber,omitempty" bson:"accountNumber,omitempty"` 47 | FavoriteEntities []primitive.ObjectID `json:"favoriteEntities,omitempty" bson:"favoriteEntities,omitempty"` 48 | } 49 | 50 | func (entity *Entity) Validate() []error { 51 | errs := []error{} 52 | 53 | if len(entity.Email) != 0 { 54 | errs = append(errs, util.ValidateEmail(entity.Email)...) 55 | } 56 | if len(entity.Status) != 0 && !util.IsValidStatus(entity.Status) { 57 | errs = append(errs, errors.New("Please specify a valid status.")) 58 | } 59 | 60 | if len(entity.Name) > 100 { 61 | errs = append(errs, errors.New("Entity name length cannot exceed 100 characters.")) 62 | } 63 | if len(entity.Telephone) > 25 { 64 | errs = append(errs, errors.New("Telephone length cannot exceed 25 characters.")) 65 | } 66 | if len(entity.IncType) > 25 { 67 | errs = append(errs, errors.New("Incorporation type length cannot exceed 25 characters.")) 68 | } 69 | if len(entity.CompanyNumber) > 20 { 70 | errs = append(errs, errors.New("Company number length cannot exceed 20 characters.")) 71 | } 72 | if len(entity.Website) > 100 { 73 | errs = append(errs, errors.New("Website URL length cannot exceed 100 characters.")) 74 | } 75 | if entity.DeclaredTurnover != nil && *entity.DeclaredTurnover < 0 { 76 | errs = append(errs, errors.New("Declared turnover should be a positive number.")) 77 | } 78 | if len(entity.Description) > 500 { 79 | errs = append(errs, errors.New("Description length cannot exceed 500 characters.")) 80 | } 81 | if len(entity.Address) > 255 { 82 | errs = append(errs, errors.New("Address length cannot exceed 255 characters.")) 83 | } 84 | if len(entity.City) > 50 { 85 | errs = append(errs, errors.New("City length cannot exceed 50 characters.")) 86 | } 87 | if len(entity.Region) > 50 { 88 | errs = append(errs, errors.New("Region length cannot exceed 50 characters.")) 89 | } 90 | if len(entity.PostalCode) > 10 { 91 | errs = append(errs, errors.New("Postal code length cannot exceed 10 characters.")) 92 | } 93 | if len(entity.Country) > 50 { 94 | errs = append(errs, errors.New("Country length cannot exceed 50 characters.")) 95 | } 96 | return errs 97 | } 98 | 99 | // Helper types 100 | 101 | type SearchEntityResult struct { 102 | Entities []*Entity 103 | NumberOfResults int 104 | TotalPages int 105 | } 106 | 107 | type UpdateOfferAndWants struct { 108 | EntityID primitive.ObjectID 109 | OriginStatus string 110 | UpdatedStatus string 111 | UpdatedOffers []string 112 | UpdatedWants []string 113 | AddedOffers []string 114 | AddedWants []string 115 | } 116 | -------------------------------------------------------------------------------- /internal/app/types/mongo_login_info.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import "time" 4 | 5 | // LoginInfo is shared by user and admin user model. 6 | type LoginInfo struct { 7 | CurrentLoginIP string `json:"currentLoginIP,omitempty" bson:"currentLoginIP,omitempty"` 8 | CurrentLoginDate time.Time `json:"currentLoginDate,omitempty" bson:"currentLoginDate,omitempty"` 9 | LastLoginIP string `json:"lastLoginIP,omitempty" bson:"lastLoginIP,omitempty"` 10 | LastLoginDate time.Time `json:"lastLoginDate,omitempty" bson:"lastLoginDate,omitempty"` 11 | } 12 | -------------------------------------------------------------------------------- /internal/app/types/mongo_lost_password.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "time" 5 | 6 | "go.mongodb.org/mongo-driver/bson/primitive" 7 | ) 8 | 9 | // LostPassword is the model representation of a lost password in the data model. 10 | type LostPassword struct { 11 | ID primitive.ObjectID `json:"_id,omitempty" bson:"_id,omitempty"` 12 | CreatedAt time.Time `json:"createdAt,omitempty" bson:"createdAt,omitempty"` 13 | Email string `json:"email,omitempty" bson:"email,omitempty"` 14 | Token string `json:"token,omitempty" bson:"token,omitempty"` 15 | TokenUsed bool `json:"tokenUsed,omitempty" bson:"tokenUsed,omitempty"` 16 | } 17 | -------------------------------------------------------------------------------- /internal/app/types/mongo_tag.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "time" 5 | 6 | "go.mongodb.org/mongo-driver/bson/primitive" 7 | ) 8 | 9 | type Tag struct { 10 | ID primitive.ObjectID `json:"_id,omitempty" bson:"_id,omitempty"` 11 | CreatedAt time.Time `json:"createdAt,omitempty" bson:"createdAt,omitempty"` 12 | UpdatedAt time.Time `json:"updatedAt,omitempty" bson:"updatedAt,omitempty"` 13 | DeletedAt time.Time `json:"deletedAt,omitempty" bson:"deletedAt,omitempty"` 14 | 15 | Name string `json:"name,omitempty" bson:"name,omitempty"` 16 | OfferAddedAt time.Time `json:"offerAddedAt,omitempty" bson:"offerAddedAt,omitempty"` 17 | WantAddedAt time.Time `json:"wantAddedAt,omitempty" bson:"wantAddedAt,omitempty"` 18 | } 19 | 20 | // Helper functions 21 | 22 | func TagToNames(tags []*Tag) []string { 23 | names := make([]string, 0, len(tags)) 24 | for _, t := range tags { 25 | names = append(names, t.Name) 26 | } 27 | return names 28 | } 29 | 30 | // Helper types 31 | 32 | type FindTagResult struct { 33 | Tags []*Tag 34 | NumberOfResults int 35 | TotalPages int 36 | } 37 | 38 | type MatchedTags struct { 39 | MatchedOffers map[string][]string `json:"matchedOffers,omitempty"` 40 | MatchedWants map[string][]string `json:"matchedWants,omitempty"` 41 | } 42 | -------------------------------------------------------------------------------- /internal/app/types/mongo_tag_field.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import "time" 4 | 5 | type TagField struct { 6 | Name string `json:"name,omitempty" bson:"name,omitempty"` 7 | CreatedAt time.Time `json:"createdAt,omitempty" bson:"createdAt,omitempty"` 8 | } 9 | 10 | func TagFieldToNames(tags []*TagField) []string { 11 | names := make([]string, 0, len(tags)) 12 | for _, t := range tags { 13 | names = append(names, t.Name) 14 | } 15 | return names 16 | } 17 | 18 | // ToTagFields converts tags into TagFields. 19 | func ToTagFields(tags []string) []*TagField { 20 | tagFields := make([]*TagField, 0, len(tags)) 21 | for _, tagName := range tags { 22 | tagField := &TagField{ 23 | Name: tagName, 24 | CreatedAt: time.Now(), 25 | } 26 | tagFields = append(tagFields, tagField) 27 | } 28 | return tagFields 29 | } 30 | -------------------------------------------------------------------------------- /internal/app/types/mongo_user.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "errors" 5 | "time" 6 | 7 | "github.com/ic3network/mccs-alpha-api/util" 8 | "go.mongodb.org/mongo-driver/bson/primitive" 9 | ) 10 | 11 | // User is the model representation of an user in the data model. 12 | type User struct { 13 | ID primitive.ObjectID `json:"_id,omitempty" bson:"_id,omitempty"` 14 | CreatedAt time.Time `json:"createdAt,omitempty" bson:"createdAt,omitempty"` 15 | UpdatedAt time.Time `json:"updatedAt,omitempty" bson:"updatedAt,omitempty"` 16 | DeletedAt time.Time `json:"deletedAt,omitempty" bson:"deletedAt,omitempty"` 17 | 18 | FirstName string `json:"firstName,omitempty" bson:"firstName,omitempty"` 19 | LastName string `json:"lastName,omitempty" bson:"lastName,omitempty"` 20 | Email string `json:"email,omitempty" bson:"email,omitempty"` 21 | Password string `json:"password,omitempty" bson:"password,omitempty"` 22 | Telephone string `json:"telephone,omitempty" bson:"telephone,omitempty"` 23 | Entities []primitive.ObjectID `json:"entities,omitempty" bson:"entities,omitempty"` 24 | 25 | CurrentLoginIP string `json:"currentLoginIP,omitempty" bson:"currentLoginIP,omitempty"` 26 | CurrentLoginDate time.Time `json:"currentLoginDate,omitempty" bson:"currentLoginDate,omitempty"` 27 | LastLoginIP string `json:"lastLoginIP,omitempty" bson:"lastLoginIP,omitempty"` 28 | LastLoginDate time.Time `json:"lastLoginDate,omitempty" bson:"lastLoginDate,omitempty"` 29 | } 30 | 31 | func (user *User) Validate() []error { 32 | errs := []error{} 33 | 34 | if len(user.Email) != 0 { 35 | errs = append(errs, util.ValidateEmail(user.Email)...) 36 | } 37 | 38 | if len(user.FirstName) > 100 { 39 | errs = append(errs, errors.New("First name length cannot exceed 100 characters.")) 40 | } 41 | if len(user.LastName) > 100 { 42 | errs = append(errs, errors.New("Last name length cannot exceed 100 characters.")) 43 | } 44 | if len(user.Telephone) > 25 { 45 | errs = append(errs, errors.New("Telephone length cannot exceed 25 characters.")) 46 | } 47 | return errs 48 | } 49 | 50 | // Helper types 51 | 52 | type SearchUserResult struct { 53 | Users []*User 54 | NumberOfResults int 55 | TotalPages int 56 | } 57 | -------------------------------------------------------------------------------- /internal/app/types/mongo_user_action.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "time" 5 | 6 | "go.mongodb.org/mongo-driver/bson/primitive" 7 | ) 8 | 9 | type UserAction struct { 10 | ID primitive.ObjectID `bson:"_id,omitempty"` 11 | UserID primitive.ObjectID `bson:"userID,omitempty"` 12 | Email string `bson:"email,omitempty"` 13 | Action string `bson:"action,omitempty"` 14 | Detail string `bson:"detail,omitempty"` 15 | Category string `bson:"category,omitempty"` 16 | CreatedAt time.Time `bson:"createdAt,omitempty"` 17 | } 18 | -------------------------------------------------------------------------------- /internal/app/types/pg_account.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "github.com/jinzhu/gorm" 5 | ) 6 | 7 | type Account struct { 8 | gorm.Model 9 | // Account has many postings, AccountID is the foreign key 10 | Postings []Posting 11 | AccountNumber string `gorm:"type:varchar(16);not null;unique_index"` 12 | Balance float64 `gorm:"not null;default:0"` 13 | } 14 | -------------------------------------------------------------------------------- /internal/app/types/pg_balance_limit.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "github.com/jinzhu/gorm" 5 | ) 6 | 7 | type BalanceLimit struct { 8 | gorm.Model 9 | // `BalanceLimit` belongs to `Account`, `AccountID` is the foreign key 10 | Account Account 11 | AccountNumber string `json:"accountNumber,omitempty" gorm:"type:varchar(16);not null;unique_index"` 12 | MaxNegBal float64 `json:"maxNegBal,omitempty" gorm:"type:real;not null"` 13 | MaxPosBal float64 `json:"maxPosBal,omitempty" gorm:"type:real;not null"` 14 | } 15 | -------------------------------------------------------------------------------- /internal/app/types/pg_journal.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/jinzhu/gorm" 7 | ) 8 | 9 | type Journal struct { 10 | gorm.Model 11 | // Journal has many postings, JournalID is the foreign key 12 | Postings []Posting 13 | 14 | TransferID string `gorm:"type:varchar(27);not null;default:''"` 15 | 16 | InitiatedBy string `gorm:"varchar(16);not null;default:''"` 17 | 18 | FromAccountNumber string `gorm:"varchar(16);not null;default:''"` 19 | FromEntityName string `gorm:"type:varchar(120);not null;default:''"` 20 | 21 | ToAccountNumber string `gorm:"varchar(16);not null;default:''"` 22 | ToEntityName string `gorm:"type:varchar(120);not null;default:''"` 23 | 24 | Amount float64 `gorm:"not null;default:0"` 25 | Description string `gorm:"type:varchar(510);not null;default:''"` 26 | Type string `gorm:"type:varchar(31);not null;default:'transfer'"` 27 | Status string `gorm:"type:varchar(31);not null;default:''"` 28 | 29 | CompletedAt time.Time 30 | 31 | CancellationReason string `gorm:"type:varchar(510);not null;default:''"` 32 | } 33 | -------------------------------------------------------------------------------- /internal/app/types/pg_posting.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "github.com/jinzhu/gorm" 5 | ) 6 | 7 | type Posting struct { 8 | gorm.Model 9 | AccountNumber string `gorm:"varchar(16);not null;default:''"` 10 | JournalID uint `gorm:"not null"` 11 | Amount float64 `gorm:"not null"` 12 | } 13 | -------------------------------------------------------------------------------- /internal/migration/migration.go: -------------------------------------------------------------------------------- 1 | package migration 2 | 3 | import ( 4 | "github.com/ic3network/mccs-alpha-api/global" 5 | ) 6 | 7 | func init() { 8 | global.Init() 9 | } 10 | -------------------------------------------------------------------------------- /internal/pkg/email/balance.go: -------------------------------------------------------------------------------- 1 | package email 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/ic3network/mccs-alpha-api/util/l" 7 | "github.com/sendgrid/sendgrid-go/helpers/mail" 8 | "github.com/spf13/viper" 9 | "go.uber.org/zap" 10 | ) 11 | 12 | type balance struct{} 13 | 14 | var Balance = &balance{} 15 | 16 | // Non-zero balance notification 17 | 18 | type NonZeroBalanceEmail struct { 19 | From time.Time 20 | To time.Time 21 | } 22 | 23 | func (_ *balance) SendNonZeroBalanceEmail(input *NonZeroBalanceEmail) { 24 | m := e.newEmail(viper.GetString("sendgrid.template_id.non_zero_balance_notification")) 25 | 26 | p := mail.NewPersonalization() 27 | tos := []*mail.Email{ 28 | mail.NewEmail(viper.GetString("email_from"), viper.GetString("sendgrid.sender_email")), 29 | } 30 | p.AddTos(tos...) 31 | 32 | p.SetDynamicTemplateData("fromTime", input.From.Format("2006-01-02 15:04:05")) 33 | p.SetDynamicTemplateData("toTime", input.To.Format("2006-01-02 15:04:05")) 34 | m.AddPersonalizations(p) 35 | 36 | err := e.send(m) 37 | if err != nil { 38 | l.Logger.Error("email.sendNonZeroBalanceEmail failed", zap.Error(err)) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /internal/pkg/email/email.go: -------------------------------------------------------------------------------- 1 | package email 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/ic3network/mccs-alpha-api/internal/app/types" 7 | "github.com/ic3network/mccs-alpha-api/util/l" 8 | "github.com/sendgrid/sendgrid-go" 9 | "github.com/sendgrid/sendgrid-go/helpers/mail" 10 | "github.com/spf13/viper" 11 | "go.uber.org/zap" 12 | ) 13 | 14 | var e *Email 15 | 16 | type Email struct{} 17 | 18 | func (_ *Email) newEmail(templateID string) *mail.SGMailV3 { 19 | m := mail.NewV3Mail() 20 | e := mail.NewEmail(viper.GetString("email_from"), viper.GetString("sendgrid.sender_email")) 21 | m.SetFrom(e) 22 | m.SetTemplateID(templateID) 23 | return m 24 | } 25 | 26 | func (_ *Email) send(m *mail.SGMailV3) error { 27 | request := sendgrid.GetRequest(viper.GetString("sendgrid.key"), "/v3/mail/send", "https://api.sendgrid.com") 28 | request.Method = "POST" 29 | var Body = mail.GetRequestBody(m) 30 | request.Body = Body 31 | _, err := sendgrid.API(request) 32 | return err 33 | } 34 | 35 | // Welcome message 36 | 37 | type WelcomeEmail struct { 38 | EntityName string 39 | Email string 40 | Receiver string 41 | } 42 | 43 | func Welcome(input *WelcomeEmail) { 44 | e.welcome(input) 45 | } 46 | func (_ *Email) welcome(input *WelcomeEmail) { 47 | m := e.newEmail(viper.GetString("sendgrid.template_id.welcome_message")) 48 | 49 | p := mail.NewPersonalization() 50 | tos := []*mail.Email{ 51 | mail.NewEmail(input.Receiver+" ", input.Email), 52 | } 53 | p.AddTos(tos...) 54 | 55 | p.SetDynamicTemplateData("entityName", input.EntityName) 56 | m.AddPersonalizations(p) 57 | 58 | err := e.send(m) 59 | if err != nil { 60 | l.Logger.Error("email.Welcome failed", zap.Error(err)) 61 | } 62 | } 63 | 64 | // Signup notification 65 | 66 | type SignupNotificationEmail struct { 67 | EntityName string 68 | ContactEmail string 69 | } 70 | 71 | func Signup(input *SignupNotificationEmail) { 72 | e.signup(input) 73 | } 74 | func (_ *Email) signup(input *SignupNotificationEmail) { 75 | if !viper.GetBool("receive_email.signup_notifications") { 76 | return 77 | } 78 | 79 | m := e.newEmail(viper.GetString("sendgrid.template_id.signup_notification")) 80 | 81 | p := mail.NewPersonalization() 82 | tos := []*mail.Email{ 83 | mail.NewEmail(viper.GetString("email_from"), viper.GetString("sendgrid.sender_email")), 84 | } 85 | p.AddTos(tos...) 86 | 87 | p.SetDynamicTemplateData("entityName", input.EntityName) 88 | p.SetDynamicTemplateData("contactEmail", input.ContactEmail) 89 | m.AddPersonalizations(p) 90 | 91 | err := e.send(m) 92 | if err != nil { 93 | l.Logger.Error("email.Signup failed", zap.Error(err)) 94 | } 95 | } 96 | 97 | // Password reset 98 | 99 | type PasswordResetEmail struct { 100 | Receiver string 101 | ReceiverEmail string 102 | Token string 103 | } 104 | 105 | func PasswordReset(input *PasswordResetEmail) { 106 | e.passwordReset(input) 107 | } 108 | func (_ *Email) passwordReset(input *PasswordResetEmail) { 109 | m := e.newEmail(viper.GetString("sendgrid.template_id.user_password_reset")) 110 | 111 | p := mail.NewPersonalization() 112 | tos := []*mail.Email{ 113 | mail.NewEmail(input.Receiver+" ", input.ReceiverEmail), 114 | } 115 | p.AddTos(tos...) 116 | 117 | p.SetDynamicTemplateData("serverAddress", viper.GetString("url")) 118 | p.SetDynamicTemplateData("token", input.Token) 119 | m.AddPersonalizations(p) 120 | 121 | err := e.send(m) 122 | if err != nil { 123 | l.Logger.Error("email.PasswordReset failed", zap.Error(err)) 124 | } 125 | } 126 | 127 | // Admin password reset 128 | 129 | type AdminPasswordResetEmail struct { 130 | Receiver string 131 | ReceiverEmail string 132 | Token string 133 | } 134 | 135 | func AdminPasswordReset(input *AdminPasswordResetEmail) { 136 | e.adminPasswordReset(input) 137 | } 138 | func (_ *Email) adminPasswordReset(input *AdminPasswordResetEmail) { 139 | m := e.newEmail(viper.GetString("sendgrid.template_id.admin_password_reset")) 140 | 141 | p := mail.NewPersonalization() 142 | tos := []*mail.Email{ 143 | mail.NewEmail(input.Receiver+" ", input.ReceiverEmail), 144 | } 145 | p.AddTos(tos...) 146 | 147 | p.SetDynamicTemplateData("serverAddress", viper.GetString("url")) 148 | p.SetDynamicTemplateData("token", input.Token) 149 | m.AddPersonalizations(p) 150 | 151 | err := e.send(m) 152 | if err != nil { 153 | l.Logger.Error("email.AdminPasswordReset failed", zap.Error(err)) 154 | } 155 | } 156 | 157 | // Trade contact 158 | 159 | type TradeContactEmail struct { 160 | Receiver string 161 | ReceiverEmail string 162 | ReplyToName string 163 | ReplyToEmail string 164 | Body string 165 | } 166 | 167 | func TradeContact(input *TradeContactEmail) { 168 | e.tradeContact(input) 169 | } 170 | func (_ *Email) tradeContact(input *TradeContactEmail) { 171 | m := e.newEmail(viper.GetString("sendgrid.template_id.trade_contact")) 172 | replyToEmail := mail.NewEmail(input.ReplyToName, input.ReplyToEmail) 173 | m.SetReplyTo(replyToEmail) 174 | 175 | p := mail.NewPersonalization() 176 | tos := []*mail.Email{ 177 | mail.NewEmail(input.Receiver+" ", input.ReceiverEmail), 178 | } 179 | if viper.GetBool("receive_email.trade_contact_emails") { 180 | tos = append(tos, mail.NewEmail(viper.GetString("email_from"), viper.GetString("sendgrid.sender_email"))) 181 | } 182 | p.AddTos(tos...) 183 | 184 | p.SetDynamicTemplateData("body", input.Body) 185 | m.AddPersonalizations(p) 186 | 187 | err := e.send(m) 188 | if err != nil { 189 | l.Logger.Error("email.TradeContact failed", zap.Error(err)) 190 | } 191 | } 192 | 193 | // DailyEmailList 194 | 195 | type DailyMatchNotification struct { 196 | Entity *types.Entity 197 | MatchedTags *types.MatchedTags 198 | } 199 | 200 | func DailyMatch(input *DailyMatchNotification) { 201 | e.dailyMatch(input) 202 | } 203 | func (_ *Email) dailyMatch(input *DailyMatchNotification) { 204 | m := e.newEmail(viper.GetString("sendgrid.template_id.daily_match_notification")) 205 | 206 | p := mail.NewPersonalization() 207 | tos := []*mail.Email{ 208 | mail.NewEmail(input.Entity.Name+" ", input.Entity.Email), 209 | } 210 | p.AddTos(tos...) 211 | 212 | p.SetDynamicTemplateData("matchedOffers", input.MatchedTags.MatchedOffers) 213 | p.SetDynamicTemplateData("matchedWants", input.MatchedTags.MatchedWants) 214 | p.SetDynamicTemplateData("lastNotificationSentDate", fmt.Sprintf("%d", input.Entity.LastNotificationSentDate.UTC().Unix())) 215 | p.SetDynamicTemplateData("url", viper.GetString("url")) 216 | m.AddPersonalizations(p) 217 | 218 | err := e.send(m) 219 | if err != nil { 220 | l.Logger.Error("email.DailyMatch failed", zap.Error(err)) 221 | } 222 | } 223 | -------------------------------------------------------------------------------- /internal/pkg/email/transfer.go: -------------------------------------------------------------------------------- 1 | package email 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/ic3network/mccs-alpha-api/global/constant" 7 | "github.com/ic3network/mccs-alpha-api/internal/app/types" 8 | "github.com/ic3network/mccs-alpha-api/util/l" 9 | "github.com/sendgrid/sendgrid-go/helpers/mail" 10 | "github.com/spf13/viper" 11 | "go.uber.org/zap" 12 | ) 13 | 14 | type transfer struct{} 15 | 16 | var Transfer = &transfer{} 17 | 18 | // Transfer initiated 19 | 20 | func (tr *transfer) Initiate(req *types.TransferReq) { 21 | url := viper.GetString("url") + "/pending-transfers" 22 | 23 | var action string 24 | if req.TransferDirection == constant.TransferDirection.Out { 25 | action = "send " + fmt.Sprintf("%.2f", req.Amount) + " Credits to you" 26 | } 27 | if req.TransferDirection == constant.TransferDirection.In { 28 | action = "receive " + fmt.Sprintf("%.2f", req.Amount) + " Credits from you" 29 | } 30 | 31 | m := e.newEmail(viper.GetString("sendgrid.template_id.transfer_initiated")) 32 | 33 | p := mail.NewPersonalization() 34 | tos := []*mail.Email{ 35 | mail.NewEmail(req.ReceiverEntityName+" ", req.ReceiverEmail), 36 | } 37 | p.AddTos(tos...) 38 | 39 | p.SetDynamicTemplateData("initiatorEntityName", req.InitiatorEntityName) 40 | p.SetDynamicTemplateData("action", action) 41 | p.SetDynamicTemplateData("url", url) 42 | m.AddPersonalizations(p) 43 | 44 | err := e.send(m) 45 | if err != nil { 46 | l.Logger.Error("email.Transfer.Initiate failed", zap.Error(err)) 47 | } 48 | } 49 | 50 | type TransferEmailInfo struct { 51 | TransferDirection string 52 | InitiatorEmail string 53 | InitiatorEntityName string 54 | ReceiverEmail string 55 | ReceiverEntityName string 56 | Reason string 57 | Amount float64 58 | } 59 | 60 | // Transfer accepted 61 | 62 | func (tr *transfer) Accept(info *TransferEmailInfo) { 63 | m := e.newEmail(viper.GetString("sendgrid.template_id.transfer_accepted")) 64 | 65 | p := mail.NewPersonalization() 66 | tos := []*mail.Email{ 67 | mail.NewEmail(info.InitiatorEntityName+" ", info.InitiatorEmail), 68 | } 69 | p.AddTos(tos...) 70 | 71 | if info.TransferDirection == "out" { 72 | p.SetDynamicTemplateData("transferDirection", "-") 73 | } else { 74 | p.SetDynamicTemplateData("transferDirection", "+") 75 | } 76 | p.SetDynamicTemplateData("receiverEntityName", info.ReceiverEntityName) 77 | p.SetDynamicTemplateData("amount", fmt.Sprintf("%.2f", info.Amount)) 78 | m.AddPersonalizations(p) 79 | 80 | err := e.send(m) 81 | if err != nil { 82 | l.Logger.Error("email.Transfer.Accept failed", zap.Error(err)) 83 | } 84 | } 85 | 86 | // Transfer rejected 87 | 88 | func (tr *transfer) Reject(info *TransferEmailInfo) { 89 | m := e.newEmail(viper.GetString("sendgrid.template_id.transfer_rejected")) 90 | 91 | p := mail.NewPersonalization() 92 | tos := []*mail.Email{ 93 | mail.NewEmail(info.InitiatorEntityName+" ", info.InitiatorEmail), 94 | } 95 | p.AddTos(tos...) 96 | 97 | if info.TransferDirection == "out" { 98 | p.SetDynamicTemplateData("transferDirection", "-") 99 | } else { 100 | p.SetDynamicTemplateData("transferDirection", "+") 101 | } 102 | p.SetDynamicTemplateData("receiverEntityName", info.ReceiverEntityName) 103 | p.SetDynamicTemplateData("amount", fmt.Sprintf("%.2f", info.Amount)) 104 | p.SetDynamicTemplateData("reason", info.Reason) 105 | m.AddPersonalizations(p) 106 | 107 | err := e.send(m) 108 | if err != nil { 109 | l.Logger.Error("email.Transfer.Reject failed", zap.Error(err)) 110 | } 111 | } 112 | 113 | // Transfer cancelled 114 | 115 | func (tr *transfer) Cancel(info *TransferEmailInfo) { 116 | m := e.newEmail(viper.GetString("sendgrid.template_id.transfer_cancelled")) 117 | 118 | p := mail.NewPersonalization() 119 | tos := []*mail.Email{ 120 | mail.NewEmail(info.ReceiverEntityName+" ", info.ReceiverEmail), 121 | } 122 | p.AddTos(tos...) 123 | 124 | if info.TransferDirection == "out" { 125 | p.SetDynamicTemplateData("transferDirection", "+") 126 | } else { 127 | p.SetDynamicTemplateData("transferDirection", "-") 128 | } 129 | p.SetDynamicTemplateData("initiatorEntityName", info.InitiatorEntityName) 130 | p.SetDynamicTemplateData("amount", fmt.Sprintf("%.2f", info.Amount)) 131 | p.SetDynamicTemplateData("reason", info.Reason) 132 | m.AddPersonalizations(p) 133 | 134 | err := e.send(m) 135 | if err != nil { 136 | l.Logger.Error("email.Transfer.Cancel failed", zap.Error(err)) 137 | } 138 | } 139 | 140 | // Transfer cancelled by system 141 | 142 | func (tr *transfer) CancelBySystem(info *TransferEmailInfo) { 143 | m := e.newEmail(viper.GetString("sendgrid.template_id.transfer_cancelled_by_system")) 144 | 145 | p := mail.NewPersonalization() 146 | tos := []*mail.Email{ 147 | mail.NewEmail(info.InitiatorEntityName+" ", info.InitiatorEmail), 148 | } 149 | p.AddTos(tos...) 150 | 151 | p.SetDynamicTemplateData("receiverEntityName", info.ReceiverEntityName) 152 | p.SetDynamicTemplateData("reason", info.Reason) 153 | m.AddPersonalizations(p) 154 | 155 | err := e.send(m) 156 | if err != nil { 157 | l.Logger.Error("email.Transfer.Cancel failed", zap.Error(err)) 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /internal/seed/data/admin_user.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "email": "api-test-admin1@ic3.dev", 4 | "name": "admin1", 5 | "password": "password1!" 6 | }, 7 | { 8 | "email": "api-test-admin2@ic3.dev", 9 | "name": "admin2", 10 | "password": "password1!" 11 | }, 12 | { 13 | "email": "api-test-admin3@ic3.dev", 14 | "name": "admin3", 15 | "password": "password1!" 16 | } 17 | ] 18 | -------------------------------------------------------------------------------- /internal/seed/data/balance_limit.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "maxPosBal": 100, 4 | "maxNegBal": 10 5 | }, 6 | { 7 | "maxPosBal": 5, 8 | "maxNegBal": 5 9 | }, 10 | { 11 | "maxPosBal": 20, 12 | "maxNegBal": 10 13 | }, 14 | { 15 | "maxPosBal": 5, 16 | "maxNegBal": 5 17 | }, 18 | { 19 | "maxPosBal": 20, 20 | "maxNegBal": 10 21 | }, 22 | { 23 | "maxPosBal": 20, 24 | "maxNegBal": 10 25 | }, 26 | { 27 | "maxPosBal": 20, 28 | "maxNegBal": 10 29 | }, 30 | { 31 | "maxPosBal": 20, 32 | "maxNegBal": 10 33 | }, 34 | { 35 | "maxPosBal": 20, 36 | "maxNegBal": 10 37 | }, 38 | { 39 | "maxPosBal": 20, 40 | "maxNegBal": 10 41 | }, 42 | { 43 | "maxPosBal": 20, 44 | "maxNegBal": 10 45 | } 46 | ] 47 | -------------------------------------------------------------------------------- /internal/seed/data/category.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "carpentry", 4 | "createdAt": "2020-02-16T08:47:56.145Z" 5 | }, 6 | { 7 | "name": "painting", 8 | "createdAt": "2020-02-16T08:40:10.255Z" 9 | }, 10 | { 11 | "name": "car-repair" 12 | }, 13 | { 14 | "name": "brewery" 15 | }, 16 | { 17 | "name": "restaurant" 18 | }, 19 | { 20 | "name": "transport" 21 | }, 22 | { 23 | "name": "organic-produce" 24 | }, 25 | { 26 | "name": "bakery" 27 | } 28 | ] 29 | -------------------------------------------------------------------------------- /internal/seed/data/tag.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "carpentry", 4 | "createdAt": "2020-02-16T08:37:08.382Z", 5 | "offerAddedAt": "2020-02-16T08:37:08.382Z", 6 | "wantAddedAt": "2020-02-20T06:45:55.849Z", 7 | "updatedAt": "2020-02-20T06:45:55.849Z" 8 | }, 9 | { 10 | "name": "pizza", 11 | "createdAt": "2020-02-16T08:37:08.423Z", 12 | "updatedAt": "2020-02-16T08:37:08.423Z", 13 | "offerAddedAt": "2020-02-27T17:12:30.849Z", 14 | "wantAddedAt": "2020-02-16T08:37:08.423Z" 15 | }, 16 | { 17 | "name": "beer", 18 | "createdAt": "2020-02-16T08:37:08.444Z", 19 | "updatedAt": "2020-02-20T06:45:55.849Z", 20 | "offerAddedAt": "2020-02-27T17:12:30.849Z", 21 | "wantAddedAt": "2020-02-20T06:45:55.849Z" 22 | }, 23 | { 24 | "name": "painting", 25 | "createdAt": "2020-02-16T08:39:10.251Z", 26 | "offerAddedAt": "2020-02-16T08:40:10.251Z", 27 | "updatedAt": "2020-02-16T08:40:10.251Z" 28 | }, 29 | { 30 | "name": "vegetables", 31 | "createdAt": "2020-02-16T08:40:10.251Z", 32 | "wantAddedAt": "2020-02-16T08:40:10.251Z", 33 | "updatedAt": "2020-02-16T08:40:10.251Z" 34 | }, 35 | { 36 | "name": "wine", 37 | "createdAt": "2020-02-16T08:40:10.251Z", 38 | "wantAddedAt": "2020-02-16T08:40:10.251Z", 39 | "updatedAt": "2020-02-16T08:40:10.251Z" 40 | }, 41 | { 42 | "name": "organic-vegetables", 43 | "createdAt": "2020-02-20T06:45:55.849Z", 44 | "wantAddedAt": "2020-02-27T17:12:30.849Z", 45 | "offerAddedAt": "2020-02-20T06:45:55.849Z" 46 | }, 47 | { 48 | "name": "organic-fruits", 49 | "createdAt": "2020-02-20T06:45:55.849Z", 50 | "wantAddedAt": "2020-02-24T09:45:52.849Z", 51 | "offerAddedAt": "2020-02-20T06:45:55.849Z" 52 | }, 53 | { 54 | "name": "flour", 55 | "createdAt": "2020-02-20T06:45:55.849Z", 56 | "offerAddedAt": "2020-02-20T06:45:55.849Z" 57 | }, 58 | { 59 | "name": "baked-goods", 60 | "createdAt": "2020-02-24T09:45:52.849Z", 61 | "offerAddedAt": "2020-02-24T09:45:52.849Z" 62 | }, 63 | { 64 | "name": "bread", 65 | "createdAt": "2020-02-24T09:45:52.849Z", 66 | "offerAddedAt": "2020-02-24T09:45:52.849Z" 67 | }, 68 | { 69 | "name": "pasteries", 70 | "createdAt": "2020-02-24T09:45:52.849Z", 71 | "offerAddedAt": "2020-02-24T09:45:52.849Z" 72 | }, 73 | { 74 | "name": "flour", 75 | "createdAt": "2020-02-24T09:45:52.849Z", 76 | "wantAddedAt": "2020-02-24T09:45:52.849Z" 77 | }, 78 | { 79 | "name": "craft-beers", 80 | "createdAt": "2020-02-27T17:12:30.849Z", 81 | "offerAddedAt": "2020-02-27T17:12:30.849Z" 82 | }, 83 | { 84 | "name": "organic-flour", 85 | "createdAt": "2020-02-27T17:12:30.849Z", 86 | "wantAddedAt": "2020-02-27T17:12:30.849Z" 87 | }] 88 | -------------------------------------------------------------------------------- /internal/seed/data/user.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "firstName": "Pete", 4 | "lastName": "Painter", 5 | "email": "api-test-user01@ic3.dev", 6 | "password": "password1!", 7 | "telephone": "+442083232323", 8 | "createdAt": "2020-02-16T08:40:10.249Z" 9 | }, 10 | { 11 | "firstName": "Charlie", 12 | "lastName": "Carpenter", 13 | "email": "api-test-user02@ic3.dev", 14 | "password": "password1!", 15 | "telephone": "+442079191919", 16 | "createdAt": "2020-02-16T08:35:55.849Z" 17 | }, 18 | { 19 | "firstName": "Fred", 20 | "lastName": "Farmer", 21 | "email": "api-test-user03@ic3.dev", 22 | "password": "password1!", 23 | "telephone": "+4420812312312", 24 | "createdAt": "2020-02-20T06:45:55.849Z" 25 | }, 26 | { 27 | "firstName": "Betty", 28 | "lastName": "Baker", 29 | "email": "api-test-user04@ic3.dev", 30 | "password": "password1!", 31 | "telephone": "+442086666888", 32 | "createdAt": "2020-02-24T09:45:52.849Z" 33 | }, 34 | { 35 | "firstName": "Herbie", 36 | "lastName": "Hipster", 37 | "email": "api-test-user05@ic3.dev", 38 | "password": "password1!", 39 | "telephone": "+442044442222", 40 | "createdAt": "2020-02-27T17:12:30.849Z" 41 | }, 42 | { 43 | "firstName": "A", 44 | "lastName": "A", 45 | "email": "api-test-user06@ic3.dev", 46 | "password": "password1!", 47 | "createdAt": "2020-02-28T17:12:30.849Z" 48 | }, 49 | { 50 | "firstName": "D", 51 | "lastName": "D", 52 | "email": "api-test-user07@ic3.dev", 53 | "password": "password1!", 54 | "createdAt": "2020-02-29T17:12:30.849Z" 55 | }, 56 | { 57 | "firstName": "E", 58 | "lastName": "E", 59 | "email": "api-test-user08@ic3.dev", 60 | "password": "password1!", 61 | "createdAt": "2020-03-01T17:12:30.849Z" 62 | }, 63 | { 64 | "firstName": "G", 65 | "lastName": "G", 66 | "email": "api-test-user09@ic3.dev", 67 | "password": "password1!", 68 | "createdAt": "2020-03-02T17:12:30.849Z" 69 | }, 70 | { 71 | "firstName": "I", 72 | "lastName": "I", 73 | "email": "api-test-user10@ic3.dev", 74 | "password": "password1!", 75 | "createdAt": "2020-03-03T07:12:30.849Z" 76 | }, 77 | { 78 | "firstName": "J", 79 | "lastName": "J", 80 | "email": "api-test-user11@ic3.dev", 81 | "password": "password1!", 82 | "createdAt": "2020-03-03T07:13:30.849Z" 83 | } 84 | ] 85 | -------------------------------------------------------------------------------- /internal/seed/es.go: -------------------------------------------------------------------------------- 1 | package seed 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/ic3network/mccs-alpha-api/internal/app/repository/es" 7 | "github.com/ic3network/mccs-alpha-api/internal/app/types" 8 | ) 9 | 10 | var ElasticSearch = elasticSearch{} 11 | 12 | type elasticSearch struct{} 13 | 14 | func (_ *elasticSearch) CreateEntity( 15 | entity *types.Entity, 16 | accountNumber string, 17 | balanceLimit types.BalanceLimit, 18 | ) error { 19 | balance := 0.0 20 | record := types.EntityESRecord{ 21 | ID: entity.ID.Hex(), 22 | Name: entity.Name, 23 | Email: entity.Email, 24 | Offers: entity.Offers, 25 | Wants: entity.Wants, 26 | Status: entity.Status, 27 | Categories: entity.Categories, 28 | // Address 29 | City: entity.City, 30 | Region: entity.Region, 31 | Country: entity.Country, 32 | // Account 33 | AccountNumber: accountNumber, 34 | Balance: &balance, 35 | MaxPosBal: &balanceLimit.MaxPosBal, 36 | MaxNegBal: &balanceLimit.MaxNegBal, 37 | } 38 | 39 | _, err := es.Client().Index(). 40 | Index("entities"). 41 | Id(entity.ID.Hex()). 42 | BodyJson(record). 43 | Do(context.Background()) 44 | if err != nil { 45 | return err 46 | } 47 | 48 | return nil 49 | } 50 | 51 | func (_ *elasticSearch) CreateUser(user *types.User) error { 52 | uRecord := types.UserESRecord{ 53 | UserID: user.ID.Hex(), 54 | FirstName: user.FirstName, 55 | LastName: user.LastName, 56 | Email: user.Email, 57 | } 58 | _, err := es.Client().Index(). 59 | Index("users"). 60 | Id(user.ID.Hex()). 61 | BodyJson(uRecord). 62 | Do(context.Background()) 63 | if err != nil { 64 | return err 65 | } 66 | 67 | return nil 68 | } 69 | 70 | func (_ *elasticSearch) CreateTag(tag *types.Tag) error { 71 | tagRecord := types.TagESRecord{ 72 | TagID: tag.ID.Hex(), 73 | Name: tag.Name, 74 | OfferAddedAt: tag.OfferAddedAt, 75 | WantAddedAt: tag.WantAddedAt, 76 | } 77 | _, err := es.Client().Index(). 78 | Index("tags"). 79 | Id(tag.ID.Hex()). 80 | BodyJson(tagRecord). 81 | Do(context.Background()) 82 | if err != nil { 83 | return err 84 | } 85 | 86 | return nil 87 | } 88 | -------------------------------------------------------------------------------- /internal/seed/mongodb.go: -------------------------------------------------------------------------------- 1 | package seed 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/ic3network/mccs-alpha-api/internal/app/repository/mongo" 7 | "github.com/ic3network/mccs-alpha-api/internal/app/types" 8 | "github.com/ic3network/mccs-alpha-api/util/bcrypt" 9 | "go.mongodb.org/mongo-driver/bson" 10 | "go.mongodb.org/mongo-driver/bson/primitive" 11 | ) 12 | 13 | var MongoDB = mongoDB{} 14 | 15 | type mongoDB struct{} 16 | 17 | func (_ *mongoDB) CreateEntity(entity types.Entity) (primitive.ObjectID, error) { 18 | res, err := mongo.DB().Collection("entities").InsertOne(context.Background(), entity) 19 | if err != nil { 20 | return primitive.ObjectID{}, err 21 | } 22 | return res.InsertedID.(primitive.ObjectID), nil 23 | } 24 | 25 | func (_ *mongoDB) CreateUser(user types.User) (primitive.ObjectID, error) { 26 | res, err := mongo.DB().Collection("users").InsertOne(context.Background(), user) 27 | if err != nil { 28 | return primitive.ObjectID{}, err 29 | } 30 | return res.InsertedID.(primitive.ObjectID), nil 31 | } 32 | 33 | func (_ *mongoDB) AssociateUserWithEntity(userID, entityID primitive.ObjectID) error { 34 | _, err := mongo.DB().Collection("entities").UpdateOne(context.Background(), bson.M{"_id": entityID}, bson.M{ 35 | "$addToSet": bson.M{"users": userID}, 36 | }) 37 | return err 38 | } 39 | 40 | func (_ *mongoDB) CreateAdminUsers(adminUsers []types.AdminUser) error { 41 | for _, u := range adminUsers { 42 | hashedPassword, _ := bcrypt.Hash(u.Password) 43 | u.Password = hashedPassword 44 | _, err := mongo.DB().Collection("adminUsers").InsertOne(context.Background(), u) 45 | if err != nil { 46 | return err 47 | } 48 | } 49 | return nil 50 | } 51 | 52 | func (_ *mongoDB) CreateTags(tags []types.Tag) error { 53 | for _, t := range tags { 54 | res, err := mongo.DB().Collection("tags").InsertOne(context.Background(), t) 55 | if err != nil { 56 | return err 57 | } 58 | t.ID = res.InsertedID.(primitive.ObjectID) 59 | 60 | err = ElasticSearch.CreateTag(&t) 61 | if err != nil { 62 | return err 63 | } 64 | } 65 | 66 | return nil 67 | } 68 | 69 | func (_ *mongoDB) CreateCategories(categories []types.Category) error { 70 | for _, a := range categories { 71 | _, err := mongo.DB().Collection("categories").InsertOne(context.Background(), a) 72 | if err != nil { 73 | return err 74 | } 75 | } 76 | return nil 77 | } 78 | -------------------------------------------------------------------------------- /internal/seed/pg.go: -------------------------------------------------------------------------------- 1 | package seed 2 | 3 | import ( 4 | "github.com/ic3network/mccs-alpha-api/internal/app/logic" 5 | "github.com/ic3network/mccs-alpha-api/internal/app/repository/pg" 6 | "github.com/ic3network/mccs-alpha-api/internal/app/types" 7 | ) 8 | 9 | var PostgresSQL = postgresSQL{} 10 | 11 | type postgresSQL struct{} 12 | 13 | func (_ *postgresSQL) CreateAccount() (string, error) { 14 | account, err := logic.Account.Create() 15 | if err != nil { 16 | return "", err 17 | } 18 | return account.AccountNumber, nil 19 | } 20 | 21 | func (_ *postgresSQL) UpdateBalanceLimits(accountNumber string, balanceLimit types.BalanceLimit) error { 22 | err := pg.DB().Exec(` 23 | UPDATE balance_limits 24 | SET max_pos_bal = ?, max_neg_bal = ? 25 | WHERE account_number = ? 26 | `, balanceLimit.MaxPosBal, balanceLimit.MaxNegBal, accountNumber).Error 27 | if err != nil { 28 | return err 29 | } 30 | return nil 31 | } 32 | -------------------------------------------------------------------------------- /internal/seed/seed.go: -------------------------------------------------------------------------------- 1 | package seed 2 | 3 | import ( 4 | "encoding/json" 5 | "io/ioutil" 6 | "log" 7 | "time" 8 | 9 | "github.com/ic3network/mccs-alpha-api/internal/app/types" 10 | "github.com/ic3network/mccs-alpha-api/util/bcrypt" 11 | ) 12 | 13 | var ( 14 | entityData []types.Entity 15 | balanceLimitData []types.BalanceLimit 16 | userData []types.User 17 | adminUserData []types.AdminUser 18 | tagData []types.Tag 19 | categoriesData []types.Category 20 | ) 21 | 22 | func LoadData() { 23 | // Load entity data. 24 | data, err := ioutil.ReadFile("internal/seed/data/entity.json") 25 | if err != nil { 26 | log.Fatal(err) 27 | } 28 | entities := make([]types.Entity, 0) 29 | json.Unmarshal(data, &entities) 30 | entityData = entities 31 | 32 | // Load balance limit data. 33 | data, err = ioutil.ReadFile("internal/seed/data/balance_limit.json") 34 | if err != nil { 35 | log.Fatal(err) 36 | } 37 | balanceLimits := make([]types.BalanceLimit, 0) 38 | json.Unmarshal(data, &balanceLimits) 39 | balanceLimitData = balanceLimits 40 | 41 | // Load user data. 42 | data, err = ioutil.ReadFile("internal/seed/data/user.json") 43 | if err != nil { 44 | log.Fatal(err) 45 | } 46 | users := make([]types.User, 0) 47 | json.Unmarshal(data, &users) 48 | userData = users 49 | 50 | // Load admin user data. 51 | data, err = ioutil.ReadFile("internal/seed/data/admin_user.json") 52 | if err != nil { 53 | log.Fatal(err) 54 | } 55 | adminUsers := make([]types.AdminUser, 0) 56 | json.Unmarshal(data, &adminUsers) 57 | adminUserData = adminUsers 58 | 59 | // Load user tag data. 60 | data, err = ioutil.ReadFile("internal/seed/data/tag.json") 61 | if err != nil { 62 | log.Fatal(err) 63 | } 64 | tags := make([]types.Tag, 0) 65 | json.Unmarshal(data, &tags) 66 | tagData = tags 67 | 68 | // Load category data. 69 | data, err = ioutil.ReadFile("internal/seed/data/category.json") 70 | if err != nil { 71 | log.Fatal(err) 72 | } 73 | categories := make([]types.Category, 0) 74 | json.Unmarshal(data, &categories) 75 | categoriesData = categories 76 | } 77 | 78 | func Run() { 79 | log.Println("start seeding") 80 | startTime := time.Now() 81 | 82 | // create users and entities. 83 | for i, b := range entityData { 84 | accountNumber, err := PostgresSQL.CreateAccount() 85 | if err != nil { 86 | log.Fatal(err) 87 | } 88 | b.AccountNumber = accountNumber 89 | 90 | entityID, err := MongoDB.CreateEntity(b) 91 | if err != nil { 92 | log.Fatal(err) 93 | } 94 | b.ID = entityID 95 | 96 | err = PostgresSQL.UpdateBalanceLimits(accountNumber, balanceLimitData[i]) 97 | if err != nil { 98 | log.Fatal(err) 99 | } 100 | 101 | err = ElasticSearch.CreateEntity(&b, accountNumber, balanceLimitData[i]) 102 | if err != nil { 103 | log.Fatal(err) 104 | } 105 | 106 | u := userData[i] 107 | u.Entities = append(u.Entities, b.ID) 108 | hashedPassword, _ := bcrypt.Hash(u.Password) 109 | u.Password = hashedPassword 110 | 111 | userID, err := MongoDB.CreateUser(u) 112 | if err != nil { 113 | log.Fatal(err) 114 | } 115 | u.ID = userID 116 | 117 | err = MongoDB.AssociateUserWithEntity(u.ID, b.ID) 118 | if err != nil { 119 | log.Fatal(err) 120 | } 121 | 122 | err = ElasticSearch.CreateUser(&u) 123 | if err != nil { 124 | log.Fatal(err) 125 | } 126 | } 127 | 128 | err := MongoDB.CreateAdminUsers(adminUserData) 129 | if err != nil { 130 | log.Fatal(err) 131 | } 132 | 133 | err = MongoDB.CreateTags(tagData) 134 | if err != nil { 135 | log.Fatal(err) 136 | } 137 | 138 | err = MongoDB.CreateCategories(categoriesData) 139 | if err != nil { 140 | log.Fatal(err) 141 | } 142 | 143 | log.Printf("took %v\n", time.Now().Sub(startTime)) 144 | } 145 | -------------------------------------------------------------------------------- /reflex.dev.conf: -------------------------------------------------------------------------------- 1 | -r '(\.go$|\.html$)' -R '_test\.go$' -s go run cmd/mccs-alpha-api/main.go 2 | -------------------------------------------------------------------------------- /util/amount.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | // IsDecimalValid checks the num is positive value and with up to two decimal places. 9 | func IsDecimalValid(num float64) bool { 10 | numArr := strings.Split(fmt.Sprintf("%g", num), ".") 11 | if len(numArr) == 1 { 12 | return true 13 | } 14 | if len(numArr) == 2 && len(numArr[1]) <= 2 { 15 | return true 16 | } 17 | return false 18 | } 19 | -------------------------------------------------------------------------------- /util/bcrypt/bcrypt.go: -------------------------------------------------------------------------------- 1 | package bcrypt 2 | 3 | import "golang.org/x/crypto/bcrypt" 4 | 5 | // Hash hashes the password. 6 | func Hash(password string) (string, error) { 7 | bytePwd := []byte(password) 8 | hash, err := bcrypt.GenerateFromPassword(bytePwd, bcrypt.DefaultCost) 9 | if err != nil { 10 | return "", err 11 | } 12 | return string(hash), nil 13 | } 14 | 15 | // CompareHash compares the hashedPassword with plainPassword. 16 | func CompareHash(hashedPassword string, plainPassword string) error { 17 | err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(plainPassword)) 18 | if err != nil { 19 | return err 20 | } 21 | return nil 22 | } 23 | -------------------------------------------------------------------------------- /util/bool.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | func ToBool(boolean *bool) bool { 4 | if boolean == nil { 5 | return false 6 | } 7 | return *boolean 8 | } 9 | -------------------------------------------------------------------------------- /util/check_field_diff.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "reflect" 7 | "strings" 8 | 9 | "gopkg.in/oleiade/reflections.v1" 10 | ) 11 | 12 | var defaultFieldsToSkip = []string{ 13 | "CurrentLoginIP", 14 | "Password", 15 | "LastLoginIP", 16 | "UpdatedAt", 17 | } 18 | 19 | // CheckFieldDiff checks what fields have been changed. 20 | func CheckFieldDiff(oldStruct interface{}, newStruct interface{}, fieldsToSkip ...string) []string { 21 | modifiedFields := []string{} 22 | 23 | structItems, _ := reflections.Items(oldStruct) 24 | skipMap := sliceToMap(append(fieldsToSkip, defaultFieldsToSkip...)) 25 | 26 | for field, origin := range structItems { 27 | if _, ok := skipMap[field]; ok { 28 | continue 29 | } 30 | update, _ := reflections.GetField(newStruct, field) 31 | if !reflect.DeepEqual(origin, update) { 32 | fieldKind, _ := reflections.GetFieldKind(oldStruct, field) 33 | switch fieldKind { 34 | case reflect.String: 35 | modifiedFields = append(modifiedFields, handleString(field, origin, update)) 36 | case reflect.Int: 37 | case reflect.Int32: 38 | case reflect.Int64: 39 | modifiedFields = append(modifiedFields, handleInt(field, origin, update)) 40 | case reflect.Float32: 41 | case reflect.Float64: 42 | modifiedFields = append(modifiedFields, handleFloat(field, origin, update)) 43 | case reflect.Bool: 44 | modifiedFields = append(modifiedFields, handleBool(field, origin, update)) 45 | case reflect.Ptr: 46 | modifiedFields = append(modifiedFields, handlePtr(field, origin, update)) 47 | case reflect.Slice: 48 | modifiedFields = append(modifiedFields, handleSlice(field, origin, update)) 49 | } 50 | } 51 | } 52 | 53 | return modifiedFields 54 | } 55 | 56 | func handleString(field string, origin interface{}, update interface{}) string { 57 | return fmt.Sprintf("%s: %s -> %s", field, origin, update) 58 | } 59 | 60 | func handleInt(field string, origin interface{}, update interface{}) string { 61 | return fmt.Sprintf("%s: %d -> %d", field, origin, update) 62 | } 63 | 64 | func handleFloat(field string, origin interface{}, update interface{}) string { 65 | return fmt.Sprintf("%s: %.2f -> %.2f", field, origin, update) 66 | } 67 | 68 | func handleBool(field string, origin interface{}, update interface{}) string { 69 | return fmt.Sprintf("%s: %t -> %t", field, origin, update) 70 | } 71 | 72 | func handlePtr(field string, origin interface{}, update interface{}) string { 73 | intPtr, ok := origin.(*int) 74 | if ok { 75 | updateIntPtr, _ := update.(*int) 76 | if intPtr == nil { 77 | return handleInt(field, 0, *updateIntPtr) 78 | } else { 79 | return handleInt(field, *intPtr, *updateIntPtr) 80 | } 81 | } 82 | floatPtr, ok := origin.(*float64) 83 | if ok { 84 | updateFloatPtr, _ := update.(*float64) 85 | if floatPtr == nil { 86 | return handleFloat(field, 0, *updateFloatPtr) 87 | } else { 88 | return handleFloat(field, *floatPtr, *updateFloatPtr) 89 | } 90 | } 91 | boolPtr, ok := origin.(*bool) 92 | if ok { 93 | updateBoolPtr, _ := update.(*bool) 94 | if boolPtr == nil { 95 | return handleBool(field, false, *updateBoolPtr) 96 | } else { 97 | return handleBool(field, *boolPtr, *updateBoolPtr) 98 | } 99 | } 100 | return "" 101 | } 102 | 103 | func handleSlice(field string, origin interface{}, update interface{}) string { 104 | o, _ := json.Marshal(origin) 105 | u, _ := json.Marshal(update) 106 | return fmt.Sprintf("%s: %+v -> %+v", field, strings.Replace(string(o), "\"", " ", -1), strings.Replace(string(u), "\"", " ", -1)) 107 | } 108 | 109 | func sliceToMap(elements []string) map[string]bool { 110 | elementMap := make(map[string]bool) 111 | for _, e := range elements { 112 | elementMap[e] = true 113 | } 114 | return elementMap 115 | } 116 | -------------------------------------------------------------------------------- /util/cookie/cookie.go: -------------------------------------------------------------------------------- 1 | package cookie 2 | 3 | import "net/http" 4 | 5 | // CreateCookie creates the default cookie. 6 | func CreateCookie(value string) *http.Cookie { 7 | return &http.Cookie{ 8 | Name: "mccsToken", 9 | Value: value, 10 | Path: "/", 11 | MaxAge: 86400, 12 | HttpOnly: true, 13 | } 14 | } 15 | 16 | // ResetCookie resets the default cookie. 17 | func ResetCookie() *http.Cookie { 18 | return &http.Cookie{ 19 | Name: "mccsToken", 20 | Value: "", 21 | Path: "/", 22 | MaxAge: -1, 23 | HttpOnly: true, 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /util/email.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "errors" 5 | "regexp" 6 | "strconv" 7 | "strings" 8 | 9 | "github.com/spf13/viper" 10 | ) 11 | 12 | var emailRe = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$") 13 | 14 | func ValidateEmail(email string) []error { 15 | errs := []error{} 16 | email = strings.ToLower(email) 17 | emailMaxLen := viper.GetInt("validate.email.maxLen") 18 | if email == "" { 19 | errs = append(errs, errors.New("Email is missing.")) 20 | } else if len(email) > emailMaxLen { 21 | errs = append(errs, errors.New("Email address length cannot exceed "+strconv.Itoa(emailMaxLen)+" characters.")) 22 | } else if IsInValidEmail(email) { 23 | errs = append(errs, errors.New("Email is invalid.")) 24 | } 25 | return errs 26 | } 27 | 28 | func IsValidEmail(email string) bool { 29 | if email == "" || !emailRe.MatchString(email) || len(email) > 100 { 30 | return false 31 | } 32 | return true 33 | } 34 | 35 | func IsInValidEmail(email string) bool { 36 | if email == "" || !emailRe.MatchString(email) || len(email) > 100 { 37 | return true 38 | } 39 | return false 40 | } 41 | -------------------------------------------------------------------------------- /util/entity_status.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "github.com/ic3network/mccs-alpha-api/global/constant" 5 | ) 6 | 7 | func IsValidStatus(status string) bool { 8 | if status == constant.Entity.Pending || 9 | status == constant.Entity.Rejected || 10 | status == constant.Entity.Accepted || 11 | status == constant.Trading.Pending || 12 | status == constant.Trading.Accepted || 13 | status == constant.Trading.Rejected { 14 | return true 15 | } 16 | return false 17 | } 18 | 19 | // IsAcceptedStatus checks if the entity status is accpeted. 20 | func IsAcceptedStatus(status string) bool { 21 | if status == constant.Entity.Accepted || 22 | status == constant.Trading.Pending || 23 | status == constant.Trading.Accepted || 24 | status == constant.Trading.Rejected { 25 | return true 26 | } 27 | return false 28 | } 29 | 30 | func IsTradingAccepted(status string) bool { 31 | if status == constant.Trading.Accepted { 32 | return true 33 | } 34 | return false 35 | } 36 | -------------------------------------------------------------------------------- /util/float.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "strconv" 5 | ) 6 | 7 | // GET /admin/entities 8 | 9 | func ToFloat64(input string) (*float64, error) { 10 | if input == "" { 11 | return nil, nil 12 | } 13 | 14 | s, err := strconv.ParseFloat(input, 64) 15 | if err != nil { 16 | return nil, err 17 | } 18 | 19 | return &s, nil 20 | } 21 | -------------------------------------------------------------------------------- /util/int.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "strconv" 5 | ) 6 | 7 | func PointerToInt(pointer *int) int { 8 | if pointer != nil { 9 | return *pointer 10 | } 11 | return 0 12 | } 13 | 14 | func ToInt(input string, defaultValue ...int) (int, error) { 15 | if input == "" { 16 | if len(defaultValue) > 0 { 17 | return defaultValue[0], nil 18 | } 19 | return 1, nil 20 | } 21 | 22 | integer, err := strconv.Atoi(input) 23 | if err != nil { 24 | return 0, err 25 | } 26 | 27 | return integer, nil 28 | } 29 | 30 | func ToInt64(input string, defaultValue ...int64) (int64, error) { 31 | if input == "" { 32 | if len(defaultValue) > 0 { 33 | return defaultValue[0], nil 34 | } 35 | return 1, nil 36 | } 37 | 38 | integer, err := strconv.ParseInt(input, 10, 64) 39 | if err != nil { 40 | return 0, err 41 | } 42 | 43 | return integer, nil 44 | } 45 | -------------------------------------------------------------------------------- /util/ip.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "net" 5 | "net/http" 6 | ) 7 | 8 | const ( 9 | XForwardedFor = "X-Forwarded-For" 10 | XRealIP = "X-Real-IP" 11 | ) 12 | 13 | func IPAddress(r *http.Request) string { 14 | remoteAddr := r.RemoteAddr 15 | 16 | if ip := r.Header.Get(XRealIP); ip != "" { 17 | remoteAddr = ip 18 | } else if ip = r.Header.Get(XForwardedFor); ip != "" { 19 | remoteAddr = ip 20 | } else { 21 | remoteAddr, _, _ = net.SplitHostPort(remoteAddr) 22 | } 23 | 24 | if remoteAddr == "::1" { 25 | remoteAddr = "127.0.0.1" 26 | } 27 | 28 | return remoteAddr 29 | } 30 | -------------------------------------------------------------------------------- /util/jwt/jwt.go: -------------------------------------------------------------------------------- 1 | package jwt 2 | 3 | import ( 4 | "crypto/rsa" 5 | "errors" 6 | "log" 7 | "os" 8 | "time" 9 | 10 | jwtlib "github.com/golang-jwt/jwt/v5" 11 | "github.com/spf13/viper" 12 | ) 13 | 14 | // JWTManager manages JWT operations. 15 | type JWTManager struct { 16 | signKey *rsa.PrivateKey 17 | verifyKey *rsa.PublicKey 18 | } 19 | 20 | // NewJWTManager initializes and returns a new JWTManager instance. 21 | func NewJWTManager() *JWTManager { 22 | privateKeyPEM := getEnvOrFallback("jwt.private_key", "JWT_PRIVATE_KEY") 23 | publicKeyPEM := getEnvOrFallback("jwt.public_key", "JWT_PUBLIC_KEY") 24 | 25 | signKey, err := jwtlib.ParseRSAPrivateKeyFromPEM([]byte(privateKeyPEM)) 26 | if err != nil { 27 | log.Fatal(err) 28 | } 29 | 30 | verifyKey, err := jwtlib.ParseRSAPublicKeyFromPEM([]byte(publicKeyPEM)) 31 | if err != nil { 32 | log.Fatal(err) 33 | } 34 | 35 | return &JWTManager{ 36 | signKey: signKey, 37 | verifyKey: verifyKey, 38 | } 39 | } 40 | 41 | type userClaims struct { 42 | jwtlib.RegisteredClaims 43 | UserID string `json:"userID"` 44 | Admin bool `json:"admin"` 45 | } 46 | 47 | // GenerateToken generates a JWT token for a user. 48 | func (jm *JWTManager) Generate( 49 | userID string, 50 | isAdmin bool, 51 | ) (string, error) { 52 | claims := userClaims{ 53 | UserID: userID, 54 | Admin: isAdmin, 55 | RegisteredClaims: jwtlib.RegisteredClaims{ 56 | ExpiresAt: jwtlib.NewNumericDate(time.Now().Add(24 * time.Hour)), 57 | }, 58 | } 59 | 60 | token := jwtlib.NewWithClaims(jwtlib.SigningMethodRS256, claims) 61 | return token.SignedString(jm.signKey) 62 | } 63 | 64 | // Validate validates a JWT token and returns the associated claims. 65 | func (jm *JWTManager) Validate(tokenString string) (*userClaims, error) { 66 | claims := &userClaims{} 67 | token, err := jwtlib.ParseWithClaims( 68 | tokenString, 69 | claims, 70 | func(token *jwtlib.Token) (interface{}, error) { 71 | return jm.verifyKey, nil 72 | }, 73 | ) 74 | 75 | if err != nil { 76 | return nil, err 77 | } 78 | 79 | if !token.Valid { 80 | return nil, errors.New("invalid token") 81 | } 82 | 83 | return claims, nil 84 | } 85 | 86 | func getEnvOrFallback(viperKey, envKey string) string { 87 | value := viper.GetString(viperKey) 88 | if value == "" { 89 | value = os.Getenv(viperKey) 90 | } 91 | if value == "" { 92 | value = os.Getenv(envKey) 93 | } 94 | return value 95 | } 96 | -------------------------------------------------------------------------------- /util/jwt/jwt_test.go: -------------------------------------------------------------------------------- 1 | package jwt_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/ic3network/mccs-alpha-api/util/jwt" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | const ( 11 | TEST_PRIVATE_KEY = `-----BEGIN RSA PRIVATE KEY----- 12 | MIIEpQIBAAKCAQEA1vl/vENflHIgomF0qxfPg9l9FqwguCYtGNIbAfvVXSsLueAV 13 | D23ZDtazisA67y+dO8TQk+KTGeCbM2Otcvvs7mdsRsbFe+demdSfydQHAp2tGb7O 14 | DBQPGJeyCnyfePB2GmqjVrBB1BjjWgnwwNaXJQkmtLruG8Sgrwl3nKjTeC3x8LjB 15 | l0gGU6UFON6SBF+/CovbOHn+P8eUC/LJrvX8dGpXfGoTzk18WKU5GzThrSgoGkL7 16 | CToAN8/JoW8G/1gtiuhFRAi53oxpt058An8ORBP8PkxGj+enjq59C/5YMYLwfKHj 17 | lylu0uRpfbmih4/46pgbYjjUyOhSXxHd1k5VmwIDAQABAoIBAQCMb29X0HeXJTtW 18 | eO3be3GQA7to3Ud+pUnephsIn7iR5bYCVnXLn4ol3HJr2QpnCKbhzcAoa+KHDCi3 19 | WI2NyS/Nynh8gAuw1sQBIFrGYaG2vsS/RdubHluCSE8B9MnFGuk8dp9/2SMX6K5V 20 | Opsxjr4sbp7/gAJe14PU9Q1TpSKIpeBvH3li+mF405YIo6JTcxZToJtJcRMB7V7P 21 | E8KI6F+cwczAmc282pMtpT8/TL1PHq7JYH8LJ18+EwQOvEvo6A8KJIDlFqEaK+yu 22 | oQhHdUxxO9ZxAlTcb48g6o2laLd+v6k8N9yQagEyqHof/cSNXA24ZnqGHS3sECri 23 | TpV1gDypAoGBAPLzXtxV2zseEDH5dYQ3vh6bdEudBFy/3YGFIMrtbMZCP8dx2Lie 24 | P9ABWJqYEUrQpSNnKk6XdNQQGiuwFmygOuZMvyw4svuGKHIIQsSFQRtn+oO6lqSe 25 | cOW+59UgBipBBjRuNvdSh3g4i+JI33bDwVedO5Qp+OinenVHMx27NHNNAoGBAOKF 26 | c2/W7PYllHMGPIfjW1/+otkHdwPyLiBleamUgs33do8YGJekddX0+2BgR1ZIoIWQ 27 | MsiWA/FcsTKERaZv220s7iz58w0GTcpbHQQW7e6D9cl+5DIXnEyG6vQ+hSiUOQXe 28 | LjblgGQJHitrH2wUW/eEjQvXYLIduKlTcOWGZ6iHAoGBAJUtLJUcPsX4+rbE1wy9 29 | cYa3q1v2aMROp0MtLGqOCJlf+muLkyghO0uMWAxszUlj/dJUOV0SkJDZ5kfnEo3W 30 | gPQCMeyEUBozUUhbnCuxKr4aRW93NaKVCvt3EkECLebqEFZHSobobPg7uGDUoCn7 31 | nw8eI4QhlY29sGqssk1SMq2NAoGBAIW12n8w8e0WH7uJ+d8IoJ5Yc44CbwlgQkQT 32 | Qi6MoG2t3kj3I0UX6gqismOgUVuoQUC17pQioS8u1NYJ6AcnzfFy7SCVZhfRGcgR 33 | 4l3QnyAEuuf2xAKhlzxBA52q7fUXEVXaYZM8A36JN0rPz9t/ZQ4FKzDLMKPTEXa5 34 | 71E89iEvAoGASgN2hEjcF4lwe5ahgrLAheibPzC+6DFdSCBw9CbL183C5s3r4JDh 35 | VDH3H2SHpB0qmBa+YLRwRvHxpWU9uq/unaJvc+AQ3JwZX3bQ8ixvyVfpeBXZF6Dh 36 | KoQ0MewWRNtrpGFa5qdWBfcenKdhWgWrdMnroNhqCHfXEIiYsj3qqWs= 37 | -----END RSA PRIVATE KEY-----` 38 | 39 | TEST_PUBLIC_KEY = `-----BEGIN PUBLIC KEY----- 40 | MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1vl/vENflHIgomF0qxfP 41 | g9l9FqwguCYtGNIbAfvVXSsLueAVD23ZDtazisA67y+dO8TQk+KTGeCbM2Otcvvs 42 | 7mdsRsbFe+demdSfydQHAp2tGb7ODBQPGJeyCnyfePB2GmqjVrBB1BjjWgnwwNaX 43 | JQkmtLruG8Sgrwl3nKjTeC3x8LjBl0gGU6UFON6SBF+/CovbOHn+P8eUC/LJrvX8 44 | dGpXfGoTzk18WKU5GzThrSgoGkL7CToAN8/JoW8G/1gtiuhFRAi53oxpt058An8O 45 | RBP8PkxGj+enjq59C/5YMYLwfKHjlylu0uRpfbmih4/46pgbYjjUyOhSXxHd1k5V 46 | mwIDAQAB 47 | -----END PUBLIC KEY-----` 48 | ) 49 | 50 | func TestJWT(t *testing.T) { 51 | tests := []struct { 52 | name string 53 | userID string 54 | isAdmin bool 55 | }{ 56 | { 57 | name: "Valid User Admin", 58 | userID: "123", 59 | isAdmin: true, 60 | }, 61 | { 62 | name: "Valid User Not Admin", 63 | userID: "456", 64 | isAdmin: false, 65 | }, 66 | } 67 | 68 | for _, tt := range tests { 69 | t.Run(tt.name, func(t *testing.T) { 70 | t.Setenv("jwt.private_key", TEST_PRIVATE_KEY) 71 | t.Setenv("jwt.public_key", TEST_PUBLIC_KEY) 72 | j := jwt.NewJWTManager() 73 | 74 | token, err := j.Generate(tt.userID, tt.isAdmin) 75 | require.NoError(t, err) 76 | 77 | claims, err := j.Validate(token) 78 | require.NoError(t, err) 79 | 80 | require.Equal(t, tt.userID, claims.UserID) 81 | require.Equal(t, tt.isAdmin, claims.Admin) 82 | }) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /util/l/logger.go: -------------------------------------------------------------------------------- 1 | package l 2 | 3 | import "go.uber.org/zap" 4 | 5 | var Logger *zap.Logger 6 | 7 | // Init initialized the logging tool. 8 | func Init(env string) { 9 | if env == "production" { 10 | Logger, _ = zap.NewProduction() 11 | } else { 12 | Logger, _ = zap.NewDevelopment() 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /util/mongo.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import "go.mongodb.org/mongo-driver/bson/primitive" 4 | 5 | func ToObjectIDs(ids []string) []primitive.ObjectID { 6 | objectIDs := []primitive.ObjectID{} 7 | for _, id := range ids { 8 | objectID, _ := primitive.ObjectIDFromHex(id) 9 | objectIDs = append(objectIDs, objectID) 10 | } 11 | return objectIDs 12 | } 13 | 14 | func ToIDStrings(ids []primitive.ObjectID) []string { 15 | idStrings := []string{} 16 | for _, objID := range ids { 17 | idStrings = append(idStrings, objID.Hex()) 18 | } 19 | return idStrings 20 | } 21 | 22 | func ContainID(list []primitive.ObjectID, str string) bool { 23 | for _, item := range list { 24 | if item == ToObjectID(str) { 25 | return true 26 | } 27 | } 28 | return false 29 | } 30 | 31 | func ToObjectID(id string) primitive.ObjectID { 32 | objectID, _ := primitive.ObjectIDFromHex(id) 33 | return objectID 34 | } 35 | -------------------------------------------------------------------------------- /util/pagination.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import "math" 4 | 5 | func GetNumberOfPages(numberOfResults int, sizeSize int) int { 6 | pages := int(math.Ceil(float64(numberOfResults) / float64(sizeSize))) 7 | if numberOfResults == 0 { 8 | return 1 9 | } 10 | return pages 11 | } 12 | -------------------------------------------------------------------------------- /util/status.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "errors" 5 | "strings" 6 | 7 | "github.com/ic3network/mccs-alpha-api/global/constant" 8 | ) 9 | 10 | func AdminMapEntityStatus(input string) ([]string, error) { 11 | splitFn := func(c rune) bool { 12 | return c == ',' || c == ' ' 13 | } 14 | statuses := strings.FieldsFunc(input, splitFn) 15 | 16 | for _, s := range statuses { 17 | if s != constant.Entity.Pending && s != constant.Entity.Accepted && s != constant.Entity.Rejected && 18 | s != constant.Trading.Pending && s != constant.Trading.Accepted && s != constant.Trading.Rejected { 19 | return nil, errors.New("Please enter a valid status.") 20 | } 21 | } 22 | 23 | return statuses, nil 24 | } 25 | -------------------------------------------------------------------------------- /util/string.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | func StringDiff(new, old []string) (added []string, removed []string) { 4 | encountered := map[string]int{} 5 | added = []string{} 6 | removed = []string{} 7 | 8 | for _, tag := range old { 9 | if _, ok := encountered[tag]; !ok { 10 | encountered[tag]++ 11 | } 12 | } 13 | for _, tag := range new { 14 | encountered[tag]-- 15 | } 16 | for name, flag := range encountered { 17 | if flag == -1 { 18 | added = append(added, name) 19 | } 20 | if flag == 1 { 21 | removed = append(removed, name) 22 | } 23 | } 24 | return added, removed 25 | } 26 | -------------------------------------------------------------------------------- /util/tag.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "regexp" 5 | "strings" 6 | ) 7 | 8 | var ( 9 | specialCharRe *regexp.Regexp 10 | multiDashRe *regexp.Regexp 11 | ltDashRe *regexp.Regexp 12 | ) 13 | 14 | func init() { 15 | specialCharRe = regexp.MustCompile("(")|([^a-zA-Z-]+)") 16 | multiDashRe = regexp.MustCompile("-+") 17 | ltDashRe = regexp.MustCompile("(^-+)|(-+$)") 18 | } 19 | 20 | func InputToTag(input string) string { 21 | if input == "" { 22 | return "" 23 | } 24 | 25 | splitFn := func(c rune) bool { 26 | return c == ',' 27 | } 28 | tagArray := strings.FieldsFunc(strings.ToLower(input), splitFn) 29 | 30 | tag := tagArray[0] 31 | tag = strings.Replace(tag, " ", "-", -1) 32 | tag = specialCharRe.ReplaceAllString(tag, "") 33 | tag = multiDashRe.ReplaceAllString(tag, "-") 34 | tag = ltDashRe.ReplaceAllString(tag, "") 35 | 36 | return tag 37 | } 38 | 39 | // GetTags transforms tags from the user inputs into a standard format. 40 | // dog walking -> dog-walking (one word) 41 | func FormatTags(tags []string) []string { 42 | encountered := map[string]bool{} 43 | formatted := make([]string, 0, len(tags)) 44 | 45 | for _, tag := range tags { 46 | tag = strings.ToLower(tag) 47 | tag = strings.Replace(tag, " ", "-", -1) 48 | tag = specialCharRe.ReplaceAllString(tag, "") 49 | tag = multiDashRe.ReplaceAllString(tag, "-") 50 | tag = ltDashRe.ReplaceAllString(tag, "") 51 | if len(tag) == 0 { 52 | continue 53 | } 54 | // remove duplicates 55 | if !encountered[tag] { 56 | formatted = append(formatted, tag) 57 | encountered[tag] = true 58 | } 59 | } 60 | 61 | return formatted 62 | } 63 | 64 | // ToSearchTags transforms tags from user inputs into searching tags. 65 | // dog walking -> dog, walking (two words) 66 | func ToSearchTags(words string) []string { 67 | splitFn := func(c rune) bool { 68 | return c == ',' || c == ' ' 69 | } 70 | tags := strings.FieldsFunc(strings.ToLower(words), splitFn) 71 | return FormatTags(tags) 72 | } 73 | -------------------------------------------------------------------------------- /util/time.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "log" 7 | "strconv" 8 | "time" 9 | 10 | "github.com/jinzhu/now" 11 | ) 12 | 13 | // ParseTime parses string into time. 14 | func ParseTime(s string) time.Time { 15 | if s == "" || s == "1-01-01 00:00:00 UTC" { 16 | return time.Time{} 17 | } 18 | 19 | parseUnixTime, err := parseAsUnixTime(s) 20 | if err == nil { 21 | return parseUnixTime 22 | } 23 | 24 | now.TimeFormats = append(now.TimeFormats, 25 | "2 January 2006", 26 | "2 January 2006 3:04 PM", 27 | "2006-01-02 03:04:05 MST", 28 | ) 29 | 30 | t, err := now.ParseInLocation(time.UTC, s) 31 | if err != nil { 32 | log.Printf("[ERROR] ParseTime failed: %+v", err) 33 | return time.Time{} 34 | } 35 | return t 36 | } 37 | 38 | func parseAsUnixTime(input string) (time.Time, error) { 39 | i, err := strconv.ParseInt(input, 10, 64) 40 | if err != nil { 41 | return time.Time{}, errors.New("error while pasing input as the unit time") 42 | } 43 | return time.Unix(i, 0), nil 44 | } 45 | 46 | // FormatTime formats time in UK format. 47 | func FormatTime(t time.Time) string { 48 | tt := t.UTC() 49 | return fmt.Sprintf("%d-%02d-%02d %02d:%02d:%02d UTC", 50 | tt.Year(), tt.Month(), tt.Day(), 51 | tt.Hour(), tt.Minute(), tt.Second()) 52 | } 53 | --------------------------------------------------------------------------------