diff --git a/.gitignore b/.gitignore index a512c8b38..c0a71ce99 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,7 @@ out/ -.idea \ No newline at end of file +.idea + +.env +togo \ No newline at end of file diff --git a/README.md b/README.md index 8df9d4d3a..a0599b662 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,137 @@ -### Requirements +# TOGO -- Implement one single API which accepts a todo task and records it - - There is a maximum **limit of N tasks per user** that can be added **per day**. - - Different users can have **different** maximum daily limit. -- Write integration (functional) tests -- Write unit tests -- Choose a suitable architecture to make your code simple, organizable, and maintainable -- Write a concise README - - How to run your code locally? - - A sample “curl” command to call your API - - How to run your unit tests locally? - - What do you love about your solution? - - What else do you want us to know about however you do not have enough time to complete? +Backend Engineer Coding Challenge -### Notes +- Language: GoLang +- Database: PostgreSQL -- We're using Golang at Manabie. **However**, we encourage you to use the programming language that you are most comfortable with because we want you to **shine** with all your skills and knowledge. +# A. How to run this code locally? -### How to submit your solution? +## Install Go -- Fork this repo and show us your development progress via a PR +Follow this link to install golang https://golang.org/doc/install -### Interesting facts about Manabie +## Setup Golang $GOPATH directory -- Monthly there are about 2 million lines of code changes (inserted/updated/deleted) committed into our GitHub repositories. To avoid **regression bugs**, we write different kinds of **automated tests** (unit/integration (functionality)/end2end) as parts of the definition of done of our assigned tasks. -- We nurture the cultural values: **knowledge sharing** and **good communication**, therefore good written documents and readable, organizable, and maintainable code are in our blood when we build any features to grow our products. -- We have **collaborative** culture at Manabie. Feel free to ask trieu@manabie.com any questions. We are very happy to answer all of them. +Follow this link to setup $GOPATH env https://golang.org/doc/gopath_code.html -Thank you for spending time to read and attempt our take-home assessment. We are looking forward to your submission. +## Clone this repository in $GOPATH/src directory + +1. Navigate to gopath directory by executing command + +``` +$ cd $GOPATH/src +``` + +2. Clone the repository by executing command + +```bash +$ git clone [this repository] +``` + +## Create `.env` file from the root folder with variables: + +``` +DATABASE_HOST= +DATABASE_PORT=5432 +DATABASE_USERNAME= +DATABASE_PASSWORD= +SSL_MODE= +``` + +## Install the database migration tool, [goose](https://github.com/pressly/goose) locally + +```bash +$ go install github.com/pressly/goose/v3/cmd/goose@latest +``` + +For macOS users goose is available as a Homebrew Formulae: + +```bash +$ brew install goose +``` + +## Run the database migration scripts + +1. Create a database in your server with the name `psql_togo_db` + +2. Navigate to the `migrations` directory + +```bash +$ cd $GOPATH/src/togo/migrations +``` + +3. Check the migration script status + +```bash +$ goose postgres "user=[your user] password=[your password] dbname=psql_togo_db host=[your host] sslmode=[your sslmode]" status +``` + +4. Run the migration sript to apply the changes in the database + +```bash +$ goose postgres "user=[your user] password=[your password] dbname=psql_togo_db host=[your host] sslmode=[your sslmode]" up +``` + +## Run the API + +### From the root directory `$GOPATH/src/togo/`, run without creating executable + +```bash +$ cd $GOPATH/src/togo/ +$ go run server.go +``` + +### Create executable file + +1. Run the command `go build` to create an executable file +2. Execute the generate file by specifying the name e.g. `./togo` + +# B. Sample “curl” command to call the API + +1. Create User + +```bash +curl -X POST http://localhost:8080/api/user -d '{"username":"readme","taskDailyLimit":2}' +``` + +2. Update User Task Daily Limit + +```bash +curl -X PATCH http://localhost:8080/api/user -d '{"username":"readme","taskDailyLimit":1}' +``` + +3. Create Task + +```bash +curl -X POST http://localhost:8080/api/task -d '{"username":"readme","title":"Sample title","description":"Sample description"}' +``` + +4. Delete User And Created Tasks + +```bash +curl -X DELETE http://localhost:8080/api/user -d '{"username":"readme"}' +``` + +# C. How to run your unit tests locally? + +1. Navigate to `rest` directory + +```bash +$ cd $GOPATH/src/togo/rest +``` + +2. Run the command to execute the test + +```bash +$ go test -v +``` + +# D. What do I love about my solution? + +Deciding to make simple APIs that enabled us to create a user, update a user daily task limit differently, create task and, delete a user that completes the cycle of integration testing based on the requirements. + +# E. What else do you want us to know about however you do not have enough time to complete? + +- Completing the go testing coverage +- I can also create a GraphQL API endpoint with schema first approach written in Golang diff --git a/Requirements.md b/Requirements.md new file mode 100644 index 000000000..8df9d4d3a --- /dev/null +++ b/Requirements.md @@ -0,0 +1,30 @@ +### Requirements + +- Implement one single API which accepts a todo task and records it + - There is a maximum **limit of N tasks per user** that can be added **per day**. + - Different users can have **different** maximum daily limit. +- Write integration (functional) tests +- Write unit tests +- Choose a suitable architecture to make your code simple, organizable, and maintainable +- Write a concise README + - How to run your code locally? + - A sample “curl” command to call your API + - How to run your unit tests locally? + - What do you love about your solution? + - What else do you want us to know about however you do not have enough time to complete? + +### Notes + +- We're using Golang at Manabie. **However**, we encourage you to use the programming language that you are most comfortable with because we want you to **shine** with all your skills and knowledge. + +### How to submit your solution? + +- Fork this repo and show us your development progress via a PR + +### Interesting facts about Manabie + +- Monthly there are about 2 million lines of code changes (inserted/updated/deleted) committed into our GitHub repositories. To avoid **regression bugs**, we write different kinds of **automated tests** (unit/integration (functionality)/end2end) as parts of the definition of done of our assigned tasks. +- We nurture the cultural values: **knowledge sharing** and **good communication**, therefore good written documents and readable, organizable, and maintainable code are in our blood when we build any features to grow our products. +- We have **collaborative** culture at Manabie. Feel free to ask trieu@manabie.com any questions. We are very happy to answer all of them. + +Thank you for spending time to read and attempt our take-home assessment. We are looking forward to your submission. diff --git a/common/environment/environment.go b/common/environment/environment.go new file mode 100644 index 000000000..21a28bbeb --- /dev/null +++ b/common/environment/environment.go @@ -0,0 +1,15 @@ +package environment + +import ( + "log" + + "github.com/joho/godotenv" +) + +// Load for loading the .env file from dynamic path +func Load(path string) { + err := godotenv.Load(path) + if err != nil { + log.Fatalf("Error to load file at %s", path) + } +} diff --git a/common/response/response.go b/common/response/response.go new file mode 100644 index 000000000..7342e59a6 --- /dev/null +++ b/common/response/response.go @@ -0,0 +1,59 @@ +package response + +import ( + "encoding/json" + "net/http" + "togo/models" +) + +// HandleStatusOK for status ok response +func HandleStatusOK(w http.ResponseWriter, message interface{}, data interface{}) { + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(models.SuccessResponse{ + Status: "Success", + Code: http.StatusOK, + Message: message, + Data: data, + }) +} + +// HandleStatusCreated for status created response +func HandleStatusCreated(w http.ResponseWriter, message interface{}, data interface{}) { + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(models.SuccessResponse{ + Status: "Success", + Code: http.StatusCreated, + Message: message, + Data: data, + }) +} + +// HandleStatusBadRequest for status bad request response +func HandleStatusBadRequest(w http.ResponseWriter, message interface{}) { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(models.ErrorResponse{ + Status: "Failed", + Code: http.StatusBadRequest, + Message: message, + }) +} + +// HandleStatusInternalServerError for status internal server error response +func HandleStatusInternalServerError(w http.ResponseWriter, message interface{}) { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(models.ErrorResponse{ + Status: "Failed", + Code: http.StatusInternalServerError, + Message: message, + }) +} + +// HandleStatusNotFound for not found error response +func HandleStatusNotFound(w http.ResponseWriter, message interface{}) { + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(models.ErrorResponse{ + Status: "Failed", + Code: http.StatusNotFound, + Message: message, + }) +} diff --git a/db/db.go b/db/db.go new file mode 100644 index 000000000..86063b10c --- /dev/null +++ b/db/db.go @@ -0,0 +1,43 @@ +package db + +import ( + "fmt" + "log" + "os" + "time" + + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +// Connect for postgreSQL DB +func Connect() (*gorm.DB, error) { + + dbname := "psql_togo_db" + + host := os.Getenv("DATABASE_HOST") + port := os.Getenv("DATABASE_PORT") + username := os.Getenv("DATABASE_USERNAME") + password := os.Getenv("DATABASE_PASSWORD") + sslmode := os.Getenv("SSL_MODE") + + dsn := fmt.Sprintf("host=%s port=%s dbname=%s user=%s password=%s sslmode=%s", host, port, dbname, username, password, sslmode) + + newLogger := logger.New( + log.New(os.Stdout, "\r\n", log.LstdFlags), + logger.Config{ + SlowThreshold: time.Second, + LogLevel: logger.Error, + IgnoreRecordNotFoundError: true, + Colorful: true, + }, + ) + + return gorm.Open(postgres.Open(dsn), + &gorm.Config{ + Logger: newLogger, + PrepareStmt: true, + SkipDefaultTransaction: true, + }) +} diff --git a/go.mod b/go.mod new file mode 100644 index 000000000..0ea11ded0 --- /dev/null +++ b/go.mod @@ -0,0 +1,30 @@ +module togo + +go 1.18 + +require ( + github.com/gorilla/mux v1.8.0 + github.com/joho/godotenv v1.4.0 + github.com/satori/go.uuid v1.2.0 + github.com/stretchr/testify v1.8.0 + gorm.io/driver/postgres v1.3.7 + gorm.io/gorm v1.23.6 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/jackc/chunkreader/v2 v2.0.1 // indirect + github.com/jackc/pgconn v1.12.1 // indirect + github.com/jackc/pgio v1.0.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgproto3/v2 v2.3.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b // indirect + github.com/jackc/pgtype v1.11.0 // indirect + github.com/jackc/pgx/v4 v4.16.1 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.4 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 // indirect + golang.org/x/text v0.3.7 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 000000000..1b8aee977 --- /dev/null +++ b/go.sum @@ -0,0 +1,199 @@ +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc= +github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= +github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I= +github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= +github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= +github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/gofrs/uuid v4.0.0+incompatible h1:1SD/1F5pU8p29ybwgQSwpQk+mwdRrXCYuPhW6m+TnJw= +github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= +github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo= +github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= +github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8= +github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= +github.com/jackc/pgconn v0.0.0-20190420214824-7e0022ef6ba3/go.mod h1:jkELnwuX+w9qN5YIfX0fl88Ehu4XC3keFuOJJk9pcnA= +github.com/jackc/pgconn v0.0.0-20190824142844-760dd75542eb/go.mod h1:lLjNuW/+OfW9/pnVKPazfWOgNfH2aPem8YQ7ilXGvJE= +github.com/jackc/pgconn v0.0.0-20190831204454-2fabfa3c18b7/go.mod h1:ZJKsE/KZfsUgOEh9hBm+xYTstcNHg7UPMVJqRfQxq4s= +github.com/jackc/pgconn v1.8.0/go.mod h1:1C2Pb36bGIP9QHGBYCjnyhqu7Rv3sGshaQUvmfGIB/o= +github.com/jackc/pgconn v1.9.0/go.mod h1:YctiPyvzfU11JFxoXokUOOKQXQmDMoJL9vJzHH8/2JY= +github.com/jackc/pgconn v1.9.1-0.20210724152538-d89c8390a530/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI= +github.com/jackc/pgconn v1.12.1 h1:rsDFzIpRk7xT4B8FufgpCCeyjdNpKyghZeSefViE5W8= +github.com/jackc/pgconn v1.12.1/go.mod h1:ZkhRC59Llhrq3oSfrikvwQ5NaxYExr6twkdkMLaKono= +github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE= +github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8= +github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE= +github.com/jackc/pgmock v0.0.0-20201204152224-4fe30f7445fd/go.mod h1:hrBW0Enj2AZTNpt/7Y5rr2xe/9Mn757Wtb2xeBzPv2c= +github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65 h1:DadwsjnMwFjfWc9y5Wi/+Zz7xoE5ALHsRQlOctkOiHc= +github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78= +github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA= +github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg= +github.com/jackc/pgproto3/v2 v2.0.0-rc3/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM= +github.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM= +github.com/jackc/pgproto3/v2 v2.0.6/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= +github.com/jackc/pgproto3/v2 v2.1.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= +github.com/jackc/pgproto3/v2 v2.3.0 h1:brH0pCGBDkBW07HWlN/oSBXrmo3WB0UvZd1pIuDcL8Y= +github.com/jackc/pgproto3/v2 v2.3.0/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= +github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b h1:C8S2+VttkHFdOOCXJe+YGfa4vHYwlt4Zx+IVXQ97jYg= +github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E= +github.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01CGwFsrv11mJRHWJ6aifDLfdV3aVjFF0zg= +github.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc= +github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw= +github.com/jackc/pgtype v1.8.1-0.20210724151600-32e20a603178/go.mod h1:C516IlIV9NKqfsMCXTdChteoXmwgUceqaLfjg2e3NlM= +github.com/jackc/pgtype v1.11.0 h1:u4uiGPz/1hryuXzyaBhSk6dnIyyG2683olG2OV+UUgs= +github.com/jackc/pgtype v1.11.0/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4= +github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y= +github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM= +github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc= +github.com/jackc/pgx/v4 v4.12.1-0.20210724153913-640aa07df17c/go.mod h1:1QD0+tgSXP7iUjYm9C1NxKhny7lq6ee99u/z+IHFcgs= +github.com/jackc/pgx/v4 v4.16.1 h1:JzTglcal01DrghUqt+PmzWsZx/Yh7SC/CTQmSBMTd0Y= +github.com/jackc/pgx/v4 v4.16.1/go.mod h1:SIhx0D5hoADaiXZVyv+3gSm3LCIIINTVO0PficsvWGQ= +github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= +github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= +github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= +github.com/jackc/puddle v1.2.1/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.4 h1:tHnRBy1i5F2Dh8BAFxqFzxKqqvezXrL2OW1TnX+Mlas= +github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/joho/godotenv v1.4.0 h1:3l4+N6zfMWnkbPEXKng2o2/MR5mSwTrBih4ZEkkz1lg= +github.com/joho/godotenv v1.4.0/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8= +github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= +github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= +github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= +github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc= +github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww= +github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= +github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= +github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ= +github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= +github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= +go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= +go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= +go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= +go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4= +go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= +go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= +go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= +go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= +go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= +golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 h1:7I4JAnoQBe7ZtJcBaYHi5UtiO8tQHbUSXxL+pnGRANg= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.3.7 h1:FKF6sIMDHDEvvMF/XJvbnCl0nu6KSKUaPXevJ4r+VYQ= +gorm.io/driver/postgres v1.3.7/go.mod h1:f02ympjIcgtHEGFMZvdgTxODZ9snAHDb4hXfigBVuNI= +gorm.io/gorm v1.23.4/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk= +gorm.io/gorm v1.23.6 h1:KFLdNgri4ExFFGTRGGFWON2P1ZN28+9SJRN8voOoYe0= +gorm.io/gorm v1.23.6/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= diff --git a/migrations/20220630010411_create_tables_users_tasks.sql b/migrations/20220630010411_create_tables_users_tasks.sql new file mode 100644 index 000000000..3fd3136d9 --- /dev/null +++ b/migrations/20220630010411_create_tables_users_tasks.sql @@ -0,0 +1,49 @@ +-- +goose Up +-- +goose StatementBegin +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +CREATE TABLE users ( + user_id UUID PRIMARY KEY NOT NULL DEFAULT uuid_generate_v1(), + username character varying(200) NOT NULL UNIQUE, + task_daily_limit int NOT NULL DEFAULT 5, + created_by UUID NOT NULL, + updated_by UUID, + created_when timestamptz NOT NULL, + updated_when timestamptz, + is_active boolean NOT NULL DEFAULT true +); + +CREATE TABLE tasks ( + task_id UUID PRIMARY KEY NOT NULL DEFAULT uuid_generate_v1(), + user_id UUID REFERENCES users(user_id) NOT NULL, + title character varying(200) NOT NULL, + description text, + created_by UUID NOT NULL, + updated_by UUID, + created_when timestamptz NOT NULL, + updated_when timestamptz, + is_active boolean NOT NULL DEFAULT true +); + +CREATE INDEX users_username_is_active_idx ON users USING BTREE (username, is_active); + +CREATE INDEX tasks_user_id_created_when_is_active_idx ON tasks USING BTREE (user_id, created_when, is_active); + +-- +goose StatementEnd +-- +goose Down +-- +goose StatementBegin +DROP INDEX IF EXISTS users_username_password_is_active_idx; + +DROP INDEX IF EXISTS user_settings_user_id_is_active_idx; + +DROP INDEX IF EXISTS tasks_user_id_is_active_created_when_idx; + +DROP TABLE IF EXISTS tasks; + +DROP TABLE IF EXISTS user_settings; + +DROP TABLE IF EXISTS users; + +DROP EXTENSION IF EXISTS "uuid-ossp"; + +-- +goose StatementEnd \ No newline at end of file diff --git a/models/base.go b/models/base.go new file mode 100644 index 000000000..1d5f7dad6 --- /dev/null +++ b/models/base.go @@ -0,0 +1,17 @@ +package models + +import ( + "time" + + uuid "github.com/satori/go.uuid" +) + +// Base model for common columns +type Base struct { + UserID uuid.UUID `json:"userId" gorm:"column:user_id;"` + IsActive bool `json:"isActive" gorm:"column:is_active;"` + CreatedBy uuid.UUID `json:"createdBy" gorm:"column:created_by;"` + UpdatedBy *uuid.UUID `json:"updatedBy" gorm:"column:updated_by;"` + CreatedWhen time.Time `json:"createdWhen" gorm:"column:created_when;"` + UpdatedWhen *time.Time `json:"updatedWhen" gorm:"column:updated_when;"` +} diff --git a/models/request.go b/models/request.go new file mode 100644 index 000000000..f30362ceb --- /dev/null +++ b/models/request.go @@ -0,0 +1,25 @@ +package models + +// CreateUserRequest model for create user request +type CreateUserRequest struct { + Username string `json:"username"` + TaskDailyLimit int32 `json:"taskDailyLimit"` +} + +// UpdateUserRequest model for update user request +type UpdateUserRequest struct { + Username string `json:"username"` + TaskDailyLimit int32 `json:"taskDailyLimit"` +} + +// CreateTaskRequest model for create task request +type CreateTaskRequest struct { + Username string `json:"username"` + Title string `json:"title"` + Description *string `json:"description"` +} + +// DeleteUserRequest model for delete user request +type DeleteUserRequest struct { + Username string `json:"username"` +} diff --git a/models/response.go b/models/response.go new file mode 100644 index 000000000..ee0f890a6 --- /dev/null +++ b/models/response.go @@ -0,0 +1,16 @@ +package models + +// ErrorResponse model for error response +type ErrorResponse struct { + Status string `json:"status"` + Code int `json:"code"` + Message interface{} `json:"message"` +} + +// SuccessResponse model for success response +type SuccessResponse struct { + Status string `json:"status"` + Code int `json:"code"` + Message interface{} `json:"message"` + Data interface{} `json:"data"` +} diff --git a/models/task.go b/models/task.go new file mode 100644 index 000000000..4ccc343d7 --- /dev/null +++ b/models/task.go @@ -0,0 +1,13 @@ +package models + +import ( + uuid "github.com/satori/go.uuid" +) + +// Task model for `tasks` table +type Task struct { + Base + TaskID uuid.UUID `json:"taskId" gorm:"column:task_id;"` + Title string `json:"title" gorm:"column:title;"` + Description *string `json:"description" gorm:"column:description;"` +} diff --git a/models/user.go b/models/user.go new file mode 100644 index 000000000..b6b4a80f2 --- /dev/null +++ b/models/user.go @@ -0,0 +1,19 @@ +package models + +import ( + "time" + + uuid "github.com/satori/go.uuid" +) + +// User model for `users` table +type User struct { + UserID uuid.UUID `json:"id" gorm:"column:user_id;primary_key;"` + Username string `json:"username" gorm:"column:username;"` + TaskDailyLimit int32 `json:"taskDailyLimit" gorm:"column:task_daily_limit;"` + IsActive bool `json:"isActive" gorm:"column:is_active;"` + CreatedWhen time.Time `json:"createdWhen" gorm:"column:created_when;"` + UpdatedWhen *time.Time `json:"updatedWhen" gorm:"column:updated_when;"` + CreatedBy uuid.UUID `json:"createdBy" gorm:"column:created_by;"` + UpdatedBy *uuid.UUID `json:"updatedBy" gorm:"column:updated_by;"` +} diff --git a/rest/handler.go b/rest/handler.go new file mode 100644 index 000000000..c69bb16d8 --- /dev/null +++ b/rest/handler.go @@ -0,0 +1,255 @@ +package rest + +import ( + "encoding/json" + "errors" + "io/ioutil" + "net/http" + "time" + "togo/common/response" + "togo/models" + + uuid "github.com/satori/go.uuid" + "gorm.io/gorm" +) + +type TogoRestService struct { + DB *gorm.DB +} + +func Handler(db *gorm.DB) TogoRestService { + return TogoRestService{ + DB: db, + } +} + +// CreateUser is the handler for POST /api/user +// This is neccessary to record username and it's daily task limit +func (restService TogoRestService) CreateUser(w http.ResponseWriter, r *http.Request) { + + userRequest := models.CreateUserRequest{} + reqBody, _ := ioutil.ReadAll(r.Body) + json.Unmarshal(reqBody, &userRequest) + + // I add validation for username since it's a required string input and based on the database schema + errInput := validateUsername(userRequest.Username) + if errInput != nil { + response.HandleStatusBadRequest(w, errInput.Error()) + return + } + + // I add validation for taskDailyLimit because it's a number input + errInput = validateTaskDailyLimit(userRequest.TaskDailyLimit) + if errInput != nil { + response.HandleStatusBadRequest(w, errInput.Error()) + return + } + + user := &models.User{} + newID := uuid.NewV1() + username := trimLowerUsername(userRequest.Username) + now := time.Now().UTC() + + err := restService.DB.First(&user, "is_active = true AND LOWER(username) = ?", username).Error + + // I add validation if the user is already existing even the users.username has a unique constraint + if !errors.Is(err, gorm.ErrRecordNotFound) { + response.HandleStatusBadRequest(w, "user already exist") + return + } + + user = &models.User{ + UserID: newID, + Username: username, + TaskDailyLimit: userRequest.TaskDailyLimit, + IsActive: true, + CreatedWhen: now, + CreatedBy: newID, + } + + // Insert the user to the `users` table + err = restService.DB.Create(&user).Error + + // I return error if the database failed to insert the user + if err != nil { + response.HandleStatusInternalServerError(w, err.Error()) + return + } + + // I return status created when the insert is success + response.HandleStatusCreated(w, "user has been successfully created", user) +} + +// UpdateUser is the handler for PATCH /api/user (optional to call) +// This enable the user to update the task daily limit differently +func (restService TogoRestService) UpdateUser(w http.ResponseWriter, r *http.Request) { + userRequest := models.UpdateUserRequest{} + reqBody, _ := ioutil.ReadAll(r.Body) + json.Unmarshal(reqBody, &userRequest) + + // I add validation for username since it's a required string input and based on the database schema + errInput := validateUsername(userRequest.Username) + if errInput != nil { + response.HandleStatusBadRequest(w, errInput.Error()) + return + } + + // I add validation for taskDailyLimit because it's a number input + errInput = validateTaskDailyLimit(userRequest.TaskDailyLimit) + if errInput != nil { + response.HandleStatusBadRequest(w, errInput.Error()) + return + } + + username := trimLowerUsername(userRequest.Username) + + user := &models.User{} + now := time.Now().UTC() + + err := restService.DB.First(&user, "is_active = true AND LOWER(username) = ?", username).Error + + // I return error if the user is not existing in users table + if errors.Is(err, gorm.ErrRecordNotFound) { + response.HandleStatusNotFound(w, "user not found") + return + } else if err != nil { + // I return error if the database failed to find the user + response.HandleStatusInternalServerError(w, err.Error()) + return + } + + user.TaskDailyLimit = userRequest.TaskDailyLimit + user.UpdatedWhen = &now + user.UpdatedBy = &user.UserID + + // I update the user to the `users` table + err = restService.DB.Save(&user).Error + + // I return error if the database failed to update the user + if err != nil { + response.HandleStatusInternalServerError(w, err.Error()) + return + } + + // I return status ok when the update is success + response.HandleStatusOK(w, "user has been successfully updated", user) +} + +// CreateTask is the handler for POST /api/task +// This enable the user to create task +func (restService TogoRestService) CreateTask(w http.ResponseWriter, r *http.Request) { + taskRequest := models.CreateTaskRequest{} + reqBody, _ := ioutil.ReadAll(r.Body) + json.Unmarshal(reqBody, &taskRequest) + + var count int64 + now := time.Now().UTC() + + // I add validation for username since it's a required string input and based on the database schema + errInput := validateUsername(taskRequest.Username) + if errInput != nil { + response.HandleStatusBadRequest(w, errInput.Error()) + return + } + + // I add validation for title since it's a required string input and based on the database schema + errInput = validateTaskTitle(taskRequest.Title) + if errInput != nil { + response.HandleStatusBadRequest(w, errInput.Error()) + return + } + + // There's no validation for description since it's nullable and no character limit in the database schema + + username := trimLowerUsername(taskRequest.Username) + user := &models.User{} + err := restService.DB.First(&user, "is_active = true AND LOWER(username) = ?", username).Error + + // I return error when the user is not existing in `users` table because the `tasks`` table has relationship to `users` table + if errors.Is(err, gorm.ErrRecordNotFound) { + response.HandleStatusNotFound(w, "user not found") + return + } else if err != nil { + // I return error if the database fail to find in users table + response.HandleStatusInternalServerError(w, err.Error()) + return + } + + // I get the datetime of the begining of the day in Local time intentionally and use it to get the count of the tasks + today := getBeginningOfDay(time.Now()) + err = restService.DB.Model(&models.Task{}). + Where("is_active = true AND user_id = ? AND created_when >= ?", user.UserID, today). + Count(&count).Error + + // I return error if the user meet the maximum daily limit + if count >= int64(user.TaskDailyLimit) { + response.HandleStatusBadRequest(w, "user meet the maximum daily limit") + return + } + + newID := uuid.NewV1() + task := models.Task{} + task.TaskID = newID + task.Title = taskRequest.Title + task.Description = taskRequest.Description + + task.UserID = user.UserID + task.IsActive = true + task.CreatedBy = user.UserID + task.CreatedWhen = now + + // Else, I insert the task in the `tasks` table + err = restService.DB.Create(&task).Error + + // I return error if the database fail to insert the task + if err != nil { + response.HandleStatusInternalServerError(w, err.Error()) + return + } + + // I return success when the task has been successfully inserted + response.HandleStatusCreated(w, "task has been successfully created", task) +} + +// DeleteUser is the handler for DELETE /api/user (optional to call) +// I created this for the clean up of integration testing to delete the test user and it's created task +func (restService TogoRestService) DeleteUser(w http.ResponseWriter, r *http.Request) { + + userRequest := models.DeleteUserRequest{} + reqBody, _ := ioutil.ReadAll(r.Body) + json.Unmarshal(reqBody, &userRequest) + + // I add validation for username since it's a required string input and based on the database schema + errInput := validateUsername(userRequest.Username) + if errInput != nil { + response.HandleStatusBadRequest(w, errInput.Error()) + return + } + + username := trimLowerUsername(userRequest.Username) + + user := &models.User{} + tasks := &[]models.Task{} + + db := restService.DB.First(&user, "is_active = true AND LOWER(username) = ?", username) + + // I return error if the user is not existing in `users` table + if db.Error != nil { + response.HandleStatusBadRequest(w, db.Error.Error()) + return + } + + // I delete the tasks created by the user + err := restService.DB.Where("is_active = true AND user_id = ?", user.UserID).Delete(&tasks).Error + // I delete the user in the `users` table + err = db.Delete(&user, "is_active = true AND user_id = ?", user.UserID).Error + + // I return error if the database failed to delete user or tasks + if err != nil { + response.HandleStatusInternalServerError(w, err.Error()) + return + } + + // return status ok when the tasks and user has been delete in the table + response.HandleStatusOK(w, "user and it's tasks has been successfully deleted", user) +} diff --git a/rest/handler_test.go b/rest/handler_test.go new file mode 100644 index 000000000..b38b9ada6 --- /dev/null +++ b/rest/handler_test.go @@ -0,0 +1,126 @@ +package rest + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "togo/common/environment" + "togo/db" + "togo/models" + + "github.com/gorilla/mux" + uuid "github.com/satori/go.uuid" + "github.com/stretchr/testify/assert" +) + +var username = uuid.NewV1().String() + +func Router() (*mux.Router, error) { + environment.Load("../.env") + db, err := db.Connect() + restService := Handler(db) + + router := mux.NewRouter() + router.HandleFunc("/api/user", restService.CreateUser).Methods("POST") + router.HandleFunc("/api/user", restService.UpdateUser).Methods("PATCH") + router.HandleFunc("/api/user", restService.DeleteUser).Methods("DELETE") + router.HandleFunc("/api/task", restService.CreateTask).Methods("POST") + + return router, err +} + +func TestCreateUser(t *testing.T) { + + requestBody, _ := json.Marshal(models.CreateUserRequest{ + Username: username, + TaskDailyLimit: 2, + }) + + request, _ := http.NewRequest("POST", "/api/user", bytes.NewBuffer(requestBody)) + + router, errRouter := Router() + + assert.Nil(t, errRouter, "The `errRouter` should be nil") + + response1 := httptest.NewRecorder() + + router.ServeHTTP(response1, request) + + assert.Equal(t, 201, response1.Code, "201 Created is expected") + + response2 := httptest.NewRecorder() + + router.ServeHTTP(response2, request) + + assert.Equal(t, 400, response2.Code, "400 Bad Request is expected") +} + +func TestUpdateUser(t *testing.T) { + + requestBody, _ := json.Marshal(models.CreateUserRequest{ + Username: username, + TaskDailyLimit: 1, + }) + + request, _ := http.NewRequest("PATCH", "/api/user", bytes.NewBuffer(requestBody)) + + router, errRouter := Router() + + assert.Nil(t, errRouter, "The `errRouter` should be nil") + + response := httptest.NewRecorder() + + router.ServeHTTP(response, request) + + assert.Equal(t, 200, response.Code, "200 OK is expected") +} + +func TestCreateTask(t *testing.T) { + + description := "Sample description" + + requestBody, _ := json.Marshal(models.CreateTaskRequest{ + Username: username, + Title: "Sample title", + Description: &description, + }) + + request, _ := http.NewRequest("POST", "/api/task", bytes.NewBuffer(requestBody)) + + router, errRouter := Router() + + assert.Nil(t, errRouter, "The `errRouter` should not be nil") + + response1 := httptest.NewRecorder() + + router.ServeHTTP(response1, request) + + assert.Equal(t, 201, response1.Code, "201 Created is expected") + + response2 := httptest.NewRecorder() + + router.ServeHTTP(response2, request) + + assert.Equal(t, 400, response2.Code, "400 Bad Request is expected") +} + +func TestDeleteUser(t *testing.T) { + + requestBody, _ := json.Marshal(models.DeleteUserRequest{ + Username: username, + }) + + request, _ := http.NewRequest("DELETE", "/api/user", bytes.NewBuffer(requestBody)) + + response := httptest.NewRecorder() + + router, errRouter := Router() + + assert.Nil(t, errRouter, "The `errRouter` should not be nil") + + router.ServeHTTP(response, request) + + assert.Equal(t, 200, response.Code, "200 OK is expected") +} diff --git a/rest/utils.go b/rest/utils.go new file mode 100644 index 000000000..1142dc340 --- /dev/null +++ b/rest/utils.go @@ -0,0 +1,46 @@ +package rest + +import ( + "errors" + "strings" + "time" +) + +func getBeginningOfDay(t time.Time) time.Time { + year, month, day := t.Date() + return time.Date(year, month, day, 0, 0, 0, 0, t.Location()) +} + +// Validate the username if empty string or if it exceed the character limit +func validateUsername(username string) error { + if trimLowerUsername(username) == "" { + return errors.New("username is required") + } else if len(trimLowerUsername(username)) > 200 { + return errors.New("username has 200 character limit") + } + + return nil +} + +// Validate the taskDailyLimit less than 1 +func validateTaskDailyLimit(limit int32) error { + if limit < 1 { + return errors.New("taskDailyLimit must be atleast 1") + } + return nil +} + +// Validate the title if empty string or if it exceed the character limit +func validateTaskTitle(title string) error { + if title == "" { + return errors.New("username is required") + } else if len(title) > 200 { + return errors.New("title has 200 character limit") + } + return nil +} + +// Trim and transform to lowercase the username +func trimLowerUsername(username string) string { + return strings.TrimSpace(strings.ToLower(username)) +} diff --git a/rest/utils_test.go b/rest/utils_test.go new file mode 100644 index 000000000..d285fe18a --- /dev/null +++ b/rest/utils_test.go @@ -0,0 +1,60 @@ +package rest + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +const characters201 = "Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Donec quam felis, ultricies nec, pellentesque eu, pretium quis, sem. Nulla consequat massa quis enim. Donec pede justo, fringilla vel, aliquet nec, vulputate eget, arcu. In enim justo, rhoncus ut, imperdiet a, venenatis vitae, justo. Nullam dictum felis eu pede mollis pretium. Integer tincidunt. Cras dapibus. Vivamus elementum semper nisi. Aenean vulputate eleifend tellus. Aenean leo ligula, porttitor eu, consequat vitae, eleifend ac, enim. Aliquam lorem ante, dapibus in, viverra quis, feugiat a, tellus. Phasellus viverra nulla ut metus varius laoreet. Quisque rutrum. Aenean imperdiet. Etiam ultricies nisi vel augue. Curabitur ullamcorper ultricies nisi. Nam eget dui. Etiam rhoncus. Maecenas tempus, tellus eget condimentum rhoncus, sem quam semper libero, sit amet adipiscing sem neque sed ipsum. Nam quam nunc, blandit vel, luctus pulvinar, hendrerit id, lorem. Maecenas nec odio et ante tincidunt tempus. Donec vitae sapien ut libero venenatis faucibus. Nullam quis ante. Etiam sit amet orci eget eros faucibus tincidunt. Duis leo. Sed fringilla mauris sit amet nibh. Donec sodales sagittis magna. Sed consequat, leo eget bibendum sodales, augue velit cursus nunc, quis." + +func TestGetBeginningOfDay(t *testing.T) { + date := getBeginningOfDay(time.Now()) + assert.NotNil(t, date, "The `date` should not be nil") + assert.Equal(t, "Local", date.Location().String(), "The `date.Location()` should be UTC") +} +func TestValidateUsername(t *testing.T) { + err := validateUsername("mariiia") + + assert.Nil(t, err, "The `err` should be nil") + + err = validateUsername("") + + assert.NotNil(t, err, "The `err` should not be nil") + + err = validateUsername(characters201) + + assert.NotNil(t, err, "The `err` should not be nil") +} + +func TestValidateTaskDailyLimit(t *testing.T) { + err := validateTaskDailyLimit(1) + + assert.Nil(t, err, "The `err` should be nil") + + err = validateTaskDailyLimit(0) + + assert.NotNil(t, err, "The `err` should not be nil") +} + +func TestValidateTaskTitle(t *testing.T) { + err := validateTaskTitle("Sample title") + + assert.Nil(t, err, "The `err` should be nil") + + err = validateTaskTitle("") + + assert.NotNil(t, err, "The `err` should not be nil") + + err = validateTaskTitle(characters201) + + assert.NotNil(t, err, "The `err` should not be nil") + +} + +func TestTrimLowerUsername(t *testing.T) { + username := trimLowerUsername(" Mariiia ") + + assert.Equal(t, "mariiia", username, "The `username` should not be `mariiia`") +} diff --git a/server.go b/server.go new file mode 100644 index 000000000..fe0e87597 --- /dev/null +++ b/server.go @@ -0,0 +1,50 @@ +package main + +import ( + "fmt" + "log" + "net/http" + "os" + "time" + "togo/common/environment" + "togo/db" + "togo/rest" + + "github.com/gorilla/mux" +) + +const defaultPort = "8080" + +func main() { + environment.Load(".env") + + db, err := db.Connect() + + if err != nil { + panic(err) + } + + sqlDB, _ := db.DB() + sqlDB.SetConnMaxLifetime(time.Minute * 30) + sqlDB.SetMaxIdleConns(125) + sqlDB.SetMaxOpenConns(250) + + defer sqlDB.Close() + + port := os.Getenv("PORT") + if port == "" { + port = defaultPort + } + + restService := rest.Handler(db) + + router := mux.NewRouter().StrictSlash(true) + router.HandleFunc("/api/user", restService.CreateUser).Methods("POST") + router.HandleFunc("/api/user", restService.UpdateUser).Methods("PATCH") + router.HandleFunc("/api/user", restService.DeleteUser).Methods("DELETE") + router.HandleFunc("/api/task", restService.CreateTask).Methods("POST") + + log.Printf("Server running on http://localhost:%s", port) + + log.Fatal(http.ListenAndServe(fmt.Sprintf(":%s", port), router)) +}