Browse Source

Feat(ktm-booking): Initial commit

master
Samuel Pua 3 years ago
commit
7cf10b07d4
  1. 2
      .dockerignore
  2. 16
      .env.example
  3. 74
      .gitignore
  4. 50
      Jenkinsfile
  5. 49
      README.md
  6. 1
      _docker_mnt/README.md
  7. 31
      backend/cmd/server/health.go
  8. 39
      backend/cmd/server/health_test.go
  9. 64
      backend/cmd/server/main.go
  10. 609
      backend/docs/docs.go
  11. 585
      backend/docs/swagger.json
  12. 387
      backend/docs/swagger.yaml
  13. 54
      backend/go.mod
  14. 304
      backend/go.sum
  15. 131
      backend/internal/common/database.go
  16. 56
      backend/internal/common/errresponse.go
  17. 24
      backend/internal/common/textresponse.go
  18. 477
      backend/internal/ktmtrainbot/backgroundbookingjob.go
  19. 82
      backend/internal/ktmtrainbot/bookingcontroller.go
  20. 92
      backend/internal/ktmtrainbot/bookingmodel.go
  21. 124
      backend/internal/ktmtrainbot/bookingroute.go
  22. 25
      backend/internal/ktmtrainbot/getcurrenttime.go
  23. 31
      backend/internal/ktmtrainbot/main.go
  24. 12
      backend/internal/ktmtrainbot/servertimeresponsemodel.go
  25. 50
      backend/internal/user/main.go
  26. 146
      backend/internal/user/main_test.go
  27. 29
      backend/internal/user/profilecontroller.go
  28. 52
      backend/internal/user/profilemodel.go
  29. 45
      backend/internal/user/profileroute.go
  30. 174
      backend/internal/user/profileroute_test.go
  31. 56
      backend/internal/user/sessioncontroller.go
  32. 29
      backend/internal/user/sessionmiddleware.go
  33. 7
      backend/internal/user/sessionmiddlewarecontext.go
  34. 17
      backend/internal/user/sessionmodel.go
  35. 71
      backend/internal/user/usercontroller.go
  36. 52
      backend/internal/user/usermodel.go
  37. 137
      backend/internal/user/userroute.go
  38. 270
      backend/internal/user/userroute_test.go
  39. 29
      docker-compose.yml
  40. 39
      docker/Dockerfile
  41. 16
      scripts/build.sh
  42. 7
      scripts/deploy.sh
  43. 9
      scripts/dev_postgres_docker.sh
  44. 15
      scripts/test.sh

2
.dockerignore

@ -0,0 +1,2 @@
_docker_mnt
.git

16
.env.example

@ -0,0 +1,16 @@
ENVIRONMENT=
DB_HOST=
DB_PORT=
DB_USER=
DB_PASS=
DB_NAME=
DB_SSL=
TEST_DB_HOST=
TEST_DB_PORT=
TEST_DB_USER=
TEST_DB_PASS=
TEST_DB_NAME=
TEST_DB_SSL=
COOKIE_STRING=
ALLOW_REGISTRATION=
LOGGER_WEBHOOK_URL=

74
.gitignore

@ -0,0 +1,74 @@
# ---> Go
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
# vendor/
# ---> Vim
# Swap
[._]*.s[a-v][a-z]
!*.svg # comment out if you don't need vector files
[._]*.sw[a-p]
[._]s[a-rt-v][a-z]
[._]ss[a-gi-z]
[._]sw[a-p]
# Session
Session.vim
Sessionx.vim
# Temporary
.netrwhist
*~
# Auto-generated tag files
tags
# Persistent undo
[._]*.un~
# ---> VisualStudioCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.code-workspace
.vscode
# Local History for Visual Studio Code
.history/
# ---> Linux
*~
# temporary files which can be created if a process still has a handle open of a deleted file
.fuse_hidden*
# KDE directory preferences
.directory
# Linux trash folder which might appear on any partition or disk
.Trash-*
# .nfs files are created when an open file is removed but is still being accessed
.nfs*
# Custom Ignores
_postgres_data
backend/server
.env
*.burp
*.burp.*
chrome-profile
screenshots
debug-screenshots

50
Jenkinsfile

@ -0,0 +1,50 @@
pipeline {
agent any
environment {
ATHENA_DEPLOYMENT_SSH_KEY = credentials("athena_ssh_key")
ENVIRONMENT = credentials("ENVIRONMENT")
DB_HOST = credentials("DB_HOST")
DB_PORT = credentials("DB_PORT")
DB_USER = credentials("DB_USER")
DB_PASS = credentials("DB_PASS")
DB_NAME = credentials("DB_NAME")
DB_SSL = credentials("DB_SSL")
COOKIE_STRING = credentials("COOKIE_STRING")
ALLOW_REGISTRATION = credentials("ALLOW_REGISTRATION")
LOGGER_WEBHOOK_URL = credentials("LOGGER_WEBHOOK_URL")
}
stages {
stage('Build') {
steps {
echo 'Creating environment variables (.env)'
sh 'echo ENVIRONMENT=$ENVIRONMENT >> .env'
sh 'echo DB_HOST=$DB_HOST >> .env'
sh 'echo DB_PORT=$DB_PORT >> .env'
sh 'echo DB_USER=$DB_USER >> .env'
sh 'echo DB_PASS=$DB_PASS >> .env'
sh 'echo DB_NAME=$DB_NAME >> .env'
sh 'echo DB_SSL=$DB_SSL >> .env'
sh 'echo COOKIE_STRING=$COOKIE_STRING >> .env'
sh 'echo ALLOW_REGISTRATION=$ALLOW_REGISTRATION >> .env'
sh 'echo LOGGER_WEBHOOK_URL=$LOGGER_WEBHOOK_URL >> .env'
echo 'Clearing Git directory'
sh 'rm -rf ./.git'
}
}
stage('Test') {
steps {
echo 'Testing..'
}
}
stage('Deploy') {
steps {
echo 'Creating SSH Key...'
sh 'apt update'
sh 'apt install -y rsync'
sh 'bash scripts/deploy.sh'
}
}
}
}

49
README.md

@ -0,0 +1,49 @@
# KTM Booking Bot
## About
The project's goal is build bot to book KTM train ticket (Restricted to 1 booking per day)
## Deployment
The project uses the following environment variables:
- ENVIRONMENT
- DB_HOST
- DB_PORT
- DB_USER
- DB_PASS
- DB_NAME
- DB_SSL
- COOKIE_STRING
- ALLOW_REGISTRATION
- LOGGER_WEBHOOK_URL
The following environment variables are used for testing
- TEST_DB_HOST
- TEST_DB_PORT
- TEST_DB_USER
- TEST_DB_PASS
- TEST_DB_NAME
- TEST_DB_SSL
The application may be deployed using the deployment script at `scripts/deploy.sh`.
## Build
The build script is available at `scripts/build.sh`.
`.env` is required to be populated. The example `.env` is available at `.env.example`.
## Run
The application may be staged and run using docker-compose. The following command may be used:
```
docker-compose up --build -d
```
## Development
The development postgres server can be launched using the script at `scripts/dev_postgres_docker.sh`.
## Testing
Testing is enabled through go test. The following command may be used to conduct testing:
```
go test -cover ./...
```
## Disclaimer
This application is built for educational purpose only. Users who use this application is assumed to be knowledgable and aware of what the application does technically. The author does not bear any liability due to the execution of this application.

1
_docker_mnt/README.md

@ -0,0 +1 @@
All docker mounts should be here so that they can be ignored

31
backend/cmd/server/health.go

@ -0,0 +1,31 @@
package main
import (
"net/http"
"github.com/go-chi/render"
)
type HealthStatus struct {
Status string `json:"status"`
}
func (*HealthStatus) Render(w http.ResponseWriter, r *http.Request) error {
// Pre-processing before a response is marshalled and sent across the wire
return nil
}
// Health Check``
// @Summary Responds to health check
// @Description Description
// @Tags Base
// @Accept json
// @Produce json
// @Success 200 {object} string
// @Failure 404 {object} string
// @Router /health [get]
func healthHandler(w http.ResponseWriter, r *http.Request) {
var okRender HealthStatus
okRender.Status = "ok"
render.Render(w, r, &okRender)
}

39
backend/cmd/server/health_test.go

@ -0,0 +1,39 @@
package main
import (
"encoding/json"
"io/ioutil"
"net/http"
"net/http/httptest"
"testing"
)
func TestHealthStatus(t *testing.T) {
handler := http.HandlerFunc(healthHandler)
req, err := http.NewRequest("GET", "/health", nil)
if err != nil {
t.Errorf("Error creating a new request: %v", err)
}
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Result().StatusCode != 200 {
t.Errorf("Health did not respond with status-code 200")
}
responseBytes, err := ioutil.ReadAll(rr.Result().Body)
if err != nil {
t.Errorf("Error reading response body: %v", err)
}
var results map[string]any
err = json.Unmarshal(responseBytes, &results)
if err != nil {
t.Errorf("Error decoding response body: %v", err)
}
if results["status"] != "ok" {
t.Errorf("Health status is not ok")
}
}

64
backend/cmd/server/main.go

@ -0,0 +1,64 @@
package main
import (
"log"
"net/http"
"os"
_ "git.samuelpua.com/telboon/ktm-train-bot/backend/docs"
"git.samuelpua.com/telboon/ktm-train-bot/backend/internal/common"
"git.samuelpua.com/telboon/ktm-train-bot/backend/internal/ktmtrainbot"
"git.samuelpua.com/telboon/ktm-train-bot/backend/internal/user"
"github.com/go-chi/chi"
"github.com/joho/godotenv"
httpSwagger "github.com/swaggo/http-swagger"
)
// Root Handler - Test
// @Summary This is test
// @Description Description
// @Tags Base
// @Accept json
// @Produce json
// @Success 200 {object} string
// @Failure 404 {object} string
// @Router / [get]
func rootHandler(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("hello world"))
}
// @title KTM Train Booking Bot
// @version 1.0
// @description API for frontend - built on Go-chi
// @BasePath /
// @contact.name Samuel Pua
// @contact.url https://git.samuelpua.com/telboon
func main() {
err := godotenv.Load()
if err != nil {
log.Fatal("Error loading .env file")
}
environment := os.Getenv("ENVIRONMENT")
db := common.InitDB()
db.AutoMigrate(&user.User{})
db.AutoMigrate(&user.Profile{})
db.AutoMigrate(&user.Session{})
db.AutoMigrate(&ktmtrainbot.Booking{})
r := chi.NewRouter()
if environment == "dev" {
r.Mount("/docs", httpSwagger.WrapHandler)
}
r.Mount("/api/v1/user", user.UserRoutes(db))
r.Mount("/api/v1/ktmtrainbot", ktmtrainbot.KTMTrainBotRoutes(db))
r.Get("/", rootHandler)
r.Get("/health", healthHandler)
server_str := ":8000"
log.Printf("Starting server at %s\n", server_str)
http.ListenAndServe(server_str, r)
}

609
backend/docs/docs.go

@ -0,0 +1,609 @@
// Package docs GENERATED BY SWAG; DO NOT EDIT
// This file was generated by swaggo/swag
package docs
import "github.com/swaggo/swag"
const docTemplate = `{
"schemes": {{ marshal .Schemes }},
"swagger": "2.0",
"info": {
"description": "{{escape .Description}}",
"title": "{{.Title}}",
"contact": {
"name": "Samuel Pua",
"url": "https://git.samuelpua.com/telboon"
},
"version": "{{.Version}}"
},
"host": "{{.Host}}",
"basePath": "{{.BasePath}}",
"paths": {
"/": {
"get": {
"description": "Description",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Base"
],
"summary": "This is test",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "string"
}
},
"404": {
"description": "Not Found",
"schema": {
"type": "string"
}
}
}
}
},
"/api/v1/ktmtrainbot/booking": {
"get": {
"description": "Description",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"ktmtrainbot Booking"
],
"summary": "Get All Booking",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/ktmtrainbot.BookingResponse"
}
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/common.ErrResponse"
}
}
}
},
"post": {
"description": "Description",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"ktmtrainbot Booking"
],
"summary": "Create New Booking",
"parameters": [
{
"description": "Booking Create Request",
"name": "user",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/ktmtrainbot.BookingCreateRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/ktmtrainbot.BookingResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/common.ErrResponse"
}
}
}
}
},
"/api/v1/ktmtrainbot/booking/{bookingID}": {
"delete": {
"description": "Description",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"ktmtrainbot Booking"
],
"summary": "Delete booking",
"parameters": [
{
"type": "string",
"description": "Booking ID",
"name": "bookingID",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/ktmtrainbot.BookingResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/common.ErrResponse"
}
}
}
}
},
"/api/v1/ktmtrainbot/current-time": {
"get": {
"description": "Description",
"produces": [
"application/json"
],
"tags": [
"Info"
],
"summary": "Get current server time",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/ktmtrainbot.ServerTimeResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/common.ErrResponse"
}
}
}
}
},
"/api/v1/user/login": {
"post": {
"description": "Description",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"User"
],
"summary": "For user login",
"parameters": [
{
"description": "User Login info",
"name": "user",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/user.UserLoginRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/user.UserResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/common.ErrResponse"
}
}
}
}
},
"/api/v1/user/logout": {
"post": {
"description": "Description",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"User"
],
"summary": "For user logout",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/common.TextResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/common.ErrResponse"
}
}
}
}
},
"/api/v1/user/me": {
"get": {
"description": "Description",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"User"
],
"summary": "Returns current logged in user",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/user.UserResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/common.ErrResponse"
}
}
}
}
},
"/api/v1/user/profile": {
"put": {
"description": "Description",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"User"
],
"summary": "For setting current user profile",
"parameters": [
{
"description": "User registration info",
"name": "user",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/user.ProfileRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/user.UserResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/common.ErrResponse"
}
}
}
}
},
"/api/v1/user/register": {
"post": {
"description": "Description",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"User"
],
"summary": "For user registration",
"parameters": [
{
"description": "User registration info",
"name": "user",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/user.UserRegisterRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/user.UserResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/common.ErrResponse"
}
}
}
}
},
"/health": {
"get": {
"description": "Description",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Base"
],
"summary": "Responds to health check",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "string"
}
},
"404": {
"description": "Not Found",
"schema": {
"type": "string"
}
}
}
}
}
},
"definitions": {
"common.ErrResponse": {
"type": "object",
"properties": {
"code": {
"description": "application-specific error code",
"type": "integer"
},
"error": {
"description": "application-level error message, for debugging",
"type": "string"
},
"status": {
"description": "user-level status message",
"type": "string"
}
}
},
"common.TextResponse": {
"type": "object",
"properties": {
"status": {
"description": "user-level status message",
"type": "string"
},
"text": {
"description": "application-specific error code",
"type": "string"
}
}
},
"ktmtrainbot.BookingCreateRequest": {
"type": "object",
"required": [
"name",
"passport",
"passportExpiry",
"travelDate"
],
"properties": {
"contact": {
"type": "string"
},
"gender": {
"type": "string"
},
"name": {
"type": "string"
},
"passport": {
"type": "string"
},
"passportExpiry": {
"type": "string"
},
"timeCode": {
"type": "string"
},
"travelDate": {
"type": "string"
}
}
},
"ktmtrainbot.BookingResponse": {
"type": "object",
"properties": {
"contact": {
"type": "string"
},
"gender": {
"type": "string"
},
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"passport": {
"type": "string"
},
"passportExpiry": {
"type": "string"
},
"status": {
"type": "string"
},
"timeCode": {
"type": "string"
},
"travelDate": {
"type": "string"
}
}
},
"ktmtrainbot.ServerTimeResponse": {
"type": "object",
"properties": {
"serverLocalTime": {
"type": "string"
}
}
},
"user.ProfileRequest": {
"type": "object",
"properties": {
"ktmTrainCreditCard": {
"type": "string"
},
"ktmTrainCreditCardCVV": {
"type": "string"
},
"ktmTrainCreditCardExpiry": {
"type": "string"
},
"ktmTrainCreditCardType": {
"type": "string"
},
"ktmTrainPassword": {
"type": "string"
},
"ktmTrainUsername": {
"type": "string"
}
}
},
"user.ProfileResponse": {
"type": "object",
"properties": {
"ktmTrainCreditCard": {
"type": "string"
},
"ktmTrainCreditCardCVV": {
"type": "string"
},
"ktmTrainCreditCardExpiry": {
"type": "string"
},
"ktmTrainCreditCardType": {
"type": "string"
},
"ktmTrainPassword": {
"type": "string"
},
"ktmTrainUsername": {
"type": "string"
}
}
},
"user.UserLoginRequest": {
"type": "object",
"required": [
"password",
"username"
],
"properties": {
"password": {
"type": "string"
},
"username": {
"type": "string",
"maxLength": 100,
"minLength": 2
}
}
},
"user.UserRegisterRequest": {
"type": "object",
"required": [
"password",
"username"
],
"properties": {
"password": {
"type": "string",
"maxLength": 100,
"minLength": 6
},
"username": {
"type": "string",
"maxLength": 100,
"minLength": 2
}
}
},
"user.UserResponse": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"profile": {
"$ref": "#/definitions/user.ProfileResponse"
},
"username": {
"type": "string"
}
}
}
}
}`
// SwaggerInfo holds exported Swagger Info so clients can modify it
var SwaggerInfo = &swag.Spec{
Version: "1.0",
Host: "",
BasePath: "/",
Schemes: []string{},
Title: "KTM Train Booking Bot",
Description: "API for frontend - built on Go-chi",
InfoInstanceName: "swagger",
SwaggerTemplate: docTemplate,
}
func init() {
swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo)
}

585
backend/docs/swagger.json

@ -0,0 +1,585 @@
{
"swagger": "2.0",
"info": {
"description": "API for frontend - built on Go-chi",
"title": "KTM Train Booking Bot",
"contact": {
"name": "Samuel Pua",
"url": "https://git.samuelpua.com/telboon"
},
"version": "1.0"
},
"basePath": "/",
"paths": {
"/": {
"get": {
"description": "Description",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Base"
],
"summary": "This is test",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "string"
}
},
"404": {
"description": "Not Found",
"schema": {
"type": "string"
}
}
}
}
},
"/api/v1/ktmtrainbot/booking": {
"get": {
"description": "Description",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"ktmtrainbot Booking"
],
"summary": "Get All Booking",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/ktmtrainbot.BookingResponse"
}
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/common.ErrResponse"
}
}
}
},
"post": {
"description": "Description",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"ktmtrainbot Booking"
],
"summary": "Create New Booking",
"parameters": [
{
"description": "Booking Create Request",
"name": "user",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/ktmtrainbot.BookingCreateRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/ktmtrainbot.BookingResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/common.ErrResponse"
}
}
}
}
},
"/api/v1/ktmtrainbot/booking/{bookingID}": {
"delete": {
"description": "Description",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"ktmtrainbot Booking"
],
"summary": "Delete booking",
"parameters": [
{
"type": "string",
"description": "Booking ID",
"name": "bookingID",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/ktmtrainbot.BookingResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/common.ErrResponse"
}
}
}
}
},
"/api/v1/ktmtrainbot/current-time": {
"get": {
"description": "Description",
"produces": [
"application/json"
],
"tags": [
"Info"
],
"summary": "Get current server time",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/ktmtrainbot.ServerTimeResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/common.ErrResponse"
}
}
}
}
},
"/api/v1/user/login": {
"post": {
"description": "Description",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"User"
],
"summary": "For user login",
"parameters": [
{
"description": "User Login info",
"name": "user",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/user.UserLoginRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/user.UserResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/common.ErrResponse"
}
}
}
}
},
"/api/v1/user/logout": {
"post": {
"description": "Description",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"User"
],
"summary": "For user logout",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/common.TextResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/common.ErrResponse"
}
}
}
}
},
"/api/v1/user/me": {
"get": {
"description": "Description",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"User"
],
"summary": "Returns current logged in user",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/user.UserResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/common.ErrResponse"
}
}
}
}
},
"/api/v1/user/profile": {
"put": {
"description": "Description",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"User"
],
"summary": "For setting current user profile",
"parameters": [
{
"description": "User registration info",
"name": "user",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/user.ProfileRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/user.UserResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/common.ErrResponse"
}
}
}
}
},
"/api/v1/user/register": {
"post": {
"description": "Description",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"User"
],
"summary": "For user registration",
"parameters": [
{
"description": "User registration info",
"name": "user",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/user.UserRegisterRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/user.UserResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/common.ErrResponse"
}
}
}
}
},
"/health": {
"get": {
"description": "Description",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Base"
],
"summary": "Responds to health check",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "string"
}
},
"404": {
"description": "Not Found",
"schema": {
"type": "string"
}
}
}
}
}
},
"definitions": {
"common.ErrResponse": {
"type": "object",
"properties": {
"code": {
"description": "application-specific error code",
"type": "integer"
},
"error": {
"description": "application-level error message, for debugging",
"type": "string"
},
"status": {
"description": "user-level status message",
"type": "string"
}
}
},
"common.TextResponse": {
"type": "object",
"properties": {
"status": {
"description": "user-level status message",
"type": "string"
},
"text": {
"description": "application-specific error code",
"type": "string"
}
}
},
"ktmtrainbot.BookingCreateRequest": {
"type": "object",
"required": [
"name",
"passport",
"passportExpiry",
"travelDate"
],
"properties": {
"contact": {
"type": "string"
},
"gender": {
"type": "string"
},
"name": {
"type": "string"
},
"passport": {
"type": "string"
},
"passportExpiry": {
"type": "string"
},
"timeCode": {
"type": "string"
},
"travelDate": {
"type": "string"
}
}
},
"ktmtrainbot.BookingResponse": {
"type": "object",
"properties": {
"contact": {
"type": "string"
},
"gender": {
"type": "string"
},
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"passport": {
"type": "string"
},
"passportExpiry": {
"type": "string"
},
"status": {
"type": "string"
},
"timeCode": {
"type": "string"
},
"travelDate": {
"type": "string"
}
}
},
"ktmtrainbot.ServerTimeResponse": {
"type": "object",
"properties": {
"serverLocalTime": {
"type": "string"
}
}
},
"user.ProfileRequest": {
"type": "object",
"properties": {
"ktmTrainCreditCard": {
"type": "string"
},
"ktmTrainCreditCardCVV": {
"type": "string"
},
"ktmTrainCreditCardExpiry": {
"type": "string"
},
"ktmTrainCreditCardType": {
"type": "string"
},
"ktmTrainPassword": {
"type": "string"
},
"ktmTrainUsername": {
"type": "string"
}
}
},
"user.ProfileResponse": {
"type": "object",
"properties": {
"ktmTrainCreditCard": {
"type": "string"
},
"ktmTrainCreditCardCVV": {
"type": "string"
},
"ktmTrainCreditCardExpiry": {
"type": "string"
},
"ktmTrainCreditCardType": {
"type": "string"
},
"ktmTrainPassword": {
"type": "string"
},
"ktmTrainUsername": {
"type": "string"
}
}
},
"user.UserLoginRequest": {
"type": "object",
"required": [
"password",
"username"
],
"properties": {
"password": {
"type": "string"
},
"username": {
"type": "string",
"maxLength": 100,
"minLength": 2
}
}
},
"user.UserRegisterRequest": {
"type": "object",
"required": [
"password",
"username"
],
"properties": {
"password": {
"type": "string",
"maxLength": 100,
"minLength": 6
},
"username": {
"type": "string",
"maxLength": 100,
"minLength": 2
}
}
},
"user.UserResponse": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"profile": {
"$ref": "#/definitions/user.ProfileResponse"
},
"username": {
"type": "string"
}
}
}
}
}

387
backend/docs/swagger.yaml

@ -0,0 +1,387 @@
basePath: /
definitions:
common.ErrResponse:
properties:
code:
description: application-specific error code
type: integer
error:
description: application-level error message, for debugging
type: string
status:
description: user-level status message
type: string
type: object
common.TextResponse:
properties:
status:
description: user-level status message
type: string
text:
description: application-specific error code
type: string
type: object
ktmtrainbot.BookingCreateRequest:
properties:
contact:
type: string
gender:
type: string
name:
type: string
passport:
type: string
passportExpiry:
type: string
timeCode:
type: string
travelDate:
type: string
required:
- name
- passport
- passportExpiry
- travelDate
type: object
ktmtrainbot.BookingResponse:
properties:
contact:
type: string
gender:
type: string
id:
type: string
name:
type: string
passport:
type: string
passportExpiry:
type: string
status:
type: string
timeCode:
type: string
travelDate:
type: string
type: object
ktmtrainbot.ServerTimeResponse:
properties:
serverLocalTime:
type: string
type: object
user.ProfileRequest:
properties:
ktmTrainCreditCard:
type: string
ktmTrainCreditCardCVV:
type: string
ktmTrainCreditCardExpiry:
type: string
ktmTrainCreditCardType:
type: string
ktmTrainPassword:
type: string
ktmTrainUsername:
type: string
type: object
user.ProfileResponse:
properties:
ktmTrainCreditCard:
type: string
ktmTrainCreditCardCVV:
type: string
ktmTrainCreditCardExpiry:
type: string
ktmTrainCreditCardType:
type: string
ktmTrainPassword:
type: string
ktmTrainUsername:
type: string
type: object
user.UserLoginRequest:
properties:
password:
type: string
username:
maxLength: 100
minLength: 2
type: string
required:
- password
- username
type: object
user.UserRegisterRequest:
properties:
password:
maxLength: 100
minLength: 6
type: string
username:
maxLength: 100
minLength: 2
type: string
required:
- password
- username
type: object
user.UserResponse:
properties:
id:
type: string
profile:
$ref: '#/definitions/user.ProfileResponse'
username:
type: string
type: object
info:
contact:
name: Samuel Pua
url: https://git.samuelpua.com/telboon
description: API for frontend - built on Go-chi
title: KTM Train Booking Bot
version: "1.0"
paths:
/:
get:
consumes:
- application/json
description: Description
produces:
- application/json
responses:
"200":
description: OK
schema:
type: string
"404":
description: Not Found
schema:
type: string
summary: This is test
tags:
- Base
/api/v1/ktmtrainbot/booking:
get:
consumes:
- application/json
description: Description
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/ktmtrainbot.BookingResponse'
type: array
"400":
description: Bad Request
schema:
$ref: '#/definitions/common.ErrResponse'
summary: Get All Booking
tags:
- ktmtrainbot Booking
post:
consumes:
- application/json
description: Description
parameters:
- description: Booking Create Request
in: body
name: user
required: true
schema:
$ref: '#/definitions/ktmtrainbot.BookingCreateRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/ktmtrainbot.BookingResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/common.ErrResponse'
summary: Create New Booking
tags:
- ktmtrainbot Booking
/api/v1/ktmtrainbot/booking/{bookingID}:
delete:
consumes:
- application/json
description: Description
parameters:
- description: Booking ID
in: path
name: bookingID
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/ktmtrainbot.BookingResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/common.ErrResponse'
summary: Delete booking
tags:
- ktmtrainbot Booking
/api/v1/ktmtrainbot/current-time:
get:
description: Description
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/ktmtrainbot.ServerTimeResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/common.ErrResponse'
summary: Get current server time
tags:
- Info
/api/v1/user/login:
post:
consumes:
- application/json
description: Description
parameters:
- description: User Login info
in: body
name: user
required: true
schema:
$ref: '#/definitions/user.UserLoginRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/user.UserResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/common.ErrResponse'
summary: For user login
tags:
- User
/api/v1/user/logout:
post:
consumes:
- application/json
description: Description
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/common.TextResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/common.ErrResponse'
summary: For user logout
tags:
- User
/api/v1/user/me:
get:
consumes:
- application/json
description: Description
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/user.UserResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/common.ErrResponse'
summary: Returns current logged in user
tags:
- User
/api/v1/user/profile:
put:
consumes:
- application/json
description: Description
parameters:
- description: User registration info
in: body
name: user
required: true
schema:
$ref: '#/definitions/user.ProfileRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/user.UserResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/common.ErrResponse'
summary: For setting current user profile
tags:
- User
/api/v1/user/register:
post:
consumes:
- application/json
description: Description
parameters:
- description: User registration info
in: body
name: user
required: true
schema:
$ref: '#/definitions/user.UserRegisterRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/user.UserResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/common.ErrResponse'
summary: For user registration
tags:
- User
/health:
get:
consumes:
- application/json
description: Description
produces:
- application/json
responses:
"200":
description: OK
schema:
type: string
"404":
description: Not Found
schema:
type: string
summary: Responds to health check
tags:
- Base
swagger: "2.0"

54
backend/go.mod

@ -0,0 +1,54 @@
module git.samuelpua.com/telboon/ktm-train-bot/backend
go 1.18
require (
github.com/go-chi/chi v1.5.4
github.com/go-chi/render v1.0.1
github.com/go-playground/validator/v10 v10.11.0
github.com/go-rod/rod v0.109.1
github.com/google/uuid v1.3.0
github.com/swaggo/http-swagger v1.2.0
github.com/swaggo/swag v1.7.9
gorm.io/driver/postgres v1.2.3
gorm.io/gorm v1.22.5
)
require (
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/PuerkitoBio/purell v1.1.1 // indirect
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
github.com/go-openapi/jsonpointer v0.19.5 // indirect
github.com/go-openapi/jsonreference v0.19.6 // indirect
github.com/go-openapi/spec v0.20.4 // indirect
github.com/go-openapi/swag v0.19.15 // indirect
github.com/go-playground/locales v0.14.0 // indirect
github.com/go-playground/universal-translator v0.18.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/leodido/go-urn v1.2.1 // indirect
github.com/mailru/easyjson v0.7.6 // indirect
github.com/swaggo/files v0.0.0-20210815190702-a29dd2bc99b2 // indirect
github.com/ysmood/goob v0.4.0 // indirect
github.com/ysmood/gson v0.7.1 // indirect
github.com/ysmood/leakless v0.8.0 // indirect
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2 // indirect
golang.org/x/tools v0.1.7 // indirect
)
require (
github.com/jackc/chunkreader/v2 v2.0.1 // indirect
github.com/jackc/pgconn v1.10.1 // indirect
github.com/jackc/pgio v1.0.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgproto3/v2 v2.2.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b // indirect
github.com/jackc/pgtype v1.9.0 // indirect
github.com/jackc/pgx/v4 v4.14.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.4 // indirect
github.com/joho/godotenv v1.4.0
golang.org/x/crypto v0.0.0-20220128200615-198e4374d7ed
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e // indirect
golang.org/x/text v0.3.7 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
)

304
backend/go.sum

@ -0,0 +1,304 @@
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
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/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI=
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
github.com/agiledragon/gomonkey/v2 v2.3.1 h1:k+UnUY0EMNYUFUAQVETGY9uUTxjMdnUkP0ARyJS1zzs=
github.com/agiledragon/gomonkey/v2 v2.3.1/go.mod h1:ap1AmDzcVOAz1YpeJ3TCzIgstoaWLA6jbbgxfB4w2iY=
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/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
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/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/go-chi/chi v1.5.4 h1:QHdzF2szwjqVV4wmByUnTcsbIg7UGaQ0tPF2t5GcAIs=
github.com/go-chi/chi v1.5.4/go.mod h1:uaf8YgoFazUOkPBG7fxPftUylNumIev9awIWOENIuEg=
github.com/go-chi/render v1.0.1 h1:4/5tis2cKaNdnv9zFLfXzcquC9HbeZgCnxGnKrltBS8=
github.com/go-chi/render v1.0.1/go.mod h1:pq4Rr7HbnsdaeHagklXub+p6Wd16Af5l9koip1OvJns=
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-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs=
github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns=
github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M=
github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I=
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.0 h1:u50s323jtVGugKlcYeyzC0etD1HifMjqmJqb8WugfUU=
github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs=
github.com/go-playground/universal-translator v0.18.0 h1:82dyy6p4OuJq4/CByFNOn/jYrnRPArHwAcmLoJZxyho=
github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA=
github.com/go-playground/validator/v10 v10.11.0 h1:0W+xRM511GY47Yy3bZUbJVitCNg2BOGlCyvTqsp/xIw=
github.com/go-playground/validator/v10 v10.11.0/go.mod h1:i+3WkQ1FvaUjjxh1kSvIA4dMGDBiPU55YFDl0WbKdWU=
github.com/go-rod/rod v0.109.1 h1:658X/G9xyQKjFUNo5apMsIyHpEb/KJnJ5LkAl6a62AI=
github.com/go-rod/rod v0.109.1/go.mod h1:GZDtmEs6RpF6kBRYpGCZXxXlKNneKVPiKOjaMbmVVjE=
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/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
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.10.1 h1:DzdIHIjG1AxGwoEEqS+mGsURyjt4enSmqzACXvVzOT8=
github.com/jackc/pgconn v1.10.1/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI=
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.2.0 h1:r7JypeP2D3onoQTCxWdTpCtJ4D+qpKr0TxvoyMhZ5ns=
github.com/jackc/pgproto3/v2 v2.2.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.9.0 h1:/SH1RxEtltvJgsDqp3TbiTFApD3mey3iygpuEGeuBXk=
github.com/jackc/pgtype v1.9.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.14.0 h1:TgdrmgnM7VY72EuSQzBbBd4JA1RLqJolrw9nQVZABVc=
github.com/jackc/pgx/v4 v4.14.0/go.mod h1:jT3ibf/A0ZVCp89rtCIN0zCJxcE74ypROmHEZYsG/j8=
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.0/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.2/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
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/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
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/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
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/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w=
github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
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/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA=
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
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/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/otiai10/copy v1.7.0 h1:hVoPiN+t+7d2nzzwMiDHPSOogsWAStewq3TwU05+clE=
github.com/otiai10/copy v1.7.0/go.mod h1:rmRl6QPdJj6EiUqXQ/4Nn2lLXoNQjFCQbbNrxgc/t3U=
github.com/otiai10/curr v0.0.0-20150429015615-9b4961190c95/go.mod h1:9qAhocn7zKJG+0mI8eUu6xqkFDYS2kb2saOteoSB3cE=
github.com/otiai10/curr v1.0.0/go.mod h1:LskTG5wDwr8Rs+nNQ+1LlxRjAtTZZjtJW4rMXl6j4vs=
github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT91xUo=
github.com/otiai10/mint v1.3.3/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
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/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
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/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
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/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
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/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
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/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.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/swaggo/files v0.0.0-20210815190702-a29dd2bc99b2 h1:+iNTcqQJy0OZ5jk6a5NLib47eqXK8uYcPX+O4+cBpEM=
github.com/swaggo/files v0.0.0-20210815190702-a29dd2bc99b2/go.mod h1:lKJPbtWzJ9JhsTN1k1gZgleJWY/cqq0psdoMmaThG3w=
github.com/swaggo/http-swagger v1.2.0 h1:G5EBD5nvw379l2sFhact660YDT++eLviczLPrgNw/lU=
github.com/swaggo/http-swagger v1.2.0/go.mod h1:P7+V1SLG2zloe+VvAGL7WgFimhJACaBLAv2N7YQ0ikI=
github.com/swaggo/swag v1.7.8/go.mod h1:gZ+TJ2w/Ve1RwQsA2IRoSOTidHz6DX+PIG8GWvbnoLU=
github.com/swaggo/swag v1.7.9 h1:6vCG5mm43ebDzGlZPMGYrYI4zKFfOr5kicQX8qjeDwc=
github.com/swaggo/swag v1.7.9/go.mod h1:gZ+TJ2w/Ve1RwQsA2IRoSOTidHz6DX+PIG8GWvbnoLU=
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
github.com/ysmood/goob v0.4.0 h1:HsxXhyLBeGzWXnqVKtmT9qM7EuVs/XOgkX7T6r1o1AQ=
github.com/ysmood/goob v0.4.0/go.mod h1:u6yx7ZhS4Exf2MwciFr6nIM8knHQIE22lFpWHnfql18=
github.com/ysmood/got v0.31.3 h1:UvvF+TDVsZLO7MSzm/Bd/H4HVp+7S5YwsxgdwaKq8uA=
github.com/ysmood/got v0.31.3/go.mod h1:pE1l4LOwOBhQg6A/8IAatkGp7uZjnalzrZolnlhhMgY=
github.com/ysmood/gotrace v0.6.0 h1:SyI1d4jclswLhg7SWTL6os3L1WOKeNn/ZtzVQF8QmdY=
github.com/ysmood/gotrace v0.6.0/go.mod h1:TzhIG7nHDry5//eYZDYcTzuJLYQIkykJzCRIo4/dzQM=
github.com/ysmood/gson v0.7.1 h1:zKL2MTGtynxdBdlZjyGsvEOZ7dkxaY5TH6QhAbTgz0Q=
github.com/ysmood/gson v0.7.1/go.mod h1:3Kzs5zDl21g5F/BlLTNcuAGAYLKt2lV5G8D1zF3RNmg=
github.com/ysmood/leakless v0.8.0 h1:BzLrVoiwxikpgEQR0Lk8NyBN5Cit2b1z+u0mgL4ZJak=
github.com/ysmood/leakless v0.8.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ=
github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
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/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.0.0-20220128200615-198e4374d7ed h1:YoWVYYAfvQ4ddHv3OKmIvX7NCAhFGTj62VP2l2kfBbA=
golang.org/x/crypto v0.0.0-20220128200615-198e4374d7ed/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
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/mod v0.4.2 h1:Gz96sIWK3OalVv/I/qNygP42zyoKp3xptRVCWRFEBvo=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
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/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2 h1:CIJ76btIcR3eFI5EgSo6k1qKw9KJexJuRLI9G7Hp5wE=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/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-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e h1:WUoyKPm6nCo1BnNUvPGnFG3T5DUVem42yDJZZ4CNxMA=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/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-20190328211700-ab21143f2384/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-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.1.7 h1:6j8CgantCy3yc8JGBqkDLMKWqZ0RDU2g1HVgacojGWQ=
golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo=
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 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
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/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
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.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.2.3 h1:f4t0TmNMy9gh3TU2PX+EppoA6YsgFnyq8Ojtddb42To=
gorm.io/driver/postgres v1.2.3/go.mod h1:pJV6RgYQPG47aM1f0QeOzFH9HxQc8JcmAgjRCgS0wjs=
gorm.io/gorm v1.22.3/go.mod h1:F+OptMscr0P2F2qU97WT1WimdH9GaQPoDW7AYd5i2Y0=
gorm.io/gorm v1.22.5 h1:lYREBgc02Be/5lSCTuysZZDb6ffL2qrat6fg9CFbvXU=
gorm.io/gorm v1.22.5/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=

131
backend/internal/common/database.go

@ -0,0 +1,131 @@
package common
import (
"fmt"
"log"
"os"
"strings"
"time"
"github.com/google/uuid"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
type Database struct {
*gorm.DB
}
var DB *gorm.DB
// Opening a database and save the reference to `Database` struct.
func InitDB() *gorm.DB {
host := os.Getenv("DB_HOST")
user := os.Getenv("DB_USER")
pass := os.Getenv("DB_PASS")
dbName := os.Getenv("DB_NAME")
port := os.Getenv("DB_PORT")
var sslMode string
if os.Getenv("DB_SSL") == "TRUE" {
sslMode = "enable"
} else {
sslMode = "disable"
}
// DB Logger config
newLogger := logger.New(
log.New(os.Stdout, "\r\n", log.LstdFlags), // io writer
logger.Config{
SlowThreshold: time.Second, // Slow SQL threshold
LogLevel: logger.Silent, // Log level
IgnoreRecordNotFoundError: true, // Ignore ErrRecordNotFound error for logger
Colorful: true, // Disable color
},
)
dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=%s TimeZone=Asia/Singapore", host, user, pass, dbName, port, sslMode)
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
Logger: newLogger,
})
if err != nil {
fmt.Println("db err: (Init) ", err)
}
// Setup UUID
// CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
db.Exec("CREATE EXTENSION IF NOT EXISTS \"uuid-ossp\";")
DB = db
return DB
}
// This function will create a temporarily database for running testing cases
func TestDBInit() *gorm.DB {
host := os.Getenv("TEST_DB_HOST")
user := os.Getenv("TEST_DB_USER")
pass := os.Getenv("TEST_DB_PASS")
dbName := fmt.Sprintf("%s_%s", os.Getenv("TEST_DB_NAME"), uuid.New().String())
dbName = strings.ReplaceAll(dbName, "-", "_")
port := os.Getenv("TEST_DB_PORT")
var sslMode string
if os.Getenv("TEST_DB_SSL") == "TRUE" {
sslMode = "enable"
} else {
sslMode = "disable"
}
// DB Logger config
newLogger := logger.New(
log.New(os.Stdout, "\r\n", log.LstdFlags), // io writer
logger.Config{
SlowThreshold: time.Second, // Slow SQL threshold
LogLevel: logger.Silent, // Log level
IgnoreRecordNotFoundError: true, // Ignore ErrRecordNotFound error for logger
Colorful: true, // Disable color
},
)
dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=%s TimeZone=Asia/Singapore", host, user, pass, "postgres", port, sslMode)
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
Logger: newLogger,
})
if err != nil {
fmt.Println("db err: (Init) ", err)
}
// Create Database
err = db.Exec(fmt.Sprintf("CREATE DATABASE %s;", dbName)).Error
if err != nil {
fmt.Println("db err: (Init) ", err)
}
// Get into testing database
dsn = fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=%s TimeZone=Asia/Singapore", host, user, pass, dbName, port, sslMode)
db, err = gorm.Open(postgres.Open(dsn), &gorm.Config{
Logger: newLogger,
})
if err != nil {
fmt.Println("db err: (Init) ", err)
}
// Setup UUID
// CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
db.Exec("CREATE EXTENSION IF NOT EXISTS \"uuid-ossp\";")
DB = db
return DB
}
func DestroyTestingDB(db *gorm.DB) {
var dbName string
db.Raw("SELECT current_database();").Scan(&dbName)
db.Exec(fmt.Sprintf("DROP DATABASE %s", dbName))
}
// Using this function to get a connection, you can create your connection pool here.
func GetDB() *gorm.DB {
return DB
}

56
backend/internal/common/errresponse.go

@ -0,0 +1,56 @@
package common
import (
"net/http"
"github.com/go-chi/render"
)
type ErrResponse struct {
Err error `json:"-"` // low-level runtime error
HTTPStatusCode int `json:"-"` // http response status code
StatusText string `json:"status"` // user-level status message
AppCode int64 `json:"code,omitempty"` // application-specific error code
ErrorText string `json:"error,omitempty"` // application-level error message, for debugging
}
func (e *ErrResponse) Render(w http.ResponseWriter, r *http.Request) error {
render.Status(r, e.HTTPStatusCode)
return nil
}
func ErrInvalidRequest(err error) render.Renderer {
return &ErrResponse{
Err: err,
HTTPStatusCode: 400,
StatusText: "Invalid request.",
ErrorText: err.Error(),
}
}
func ErrValidationError(err error) render.Renderer {
return &ErrResponse{
Err: err,
HTTPStatusCode: 422,
StatusText: "Validation error.",
ErrorText: err.Error(),
}
}
func ErrInternalError(err error) render.Renderer {
return &ErrResponse{
Err: err,
HTTPStatusCode: 500,
StatusText: "Invalid request.",
ErrorText: err.Error(),
}
}
func ErrNotFound(err error) render.Renderer {
return &ErrResponse{
HTTPStatusCode: 404,
StatusText: "Resource not found.",
ErrorText: err.Error(),
}
}

24
backend/internal/common/textresponse.go

@ -0,0 +1,24 @@
package common
import (
"net/http"
"github.com/go-chi/render"
)
type TextResponse struct {
Status string `json:"status"` // user-level status message
Text string `json:"text"` // application-specific error code
}
func (e *TextResponse) Render(w http.ResponseWriter, r *http.Request) error {
render.Status(r, http.StatusOK)
return nil
}
func NewGenericTextResponse(status string, text string) render.Renderer {
return &TextResponse{
Status: status,
Text: text,
}
}

477
backend/internal/ktmtrainbot/backgroundbookingjob.go

@ -0,0 +1,477 @@
package ktmtrainbot
import (
"context"
"errors"
"fmt"
"log"
"os"
"strconv"
"strings"
"time"
"git.samuelpua.com/telboon/ktm-train-bot/backend/internal/user"
"github.com/go-rod/rod"
"github.com/go-rod/rod/lib/devices"
"github.com/go-rod/rod/lib/launcher"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
const IMAGE_DIR = "/tmp/screenshots"
const TIMEOUT_MINUTE = 60
func (env *Env) BackgroundJobRunner() {
log.Println("Initialising background job...")
initialiseRodBrowser()
log.Println("Browser initialised...")
// Initialise silent logger
newLogger := logger.New(
log.New(os.Stdout, "\r\n", log.LstdFlags), // io writer
logger.Config{
SlowThreshold: time.Second, // Slow SQL threshold
LogLevel: logger.Silent, // Log level
IgnoreRecordNotFoundError: true, // Ignore ErrRecordNotFound error for logger
Colorful: true, // Disable color
},
)
tx := env.DB.Session(&gorm.Session{Logger: newLogger})
for {
var jobToDo Booking
err := tx.Model(&jobToDo).
Where("status = ?", "pending").
Preload("User").
First(&jobToDo).Error
// if no jobs pending found
if err != nil {
time.Sleep(time.Second)
continue
} else { // if there's job to do
// Create next run where it's not the past (either from old NextRun or now())
timeNow := time.Now()
if timeNow.Hour() == 0 && timeNow.Minute() == 10 {
err := env.DB.Where(&user.Profile{UserID: jobToDo.UserID}).First(&jobToDo.User.Profile).Error
if err != nil {
log.Println(err)
}
log.Printf("Start doing job: %v", jobToDo.ID)
username := jobToDo.User.Profile.KtmTrainUsername
password := jobToDo.User.Profile.KtmTrainPassword
creditCardType := jobToDo.User.Profile.KtmTrainCreditCardType
creditCard := jobToDo.User.Profile.KtmTrainCreditCard
creditCardCVV := jobToDo.User.Profile.KtmTrainCreditCardCVV
creditCardExpiry := jobToDo.User.Profile.KtmTrainCreditCardExpiry
func() {
defer func() {
if r := recover(); r != nil {
log.Println("Recovering from job panic...")
jobToDo.Status = "pending"
env.DB.Save(&jobToDo)
}
}()
success := env.startBooking(&jobToDo, username, password, creditCardType, creditCard, creditCardCVV, creditCardExpiry)
if success {
fmt.Println("Successfully made a booking.")
jobToDo.Status = "success"
env.DB.Save(jobToDo)
} else {
jobToDo.Status = "pending"
env.DB.Save(&jobToDo)
fmt.Println("Failed to make a booking.")
}
}()
jobToDo.Status = "running"
env.DB.Save(&jobToDo)
log.Printf("Job Started: %v", jobToDo.ID)
}
}
}
}
func initialiseRodBrowser() {
u := launcher.New().
Set("headless").
MustLaunch()
defaultDevice := devices.LaptopWithMDPIScreen
browser := rod.New().ControlURL(u).MustConnect().DefaultDevice(defaultDevice)
page := browser.MustPage("https://www.google.com").MustWindowFullscreen()
page.MustWaitLoad()
browser.MustClose()
// Initialise screenshot directory
if _, err := os.Stat(IMAGE_DIR); errors.Is(err, os.ErrNotExist) {
err := os.Mkdir(IMAGE_DIR, os.ModePerm)
if err != nil {
log.Println(err)
}
}
}
func (env *Env) startBooking(job *Booking, username string, password string, creditCardType string, creditCard string, creditCardCVV string, creditCardExpiry string) bool {
timerCtx, cancelTimer := context.WithTimeout(context.Background(), TIMEOUT_MINUTE*time.Minute)
defer cancelTimer()
headless := os.Getenv("HEADLESS")
var u string
if strings.ToUpper(headless) == "FALSE" {
u = launcher.New().
Set("headless").
Delete("--headless").
MustLaunch()
} else {
u = launcher.New().
Set("headless").
MustLaunch()
}
defaultDevice := devices.LaptopWithMDPIScreen
// defaultDevice.Screen.Vertical.Height = defaultDevice.Screen.Horizontal.Height
// defaultDevice.Screen.Vertical.Width = defaultDevice.Screen.Horizontal.Width
defaultDevice.UserAgent = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.0.0 Safari/537.36"
browser := rod.New().ControlURL(u).MustConnect().DefaultDevice(defaultDevice)
// Defer closing browser
defer browser.MustClose()
postLoginPage := ktmTrainLogin(browser, username, password)
nowTimeStr := time.Now().Format("2006-01-02-15_04_05")
postLoginPage.MustWaitLoad().MustScreenshot(fmt.Sprintf("%s/%s-01-login.png", IMAGE_DIR, nowTimeStr))
// Exits if context cancelled
select {
case <-timerCtx.Done():
browser.MustClose()
return false
default:
}
var page *rod.Page
getBookingSlotCtx, cancelGetBookingSlot := context.WithTimeout(context.Background(), TIMEOUT_MINUTE*time.Minute)
defer cancelGetBookingSlot()
pageChan := make(chan *rod.Page)
onwardDate := job.TravelDate.Format("2 Jan 2006")
timeCode := job.TimeCode
name := job.Name
gender := job.Gender
passport := job.Passport
passportExpiry := job.PassportExpiry.Format("2 Jan 2006")
contact := job.Contact
threadCount := 10
for i := 0; i < threadCount; i++ {
time.Sleep(time.Millisecond * 100)
go func() {
currPage := getBookingSlots(browser, onwardDate)
log.Println("Booking page loaded.")
currPage = selectBookingSlot(getBookingSlotCtx, currPage, timeCode)
log.Println("Booking slot selected.")
select {
case <-getBookingSlotCtx.Done():
return
default:
log.Println("First page loaded.")
}
// Cancelling context
cancelGetBookingSlot()
pageChan <- currPage
}()
}
page = <-pageChan
page.MustActivate()
// Exits if context cancelled
select {
case <-timerCtx.Done():
browser.MustClose()
return false
default:
}
page = fillPassengerDetails(page, name, gender, passport, passportExpiry, contact)
log.Println("Passenger details filled.")
// Exits if context cancelled
select {
case <-timerCtx.Done():
browser.MustClose()
return false
default:
}
page = choosePayment(page)
log.Println("Payment method chosen.")
// Wait 5 seconds for payment gateway to load
time.Sleep(time.Second * 5)
for _, currPage := range browser.MustPages() {
currPage.MustWaitLoad()
var currTitle string
err := rod.Try(func() {
currTitle = currPage.Timeout(100 * time.Millisecond).MustElement("title").MustText()
})
if err != nil {
currTitle = ""
}
if strings.Contains(currTitle, "Payment Acceptance") {
page = currPage
}
}
// Exits if context cancelled
select {
case <-timerCtx.Done():
browser.MustClose()
return false
default:
}
expiryMonth := strings.Split(creditCardExpiry, "/")[0]
expiryYear := strings.Split(creditCardExpiry, "/")[1]
page = makePayment(page, creditCardType, creditCard, expiryMonth, expiryYear, creditCardCVV)
log.Println("Payment made.")
// // Start debug screenshots
// debugScreenshotCtx, cancelDebugScreenshot := context.WithCancel(context.Background())
// go takeDebugScreenshots(debugScreenshotCtx, courtPage)
// // Defer done with debug screenshot
// defer cancelDebugScreenshot()
time.Sleep(600 * time.Second)
_ = page
browser.MustClose()
return true
}
func ktmTrainLogin(browser *rod.Browser, username string, password string) *rod.Page {
page := browser.MustPage("https://online.ktmb.com.my/Account/Login")
page.MustElement("#Email").MustInput(username)
page.MustElement("#Password").MustInput(password)
page.MustElement("#LoginButton").MustClick()
return page
}
func getBookingSlots(browser *rod.Browser, onwardDate string) *rod.Page {
page := browser.MustPage("https://shuttleonline.ktmb.com.my/Home/Shuttle")
page.MustWaitLoad()
// Dismiss system maintenance warning
bodyText := page.MustElement("body").MustText()
containsCheckStr := "System maintenance scheduled at 23:00 to 00:15"
if strings.Contains(bodyText, containsCheckStr) {
page.MustEval(`() => document.querySelector("#validationSummaryModal > div > div > div.modal-body > div > div.text-center > button").click()`)
}
passengerCount := 1
passengerCountStr := strconv.Itoa(passengerCount)
requestVerificationToken := page.MustElement("#theForm > input[name=__RequestVerificationToken]").MustAttribute("value")
// Get JB Sentral Station Info
jBSentralData := page.MustElement("#FromStationData").MustAttribute("value")
jBSentralID := page.MustElement("#FromStationId").MustAttribute("value")
// Get Woodlands Station Info
woodlandsData := page.MustElement("#ToStationData").MustAttribute("value")
woodlandsID := page.MustElement("#ToStationId").MustAttribute("value")
sensitiveCustomForm(page, *woodlandsData, *jBSentralData, *woodlandsID, *jBSentralID, onwardDate, passengerCountStr, *requestVerificationToken)
page.MustWaitLoad()
return page
}
func selectBookingSlot(ctx context.Context, page *rod.Page, timeCode string) *rod.Page {
time.Sleep(1 * time.Second)
// Initial closing of maintenance modal
bodyText := page.MustElement("body").MustText()
if strings.Contains(bodyText, "System maintenance scheduled at 23:00 to 00:15 (UTC+8)") {
closeModalButton := page.MustElement("#popupModalCloseButton")
closeModalButton.Eval(`this.click()`)
time.Sleep(1000 * time.Millisecond)
}
// Start probing
completed := false
for !completed {
page.MustWaitLoad()
departTripsTable := page.MustElement(".depart-trips")
departRows := departTripsTable.MustElements("tr")
var rowElement *rod.Element
for _, row := range departRows {
timeCodeElement := row.MustAttribute("data-hourminute")
if *timeCodeElement == timeCode {
rowElement = row
}
}
// Checks for context before clicking
select {
case <-ctx.Done():
return page
default:
}
selectButtonElement := rowElement.MustElement("a")
selectButtonElement.Eval(`this.click()`)
time.Sleep(1000 * time.Millisecond)
page.MustWaitLoad()
// Check before exiting
bodyText := page.MustElement("body").MustText()
if strings.Contains(bodyText, "System maintenance scheduled at 23:00 to 00:15 (UTC+8).") {
completed = false
closeModalButton := page.MustElement("#popupModalCloseButton")
closeModalButton.Eval(`this.click()`)
time.Sleep(1000 * time.Millisecond)
} else {
log.Println("Completed probing")
completed = true
}
}
// Checks for context before clicking
select {
case <-ctx.Done():
return page
default:
}
proceedButton := page.MustElement(".proceed-btn")
proceedButton.Eval(`this.click()`)
return page
}
func fillPassengerDetails(page *rod.Page, name string, gender string, passport string, passportExpiry string, contact string) *rod.Page {
ticketType := "DEWASA/ADULT"
nameElement := page.MustElement(".FullName")
nameElement.MustInput(name)
passportElement := page.MustElement(".PassportNo")
passportElement.MustInput(passport)
passportExpiryElement := page.MustElement("#Passengers_0__PassportExpiryDate")
passportExpiryElement.MustInput(passportExpiry)
contactElement := page.MustElement(".ContactNo")
contactElement.MustInput(contact)
if gender == "M" {
maleElement := page.MustElement("#Passengers_0__GenderMale")
maleElement.Eval(`this.click()`)
} else {
femaleElement := page.MustElement("#Passengers_0__GenderFemale")
femaleElement.Eval(`this.click()`)
}
ticketTypeElement := page.MustElement("#Passengers_0__TicketTypeId")
ticketTypeElement.MustSelect(ticketType)
paymentButton := page.MustElement("#btnConfirmPayment")
paymentButton.Eval(`this.click()`)
page.MustWaitLoad()
return page
}
func choosePayment(page *rod.Page) *rod.Page {
creditCardButton := page.MustElement(".btn-public-bank")
creditCardButton.Eval(`this.click()`)
page.MustWaitLoad()
paymentGatewayButton := page.MustElement("#PaymentGateway")
paymentGatewayButton.Eval(`this.click()`)
page.MustWaitLoad()
return page
}
func makePayment(page *rod.Page, cardType string, creditCard string, expiryMonth string, expiryYear string, creditCardCVV string) *rod.Page {
if cardType == "Visa" {
visaRadio := page.MustElement("#card_type_001")
visaRadio.Eval(`this.click()`)
} else if cardType == "Mastercard" {
masterRadio := page.MustElement("#card_type_002")
masterRadio.Eval(`this.click()`)
}
creditCardElement := page.MustElement("#card_number")
creditCardElement.MustInput(creditCard)
expiryMonthElement := page.MustElement("#card_expiry_month")
expiryMonthElement.MustSelect(expiryMonth)
expiryYearElement := page.MustElement("#card_expiry_year")
expiryYearElement.MustSelect(expiryYear)
creditCardCVVElement := page.MustElement("#card_cvn")
creditCardCVVElement.MustInput(creditCardCVV)
log.Println("Before payment")
time.Sleep(10 * time.Millisecond)
payButton := page.MustElement(".pay_button")
payButton.Eval(`this.click()`)
return page
}
func sensitiveCustomForm(
page *rod.Page,
fromStationData string,
toStationData string,
fromStationID string,
toStationID string,
onwardDate string,
passengerCount string,
csrf string,
) *rod.Page {
defer func() {
if r := recover(); r != nil {
log.Println("Recovered in sensitiveCustomForm", r)
}
}()
formHTML := fmt.Sprintf(`
<form action="https://shuttleonline.ktmb.com.my/ShuttleTrip" method="POST">
<input type="hidden" name="FromStationData" value="%s" />
<input type="hidden" name="ToStationData" value="%s" />
<input type="hidden" name="FromStationId" value="%s" />
<input type="hidden" name="ToStationId" value="%s" />
<input type="hidden" name="OnwardDate" value="%s" />
<input type="hidden" name="ReturnDate" value="" />
<input type="hidden" name="PassengerCount" value="%s" />
<input type="hidden" name="__RequestVerificationToken" value="%s" />
<input type="submit" id="presshere" value="Submit request" />
</form>
UniqueStringHere
`, fromStationData, toStationData, fromStationID, toStationID, onwardDate, passengerCount, csrf)
page.MustElement("body").MustEval(fmt.Sprintf("() => this.innerHTML = `%s`", formHTML))
// page.MustElement("#presshere").MustClick()
page.MustEval(`() => document.querySelector("#presshere").click()`)
return page
}

82
backend/internal/ktmtrainbot/bookingcontroller.go

@ -0,0 +1,82 @@
package ktmtrainbot
import (
"errors"
"log"
"time"
"git.samuelpua.com/telboon/ktm-train-bot/backend/internal/user"
"github.com/google/uuid"
)
func (env *Env) createBooking(
user *user.User,
travelDate time.Time,
timeCode string,
name string,
gender string,
passport string,
passportExpiry time.Time,
contact string,
) (*Booking, error) {
var newBooking Booking
newBooking.User = *user
newBooking.TravelDate = travelDate
newBooking.TimeCode = timeCode
newBooking.Name = name
newBooking.Gender = gender
newBooking.Passport = passport
newBooking.PassportExpiry = passportExpiry
newBooking.Status = "pending"
err := env.DB.Create(&newBooking).Error
if err != nil {
log.Println(err)
return nil, errors.New("failed create new booking")
}
return &newBooking, nil
}
func (env *Env) getAllBooking(user *user.User) ([]Booking, error) {
var booking []Booking
err := env.DB.Where("user_id = ?", user.ID).Order("court_weekday asc").Find(&booking).Error
if err != nil {
log.Println(err)
return nil, errors.New("failed get booking")
}
return booking, nil
}
func (env *Env) deleteBooking(
user *user.User,
bookingIDStr string,
) (*Booking, error) {
var newBooking Booking
bookingID, err := uuid.Parse(bookingIDStr)
if err != nil {
log.Println(err)
return nil, errors.New("invalid uuid")
}
err = env.DB.Where(&Booking{ID: bookingID}).Where("user_id = ?", user.ID).First(&newBooking).Error
if err != nil {
log.Println(err)
return nil, errors.New("failed retrieve booking")
}
err = env.DB.Delete(&newBooking).Error
if err != nil {
log.Println(err)
return nil, errors.New("failed to delete booking")
}
return &newBooking, nil
}

92
backend/internal/ktmtrainbot/bookingmodel.go

@ -0,0 +1,92 @@
package ktmtrainbot
import (
"net/http"
"time"
"git.samuelpua.com/telboon/ktm-train-bot/backend/internal/user"
"github.com/google/uuid"
"gorm.io/gorm"
)
type Booking struct {
ID uuid.UUID `gorm:"primaryKey;type:uuid;default:uuid_generate_v4()"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
User user.User
UserID uuid.UUID
TravelDate time.Time // Only date matters
TimeCode string // e.g. 1400
Name string
Gender string // M/F
Passport string
PassportExpiry time.Time // Only date matters
Contact string // +6512345678
Status string // "success", "error", "pending", "running"
}
type BookingCreateRequest struct {
TravelDate time.Time `json:"travelDate" validate:"required"`
TimeCode string `json:"timeCode" validate:"required len=4"`
Name string `json:"name" validate:"required"`
Gender string `json:"gender" validate:"required len=1 containsany=MF"`
Passport string `json:"passport" validate:"required"`
PassportExpiry time.Time `json:"passportExpiry" validate:"required"`
Contact string `json:"contact" validate:"required e164"`
}
type BookingResponse struct {
ID uuid.UUID `json:"id"`
TravelDate time.Time `json:"travelDate"`
TimeCode string `json:"timeCode"`
Name string `json:"name"`
Gender string `json:"gender"`
Passport string `json:"passport"`
PassportExpiry time.Time `json:"passportExpiry"`
Contact string `json:"contact"`
Status string `json:"status"`
}
type BookingListResponse []BookingResponse
func (res *BookingResponse) Render(w http.ResponseWriter, r *http.Request) error {
// Pre-processing before a response is marshalled and sent across the wire
return nil
}
func (res BookingListResponse) Render(w http.ResponseWriter, r *http.Request) error {
// Pre-processing before a response is marshalled and sent across the wire
// if res == nil {
// var empty []BookingResponse
// res = empty
// }
return nil
}
func (env *Env) NewBookingResponse(model *Booking) *BookingResponse {
res := &BookingResponse{
ID: model.ID,
TravelDate: model.TravelDate,
TimeCode: model.TimeCode,
Name: model.Name,
Gender: model.Gender,
Passport: model.Passport,
PassportExpiry: model.PassportExpiry,
Contact: model.Contact,
Status: model.Status,
}
return res
}
func (env *Env) NewBookingListResponse(model []Booking) BookingListResponse {
var res []BookingResponse
for _, item := range model {
curr := env.NewBookingResponse(&item)
res = append(res, *curr)
}
return res
}

124
backend/internal/ktmtrainbot/bookingroute.go

@ -0,0 +1,124 @@
package ktmtrainbot
import (
"errors"
"net/http"
"git.samuelpua.com/telboon/ktm-train-bot/backend/internal/common"
"git.samuelpua.com/telboon/ktm-train-bot/backend/internal/user"
"github.com/go-chi/chi"
"github.com/go-chi/render"
"github.com/go-playground/validator/v10"
)
// Get All Bookings
// @Summary Get All Booking
// @Description Description
// @Tags ktmtrainbot Booking
// @Accept json
// @Produce json
// @Success 200 {object} []BookingResponse
// @Failure 400 {object} common.ErrResponse
// @Router /api/v1/ktmtrainbot/booking [get]
func (env *Env) getBookingRoute(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
currUser, ok := ctx.Value(user.UserContextKey).(*user.User)
if !ok {
err := errors.New("user not logged in")
render.Render(w, r, common.ErrInternalError(err))
return
}
_ = currUser
booking, err := env.getAllBooking(currUser)
if err != nil {
render.Render(w, r, common.ErrInvalidRequest(err))
return
}
render.Render(w, r, env.NewBookingListResponse(booking))
}
// Create New Booking
// @Summary Create New Booking
// @Description Description
// @Tags ktmtrainbot Booking
// @Accept json
// @Produce json
// @Param user body BookingCreateRequest true "Booking Create Request"
// @Success 200 {object} BookingResponse
// @Failure 400 {object} common.ErrResponse
// @Router /api/v1/ktmtrainbot/booking [post]
func (env *Env) createBookingRoute(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
currUser, ok := ctx.Value(user.UserContextKey).(*user.User)
if !ok {
err := errors.New("user not logged in")
render.Render(w, r, common.ErrInternalError(err))
return
}
_ = currUser
data := &BookingCreateRequest{}
err := render.DecodeJSON(r.Body, data)
if err != nil {
render.Render(w, r, common.ErrInvalidRequest(err))
return
}
err = validator.New().Struct(data)
if err != nil {
render.Render(w, r, common.ErrValidationError(err))
return
}
booking, err := env.createBooking(
currUser,
data.TravelDate,
data.TimeCode,
data.Name,
data.Gender,
data.Passport,
data.PassportExpiry,
data.Contact,
)
if err != nil {
render.Render(w, r, common.ErrInvalidRequest(err))
return
}
render.Render(w, r, env.NewBookingResponse(booking))
}
// Delete booking
// @Summary Delete booking
// @Description Description
// @Tags ktmtrainbot Booking
// @Accept json
// @Produce json
// @Param bookingID path string true "Booking ID"
// @Success 200 {object} BookingResponse
// @Failure 400 {object} common.ErrResponse
// @Router /api/v1/ktmtrainbot/booking/{bookingID} [delete]
func (env *Env) deleteBookingRoute(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
currUser, ok := ctx.Value(user.UserContextKey).(*user.User)
if !ok {
err := errors.New("user not logged in")
render.Render(w, r, common.ErrInternalError(err))
return
}
_ = currUser
bookingID := chi.URLParam(r, "bookingID")
booking, err := env.deleteBooking(currUser, bookingID)
if err != nil {
render.Render(w, r, common.ErrInvalidRequest(err))
return
}
render.Render(w, r, env.NewBookingResponse(booking))
}

25
backend/internal/ktmtrainbot/getcurrenttime.go

@ -0,0 +1,25 @@
package ktmtrainbot
import (
"net/http"
"time"
"github.com/go-chi/render"
)
// Get Current Server Time
// @Summary Get current server time
// @Description Description
// @Tags Info
// @Produce json
// @Success 200 {object} ServerTimeResponse
// @Failure 400 {object} common.ErrResponse
// @Router /api/v1/ktmtrainbot/current-time [get]
func (env *Env) getCurrentTime(w http.ResponseWriter, r *http.Request) {
timeNow := time.Now()
var res ServerTimeResponse
res.ServerLocalTime = timeNow.In(time.Local).Format(time.RFC1123Z)
render.Render(w, r, &res)
}

31
backend/internal/ktmtrainbot/main.go

@ -0,0 +1,31 @@
package ktmtrainbot
import (
"git.samuelpua.com/telboon/ktm-train-bot/backend/internal/user"
"github.com/go-chi/chi"
"gorm.io/gorm"
)
type Env struct {
DB *gorm.DB
}
func KTMTrainBotRoutes(db *gorm.DB) chi.Router {
var env Env
env.DB = db
// Start running job
go env.BackgroundJobRunner()
userEnv := user.NewUserEnv(db)
r := chi.NewRouter()
checkLoggedInUserGroup := r.Group(nil)
r.Get("/current-time", env.getCurrentTime)
checkLoggedInUserGroup.Use(userEnv.CheckUserMiddleware)
checkLoggedInUserGroup.Get("/booking", env.getBookingRoute)
checkLoggedInUserGroup.Post("/booking", env.createBookingRoute)
checkLoggedInUserGroup.Delete("/booking/{bookingID}", env.deleteBookingRoute)
return r
}

12
backend/internal/ktmtrainbot/servertimeresponsemodel.go

@ -0,0 +1,12 @@
package ktmtrainbot
import "net/http"
type ServerTimeResponse struct {
ServerLocalTime string `json:"serverLocalTime"`
}
func (serverTimeResponse *ServerTimeResponse) Render(w http.ResponseWriter, r *http.Request) error {
// Pre-processing before a response is marshalled and sent across the wire
return nil
}

50
backend/internal/user/main.go

@ -0,0 +1,50 @@
package user
import (
"log"
"os"
"strings"
"github.com/go-chi/chi"
"gorm.io/gorm"
)
type Env struct {
DB *gorm.DB
CookieString string
}
func UserRoutes(db *gorm.DB) chi.Router {
var env Env
env.DB = db
env.CookieString = os.Getenv("COOKIE_STRING")
if env.CookieString == "" {
env.CookieString = "cookie_string"
}
r := chi.NewRouter()
allowRegistration := os.Getenv("ALLOW_REGISTRATION")
if strings.ToUpper(allowRegistration) == "TRUE" || allowRegistration == "1" {
log.Println("Registration enabled.")
r.Post("/register", env.registerRouteHandler)
}
r.Post("/login", env.loginRouteHandler)
r.Post("/logout", env.logoutRouteHandler)
checkLoggedInUserGroup := r.Group(nil)
checkLoggedInUserGroup.Use(env.CheckUserMiddleware)
checkLoggedInUserGroup.Get("/me", env.meRouteHandler)
checkLoggedInUserGroup.Put("/profile", env.setProfileRouteHandler)
return r
}
func NewUserEnv(db *gorm.DB) *Env {
var env Env
env.DB = db
env.CookieString = os.Getenv("COOKIE_STRING")
if env.CookieString == "" {
env.CookieString = "cooking_string"
}
return &env
}

146
backend/internal/user/main_test.go

@ -0,0 +1,146 @@
package user
import (
"bytes"
"encoding/json"
"io/ioutil"
"net/http"
"net/http/httptest"
"testing"
"git.samuelpua.com/telboon/ktm-train-bot/backend/internal/common"
)
func TestUserRoutesRegistration(t *testing.T) {
testCases := []struct {
allowRegistrationFlag bool
allowRegistrationFlagSet bool
statusCode int
}{
{
allowRegistrationFlag: true,
allowRegistrationFlagSet: true,
statusCode: 201,
},
{
allowRegistrationFlag: false,
allowRegistrationFlagSet: true,
statusCode: 404,
},
{
allowRegistrationFlag: false,
allowRegistrationFlagSet: false,
statusCode: 404,
},
}
db := common.TestDBInit()
defer common.DestroyTestingDB(db)
db.AutoMigrate(&User{})
db.AutoMigrate(&Profile{})
db.AutoMigrate(&Session{})
for _, currTestCase := range testCases {
if currTestCase.allowRegistrationFlagSet {
if currTestCase.allowRegistrationFlag {
t.Setenv("ALLOW_REGISTRATION", "true")
} else {
t.Setenv("ALLOW_REGISTRATION", "false")
}
} else {
t.Setenv("ALLOW_REGISTRATION", "")
}
router := UserRoutes(db)
rr := httptest.NewRecorder()
reqBody := struct {
Username string `json:"username"`
Password string `json:"password"`
}{
Username: "testusername",
Password: "testpassword",
}
reqBodyBytes, err := json.Marshal(reqBody)
if err != nil {
t.Errorf("Error creating a new request body: %v", err)
}
reqBodyReader := bytes.NewReader(reqBodyBytes)
req, err := http.NewRequest("POST", "/register", reqBodyReader)
if err != nil {
t.Errorf("Error creating a new request: %v", err)
}
router.ServeHTTP(rr, req)
currStatusCode := rr.Result().StatusCode
if currStatusCode != currTestCase.statusCode {
t.Errorf("Wrong status code: Expected - %d. Got - %d", currTestCase.statusCode, rr.Result().StatusCode)
}
}
}
func TestUserRoutesLoggedIn(t *testing.T) {
db := common.TestDBInit()
defer common.DestroyTestingDB(db)
db.AutoMigrate(&User{})
db.AutoMigrate(&Profile{})
db.AutoMigrate(&Session{})
t.Setenv("ALLOW_REGISTRATION", "true")
router := UserRoutes(db)
rr := httptest.NewRecorder()
// Testing /me
req, err := http.NewRequest("GET", "/me", nil)
if err != nil {
t.Errorf("Error creating a new request: %v", err)
}
router.ServeHTTP(rr, req)
if rr.Result().StatusCode != 500 {
t.Errorf("Wrong status code: Expected - %d. Got - %d", 500, rr.Result().StatusCode)
}
responseBytes, err := ioutil.ReadAll(rr.Result().Body)
if err != nil {
t.Errorf("Error reading response body: %v", err)
}
var results map[string]any
err = json.Unmarshal(responseBytes, &results)
if err != nil {
t.Errorf("Error decoding response body: %v", err)
}
if results["error"] != "user not logged in" {
t.Errorf("Error for %s not correct", "/me")
}
// Testing /profile
rr = httptest.NewRecorder()
emptyJSON := bytes.NewReader([]byte("{}"))
req, err = http.NewRequest("PUT", "/profile", emptyJSON)
if err != nil {
t.Errorf("Error creating a new request: %v", err)
}
router.ServeHTTP(rr, req)
if rr.Result().StatusCode != 500 {
t.Errorf("Wrong status code: Expected - %d. Got - %d", 500, rr.Result().StatusCode)
}
responseBytes, err = ioutil.ReadAll(rr.Result().Body)
if err != nil {
t.Errorf("Error reading response body: %v", err)
}
err = json.Unmarshal(responseBytes, &results)
if err != nil {
t.Errorf("Error decoding response body: %v", err)
}
if results["error"] != "user not logged in" {
t.Errorf("Error for %s not correct", "/me")
}
}

29
backend/internal/user/profilecontroller.go

@ -0,0 +1,29 @@
package user
import (
"log"
"gorm.io/gorm/clause"
)
func (env *Env) setProfile(currUser *User, ktmTrainUsername string, ktmTrainPassword string, ktmTrainCreditCardType string, ktmTrainCreditCard string, ktmTrainCreditCardExpiry string, ktmTrainCreditCardCVV string) (*User, error) {
profile := &Profile{
UserID: currUser.ID,
KtmTrainUsername: ktmTrainUsername,
KtmTrainPassword: ktmTrainPassword,
KtmTrainCreditCardType: ktmTrainCreditCardType,
KtmTrainCreditCard: ktmTrainCreditCard,
KtmTrainCreditCardExpiry: ktmTrainCreditCardExpiry,
KtmTrainCreditCardCVV: ktmTrainCreditCardCVV,
}
if err := env.DB.Clauses(clause.OnConflict{
UpdateAll: true,
}).Create(profile).Error; err != nil {
log.Println("Error creating profile", err)
return nil, err
}
currUser.Profile = *profile
return currUser, nil
}

52
backend/internal/user/profilemodel.go

@ -0,0 +1,52 @@
package user
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type Profile struct {
ID uuid.UUID `gorm:"primaryKey;type:uuid;default:uuid_generate_v4()"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
UserID uuid.UUID `gorm:"index"`
KtmTrainUsername string
KtmTrainPassword string
KtmTrainCreditCardType string // Visa/Mastercard
KtmTrainCreditCard string
KtmTrainCreditCardExpiry string
KtmTrainCreditCardCVV string
}
type ProfileRequest struct {
KtmTrainUsername string `json:"ktmTrainUsername"`
KtmTrainPassword string `json:"ktmTrainPassword"`
KtmTrainCreditCardType string `json:"ktmTrainCreditCardType"`
KtmTrainCreditCard string `json:"ktmTrainCreditCard"`
KtmTrainCreditCardExpiry string `json:"ktmTrainCreditCardExpiry"`
KtmTrainCreditCardCVV string `json:"ktmTrainCreditCardCVV"`
}
type ProfileResponse struct {
KtmTrainUsername string `json:"ktmTrainUsername"`
KtmTrainPassword string `json:"ktmTrainPassword"`
KtmTrainCreditCardType string `json:"ktmTrainCreditCardType"`
KtmTrainCreditCard string `json:"ktmTrainCreditCard"`
KtmTrainCreditCardExpiry string `json:"ktmTrainCreditCardExpiry"`
KtmTrainCreditCardCVV string `json:"ktmTrainCreditCardCVV"`
}
func (env *Env) NewProfileResponse(model *Profile) *ProfileResponse {
res := &ProfileResponse{
KtmTrainUsername: model.KtmTrainUsername,
KtmTrainPassword: model.KtmTrainPassword,
KtmTrainCreditCardType: model.KtmTrainCreditCardType,
KtmTrainCreditCard: model.KtmTrainCreditCard,
KtmTrainCreditCardExpiry: model.KtmTrainCreditCardExpiry,
KtmTrainCreditCardCVV: model.KtmTrainCreditCardCVV,
}
return res
}

45
backend/internal/user/profileroute.go

@ -0,0 +1,45 @@
package user
import (
"errors"
"net/http"
"git.samuelpua.com/telboon/ktm-train-bot/backend/internal/common"
"github.com/go-chi/render"
)
// Set current user profile
// @Summary For setting current user profile
// @Description Description
// @Tags User
// @Accept json
// @Produce json
// @Param user body ProfileRequest true "User registration info"
// @Success 200 {object} UserResponse
// @Failure 400 {object} common.ErrResponse
// @Router /api/v1/user/profile [put]
func (env *Env) setProfileRouteHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
data := &ProfileRequest{}
err := render.DecodeJSON(r.Body, data)
if err != nil {
render.Render(w, r, common.ErrInvalidRequest(err))
return
}
currUser, ok := ctx.Value(UserContextKey).(*User)
if !ok {
err := errors.New("user not logged in")
render.Render(w, r, common.ErrInternalError(err))
return
}
currUser, err = env.setProfile(currUser, data.KtmTrainUsername, data.KtmTrainPassword, data.KtmTrainCreditCardType, data.KtmTrainCreditCard, data.KtmTrainCreditCardExpiry, data.KtmTrainCreditCardCVV)
if err != nil {
render.Render(w, r, common.ErrInternalError(err))
return
}
render.Render(w, r, env.NewUserResponse(currUser))
}

174
backend/internal/user/profileroute_test.go

@ -0,0 +1,174 @@
package user
import (
"bytes"
"encoding/json"
"io/ioutil"
"net/http/httptest"
"testing"
"git.samuelpua.com/telboon/ktm-train-bot/backend/internal/common"
)
func TestSetProfile(t *testing.T) {
db := common.TestDBInit()
defer common.DestroyTestingDB(db)
db.AutoMigrate(&User{})
db.AutoMigrate(&Profile{})
db.AutoMigrate(&Session{})
t.Setenv("ALLOW_REGISTRATION", "true")
t.Setenv("COOKIE_STRING", "supercustomcookie")
router := UserRoutes(db)
testCases := []struct {
ktmTrainUsername string
ktmTrainPassword string
ktmTrainCreditCardType string
ktmTrainCreditCard string
ktmTrainCreditCardExpiry string
ktmTrainCreditCardCVV string
statusCode int
cookieEnabled bool
}{
{
ktmTrainUsername: "test",
ktmTrainPassword: "test",
ktmTrainCreditCardType: "Visa",
ktmTrainCreditCard: "1234567890123456",
ktmTrainCreditCardExpiry: "05/2025",
ktmTrainCreditCardCVV: "123",
statusCode: 200,
cookieEnabled: true,
},
{
ktmTrainUsername: "",
ktmTrainPassword: "",
ktmTrainCreditCardType: "",
ktmTrainCreditCard: "",
ktmTrainCreditCardExpiry: "",
ktmTrainCreditCardCVV: "",
statusCode: 200,
cookieEnabled: true,
},
{
ktmTrainUsername: "test",
ktmTrainPassword: "test",
ktmTrainCreditCardType: "Visa",
ktmTrainCreditCard: "1234567890123456",
ktmTrainCreditCardExpiry: "05/2025",
ktmTrainCreditCardCVV: "123",
statusCode: 500,
cookieEnabled: false,
},
}
// Register user
rr := httptest.NewRecorder()
currBody := struct {
Username string `json:"username"`
Password string `json:"password"`
}{
Username: "testusername",
Password: "testpassword",
}
reqBody, err := json.Marshal(currBody)
if err != nil {
t.Errorf("Error creating a new request body: %v", err)
}
reqBodyReader := bytes.NewReader(reqBody)
req := httptest.NewRequest("POST", "/register", reqBodyReader)
router.ServeHTTP(rr, req)
// Check registration results
if rr.Code != 201 {
t.Errorf("Expected status code %d, got %d", 201, rr.Code)
}
// Login to get cookie
rr = httptest.NewRecorder()
currBodyLogin := struct {
Username string `json:"username"`
Password string `json:"password"`
}{
Username: "testusername",
Password: "testpassword",
}
reqBody, err = json.Marshal(currBodyLogin)
if err != nil {
t.Errorf("Error creating a new request body: %v", err)
}
reqBodyReader = bytes.NewReader(reqBody)
req = httptest.NewRequest("POST", "/login", reqBodyReader)
router.ServeHTTP(rr, req)
for _, currentTestCase := range testCases {
// Start checking Profile
rrProfile := httptest.NewRecorder()
currBody := struct {
KtmTrainUsername string `json:"ktmTrainUsername"`
KtmTrainPassword string `json:"ktmTrainPassword"`
KtmTrainCreditCardType string `json:"ktmTrainCreditCardType"`
KtmTrainCreditCard string `json:"ktmTrainCreditCard"`
KtmTrainCreditCardExpiry string `json:"ktmTrainCreditCardExpiry"`
KtmTrainCreditCardCVV string `json:"ktmTrainCreditCardCVV"`
}{
KtmTrainUsername: currentTestCase.ktmTrainUsername,
KtmTrainPassword: currentTestCase.ktmTrainPassword,
KtmTrainCreditCardType: currentTestCase.ktmTrainCreditCardType,
KtmTrainCreditCard: currentTestCase.ktmTrainCreditCard,
KtmTrainCreditCardExpiry: currentTestCase.ktmTrainCreditCardExpiry,
KtmTrainCreditCardCVV: currentTestCase.ktmTrainCreditCardCVV,
}
reqBody, err = json.Marshal(currBody)
if err != nil {
t.Errorf("Error creating a new request body: %v", err)
}
reqBodyReader = bytes.NewReader(reqBody)
req = httptest.NewRequest("PUT", "/profile", reqBodyReader)
if currentTestCase.cookieEnabled {
req.AddCookie(rr.Result().Cookies()[0])
}
router.ServeHTTP(rrProfile, req)
// Check Profile results
if rrProfile.Code != currentTestCase.statusCode {
t.Errorf("Expected status code %d, got %d", currentTestCase.statusCode, rrProfile.Code)
}
if currentTestCase.statusCode == 200 {
var resultsObj map[string]any
resBodyBytes, err := ioutil.ReadAll(rrProfile.Body)
if err != nil {
t.Errorf("Error reading response body: %v", err)
}
err = json.Unmarshal(resBodyBytes, &resultsObj)
if err != nil {
t.Errorf("Error unmarshalling response body: %v", err)
}
resultsProfile := resultsObj["profile"].(map[string]any)
if resultsObj["username"] != "testusername" {
t.Errorf("Expected username %s, got %s", "testusername", resultsObj["username"])
}
if resultsProfile["ktmTrainUsername"] != currentTestCase.ktmTrainUsername {
t.Errorf("Expected ktmTrainUsername %s, got %s", currentTestCase.ktmTrainUsername, resultsProfile["ktmTrainUsername"])
}
if resultsProfile["ktmTrainPassword"] != currentTestCase.ktmTrainPassword {
t.Errorf("Expected ktmTrainPassword %s, got %s", currentTestCase.ktmTrainPassword, resultsProfile["ktmTrainPassword"])
}
if resultsProfile["ktmTrainCreditCardType"] != currentTestCase.ktmTrainCreditCardType {
t.Errorf("Expected ktmTrainCreditCardType %s, got %s", currentTestCase.ktmTrainCreditCardType, resultsProfile["ktmTrainCreditCardType"])
}
if resultsProfile["ktmTrainCreditCard"] != currentTestCase.ktmTrainCreditCard {
t.Errorf("Expected ktmTrainCreditCard %s, got %s", currentTestCase.ktmTrainCreditCard, resultsProfile["ktmTrainCreditCard"])
}
if resultsProfile["ktmTrainCreditCardExpiry"] != currentTestCase.ktmTrainCreditCardExpiry {
t.Errorf("Expected ktmTrainCreditCardExpiry %s, got %s", currentTestCase.ktmTrainCreditCardExpiry, resultsProfile["ktmTrainCreditCardExpiry"])
}
if resultsProfile["ktmTrainCreditCardCVV"] != currentTestCase.ktmTrainCreditCardCVV {
t.Errorf("Expected ktmTrainCreditCardCVV %s, got %s", currentTestCase.ktmTrainCreditCardCVV, resultsProfile["ktmTrainCreditCardCVV"])
}
}
}
}

56
backend/internal/user/sessioncontroller.go

@ -0,0 +1,56 @@
package user
import (
"errors"
"log"
"github.com/google/uuid"
)
func (env *Env) createSession(user *User) (string, error) {
var newSession Session
newSession.UserID = user.ID
newSession.SessionToken = uuid.New().String()
err := env.DB.Create(&newSession).Error
if err != nil {
log.Println(err)
return "", errors.New("failed write to database")
}
return newSession.SessionToken, nil
}
func (env *Env) getUserFromSessionToken(sessionToken string) (*User, error) {
var currUser User
err := env.DB.Table("sessions").Select("users.*").Joins("left join users on users.id = sessions.user_id").Where("sessions.session_token = ?", sessionToken).First(&currUser).Error
if err != nil {
log.Println(err)
return nil, errors.New("failed get user")
}
err = env.DB.Preload("Profile").Where(&currUser).First(&currUser).Error
if err != nil {
log.Println(err)
return nil, errors.New("failed get user")
}
return &currUser, nil
}
func (env *Env) logout(sessionToken string) error {
var currSession Session
err := env.DB.Where(&Session{SessionToken: sessionToken}).First(&currSession).Error
if err != nil {
log.Println(err)
return errors.New("failed get session")
}
err = env.DB.Delete(&currSession).Error
if err != nil {
log.Println(err)
return errors.New("failed to logout")
}
return nil
}

29
backend/internal/user/sessionmiddleware.go

@ -0,0 +1,29 @@
package user
import (
"context"
"errors"
"net/http"
"git.samuelpua.com/telboon/ktm-train-bot/backend/internal/common"
"github.com/go-chi/render"
)
func (env *Env) CheckUserMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(env.CookieString)
if err != nil {
err = errors.New("user not logged in")
render.Render(w, r, common.ErrInternalError(err))
return
}
user, err := env.getUserFromSessionToken(cookie.Value)
if err != nil {
render.Render(w, r, common.ErrInternalError(err))
return
}
ctx := context.WithValue(r.Context(), UserContextKey, user)
next.ServeHTTP(w, r.WithContext(ctx))
})
}

7
backend/internal/user/sessionmiddlewarecontext.go

@ -0,0 +1,7 @@
package user
const (
UserContextKey ContextKey = "user"
)
type ContextKey string

17
backend/internal/user/sessionmodel.go

@ -0,0 +1,17 @@
package user
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type Session struct {
ID uuid.UUID `gorm:"primaryKey;type:uuid;default:uuid_generate_v4()"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
SessionToken string
UserID uuid.UUID
}

71
backend/internal/user/usercontroller.go

@ -0,0 +1,71 @@
package user
import (
"errors"
"log"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
const (
BCRYPTCOST = 12
)
func (env *Env) createUser(username string, password string) (*User, error) {
var createdUser User
passwordByte := []byte(password)
createdHashBytes, err := bcrypt.GenerateFromPassword(passwordByte, BCRYPTCOST)
if err != nil {
return nil, errors.New("failed to generate bcrypt")
}
createdUser.Username = username
createdUser.PasswordBcrypt = string(createdHashBytes)
return &createdUser, nil
}
func (env *Env) registerUser(username string, password string) (*User, error) {
newUser, err := env.createUser(username, password)
if err != nil {
log.Println(err)
return nil, errors.New("failed to register user")
}
// Check existing username
var checkUser User
env.DB.Where(&User{Username: username}).First(&checkUser)
if checkUser.ID != uuid.Nil {
log.Println(err)
return nil, errors.New("user already exists")
}
err = env.DB.Create(newUser).Error
if err != nil {
log.Println(err)
return nil, errors.New("failed write to database")
}
return newUser, nil
}
func (env *Env) checkLogin(username string, password string) (*User, error) {
var currUser User
env.DB.Preload("Profile").Where(&User{Username: username}).First(&currUser)
// Prevent username enum by parsing password
if currUser.ID == uuid.Nil {
bcrypt.GenerateFromPassword([]byte{}, BCRYPTCOST)
return nil, errors.New("invalid username or password")
}
err := bcrypt.CompareHashAndPassword([]byte(currUser.PasswordBcrypt), []byte(password))
if err != nil {
return nil, errors.New("invalid username or password")
} else {
return &currUser, nil
}
}

52
backend/internal/user/usermodel.go

@ -0,0 +1,52 @@
package user
import (
"net/http"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type User struct {
ID uuid.UUID `gorm:"primaryKey;type:uuid;default:uuid_generate_v4()"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
Username string
PasswordBcrypt string
Profile Profile
Sessions []Session
}
type UserResponse struct {
ID uuid.UUID `json:"id"`
Username string `json:"username"`
Profile *ProfileResponse `json:"profile"`
}
type UserRegisterRequest struct {
Username string `json:"username" validate:"required,min=2,max=100"`
Password string `json:"password" validate:"required,min=6,max=100"`
}
type UserLoginRequest struct {
Username string `json:"username" validate:"required,min=2,max=100"`
Password string `json:"password" validate:"required"`
}
func (userResponse *UserResponse) Render(w http.ResponseWriter, r *http.Request) error {
// Pre-processing before a response is marshalled and sent across the wire
return nil
}
func (env *Env) NewUserResponse(user *User) *UserResponse {
profileResponse := env.NewProfileResponse(&user.Profile)
userResponse := &UserResponse{
ID: user.ID,
Username: user.Username,
Profile: profileResponse,
}
return userResponse
}

137
backend/internal/user/userroute.go

@ -0,0 +1,137 @@
package user
import (
"errors"
"net/http"
"git.samuelpua.com/telboon/ktm-train-bot/backend/internal/common"
"github.com/go-chi/render"
"github.com/go-playground/validator/v10"
)
// User Register
// @Summary For user registration
// @Description Description
// @Tags User
// @Accept json
// @Produce json
// @Param user body UserRegisterRequest true "User registration info"
// @Success 200 {object} UserResponse
// @Failure 400 {object} common.ErrResponse
// @Router /api/v1/user/register [post]
func (env *Env) registerRouteHandler(w http.ResponseWriter, r *http.Request) {
data := &UserRegisterRequest{}
err := render.DecodeJSON(r.Body, data)
if err != nil {
render.Render(w, r, common.ErrInvalidRequest(err))
return
}
err = validator.New().Struct(data)
if err != nil {
render.Render(w, r, common.ErrValidationError(err))
return
}
createdUser, err := env.registerUser(data.Username, data.Password)
if err != nil {
render.Render(w, r, common.ErrInternalError(err))
return
}
render.Status(r, http.StatusCreated)
render.Render(w, r, env.NewUserResponse(createdUser))
}
// Login
// @Summary For user login
// @Description Description
// @Tags User
// @Accept json
// @Produce json
// @Param user body UserLoginRequest true "User Login info"
// @Success 200 {object} UserResponse
// @Failure 400 {object} common.ErrResponse
// @Router /api/v1/user/login [post]
func (env *Env) loginRouteHandler(w http.ResponseWriter, r *http.Request) {
data := &UserLoginRequest{}
err := render.DecodeJSON(r.Body, data)
if err != nil {
render.Render(w, r, common.ErrInvalidRequest(err))
return
}
err = validator.New().Struct(data)
if err != nil {
render.Render(w, r, common.ErrValidationError(err))
return
}
loginUser, err := env.checkLogin(data.Username, data.Password)
if err != nil {
render.Render(w, r, common.ErrInternalError(err))
return
}
sessionToken, err := env.createSession(loginUser)
if err != nil {
render.Render(w, r, common.ErrInternalError(err))
return
}
loginCookie := http.Cookie{
Name: env.CookieString,
Value: sessionToken,
MaxAge: 7776000,
Path: "/",
}
http.SetCookie(w, &loginCookie)
render.Render(w, r, env.NewUserResponse(loginUser))
}
// Logout
// @Summary For user logout
// @Description Description
// @Tags User
// @Accept json
// @Produce json
// @Success 200 {object} common.TextResponse
// @Failure 400 {object} common.ErrResponse
// @Router /api/v1/user/logout [post]
func (env *Env) logoutRouteHandler(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(env.CookieString)
if err != nil {
err = errors.New("user not logged in")
render.Render(w, r, common.ErrInternalError(err))
return
}
err = env.logout(cookie.Value)
if err != nil {
render.Render(w, r, common.ErrInternalError(err))
return
}
render.Render(w, r, common.NewGenericTextResponse("Ok", "Successfully logged out"))
}
// Check current user
// @Summary Returns current logged in user
// @Description Description
// @Tags User
// @Accept json
// @Produce json
// @Success 200 {object} UserResponse
// @Failure 400 {object} common.ErrResponse
// @Router /api/v1/user/me [get]
func (env *Env) meRouteHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
currUser, ok := ctx.Value(UserContextKey).(*User)
if !ok {
err := errors.New("user not logged in")
render.Render(w, r, common.ErrInternalError(err))
return
}
render.Render(w, r, env.NewUserResponse(currUser))
}

270
backend/internal/user/userroute_test.go

@ -0,0 +1,270 @@
package user
import (
"bytes"
"encoding/json"
"io/ioutil"
"net/http/httptest"
"testing"
"git.samuelpua.com/telboon/ktm-train-bot/backend/internal/common"
)
func TestRegistration(t *testing.T) {
db := common.TestDBInit()
defer common.DestroyTestingDB(db)
db.AutoMigrate(&User{})
db.AutoMigrate(&Profile{})
db.AutoMigrate(&Session{})
t.Setenv("ALLOW_REGISTRATION", "true")
router := UserRoutes(db)
testCases := []struct {
username string
password string
statusCode int
}{
{
username: "testusername",
password: "testpassword",
statusCode: 201,
},
{
username: "",
password: "testpassword",
statusCode: 422,
},
{
username: "testusername",
password: "",
statusCode: 422,
},
{
username: "t",
password: "testpassword",
statusCode: 422,
},
{
username: "testusername",
password: "test",
statusCode: 422,
},
}
for _, currentTestCase := range testCases {
rr := httptest.NewRecorder()
currBody := struct {
Username string `json:"username"`
Password string `json:"password"`
}{
Username: currentTestCase.username,
Password: currentTestCase.password,
}
reqBody, err := json.Marshal(currBody)
if err != nil {
t.Errorf("Error creating a new request body: %v", err)
}
reqBodyReader := bytes.NewReader(reqBody)
req := httptest.NewRequest("POST", "/register", reqBodyReader)
router.ServeHTTP(rr, req)
// Check results
if rr.Code != currentTestCase.statusCode {
t.Errorf("Expected status code %d, got %d", currentTestCase.statusCode, rr.Code)
}
}
}
func TestLogin(t *testing.T) {
db := common.TestDBInit()
defer common.DestroyTestingDB(db)
db.AutoMigrate(&User{})
db.AutoMigrate(&Profile{})
db.AutoMigrate(&Session{})
t.Setenv("ALLOW_REGISTRATION", "true")
t.Setenv("COOKIE_STRING", "supercustomcookie")
router := UserRoutes(db)
testCases := []struct {
username string
password string
statusCode int
}{
{
username: "testusername",
password: "testpassword",
statusCode: 200,
},
{
username: "",
password: "testpassword",
statusCode: 422,
},
{
username: "testusername",
password: "",
statusCode: 422,
},
{
username: "t",
password: "testpassword",
statusCode: 422,
},
{
username: "testusername",
password: "test",
statusCode: 500,
},
{
username: "testusername",
password: "",
statusCode: 422,
},
}
// Register user
rr := httptest.NewRecorder()
currBody := struct {
Username string `json:"username"`
Password string `json:"password"`
}{
Username: "testusername",
Password: "testpassword",
}
reqBody, err := json.Marshal(currBody)
if err != nil {
t.Errorf("Error creating a new request body: %v", err)
}
reqBodyReader := bytes.NewReader(reqBody)
req := httptest.NewRequest("POST", "/register", reqBodyReader)
router.ServeHTTP(rr, req)
// Check registration results
if rr.Code != 201 {
t.Errorf("Expected status code %d, got %d", 201, rr.Code)
}
for _, currentTestCase := range testCases {
// Start checking login
rrLogin := httptest.NewRecorder()
currBody = struct {
Username string `json:"username"`
Password string `json:"password"`
}{
Username: currentTestCase.username,
Password: currentTestCase.password,
}
reqBody, err = json.Marshal(currBody)
if err != nil {
t.Errorf("Error creating a new request body: %v", err)
}
reqBodyReader = bytes.NewReader(reqBody)
req = httptest.NewRequest("POST", "/login", reqBodyReader)
router.ServeHTTP(rrLogin, req)
// Check login results
if rrLogin.Code != currentTestCase.statusCode {
t.Errorf("Expected status code %d, got %d", currentTestCase.statusCode, rrLogin.Code)
}
if rrLogin.Code == 200 {
if rrLogin.Header().Get("Set-Cookie") == "" {
t.Errorf("Expected a cookie to be set, but it wasn't")
}
}
}
}
func TestGetMe(t *testing.T) {
db := common.TestDBInit()
defer common.DestroyTestingDB(db)
db.AutoMigrate(&User{})
db.AutoMigrate(&Profile{})
db.AutoMigrate(&Session{})
t.Setenv("ALLOW_REGISTRATION", "true")
t.Setenv("COOKIE_STRING", "supercustomcookie")
router := UserRoutes(db)
testCases := []struct {
cookieEnabled bool
statusCode int
}{
{
cookieEnabled: true,
statusCode: 200,
},
{
cookieEnabled: false,
statusCode: 500,
},
}
// Register user
rr := httptest.NewRecorder()
currBody := struct {
Username string `json:"username"`
Password string `json:"password"`
}{
Username: "testusername",
Password: "testpassword",
}
reqBody, err := json.Marshal(currBody)
if err != nil {
t.Errorf("Error creating a new request body: %v", err)
}
reqBodyReader := bytes.NewReader(reqBody)
req := httptest.NewRequest("POST", "/register", reqBodyReader)
router.ServeHTTP(rr, req)
// Check registration results
if rr.Code != 201 {
t.Errorf("Expected status code %d, got %d", 201, rr.Code)
}
// Login to get cookie
rr = httptest.NewRecorder()
currBodyLogin := struct {
Username string `json:"username"`
Password string `json:"password"`
}{
Username: "testusername",
Password: "testpassword",
}
reqBody, err = json.Marshal(currBodyLogin)
if err != nil {
t.Errorf("Error creating a new request body: %v", err)
}
reqBodyReader = bytes.NewReader(reqBody)
req = httptest.NewRequest("POST", "/login", reqBodyReader)
router.ServeHTTP(rr, req)
for _, currentTestCase := range testCases {
// Start checking Profile
rrProfile := httptest.NewRecorder()
if err != nil {
t.Errorf("Error creating a new request body: %v", err)
}
req = httptest.NewRequest("GET", "/me", nil)
if currentTestCase.cookieEnabled {
req.AddCookie(rr.Result().Cookies()[0])
}
router.ServeHTTP(rrProfile, req)
// Check Profile results
if rrProfile.Code != currentTestCase.statusCode {
t.Errorf("Expected status code %d, got %d", currentTestCase.statusCode, rrProfile.Code)
}
if currentTestCase.statusCode == 200 {
var resultsObj map[string]any
resBodyBytes, err := ioutil.ReadAll(rrProfile.Body)
if err != nil {
t.Errorf("Error reading response body: %v", err)
}
err = json.Unmarshal(resBodyBytes, &resultsObj)
if err != nil {
t.Errorf("Error unmarshalling response body: %v", err)
}
if resultsObj["username"] != "testusername" {
t.Errorf("Expected username %s, got %s", "testusername", resultsObj["username"])
}
}
}
}

29
docker-compose.yml

@ -0,0 +1,29 @@
version: '3'
services:
#######################################
# KTM Train Booking Bot
#######################################
ktm-train-bot:
restart: always
build:
context: .
dockerfile: docker/Dockerfile
environment:
- "TZ=Asia/Singapore"
- "LOGGER_WEBHOOK_URL=${LOGGER_WEBHOOK_URL}"
ports:
- "127.0.0.1:8007:8000"
entrypoint: ["/bin/bash", "-c", "/app/server 2>&1 | /app/messenger --webhook --url $LOGGER_WEBHOOK_URL"]
#######################################
# Postgres server
#######################################
postgres-ktm-train-bot:
image: postgres
restart: always
volumes:
- "./_docker_mnt/_postgres_data:/var/lib/postgresql/data"
environment:
- "POSTGRES_USER=${DB_USER}"
- "POSTGRES_PASSWORD=${DB_PASS}"
- "POSTGRES_DB=${DB_NAME}"

39
docker/Dockerfile

@ -0,0 +1,39 @@
FROM golang:1.19-buster as go-builder
COPY . /build
WORKDIR /build/
RUN /build/scripts/build.sh
RUN git clone https://git.samuelpua.com/telboon/messenger /messenger
WORKDIR /messenger
RUN go build git.samuelpua.com/telboon/messenger/cmd/messenger
FROM ubuntu
ENV debian_frontend=noninteractive
WORKDIR /app/
RUN apt update
RUN apt install -y ca-certificates
RUN apt install -y tzdata
RUN apt install -y wget
RUN ln -fs /usr/share/zoneinfo/Asia/Singapore /etc/localtime
RUN dpkg-reconfigure --frontend noninteractive tzdata
# Install Chrome
RUN wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
RUN apt install -y ./google-chrome-stable_current_amd64.deb
COPY --from=go-builder /messenger/messenger /app/messenger
COPY --from=go-builder /build/backend/server /app/server
COPY .env /app/.env
RUN useradd -ms /bin/bash bot
USER bot
ENV TZ="Asia/Singapore"
RUN date
ENTRYPOINT ["/app/server"]

16
scripts/build.sh

@ -0,0 +1,16 @@
#! /bin/bash
# Install goswag
go install github.com/swaggo/swag/cmd/swag@v1.8.4
# go to backend directory
cd backend
# rebuild swagger docs
swag init --dir cmd/server/ --parseDependency
# build binary
go build git.samuelpua.com/telboon/ktm-train-bot/backend/cmd/server
# go to base directory
cd ..

7
scripts/deploy.sh

@ -0,0 +1,7 @@
#!/bin/bash
echo $ATHENA_DEPLOYMENT_SSH_KEY | base64 -d > /tmp/ssh-key
chmod 600 /tmp/ssh-key
rsync -v -e "ssh -o StrictHostKeyChecking=no -i /tmp/ssh-key -p 777" -a --exclude="_docker_mnt/_postgres_data" --delete . samuel@athena.gaia:~/ktm-booking-bot || true
ssh -p 777 -o StrictHostKeyChecking=no -i /tmp/ssh-key samuel@athena.gaia "cd /home/samuel/ktm-booking-bot && docker-compose up --build -d"
rm /tmp/ssh-key

9
scripts/dev_postgres_docker.sh

@ -0,0 +1,9 @@
docker rm -f dev_postgres
docker run -d \
-v `pwd`/_docker_mnt/_postgres_data:/var/lib/postgresql/data \
-e POSTGRES_USER=testuser \
-e POSTGRES_PASSWORD=testpassword \
-e POSTGRES_DB=testdb \
--name dev_postgres \
-p 127.0.0.1:5432:5432 \
postgres:14

15
scripts/test.sh

@ -0,0 +1,15 @@
#! /bin/bash
# source .env
set -a
source .env
# go to backend directory
cd backend
# build binary
go test -cover ./...
# go to base directory
cd ..
Loading…
Cancel
Save