diff --git a/cmd/main.go b/cmd/main.go index 2e0c742..ebc19fa 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -8,18 +8,34 @@ import ( "github.com/SamuelTariku/FortuneBet-Backend/internal/config" customlogger "github.com/SamuelTariku/FortuneBet-Backend/internal/logger" + mockemail "github.com/SamuelTariku/FortuneBet-Backend/internal/mocks/mock_email" + mocksms "github.com/SamuelTariku/FortuneBet-Backend/internal/mocks/mock_sms" "github.com/SamuelTariku/FortuneBet-Backend/internal/repository" - notificationservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/notfication" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/authentication" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/bet" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/ticket" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/user" httpserver "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server" - "github.com/joho/godotenv" + jwtutil "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/jwt" + customvalidator "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/validator" + "github.com/go-playground/validator/v10" + notificationservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/notfication" ) +// @title FortuneBet API +// @version 1.0 +// @description This is server for FortuneBet. +// @termsOfService http://swagger.io/terms/ +// @contact.name API Support +// @contact.url http://www.swagger.io/support +// @contact.email support@swagger.io +// @license.name Apache 2.0 +// @license.url http://www.apache.org/licenses/LICENSE-2.0.html +// @SecurityDefinitions.apiKey Bearer +// @in header +// @name Authorization +// @BasePath / func main() { - err := godotenv.Load() - if err != nil { - slog.Error(err.Error()) - os.Exit(1) - } cfg, err := config.NewConfig() if err != nil { slog.Error(err.Error()) @@ -28,18 +44,34 @@ func main() { db, _, err := repository.OpenDB(cfg.DbUrl) if err != nil { - fmt.Print(err) + fmt.Print("db", err) os.Exit(1) } - - logger := customlogger.NewLogger("development", slog.LevelDebug, "1.0") - + logger := customlogger.NewLogger(cfg.Env, cfg.LogLevel) store := repository.NewStore(db) + v := customvalidator.NewCustomValidator(validator.New()) + + authSvc := authentication.NewService(store, store, cfg.RefreshExpiry) + mockSms := mocksms.NewMockSMS() + mockemail := mockemail.NewMockEmail() + + userSvc := user.NewService(store, store, mockSms, mockemail) + ticketSvc := ticket.NewService(store) + betSvc := bet.NewService(store) + notificationRepo := repository.NewNotificationRepository(store) notificationSvc := notificationservice.New(notificationRepo, logger) - app := httpserver.NewApp(cfg.Port, logger, notificationSvc) + app := httpserver.NewApp(cfg.Port, v, authSvc, logger, jwtutil.JwtConfig{ + JwtAccessKey: cfg.JwtKey, + JwtAccessExpiry: cfg.AccessExpiry, + }, userSvc, ticketSvc, betSvc, notificationSvc, + ) + logger.Info("Starting server", "port", cfg.Port) + if err := app.Run(); err != nil { - log.Fatal("Failed to start server with error: ", err) + logger.Error("Failed to start server", "error", err) + os.Exit(1) } + } diff --git a/db/migrations/000001_fortune.down.sql b/db/migrations/000001_fortune.down.sql index 489466f..2109826 100644 --- a/db/migrations/000001_fortune.down.sql +++ b/db/migrations/000001_fortune.down.sql @@ -72,3 +72,8 @@ DROP TABLE IF EXISTS ussd_account; DROP TYPE IF EXISTS ua_pin_status; DROP TYPE IF EXISTS ua_status; DROP TYPE IF EXISTS ua_registaration_type; + +-- Drop FortuneBet +DROP TABLE IF EXIST tickets; +DROP TABLE IF EXIST bets; + diff --git a/db/migrations/000001_fortune.up.sql b/db/migrations/000001_fortune.up.sql index 21db22c..322bed9 100644 --- a/db/migrations/000001_fortune.up.sql +++ b/db/migrations/000001_fortune.up.sql @@ -2,11 +2,101 @@ CREATE TABLE IF NOT EXISTS users ( id BIGSERIAL PRIMARY KEY, first_name VARCHAR(255) NOT NULL, last_name VARCHAR(255) NOT NULL, - email VARCHAR(255) UNIQUE NOT NULL, - phone_number VARCHAR(20) UNIQUE NOT NULL, - password TEXT NOT NULL, + email VARCHAR(255) UNIQUE , + phone_number VARCHAR(20) UNIQUE, role VARCHAR(50) NOT NULL, - verified BOOLEAN DEFAULT FALSE, - created_at TIMESTAMP, - updated_at TIMESTAMP + password BYTEA NOT NULL, + email_verified BOOLEAN NOT NULL DEFAULT FALSE, + phone_verified BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ , + -- + suspended_at TIMESTAMPTZ NULL, -- this can be NULL if the user is not suspended + suspended BOOLEAN NOT NULL DEFAULT FALSE, + CHECK (email IS NOT NULL OR phone_number IS NOT NULL) ); +CREATE TABLE refresh_tokens ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, + token TEXT NOT NULL UNIQUE, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP NOT NULL, + revoked BOOLEAN DEFAULT FALSE NOT NULL, + CONSTRAINT unique_token UNIQUE (token) +); +----- + CREATE TABLE otps ( + id BIGSERIAL PRIMARY KEY, + sent_to VARCHAR(255) NOT NULL, + medium VARCHAR(50) NOT NULL, + otp_for VARCHAR(50) NOT NULL, + otp VARCHAR(10) NOT NULL, + used BOOLEAN NOT NULL DEFAULT FALSE, + used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMPTZ NOT NULL +); + +CREATE TABLE IF NOT EXISTS bets ( + id BIGSERIAL PRIMARY KEY, + amount BIGINT NOT NULL, + total_odds REAL NOT NULL, + status INT NOT NULL, + full_name VARCHAR(255) NOT NULL, + phone_number VARCHAR(255) NOT NULL, + branch_id BIGINT, + user_id BIGINT, + cashed_out BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP, + updated_at TIMESTAMP, + is_shop_bet BOOLEAN NOT NULL, + CHECK (user_id IS NOT NULL OR branch_id IS NOT NULL) +); + +CREATE TABLE IF NOT EXISTS tickets ( + id BIGSERIAL PRIMARY KEY, + amount BIGINT NULL, + total_odds REAL NOT NULL, + created_at TIMESTAMP, + updated_at TIMESTAMP +); + + +-- CREATE TABLE IF NOT EXISTS bet_outcomes ( +-- id BIGSERIAL PRIMARY KEY, +-- bet_id BIGINT NOT NULL, +-- outcome_id BIGINT NOT NULL, +-- ); + +-- CREATE TABLE IF NOT EXISTS ticket_outcomes ( +-- id BIGSERIAL PRIMARY KEY, +-- ticket_id BIGINT NOT NULL, +-- outcome_id BIGINT NOT NULL, +-- ); + +----------------------------------------------seed data------------------------------------------------------------- +-------------------------------------- DO NOT USE IN PRODUCTION------------------------------------------------- + +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +INSERT INTO users ( + first_name, last_name, email, phone_number, password, role, + email_verified, phone_verified, created_at, updated_at, + suspended_at, suspended +) VALUES ( + 'John', + 'Doe', + 'john.doe@example.com', + NULL, + crypt('password123', gen_salt('bf'))::bytea, + 'customer', + TRUE, + FALSE, + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP, + NULL, + FALSE +); + + + diff --git a/db/query/auth.sql b/db/query/auth.sql new file mode 100644 index 0000000..71e45ec --- /dev/null +++ b/db/query/auth.sql @@ -0,0 +1,16 @@ +-- name: GetUserByEmailPhone :one +SELECT * FROM users +WHERE email = $1 OR phone_number = $2; + +-- name: CreateRefreshToken :exec +INSERT INTO refresh_tokens (user_id, token, expires_at, created_at, revoked) +VALUES ($1, $2, $3, $4, $5); + +-- name: GetRefreshToken :one +SELECT * FROM refresh_tokens +WHERE token = $1; + +-- name: RevokeRefreshToken :exec +UPDATE refresh_tokens +SET revoked = TRUE +WHERE token = $1; \ No newline at end of file diff --git a/db/query/bet.sql b/db/query/bet.sql new file mode 100644 index 0000000..bf0d466 --- /dev/null +++ b/db/query/bet.sql @@ -0,0 +1,16 @@ +-- name: CreateBet :one +INSERT INTO bets (amount, total_odds, status, full_name, phone_number, branch_id, user_id, is_shop_bet) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8) +RETURNING *; + +-- name: GetAllBets :many +SELECT * FROM bets; + +-- name: GetBetByID :one +SELECT * FROM bets WHERE id = $1; + +-- name: UpdateCashOut :exec +UPDATE bets SET cashed_out = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $1; + +-- name: DeleteBet :exec +DELETE FROM bets WHERE id = $1; diff --git a/db/query/otp.sql b/db/query/otp.sql new file mode 100644 index 0000000..c2bd2e7 --- /dev/null +++ b/db/query/otp.sql @@ -0,0 +1,14 @@ +-- name: CreateOtp :exec +INSERT INTO otps (sent_to, medium, otp_for, otp, used, created_at, expires_at) +VALUES ($1, $2, $3, $4, FALSE, $5, $6); + +-- name: GetOtp :one +SELECT id, sent_to, medium, otp_for, otp, used, used_at, created_at, expires_at +FROM otps +WHERE sent_to = $1 AND otp_for = $2 AND medium = $3 +ORDER BY created_at DESC LIMIT 1; + +-- name: MarkOtpAsUsed :exec +UPDATE otps +SET used = TRUE, used_at = $2 +WHERE id = $1; \ No newline at end of file diff --git a/db/query/ticket.sql b/db/query/ticket.sql new file mode 100644 index 0000000..04be763 --- /dev/null +++ b/db/query/ticket.sql @@ -0,0 +1,16 @@ +-- name: CreateTicket :one +INSERT INTO tickets (amount, total_odds) +VALUES ($1, $2) +RETURNING *; + +-- name: GetAllTickets :many +SELECT * FROM tickets; + +-- name: GetTicketByID :one +SELECT * FROM tickets WHERE id = $1; + +-- name: DeleteTicket :exec +DELETE FROM tickets WHERE id = $1; + +-- name: DeleteOldTickets :exec +Delete from tickets where created_at < now() - interval '1 day'; diff --git a/db/query/user.sql b/db/query/user.sql index 1d356e9..04cbf84 100644 --- a/db/query/user.sql +++ b/db/query/user.sql @@ -1,16 +1,42 @@ -- name: CreateUser :one -INSERT INTO users (first_name, last_name, email, phone_number, password, role, verified) -VALUES ($1, $2, $3, $4, $5, $6, $7) -RETURNING *; + +INSERT INTO users (first_name, last_name, email, phone_number, role, password, email_verified, phone_verified, created_at, updated_at) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) +RETURNING id, first_name, last_name, email, phone_number, role, email_verified, phone_verified, created_at, updated_at; -- name: GetUserByID :one -SELECT * FROM users WHERE id = $1; +SELECT * +FROM users +WHERE id = $1; -- name: GetAllUsers :many -SELECT * FROM users; +SELECT id, first_name, last_name, email, phone_number, role, email_verified, phone_verified, created_at, updated_at +FROM users; -- name: UpdateUser :exec -UPDATE users SET first_name = $2, last_name = $3, email = $4, phone_number = $5, password = $6, role = $7, verified = $8, updated_at = CURRENT_TIMESTAMP WHERE id = $1; +UPDATE users +SET first_name = $1, last_name = $2, email = $3, phone_number = $4, role = $5, updated_at = $6 +WHERE id = $7; -- name: DeleteUser :exec -DELETE FROM users WHERE id = $1; +DELETE FROM users +WHERE id = $1; + +-- name: CheckPhoneEmailExist :one +SELECT + EXISTS (SELECT 1 FROM users WHERE users.phone_number = $1 AND users.phone_number IS NOT NULL) AS phone_exists, + EXISTS (SELECT 1 FROM users WHERE users.email = $2 AND users.email IS NOT NULL) AS email_exists; +-- name: GetUserByEmail :one +SELECT id, first_name, last_name, email, phone_number, role, email_verified, phone_verified, created_at, updated_at +FROM users +WHERE email = $1; + +-- name: GetUserByPhone :one +SELECT id, first_name, last_name, email, phone_number, role, email_verified, phone_verified, created_at, updated_at +FROM users +WHERE phone_number = $1; + +-- name: UpdatePassword :exec +UPDATE users +SET password = $1, updated_at = $4 +WHERE (email = $2 OR phone_number = $3); \ No newline at end of file diff --git a/docs/docs.go b/docs/docs.go new file mode 100644 index 0000000..49956c9 --- /dev/null +++ b/docs/docs.go @@ -0,0 +1,1226 @@ +// Package docs Code generated by swaggo/swag. DO NOT EDIT +package docs + +import "github.com/swaggo/swag" + +const docTemplate = `{ + "schemes": {{ marshal .Schemes }}, + "swagger": "2.0", + "info": { + "description": "{{escape .Description}}", + "title": "{{.Title}}", + "termsOfService": "http://swagger.io/terms/", + "contact": { + "name": "API Support", + "url": "http://www.swagger.io/support", + "email": "support@swagger.io" + }, + "license": { + "name": "Apache 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.html" + }, + "version": "{{.Version}}" + }, + "host": "{{.Host}}", + "basePath": "{{.BasePath}}", + "paths": { + "/auth/login": { + "post": { + "description": "Login customer", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Login customer", + "parameters": [ + { + "description": "Login customer", + "name": "login", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.loginCustomerReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.loginCustomerRes" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, + "/auth/logout": { + "post": { + "description": "Logout customer", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Logout customer", + "parameters": [ + { + "description": "Logout customer", + "name": "logout", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.logoutReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, + "/auth/refresh": { + "post": { + "description": "Refresh token", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Refresh token", + "parameters": [ + { + "description": "tokens", + "name": "refresh", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.refreshToken" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.loginCustomerRes" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, + "/bet": { + "get": { + "description": "Gets all the bets", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "bet" + ], + "summary": "Gets all bets", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/handlers.BetRes" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + }, + "post": { + "description": "Creates a bet", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "bet" + ], + "summary": "Create a bet", + "parameters": [ + { + "description": "Creates bet", + "name": "createBet", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.CreateBetReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.BetRes" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, + "/bet/{id}": { + "get": { + "description": "Gets a single bet by id", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "bet" + ], + "summary": "Gets bet by id", + "parameters": [ + { + "type": "integer", + "description": "Bet ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.BetRes" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + }, + "delete": { + "description": "Deletes bet by id", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "bet" + ], + "summary": "Deletes bet by id", + "parameters": [ + { + "type": "integer", + "description": "Bet ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + }, + "patch": { + "description": "Updates the cashed out field", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "bet" + ], + "summary": "Updates the cashed out field", + "parameters": [ + { + "type": "integer", + "description": "Bet ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Updates Cashed Out", + "name": "updateCashOut", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.UpdateCashOutReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, + "/ticket": { + "get": { + "description": "Retrieve all tickets", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "ticket" + ], + "summary": "Get all tickets", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/handlers.TicketRes" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + }, + "post": { + "description": "Creates a temporary ticket", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "ticket" + ], + "summary": "Create a temporary ticket", + "parameters": [ + { + "description": "Creates ticket", + "name": "createTicket", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.CreateTicketReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.CreateTicketRes" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, + "/ticket/{id}": { + "get": { + "description": "Retrieve ticket details by ticket ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "ticket" + ], + "summary": "Get ticket by ID", + "parameters": [ + { + "type": "integer", + "description": "Ticket ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.TicketRes" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, + "/user/checkPhoneEmailExist": { + "post": { + "description": "Check if phone number or email exist", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Check if phone number or email exist", + "parameters": [ + { + "description": "Check phone number or email exist", + "name": "checkPhoneEmailExist", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.CheckPhoneEmailExistReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.CheckPhoneEmailExistRes" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, + "/user/profile": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Get user profile", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Get user profile", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.UserProfileRes" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, + "/user/register": { + "post": { + "description": "Register user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Register user", + "parameters": [ + { + "description": "Register user", + "name": "registerUser", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.RegisterUserReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, + "/user/resetPassword": { + "post": { + "description": "Reset password", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Reset password", + "parameters": [ + { + "description": "Reset password", + "name": "resetPassword", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.ResetPasswordReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, + "/user/sendRegisterCode": { + "post": { + "description": "Send register code", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Send register code", + "parameters": [ + { + "description": "Send register code", + "name": "registerCode", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.RegisterCodeReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, + "/user/sendResetCode": { + "post": { + "description": "Send reset code", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Send reset code", + "parameters": [ + { + "description": "Send reset code", + "name": "resetCode", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.ResetCodeReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + } + }, + "definitions": { + "domain.BetStatus": { + "type": "integer", + "enum": [ + 0, + 1, + 2, + 3 + ], + "x-enum-varnames": [ + "BET_STATUS_PENDING", + "BET_STATUS_WIN", + "BET_STATUS_LOSS", + "BET_STATUS_ERROR" + ] + }, + "domain.Outcome": { + "type": "object" + }, + "domain.Role": { + "type": "string", + "enum": [ + "admin", + "customer", + "super_admin", + "branch_manager", + "cashier" + ], + "x-enum-varnames": [ + "RoleAdmin", + "RoleCustomer", + "RoleSuperAdmin", + "RoleBranchManager", + "RoleCashier" + ] + }, + "handlers.BetRes": { + "type": "object", + "properties": { + "amount": { + "type": "number", + "example": 100 + }, + "branch_id": { + "type": "integer", + "example": 2 + }, + "full_name": { + "type": "string", + "example": "John" + }, + "id": { + "type": "integer", + "example": 1 + }, + "is_shop_bet": { + "type": "boolean", + "example": false + }, + "outcomes": { + "type": "array", + "items": { + "$ref": "#/definitions/domain.Outcome" + } + }, + "phone_number": { + "type": "string", + "example": "1234567890" + }, + "status": { + "allOf": [ + { + "$ref": "#/definitions/domain.BetStatus" + } + ], + "example": 1 + }, + "total_odds": { + "type": "number", + "example": 4.22 + }, + "user_id": { + "type": "integer", + "example": 2 + } + } + }, + "handlers.CheckPhoneEmailExistReq": { + "type": "object", + "properties": { + "email": { + "type": "string", + "example": "john.doe@example.com" + }, + "phone_number": { + "type": "string", + "example": "1234567890" + } + } + }, + "handlers.CheckPhoneEmailExistRes": { + "type": "object", + "properties": { + "email_exist": { + "type": "boolean" + }, + "phone_number_exist": { + "type": "boolean" + } + } + }, + "handlers.CreateBetReq": { + "type": "object", + "properties": { + "amount": { + "type": "number", + "example": 100 + }, + "full_name": { + "type": "string", + "example": "John" + }, + "is_shop_bet": { + "type": "boolean", + "example": false + }, + "outcomes": { + "type": "array", + "items": { + "type": "integer" + } + }, + "phone_number": { + "type": "string", + "example": "1234567890" + }, + "status": { + "allOf": [ + { + "$ref": "#/definitions/domain.BetStatus" + } + ], + "example": 1 + }, + "total_odds": { + "type": "number", + "example": 4.22 + } + } + }, + "handlers.CreateTicketReq": { + "type": "object", + "properties": { + "amount": { + "type": "number", + "example": 100 + }, + "outcomes": { + "type": "array", + "items": { + "type": "integer" + } + }, + "total_odds": { + "type": "number", + "example": 4.22 + } + } + }, + "handlers.CreateTicketRes": { + "type": "object", + "properties": { + "fast_code": { + "type": "integer", + "example": 1234 + } + } + }, + "handlers.RegisterCodeReq": { + "type": "object", + "properties": { + "email": { + "type": "string", + "example": "john.doe@example.com" + }, + "phone_number": { + "type": "string", + "example": "1234567890" + } + } + }, + "handlers.RegisterUserReq": { + "type": "object", + "properties": { + "email": { + "type": "string", + "example": "john.doe@example.com" + }, + "first_name": { + "type": "string", + "example": "John" + }, + "last_name": { + "type": "string", + "example": "Doe" + }, + "otp": { + "description": "Role string", + "type": "string", + "example": "123456" + }, + "password": { + "type": "string", + "example": "password123" + }, + "phone_number": { + "type": "string", + "example": "1234567890" + }, + "referal_code": { + "type": "string", + "example": "ABC123" + } + } + }, + "handlers.ResetCodeReq": { + "type": "object", + "properties": { + "email": { + "type": "string", + "example": "john.doe@example.com" + }, + "phone_number": { + "type": "string", + "example": "1234567890" + } + } + }, + "handlers.ResetPasswordReq": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "otp": { + "type": "string" + }, + "password": { + "type": "string" + }, + "phoneNumber": { + "type": "string" + } + } + }, + "handlers.TicketRes": { + "type": "object", + "properties": { + "amount": { + "type": "number", + "example": 100 + }, + "id": { + "type": "integer", + "example": 1 + }, + "outcomes": { + "type": "array", + "items": { + "$ref": "#/definitions/domain.Outcome" + } + }, + "total_odds": { + "type": "number", + "example": 4.22 + } + } + }, + "handlers.UpdateCashOutReq": { + "type": "object", + "properties": { + "cashedOut": { + "type": "boolean" + } + } + }, + "handlers.UserProfileRes": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "email": { + "type": "string" + }, + "email_verified": { + "type": "boolean" + }, + "first_name": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "last_name": { + "type": "string" + }, + "phone_number": { + "type": "string" + }, + "phone_verified": { + "type": "boolean" + }, + "role": { + "$ref": "#/definitions/domain.Role" + }, + "suspended": { + "type": "boolean" + }, + "suspended_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "handlers.loginCustomerReq": { + "type": "object", + "properties": { + "email": { + "type": "string", + "example": "john.doe@example.com" + }, + "password": { + "type": "string", + "example": "password123" + }, + "phone_number": { + "type": "string", + "example": "1234567890" + } + } + }, + "handlers.loginCustomerRes": { + "type": "object", + "properties": { + "access_token": { + "type": "string" + }, + "refresh_token": { + "type": "string" + } + } + }, + "handlers.logoutReq": { + "type": "object", + "properties": { + "refresh_token": { + "type": "string" + } + } + }, + "handlers.refreshToken": { + "type": "object", + "properties": { + "access_token": { + "type": "string" + }, + "refresh_token": { + "type": "string" + } + } + }, + "response.APIResponse": { + "type": "object", + "properties": { + "data": {}, + "message": { + "type": "string" + }, + "metadata": {}, + "status": { + "$ref": "#/definitions/response.Status" + }, + "timestamp": { + "type": "string" + } + } + }, + "response.Status": { + "type": "string", + "enum": [ + "error", + "success" + ], + "x-enum-varnames": [ + "Error", + "Success" + ] + } + }, + "securityDefinitions": { + "Bearer": { + "type": "apiKey", + "name": "Authorization", + "in": "header" + } + } +}` + +// SwaggerInfo holds exported Swagger Info so clients can modify it +var SwaggerInfo = &swag.Spec{ + Version: "1.0", + Host: "", + BasePath: "", + Schemes: []string{}, + Title: "FortuneBet API", + Description: "This is server for FortuneBet.", + InfoInstanceName: "swagger", + SwaggerTemplate: docTemplate, + LeftDelim: "{{", + RightDelim: "}}", +} + +func init() { + swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo) +} diff --git a/docs/swagger.json b/docs/swagger.json new file mode 100644 index 0000000..1fec68a --- /dev/null +++ b/docs/swagger.json @@ -0,0 +1,1200 @@ +{ + "swagger": "2.0", + "info": { + "description": "This is server for FortuneBet.", + "title": "FortuneBet API", + "termsOfService": "http://swagger.io/terms/", + "contact": { + "name": "API Support", + "url": "http://www.swagger.io/support", + "email": "support@swagger.io" + }, + "license": { + "name": "Apache 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.html" + }, + "version": "1.0" + }, + "paths": { + "/auth/login": { + "post": { + "description": "Login customer", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Login customer", + "parameters": [ + { + "description": "Login customer", + "name": "login", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.loginCustomerReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.loginCustomerRes" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, + "/auth/logout": { + "post": { + "description": "Logout customer", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Logout customer", + "parameters": [ + { + "description": "Logout customer", + "name": "logout", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.logoutReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, + "/auth/refresh": { + "post": { + "description": "Refresh token", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Refresh token", + "parameters": [ + { + "description": "tokens", + "name": "refresh", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.refreshToken" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.loginCustomerRes" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, + "/bet": { + "get": { + "description": "Gets all the bets", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "bet" + ], + "summary": "Gets all bets", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/handlers.BetRes" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + }, + "post": { + "description": "Creates a bet", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "bet" + ], + "summary": "Create a bet", + "parameters": [ + { + "description": "Creates bet", + "name": "createBet", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.CreateBetReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.BetRes" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, + "/bet/{id}": { + "get": { + "description": "Gets a single bet by id", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "bet" + ], + "summary": "Gets bet by id", + "parameters": [ + { + "type": "integer", + "description": "Bet ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.BetRes" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + }, + "delete": { + "description": "Deletes bet by id", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "bet" + ], + "summary": "Deletes bet by id", + "parameters": [ + { + "type": "integer", + "description": "Bet ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + }, + "patch": { + "description": "Updates the cashed out field", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "bet" + ], + "summary": "Updates the cashed out field", + "parameters": [ + { + "type": "integer", + "description": "Bet ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Updates Cashed Out", + "name": "updateCashOut", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.UpdateCashOutReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, + "/ticket": { + "get": { + "description": "Retrieve all tickets", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "ticket" + ], + "summary": "Get all tickets", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/handlers.TicketRes" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + }, + "post": { + "description": "Creates a temporary ticket", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "ticket" + ], + "summary": "Create a temporary ticket", + "parameters": [ + { + "description": "Creates ticket", + "name": "createTicket", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.CreateTicketReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.CreateTicketRes" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, + "/ticket/{id}": { + "get": { + "description": "Retrieve ticket details by ticket ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "ticket" + ], + "summary": "Get ticket by ID", + "parameters": [ + { + "type": "integer", + "description": "Ticket ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.TicketRes" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, + "/user/checkPhoneEmailExist": { + "post": { + "description": "Check if phone number or email exist", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Check if phone number or email exist", + "parameters": [ + { + "description": "Check phone number or email exist", + "name": "checkPhoneEmailExist", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.CheckPhoneEmailExistReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.CheckPhoneEmailExistRes" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, + "/user/profile": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Get user profile", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Get user profile", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.UserProfileRes" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, + "/user/register": { + "post": { + "description": "Register user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Register user", + "parameters": [ + { + "description": "Register user", + "name": "registerUser", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.RegisterUserReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, + "/user/resetPassword": { + "post": { + "description": "Reset password", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Reset password", + "parameters": [ + { + "description": "Reset password", + "name": "resetPassword", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.ResetPasswordReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, + "/user/sendRegisterCode": { + "post": { + "description": "Send register code", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Send register code", + "parameters": [ + { + "description": "Send register code", + "name": "registerCode", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.RegisterCodeReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, + "/user/sendResetCode": { + "post": { + "description": "Send reset code", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Send reset code", + "parameters": [ + { + "description": "Send reset code", + "name": "resetCode", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.ResetCodeReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + } + }, + "definitions": { + "domain.BetStatus": { + "type": "integer", + "enum": [ + 0, + 1, + 2, + 3 + ], + "x-enum-varnames": [ + "BET_STATUS_PENDING", + "BET_STATUS_WIN", + "BET_STATUS_LOSS", + "BET_STATUS_ERROR" + ] + }, + "domain.Outcome": { + "type": "object" + }, + "domain.Role": { + "type": "string", + "enum": [ + "admin", + "customer", + "super_admin", + "branch_manager", + "cashier" + ], + "x-enum-varnames": [ + "RoleAdmin", + "RoleCustomer", + "RoleSuperAdmin", + "RoleBranchManager", + "RoleCashier" + ] + }, + "handlers.BetRes": { + "type": "object", + "properties": { + "amount": { + "type": "number", + "example": 100 + }, + "branch_id": { + "type": "integer", + "example": 2 + }, + "full_name": { + "type": "string", + "example": "John" + }, + "id": { + "type": "integer", + "example": 1 + }, + "is_shop_bet": { + "type": "boolean", + "example": false + }, + "outcomes": { + "type": "array", + "items": { + "$ref": "#/definitions/domain.Outcome" + } + }, + "phone_number": { + "type": "string", + "example": "1234567890" + }, + "status": { + "allOf": [ + { + "$ref": "#/definitions/domain.BetStatus" + } + ], + "example": 1 + }, + "total_odds": { + "type": "number", + "example": 4.22 + }, + "user_id": { + "type": "integer", + "example": 2 + } + } + }, + "handlers.CheckPhoneEmailExistReq": { + "type": "object", + "properties": { + "email": { + "type": "string", + "example": "john.doe@example.com" + }, + "phone_number": { + "type": "string", + "example": "1234567890" + } + } + }, + "handlers.CheckPhoneEmailExistRes": { + "type": "object", + "properties": { + "email_exist": { + "type": "boolean" + }, + "phone_number_exist": { + "type": "boolean" + } + } + }, + "handlers.CreateBetReq": { + "type": "object", + "properties": { + "amount": { + "type": "number", + "example": 100 + }, + "full_name": { + "type": "string", + "example": "John" + }, + "is_shop_bet": { + "type": "boolean", + "example": false + }, + "outcomes": { + "type": "array", + "items": { + "type": "integer" + } + }, + "phone_number": { + "type": "string", + "example": "1234567890" + }, + "status": { + "allOf": [ + { + "$ref": "#/definitions/domain.BetStatus" + } + ], + "example": 1 + }, + "total_odds": { + "type": "number", + "example": 4.22 + } + } + }, + "handlers.CreateTicketReq": { + "type": "object", + "properties": { + "amount": { + "type": "number", + "example": 100 + }, + "outcomes": { + "type": "array", + "items": { + "type": "integer" + } + }, + "total_odds": { + "type": "number", + "example": 4.22 + } + } + }, + "handlers.CreateTicketRes": { + "type": "object", + "properties": { + "fast_code": { + "type": "integer", + "example": 1234 + } + } + }, + "handlers.RegisterCodeReq": { + "type": "object", + "properties": { + "email": { + "type": "string", + "example": "john.doe@example.com" + }, + "phone_number": { + "type": "string", + "example": "1234567890" + } + } + }, + "handlers.RegisterUserReq": { + "type": "object", + "properties": { + "email": { + "type": "string", + "example": "john.doe@example.com" + }, + "first_name": { + "type": "string", + "example": "John" + }, + "last_name": { + "type": "string", + "example": "Doe" + }, + "otp": { + "description": "Role string", + "type": "string", + "example": "123456" + }, + "password": { + "type": "string", + "example": "password123" + }, + "phone_number": { + "type": "string", + "example": "1234567890" + }, + "referal_code": { + "type": "string", + "example": "ABC123" + } + } + }, + "handlers.ResetCodeReq": { + "type": "object", + "properties": { + "email": { + "type": "string", + "example": "john.doe@example.com" + }, + "phone_number": { + "type": "string", + "example": "1234567890" + } + } + }, + "handlers.ResetPasswordReq": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "otp": { + "type": "string" + }, + "password": { + "type": "string" + }, + "phoneNumber": { + "type": "string" + } + } + }, + "handlers.TicketRes": { + "type": "object", + "properties": { + "amount": { + "type": "number", + "example": 100 + }, + "id": { + "type": "integer", + "example": 1 + }, + "outcomes": { + "type": "array", + "items": { + "$ref": "#/definitions/domain.Outcome" + } + }, + "total_odds": { + "type": "number", + "example": 4.22 + } + } + }, + "handlers.UpdateCashOutReq": { + "type": "object", + "properties": { + "cashedOut": { + "type": "boolean" + } + } + }, + "handlers.UserProfileRes": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "email": { + "type": "string" + }, + "email_verified": { + "type": "boolean" + }, + "first_name": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "last_name": { + "type": "string" + }, + "phone_number": { + "type": "string" + }, + "phone_verified": { + "type": "boolean" + }, + "role": { + "$ref": "#/definitions/domain.Role" + }, + "suspended": { + "type": "boolean" + }, + "suspended_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "handlers.loginCustomerReq": { + "type": "object", + "properties": { + "email": { + "type": "string", + "example": "john.doe@example.com" + }, + "password": { + "type": "string", + "example": "password123" + }, + "phone_number": { + "type": "string", + "example": "1234567890" + } + } + }, + "handlers.loginCustomerRes": { + "type": "object", + "properties": { + "access_token": { + "type": "string" + }, + "refresh_token": { + "type": "string" + } + } + }, + "handlers.logoutReq": { + "type": "object", + "properties": { + "refresh_token": { + "type": "string" + } + } + }, + "handlers.refreshToken": { + "type": "object", + "properties": { + "access_token": { + "type": "string" + }, + "refresh_token": { + "type": "string" + } + } + }, + "response.APIResponse": { + "type": "object", + "properties": { + "data": {}, + "message": { + "type": "string" + }, + "metadata": {}, + "status": { + "$ref": "#/definitions/response.Status" + }, + "timestamp": { + "type": "string" + } + } + }, + "response.Status": { + "type": "string", + "enum": [ + "error", + "success" + ], + "x-enum-varnames": [ + "Error", + "Success" + ] + } + }, + "securityDefinitions": { + "Bearer": { + "type": "apiKey", + "name": "Authorization", + "in": "header" + } + } +} \ No newline at end of file diff --git a/docs/swagger.yaml b/docs/swagger.yaml new file mode 100644 index 0000000..4cd5fdd --- /dev/null +++ b/docs/swagger.yaml @@ -0,0 +1,801 @@ +definitions: + domain.BetStatus: + enum: + - 0 + - 1 + - 2 + - 3 + type: integer + x-enum-varnames: + - BET_STATUS_PENDING + - BET_STATUS_WIN + - BET_STATUS_LOSS + - BET_STATUS_ERROR + domain.Outcome: + type: object + domain.Role: + enum: + - admin + - customer + - super_admin + - branch_manager + - cashier + type: string + x-enum-varnames: + - RoleAdmin + - RoleCustomer + - RoleSuperAdmin + - RoleBranchManager + - RoleCashier + handlers.BetRes: + properties: + amount: + example: 100 + type: number + branch_id: + example: 2 + type: integer + full_name: + example: John + type: string + id: + example: 1 + type: integer + is_shop_bet: + example: false + type: boolean + outcomes: + items: + $ref: '#/definitions/domain.Outcome' + type: array + phone_number: + example: "1234567890" + type: string + status: + allOf: + - $ref: '#/definitions/domain.BetStatus' + example: 1 + total_odds: + example: 4.22 + type: number + user_id: + example: 2 + type: integer + type: object + handlers.CheckPhoneEmailExistReq: + properties: + email: + example: john.doe@example.com + type: string + phone_number: + example: "1234567890" + type: string + type: object + handlers.CheckPhoneEmailExistRes: + properties: + email_exist: + type: boolean + phone_number_exist: + type: boolean + type: object + handlers.CreateBetReq: + properties: + amount: + example: 100 + type: number + full_name: + example: John + type: string + is_shop_bet: + example: false + type: boolean + outcomes: + items: + type: integer + type: array + phone_number: + example: "1234567890" + type: string + status: + allOf: + - $ref: '#/definitions/domain.BetStatus' + example: 1 + total_odds: + example: 4.22 + type: number + type: object + handlers.CreateTicketReq: + properties: + amount: + example: 100 + type: number + outcomes: + items: + type: integer + type: array + total_odds: + example: 4.22 + type: number + type: object + handlers.CreateTicketRes: + properties: + fast_code: + example: 1234 + type: integer + type: object + handlers.RegisterCodeReq: + properties: + email: + example: john.doe@example.com + type: string + phone_number: + example: "1234567890" + type: string + type: object + handlers.RegisterUserReq: + properties: + email: + example: john.doe@example.com + type: string + first_name: + example: John + type: string + last_name: + example: Doe + type: string + otp: + description: Role string + example: "123456" + type: string + password: + example: password123 + type: string + phone_number: + example: "1234567890" + type: string + referal_code: + example: ABC123 + type: string + type: object + handlers.ResetCodeReq: + properties: + email: + example: john.doe@example.com + type: string + phone_number: + example: "1234567890" + type: string + type: object + handlers.ResetPasswordReq: + properties: + email: + type: string + otp: + type: string + password: + type: string + phoneNumber: + type: string + type: object + handlers.TicketRes: + properties: + amount: + example: 100 + type: number + id: + example: 1 + type: integer + outcomes: + items: + $ref: '#/definitions/domain.Outcome' + type: array + total_odds: + example: 4.22 + type: number + type: object + handlers.UpdateCashOutReq: + properties: + cashedOut: + type: boolean + type: object + handlers.UserProfileRes: + properties: + created_at: + type: string + email: + type: string + email_verified: + type: boolean + first_name: + type: string + id: + type: integer + last_name: + type: string + phone_number: + type: string + phone_verified: + type: boolean + role: + $ref: '#/definitions/domain.Role' + suspended: + type: boolean + suspended_at: + type: string + updated_at: + type: string + type: object + handlers.loginCustomerReq: + properties: + email: + example: john.doe@example.com + type: string + password: + example: password123 + type: string + phone_number: + example: "1234567890" + type: string + type: object + handlers.loginCustomerRes: + properties: + access_token: + type: string + refresh_token: + type: string + type: object + handlers.logoutReq: + properties: + refresh_token: + type: string + type: object + handlers.refreshToken: + properties: + access_token: + type: string + refresh_token: + type: string + type: object + response.APIResponse: + properties: + data: {} + message: + type: string + metadata: {} + status: + $ref: '#/definitions/response.Status' + timestamp: + type: string + type: object + response.Status: + enum: + - error + - success + type: string + x-enum-varnames: + - Error + - Success +info: + contact: + email: support@swagger.io + name: API Support + url: http://www.swagger.io/support + description: This is server for FortuneBet. + license: + name: Apache 2.0 + url: http://www.apache.org/licenses/LICENSE-2.0.html + termsOfService: http://swagger.io/terms/ + title: FortuneBet API + version: "1.0" +paths: + /auth/login: + post: + consumes: + - application/json + description: Login customer + parameters: + - description: Login customer + in: body + name: login + required: true + schema: + $ref: '#/definitions/handlers.loginCustomerReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.loginCustomerRes' + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.APIResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/response.APIResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.APIResponse' + summary: Login customer + tags: + - auth + /auth/logout: + post: + consumes: + - application/json + description: Logout customer + parameters: + - description: Logout customer + in: body + name: logout + required: true + schema: + $ref: '#/definitions/handlers.logoutReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/response.APIResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.APIResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/response.APIResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.APIResponse' + summary: Logout customer + tags: + - auth + /auth/refresh: + post: + consumes: + - application/json + description: Refresh token + parameters: + - description: tokens + in: body + name: refresh + required: true + schema: + $ref: '#/definitions/handlers.refreshToken' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.loginCustomerRes' + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.APIResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/response.APIResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.APIResponse' + summary: Refresh token + tags: + - auth + /bet: + get: + consumes: + - application/json + description: Gets all the bets + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/handlers.BetRes' + type: array + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.APIResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.APIResponse' + summary: Gets all bets + tags: + - bet + post: + consumes: + - application/json + description: Creates a bet + parameters: + - description: Creates bet + in: body + name: createBet + required: true + schema: + $ref: '#/definitions/handlers.CreateBetReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.BetRes' + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.APIResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.APIResponse' + summary: Create a bet + tags: + - bet + /bet/{id}: + delete: + consumes: + - application/json + description: Deletes bet by id + parameters: + - description: Bet ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/response.APIResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.APIResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.APIResponse' + summary: Deletes bet by id + tags: + - bet + get: + consumes: + - application/json + description: Gets a single bet by id + parameters: + - description: Bet ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.BetRes' + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.APIResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.APIResponse' + summary: Gets bet by id + tags: + - bet + patch: + consumes: + - application/json + description: Updates the cashed out field + parameters: + - description: Bet ID + in: path + name: id + required: true + type: integer + - description: Updates Cashed Out + in: body + name: updateCashOut + required: true + schema: + $ref: '#/definitions/handlers.UpdateCashOutReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/response.APIResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.APIResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.APIResponse' + summary: Updates the cashed out field + tags: + - bet + /ticket: + get: + consumes: + - application/json + description: Retrieve all tickets + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/handlers.TicketRes' + type: array + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.APIResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.APIResponse' + summary: Get all tickets + tags: + - ticket + post: + consumes: + - application/json + description: Creates a temporary ticket + parameters: + - description: Creates ticket + in: body + name: createTicket + required: true + schema: + $ref: '#/definitions/handlers.CreateTicketReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.CreateTicketRes' + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.APIResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.APIResponse' + summary: Create a temporary ticket + tags: + - ticket + /ticket/{id}: + get: + consumes: + - application/json + description: Retrieve ticket details by ticket ID + parameters: + - description: Ticket ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.TicketRes' + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.APIResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.APIResponse' + summary: Get ticket by ID + tags: + - ticket + /user/checkPhoneEmailExist: + post: + consumes: + - application/json + description: Check if phone number or email exist + parameters: + - description: Check phone number or email exist + in: body + name: checkPhoneEmailExist + required: true + schema: + $ref: '#/definitions/handlers.CheckPhoneEmailExistReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.CheckPhoneEmailExistRes' + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.APIResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.APIResponse' + summary: Check if phone number or email exist + tags: + - user + /user/profile: + get: + consumes: + - application/json + description: Get user profile + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.UserProfileRes' + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.APIResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.APIResponse' + security: + - Bearer: [] + summary: Get user profile + tags: + - user + /user/register: + post: + consumes: + - application/json + description: Register user + parameters: + - description: Register user + in: body + name: registerUser + required: true + schema: + $ref: '#/definitions/handlers.RegisterUserReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/response.APIResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.APIResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.APIResponse' + summary: Register user + tags: + - user + /user/resetPassword: + post: + consumes: + - application/json + description: Reset password + parameters: + - description: Reset password + in: body + name: resetPassword + required: true + schema: + $ref: '#/definitions/handlers.ResetPasswordReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/response.APIResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.APIResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.APIResponse' + summary: Reset password + tags: + - user + /user/sendRegisterCode: + post: + consumes: + - application/json + description: Send register code + parameters: + - description: Send register code + in: body + name: registerCode + required: true + schema: + $ref: '#/definitions/handlers.RegisterCodeReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/response.APIResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.APIResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.APIResponse' + summary: Send register code + tags: + - user + /user/sendResetCode: + post: + consumes: + - application/json + description: Send reset code + parameters: + - description: Send reset code + in: body + name: resetCode + required: true + schema: + $ref: '#/definitions/handlers.ResetCodeReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/response.APIResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.APIResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.APIResponse' + summary: Send reset code + tags: + - user +securityDefinitions: + Bearer: + in: header + name: Authorization + type: apiKey +swagger: "2.0" diff --git a/gen/db/auth.sql.go b/gen/db/auth.sql.go new file mode 100644 index 0000000..c826c36 --- /dev/null +++ b/gen/db/auth.sql.go @@ -0,0 +1,97 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.28.0 +// source: auth.sql + +package dbgen + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const CreateRefreshToken = `-- name: CreateRefreshToken :exec +INSERT INTO refresh_tokens (user_id, token, expires_at, created_at, revoked) +VALUES ($1, $2, $3, $4, $5) +` + +type CreateRefreshTokenParams struct { + UserID int64 + Token string + ExpiresAt pgtype.Timestamptz + CreatedAt pgtype.Timestamptz + Revoked bool +} + +func (q *Queries) CreateRefreshToken(ctx context.Context, arg CreateRefreshTokenParams) error { + _, err := q.db.Exec(ctx, CreateRefreshToken, + arg.UserID, + arg.Token, + arg.ExpiresAt, + arg.CreatedAt, + arg.Revoked, + ) + return err +} + +const GetRefreshToken = `-- name: GetRefreshToken :one +SELECT id, user_id, token, expires_at, created_at, revoked FROM refresh_tokens +WHERE token = $1 +` + +func (q *Queries) GetRefreshToken(ctx context.Context, token string) (RefreshToken, error) { + row := q.db.QueryRow(ctx, GetRefreshToken, token) + var i RefreshToken + err := row.Scan( + &i.ID, + &i.UserID, + &i.Token, + &i.ExpiresAt, + &i.CreatedAt, + &i.Revoked, + ) + return i, err +} + +const GetUserByEmailPhone = `-- name: GetUserByEmailPhone :one +SELECT id, first_name, last_name, email, phone_number, role, password, email_verified, phone_verified, created_at, updated_at, suspended_at, suspended FROM users +WHERE email = $1 OR phone_number = $2 +` + +type GetUserByEmailPhoneParams struct { + Email pgtype.Text + PhoneNumber pgtype.Text +} + +func (q *Queries) GetUserByEmailPhone(ctx context.Context, arg GetUserByEmailPhoneParams) (User, error) { + row := q.db.QueryRow(ctx, GetUserByEmailPhone, arg.Email, arg.PhoneNumber) + var i User + err := row.Scan( + &i.ID, + &i.FirstName, + &i.LastName, + &i.Email, + &i.PhoneNumber, + &i.Role, + &i.Password, + &i.EmailVerified, + &i.PhoneVerified, + &i.CreatedAt, + &i.UpdatedAt, + &i.SuspendedAt, + &i.Suspended, + ) + return i, err +} + +const RevokeRefreshToken = `-- name: RevokeRefreshToken :exec +UPDATE refresh_tokens +SET revoked = TRUE +WHERE token = $1 +` + +func (q *Queries) RevokeRefreshToken(ctx context.Context, token string) error { + _, err := q.db.Exec(ctx, RevokeRefreshToken, token) + return err +} diff --git a/gen/db/bet.sql.go b/gen/db/bet.sql.go new file mode 100644 index 0000000..f3667c6 --- /dev/null +++ b/gen/db/bet.sql.go @@ -0,0 +1,142 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.28.0 +// source: bet.sql + +package dbgen + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const CreateBet = `-- name: CreateBet :one +INSERT INTO bets (amount, total_odds, status, full_name, phone_number, branch_id, user_id, is_shop_bet) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8) +RETURNING id, amount, total_odds, status, full_name, phone_number, branch_id, user_id, cashed_out, created_at, updated_at, is_shop_bet +` + +type CreateBetParams struct { + Amount int64 + TotalOdds float32 + Status int32 + FullName string + PhoneNumber string + BranchID pgtype.Int8 + UserID pgtype.Int8 + IsShopBet bool +} + +func (q *Queries) CreateBet(ctx context.Context, arg CreateBetParams) (Bet, error) { + row := q.db.QueryRow(ctx, CreateBet, + arg.Amount, + arg.TotalOdds, + arg.Status, + arg.FullName, + arg.PhoneNumber, + arg.BranchID, + arg.UserID, + arg.IsShopBet, + ) + var i Bet + err := row.Scan( + &i.ID, + &i.Amount, + &i.TotalOdds, + &i.Status, + &i.FullName, + &i.PhoneNumber, + &i.BranchID, + &i.UserID, + &i.CashedOut, + &i.CreatedAt, + &i.UpdatedAt, + &i.IsShopBet, + ) + return i, err +} + +const DeleteBet = `-- name: DeleteBet :exec +DELETE FROM bets WHERE id = $1 +` + +func (q *Queries) DeleteBet(ctx context.Context, id int64) error { + _, err := q.db.Exec(ctx, DeleteBet, id) + return err +} + +const GetAllBets = `-- name: GetAllBets :many +SELECT id, amount, total_odds, status, full_name, phone_number, branch_id, user_id, cashed_out, created_at, updated_at, is_shop_bet FROM bets +` + +func (q *Queries) GetAllBets(ctx context.Context) ([]Bet, error) { + rows, err := q.db.Query(ctx, GetAllBets) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Bet + for rows.Next() { + var i Bet + if err := rows.Scan( + &i.ID, + &i.Amount, + &i.TotalOdds, + &i.Status, + &i.FullName, + &i.PhoneNumber, + &i.BranchID, + &i.UserID, + &i.CashedOut, + &i.CreatedAt, + &i.UpdatedAt, + &i.IsShopBet, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const GetBetByID = `-- name: GetBetByID :one +SELECT id, amount, total_odds, status, full_name, phone_number, branch_id, user_id, cashed_out, created_at, updated_at, is_shop_bet FROM bets WHERE id = $1 +` + +func (q *Queries) GetBetByID(ctx context.Context, id int64) (Bet, error) { + row := q.db.QueryRow(ctx, GetBetByID, id) + var i Bet + err := row.Scan( + &i.ID, + &i.Amount, + &i.TotalOdds, + &i.Status, + &i.FullName, + &i.PhoneNumber, + &i.BranchID, + &i.UserID, + &i.CashedOut, + &i.CreatedAt, + &i.UpdatedAt, + &i.IsShopBet, + ) + return i, err +} + +const UpdateCashOut = `-- name: UpdateCashOut :exec +UPDATE bets SET cashed_out = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $1 +` + +type UpdateCashOutParams struct { + ID int64 + CashedOut pgtype.Bool +} + +func (q *Queries) UpdateCashOut(ctx context.Context, arg UpdateCashOutParams) error { + _, err := q.db.Exec(ctx, UpdateCashOut, arg.ID, arg.CashedOut) + return err +} diff --git a/gen/db/models.go b/gen/db/models.go index 176b692..8703486 100644 --- a/gen/db/models.go +++ b/gen/db/models.go @@ -8,6 +8,21 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +type Bet struct { + ID int64 + Amount int64 + TotalOdds float32 + Status int32 + FullName string + PhoneNumber string + BranchID pgtype.Int8 + UserID pgtype.Int8 + CashedOut pgtype.Bool + CreatedAt pgtype.Timestamp + UpdatedAt pgtype.Timestamp + IsShopBet bool +} + type Notification struct { ID string RecipientID string @@ -25,15 +40,47 @@ type Notification struct { Metadata []byte } -type User struct { - ID int64 - FirstName string - LastName string - Email string - PhoneNumber string - Password string - Role string - Verified pgtype.Bool - CreatedAt pgtype.Timestamp - UpdatedAt pgtype.Timestamp +type Otp struct { + ID int64 + SentTo string + Medium string + OtpFor string + Otp string + Used bool + UsedAt pgtype.Timestamptz + CreatedAt pgtype.Timestamptz + ExpiresAt pgtype.Timestamptz +} + +type RefreshToken struct { + ID int64 + UserID int64 + Token string + ExpiresAt pgtype.Timestamptz + CreatedAt pgtype.Timestamptz + Revoked bool +} + +type Ticket struct { + ID int64 + Amount pgtype.Int8 + TotalOdds float32 + CreatedAt pgtype.Timestamp + UpdatedAt pgtype.Timestamp +} + +type User struct { + ID int64 + FirstName string + LastName string + Email pgtype.Text + PhoneNumber pgtype.Text + Role string + Password []byte + EmailVerified bool + PhoneVerified bool + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz + SuspendedAt pgtype.Timestamptz + Suspended bool } diff --git a/gen/db/otp.sql.go b/gen/db/otp.sql.go new file mode 100644 index 0000000..e0b9806 --- /dev/null +++ b/gen/db/otp.sql.go @@ -0,0 +1,84 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.28.0 +// source: otp.sql + +package dbgen + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const CreateOtp = `-- name: CreateOtp :exec +INSERT INTO otps (sent_to, medium, otp_for, otp, used, created_at, expires_at) +VALUES ($1, $2, $3, $4, FALSE, $5, $6) +` + +type CreateOtpParams struct { + SentTo string + Medium string + OtpFor string + Otp string + CreatedAt pgtype.Timestamptz + ExpiresAt pgtype.Timestamptz +} + +func (q *Queries) CreateOtp(ctx context.Context, arg CreateOtpParams) error { + _, err := q.db.Exec(ctx, CreateOtp, + arg.SentTo, + arg.Medium, + arg.OtpFor, + arg.Otp, + arg.CreatedAt, + arg.ExpiresAt, + ) + return err +} + +const GetOtp = `-- name: GetOtp :one +SELECT id, sent_to, medium, otp_for, otp, used, used_at, created_at, expires_at +FROM otps +WHERE sent_to = $1 AND otp_for = $2 AND medium = $3 +ORDER BY created_at DESC LIMIT 1 +` + +type GetOtpParams struct { + SentTo string + OtpFor string + Medium string +} + +func (q *Queries) GetOtp(ctx context.Context, arg GetOtpParams) (Otp, error) { + row := q.db.QueryRow(ctx, GetOtp, arg.SentTo, arg.OtpFor, arg.Medium) + var i Otp + err := row.Scan( + &i.ID, + &i.SentTo, + &i.Medium, + &i.OtpFor, + &i.Otp, + &i.Used, + &i.UsedAt, + &i.CreatedAt, + &i.ExpiresAt, + ) + return i, err +} + +const MarkOtpAsUsed = `-- name: MarkOtpAsUsed :exec +UPDATE otps +SET used = TRUE, used_at = $2 +WHERE id = $1 +` + +type MarkOtpAsUsedParams struct { + ID int64 + UsedAt pgtype.Timestamptz +} + +func (q *Queries) MarkOtpAsUsed(ctx context.Context, arg MarkOtpAsUsedParams) error { + _, err := q.db.Exec(ctx, MarkOtpAsUsed, arg.ID, arg.UsedAt) + return err +} diff --git a/gen/db/ticket.sql.go b/gen/db/ticket.sql.go new file mode 100644 index 0000000..d7e5ff3 --- /dev/null +++ b/gen/db/ticket.sql.go @@ -0,0 +1,101 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.28.0 +// source: ticket.sql + +package dbgen + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const CreateTicket = `-- name: CreateTicket :one +INSERT INTO tickets (amount, total_odds) +VALUES ($1, $2) +RETURNING id, amount, total_odds, created_at, updated_at +` + +type CreateTicketParams struct { + Amount pgtype.Int8 + TotalOdds float32 +} + +func (q *Queries) CreateTicket(ctx context.Context, arg CreateTicketParams) (Ticket, error) { + row := q.db.QueryRow(ctx, CreateTicket, arg.Amount, arg.TotalOdds) + var i Ticket + err := row.Scan( + &i.ID, + &i.Amount, + &i.TotalOdds, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const DeleteOldTickets = `-- name: DeleteOldTickets :exec +Delete from tickets where created_at < now() - interval '1 day' +` + +func (q *Queries) DeleteOldTickets(ctx context.Context) error { + _, err := q.db.Exec(ctx, DeleteOldTickets) + return err +} + +const DeleteTicket = `-- name: DeleteTicket :exec +DELETE FROM tickets WHERE id = $1 +` + +func (q *Queries) DeleteTicket(ctx context.Context, id int64) error { + _, err := q.db.Exec(ctx, DeleteTicket, id) + return err +} + +const GetAllTickets = `-- name: GetAllTickets :many +SELECT id, amount, total_odds, created_at, updated_at FROM tickets +` + +func (q *Queries) GetAllTickets(ctx context.Context) ([]Ticket, error) { + rows, err := q.db.Query(ctx, GetAllTickets) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Ticket + for rows.Next() { + var i Ticket + if err := rows.Scan( + &i.ID, + &i.Amount, + &i.TotalOdds, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const GetTicketByID = `-- name: GetTicketByID :one +SELECT id, amount, total_odds, created_at, updated_at FROM tickets WHERE id = $1 +` + +func (q *Queries) GetTicketByID(ctx context.Context, id int64) (Ticket, error) { + row := q.db.QueryRow(ctx, GetTicketByID, id) + var i Ticket + err := row.Scan( + &i.ID, + &i.Amount, + &i.TotalOdds, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/gen/db/user.sql.go b/gen/db/user.sql.go index c5b0e90..39f0a5c 100644 --- a/gen/db/user.sql.go +++ b/gen/db/user.sql.go @@ -11,42 +11,85 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +const CheckPhoneEmailExist = `-- name: CheckPhoneEmailExist :one +SELECT + EXISTS (SELECT 1 FROM users WHERE users.phone_number = $1 AND users.phone_number IS NOT NULL) AS phone_exists, + EXISTS (SELECT 1 FROM users WHERE users.email = $2 AND users.email IS NOT NULL) AS email_exists +` + +type CheckPhoneEmailExistParams struct { + PhoneNumber pgtype.Text + Email pgtype.Text +} + +type CheckPhoneEmailExistRow struct { + PhoneExists bool + EmailExists bool +} + +func (q *Queries) CheckPhoneEmailExist(ctx context.Context, arg CheckPhoneEmailExistParams) (CheckPhoneEmailExistRow, error) { + row := q.db.QueryRow(ctx, CheckPhoneEmailExist, arg.PhoneNumber, arg.Email) + var i CheckPhoneEmailExistRow + err := row.Scan(&i.PhoneExists, &i.EmailExists) + return i, err +} + const CreateUser = `-- name: CreateUser :one -INSERT INTO users (first_name, last_name, email, phone_number, password, role, verified) -VALUES ($1, $2, $3, $4, $5, $6, $7) -RETURNING id, first_name, last_name, email, phone_number, password, role, verified, created_at, updated_at + +INSERT INTO users (first_name, last_name, email, phone_number, role, password, email_verified, phone_verified, created_at, updated_at) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) +RETURNING id, first_name, last_name, email, phone_number, role, email_verified, phone_verified, created_at, updated_at ` type CreateUserParams struct { - FirstName string - LastName string - Email string - PhoneNumber string - Password string - Role string - Verified pgtype.Bool + FirstName string + LastName string + Email pgtype.Text + PhoneNumber pgtype.Text + Role string + Password []byte + EmailVerified bool + PhoneVerified bool + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz } -func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) { +type CreateUserRow struct { + ID int64 + FirstName string + LastName string + Email pgtype.Text + PhoneNumber pgtype.Text + Role string + EmailVerified bool + PhoneVerified bool + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz +} + +func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (CreateUserRow, error) { row := q.db.QueryRow(ctx, CreateUser, arg.FirstName, arg.LastName, arg.Email, arg.PhoneNumber, - arg.Password, arg.Role, - arg.Verified, + arg.Password, + arg.EmailVerified, + arg.PhoneVerified, + arg.CreatedAt, + arg.UpdatedAt, ) - var i User + var i CreateUserRow err := row.Scan( &i.ID, &i.FirstName, &i.LastName, &i.Email, &i.PhoneNumber, - &i.Password, &i.Role, - &i.Verified, + &i.EmailVerified, + &i.PhoneVerified, &i.CreatedAt, &i.UpdatedAt, ) @@ -54,7 +97,8 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e } const DeleteUser = `-- name: DeleteUser :exec -DELETE FROM users WHERE id = $1 +DELETE FROM users +WHERE id = $1 ` func (q *Queries) DeleteUser(ctx context.Context, id int64) error { @@ -63,27 +107,41 @@ func (q *Queries) DeleteUser(ctx context.Context, id int64) error { } const GetAllUsers = `-- name: GetAllUsers :many -SELECT id, first_name, last_name, email, phone_number, password, role, verified, created_at, updated_at FROM users +SELECT id, first_name, last_name, email, phone_number, role, email_verified, phone_verified, created_at, updated_at +FROM users ` -func (q *Queries) GetAllUsers(ctx context.Context) ([]User, error) { +type GetAllUsersRow struct { + ID int64 + FirstName string + LastName string + Email pgtype.Text + PhoneNumber pgtype.Text + Role string + EmailVerified bool + PhoneVerified bool + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz +} + +func (q *Queries) GetAllUsers(ctx context.Context) ([]GetAllUsersRow, error) { rows, err := q.db.Query(ctx, GetAllUsers) if err != nil { return nil, err } defer rows.Close() - var items []User + var items []GetAllUsersRow for rows.Next() { - var i User + var i GetAllUsersRow if err := rows.Scan( &i.ID, &i.FirstName, &i.LastName, &i.Email, &i.PhoneNumber, - &i.Password, &i.Role, - &i.Verified, + &i.EmailVerified, + &i.PhoneVerified, &i.CreatedAt, &i.UpdatedAt, ); err != nil { @@ -97,8 +155,47 @@ func (q *Queries) GetAllUsers(ctx context.Context) ([]User, error) { return items, nil } +const GetUserByEmail = `-- name: GetUserByEmail :one +SELECT id, first_name, last_name, email, phone_number, role, email_verified, phone_verified, created_at, updated_at +FROM users +WHERE email = $1 +` + +type GetUserByEmailRow struct { + ID int64 + FirstName string + LastName string + Email pgtype.Text + PhoneNumber pgtype.Text + Role string + EmailVerified bool + PhoneVerified bool + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz +} + +func (q *Queries) GetUserByEmail(ctx context.Context, email pgtype.Text) (GetUserByEmailRow, error) { + row := q.db.QueryRow(ctx, GetUserByEmail, email) + var i GetUserByEmailRow + err := row.Scan( + &i.ID, + &i.FirstName, + &i.LastName, + &i.Email, + &i.PhoneNumber, + &i.Role, + &i.EmailVerified, + &i.PhoneVerified, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + const GetUserByID = `-- name: GetUserByID :one -SELECT id, first_name, last_name, email, phone_number, password, role, verified, created_at, updated_at FROM users WHERE id = $1 +SELECT id, first_name, last_name, email, phone_number, role, password, email_verified, phone_verified, created_at, updated_at, suspended_at, suspended +FROM users +WHERE id = $1 ` func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) { @@ -110,40 +207,103 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) { &i.LastName, &i.Email, &i.PhoneNumber, - &i.Password, &i.Role, - &i.Verified, + &i.Password, + &i.EmailVerified, + &i.PhoneVerified, + &i.CreatedAt, + &i.UpdatedAt, + &i.SuspendedAt, + &i.Suspended, + ) + return i, err +} + +const GetUserByPhone = `-- name: GetUserByPhone :one +SELECT id, first_name, last_name, email, phone_number, role, email_verified, phone_verified, created_at, updated_at +FROM users +WHERE phone_number = $1 +` + +type GetUserByPhoneRow struct { + ID int64 + FirstName string + LastName string + Email pgtype.Text + PhoneNumber pgtype.Text + Role string + EmailVerified bool + PhoneVerified bool + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz +} + +func (q *Queries) GetUserByPhone(ctx context.Context, phoneNumber pgtype.Text) (GetUserByPhoneRow, error) { + row := q.db.QueryRow(ctx, GetUserByPhone, phoneNumber) + var i GetUserByPhoneRow + err := row.Scan( + &i.ID, + &i.FirstName, + &i.LastName, + &i.Email, + &i.PhoneNumber, + &i.Role, + &i.EmailVerified, + &i.PhoneVerified, &i.CreatedAt, &i.UpdatedAt, ) return i, err } +const UpdatePassword = `-- name: UpdatePassword :exec +UPDATE users +SET password = $1, updated_at = $4 +WHERE (email = $2 OR phone_number = $3) +` + +type UpdatePasswordParams struct { + Password []byte + Email pgtype.Text + PhoneNumber pgtype.Text + UpdatedAt pgtype.Timestamptz +} + +func (q *Queries) UpdatePassword(ctx context.Context, arg UpdatePasswordParams) error { + _, err := q.db.Exec(ctx, UpdatePassword, + arg.Password, + arg.Email, + arg.PhoneNumber, + arg.UpdatedAt, + ) + return err +} + const UpdateUser = `-- name: UpdateUser :exec -UPDATE users SET first_name = $2, last_name = $3, email = $4, phone_number = $5, password = $6, role = $7, verified = $8, updated_at = CURRENT_TIMESTAMP WHERE id = $1 +UPDATE users +SET first_name = $1, last_name = $2, email = $3, phone_number = $4, role = $5, updated_at = $6 +WHERE id = $7 ` type UpdateUserParams struct { - ID int64 FirstName string LastName string - Email string - PhoneNumber string - Password string + Email pgtype.Text + PhoneNumber pgtype.Text Role string - Verified pgtype.Bool + UpdatedAt pgtype.Timestamptz + ID int64 } func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) error { _, err := q.db.Exec(ctx, UpdateUser, - arg.ID, arg.FirstName, arg.LastName, arg.Email, arg.PhoneNumber, - arg.Password, arg.Role, - arg.Verified, + arg.UpdatedAt, + arg.ID, ) return err } diff --git a/go.mod b/go.mod index 9f9763c..359b584 100644 --- a/go.mod +++ b/go.mod @@ -4,36 +4,54 @@ go 1.24.1 require ( github.com/bytedance/sonic v1.13.2 + github.com/go-playground/validator/v10 v10.26.0 github.com/gofiber/fiber/v2 v2.52.6 + github.com/golang-jwt/jwt/v5 v5.2.2 github.com/gofiber/websocket/v2 v2.2.1 github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.7.4 github.com/joho/godotenv v1.5.1 + github.com/swaggo/fiber-swagger v1.3.0 + github.com/swaggo/swag v1.16.4 + golang.org/x/crypto v0.36.0 ) require ( - github.com/andybalholm/brotli v1.1.0 // indirect + github.com/KyleBanks/depth v1.2.1 // indirect + github.com/andybalholm/brotli v1.1.1 // indirect github.com/bytedance/sonic/loader v0.2.4 // indirect github.com/cloudwego/base64x v0.1.5 // indirect + github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/go-openapi/jsonpointer v0.21.1 // indirect + github.com/go-openapi/jsonreference v0.21.0 // indirect + github.com/go-openapi/spec v0.21.0 // indirect + github.com/go-openapi/swag v0.23.1 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect github.com/fasthttp/websocket v1.5.3 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect - github.com/klauspost/compress v1.17.9 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/cpuid/v2 v2.0.9 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mailru/easyjson v0.9.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect - github.com/rivo/uniseg v0.2.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee // indirect - github.com/stretchr/testify v1.8.4 // indirect + github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect - github.com/valyala/fasthttp v1.51.0 // indirect - github.com/valyala/tcplisten v1.0.0 // indirect + github.com/valyala/fasthttp v1.59.0 // indirect golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect - golang.org/x/crypto v0.32.0 // indirect - golang.org/x/sync v0.10.0 // indirect - golang.org/x/sys v0.29.0 // indirect - golang.org/x/text v0.21.0 // indirect + golang.org/x/net v0.38.0 // indirect + golang.org/x/sync v0.12.0 // indirect + golang.org/x/sys v0.31.0 // indirect + golang.org/x/text v0.23.0 // indirect + golang.org/x/tools v0.31.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 0801c72..fde9517 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,12 @@ -github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= -github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= +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/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= +github.com/agiledragon/gomonkey/v2 v2.3.1/go.mod h1:ap1AmDzcVOAz1YpeJ3TCzIgstoaWLA6jbbgxfB4w2iY= +github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= +github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ= github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= @@ -8,17 +15,48 @@ github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFos github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4= github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +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.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/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= +github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic= +github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk= +github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns= +github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= +github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= +github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I= +github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY= +github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk= +github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= +github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU= +github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k= +github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/gofiber/fiber/v2 v2.32.0/go.mod h1:CMy5ZLiXkn6qwthrl03YMyW1NLfj0rhxz2LKl4t7ZTY= github.com/fasthttp/websocket v1.5.3 h1:TPpQuLwJYfd4LJPXvHDYPMFWbLjsT91n3GpWtCQtdek= github.com/fasthttp/websocket v1.5.3/go.mod h1:46gg/UBmTU1kUaTcwQXpUxtRwG2PvIZYeA8oL6vF3Fs= github.com/gofiber/fiber/v2 v2.52.6 h1:Rfp+ILPiYSvvVuIPvxrBns+HJp8qGLDnLJawAu27XVI= github.com/gofiber/fiber/v2 v2.52.6/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/gofiber/websocket/v2 v2.2.1 h1:C9cjxvloojayOp9AovmpQrk8VqvVnT8Oao3+IUygH7w= github.com/gofiber/websocket/v2 v2.2.1/go.mod h1:Ao/+nyNnX5u/hIFPuHl28a+NIkrqK7PRimyKaj4JxVU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 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/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -29,56 +67,146 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= -github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +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/klauspost/compress v1.15.0/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +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.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +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/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= +github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +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/savsgio/gotils v0.0.0-20230208104028-c358bd845dee h1:8Iv5m6xEo1NR1AvpV+7XmhI4r39LGNzwUL4YpMuL5vk= github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee/go.mod h1:qwtSXrKuJh/zsFQ12yEE89xfCrGKK63Rr7ctU/uCo4g= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/swaggo/fiber-swagger v1.3.0 h1:RMjIVDleQodNVdKuu7GRs25Eq8RVXK7MwY9f5jbobNg= +github.com/swaggo/fiber-swagger v1.3.0/go.mod h1:18MuDqBkYEiUmeM/cAAB8CI28Bi62d/mys39j1QqF9w= +github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe h1:K8pHPVoTgxFJt1lXuIzzOX7zZhZFldJQK/CgKx9BFIc= +github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe/go.mod h1:lKJPbtWzJ9JhsTN1k1gZgleJWY/cqq0psdoMmaThG3w= +github.com/swaggo/swag v1.8.1/go.mod h1:ugemnJsPZm/kRwFUnzBlbHRd0JY9zE1M4F+uy2pAaPQ= +github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A= +github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= -github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= -github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= +github.com/valyala/fasthttp v1.35.0/go.mod h1:t/G+3rLek+CyY9bnIE+YlMRddxVAAGjhxndDB4i4C0I= +github.com/valyala/fasthttp v1.36.0/go.mod h1:t/G+3rLek+CyY9bnIE+YlMRddxVAAGjhxndDB4i4C0I= +github.com/valyala/fasthttp v1.59.0 h1:Qu0qYHfXvPk1mSLNqcFtEk6DpxgA26hy6bmydotDpRI= +github.com/valyala/fasthttp v1.59.0/go.mod h1:GTxNb9Bc6r2a9D0TWNSPwDz78UxnTGBViY3xZNEqyYU= github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= golang.org/x/arch v0.0.0-20210923205945-b76863e36670 h1:18EFjUmQOcUvxNYSkA6jO9VAiXCnxFY6NyDX0bHDmkU= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= -golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= -golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= -golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= -golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= +golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +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-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/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= +golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +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/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= +golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/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-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= -golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo= +golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU= +golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/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/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/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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= diff --git a/internal/config/config.go b/internal/config/config.go index 0d53448..229bd47 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -2,18 +2,33 @@ package config import ( "errors" + "log/slog" "os" "strconv" + + customlogger "github.com/SamuelTariku/FortuneBet-Backend/internal/logger" + "github.com/joho/godotenv" ) var ( - ErrInvalidDbUrl = errors.New("db url is invalid") - ErrInvalidPort = errors.New("port number is invalid") + ErrInvalidDbUrl = errors.New("db url is invalid") + ErrInvalidPort = errors.New("port number is invalid") + ErrRefreshExpiry = errors.New("refresh token expiry is invalid") + ErrAccessExpiry = errors.New("access token expiry is invalid") + ErrInvalidJwtKey = errors.New("jwt key is invalid") + ErrLogLevel = errors.New("log level not set") + ErrInvalidLevel = errors.New("invalid log level") + ErrInvalidEnv = errors.New("env not set or invalid") ) type Config struct { - Port int - DbUrl string + Port int + DbUrl string + RefreshExpiry int + AccessExpiry int + JwtKey string + LogLevel slog.Level + Env string } func NewConfig() (*Config, error) { @@ -24,7 +39,16 @@ func NewConfig() (*Config, error) { return config, nil } func (c *Config) loadEnv() error { - + err := godotenv.Load() + if err != nil { + return errors.New("failed to load env file") + } + // env + env := os.Getenv("ENV") + if env == "" { + return ErrInvalidEnv + } + c.Env = env portStr := os.Getenv("PORT") port, err := strconv.Atoi(portStr) if err != nil { @@ -37,6 +61,33 @@ func (c *Config) loadEnv() error { return ErrInvalidDbUrl } c.DbUrl = dbUrl + refreshExpiryStr := os.Getenv("REFRESH_EXPIRY") + refreshExpiry, err := strconv.Atoi(refreshExpiryStr) + if err != nil { + return ErrRefreshExpiry + } + c.RefreshExpiry = refreshExpiry + jwtKey := os.Getenv("JWT_KEY") + if jwtKey == "" { + return ErrInvalidJwtKey + } + c.JwtKey = jwtKey + accessExpiryStr := os.Getenv("ACCESS_EXPIRY") + accessExpiry, err := strconv.Atoi(accessExpiryStr) + if err != nil { + return ErrAccessExpiry + } + c.AccessExpiry = accessExpiry + // log level + logLevel := os.Getenv("LOG_LEVEL") + if logLevel == "" { + return ErrLogLevel + } + lvl, ok := customlogger.LogLevels[logLevel] + if !ok { + return ErrInvalidLevel + } + c.LogLevel = lvl return nil } diff --git a/internal/domain/auth.go b/internal/domain/auth.go index 4188b5a..513ff8e 100644 --- a/internal/domain/auth.go +++ b/internal/domain/auth.go @@ -1 +1,12 @@ package domain + +import "time" + +type RefreshToken struct { + ID int64 + UserID int64 + Token string + ExpiresAt time.Time + CreatedAt time.Time + Revoked bool +} diff --git a/internal/domain/bet.go b/internal/domain/bet.go new file mode 100644 index 0000000..87bc936 --- /dev/null +++ b/internal/domain/bet.go @@ -0,0 +1,44 @@ +package domain + +type BetStatus int + +const ( + BET_STATUS_PENDING BetStatus = iota + BET_STATUS_WIN + BET_STATUS_LOSS + BET_STATUS_ERROR +) + +// If it is a ShopBet then UserID will be the cashier +// If it is a DigitalBet then UserID will be the user and the branchID will be 0 or nil +type Bet struct { + ID int64 + Outcomes []Outcome + Amount Currency + TotalOdds float32 + Status BetStatus + FullName string + PhoneNumber string + BranchID ValidInt64 // Can Be Nullable + UserID ValidInt64 // Can Be Nullable + IsShopBet bool + CashedOut bool +} + +type CreateBet struct { + Outcomes []int64 + Amount Currency + TotalOdds float32 + Status BetStatus + FullName string + PhoneNumber string + BranchID ValidInt64 // Can Be Nullable + UserID ValidInt64 // Can Be Nullable + IsShopBet bool +} + +func (b BetStatus) String() string { + return []string{"Pending", "Win", "Loss", "Error"}[b] +} + +// func isBetStatusValid() diff --git a/internal/domain/branch.go b/internal/domain/branch.go new file mode 100644 index 0000000..54bdcf7 --- /dev/null +++ b/internal/domain/branch.go @@ -0,0 +1,13 @@ +package domain + + +type Branch struct { + ID int64 + Name string + Location string + BranchManagerID int64 + IsSelfOwned bool + IsSupportingSportBook bool + IsSupportingVirtual bool + IsSupportingGameZone bool +} \ No newline at end of file diff --git a/internal/domain/common.go b/internal/domain/common.go new file mode 100644 index 0000000..985e97e --- /dev/null +++ b/internal/domain/common.go @@ -0,0 +1,39 @@ +package domain + +import "fmt" + +type ValidInt64 struct { + Value int64 + Valid bool +} + +type ValidString struct { + Value string + Valid bool +} +type ValidBool struct { + Value bool + Valid bool +} + +type Currency int64 + +// ToCurrency converts a float32 to Currency +func ToCurrency(f float32) Currency { + return Currency((f * 100) + 0.5) +} + +// Float64 converts a Currency to float32 +func (m Currency) Float64() float32 { + x := float32(m) + x = x / 100 + return x +} + +// String returns a formatted Currency value +func (m Currency) String() string { + x := float32(m) + x = x / 100 + return fmt.Sprintf("$%.2f", x) + +} diff --git a/internal/domain/event.go b/internal/domain/event.go new file mode 100644 index 0000000..e5cc881 --- /dev/null +++ b/internal/domain/event.go @@ -0,0 +1,6 @@ +package domain + +type Event struct {} + +type Outcome struct {} + diff --git a/internal/domain/otp.go b/internal/domain/otp.go new file mode 100644 index 0000000..a6904e4 --- /dev/null +++ b/internal/domain/otp.go @@ -0,0 +1,39 @@ +package domain + +import ( + "errors" + "time" +) + +var ( + ErrOtpNotFound = errors.New("otp not found") + ErrOtpAlreadyUsed = errors.New("otp already used") + ErrInvalidOtp = errors.New("invalid otp") + ErrOtpExpired = errors.New("otp expired") +) + +type OtpFor string + +const ( + OtpReset OtpFor = "reset" + OtpRegister OtpFor = "register" +) + +type OtpMedium string + +const ( + OtpMediumEmail OtpMedium = "email" + OtpMediumSms OtpMedium = "sms" +) + +type Otp struct { + ID int64 + SentTo string + Medium OtpMedium + For OtpFor + Otp string + Used bool + UsedAt time.Time + CreatedAt time.Time + ExpiresAt time.Time +} diff --git a/internal/domain/role.go b/internal/domain/role.go new file mode 100644 index 0000000..2643782 --- /dev/null +++ b/internal/domain/role.go @@ -0,0 +1,11 @@ +package domain + +type Role string + +const ( + RoleSuperAdmin Role = "super_admin" + RoleAdmin Role = "admin" + RoleBranchManager Role = "branch_manager" + RoleCustomer Role = "customer" + RoleCashier Role = "cashier" +) diff --git a/internal/domain/ticket.go b/internal/domain/ticket.go new file mode 100644 index 0000000..b1c000f --- /dev/null +++ b/internal/domain/ticket.go @@ -0,0 +1,15 @@ +package domain + +// ID will serve as the fast code since this doesn't need to be secure +type Ticket struct { + ID int64 + Outcomes []Outcome + Amount Currency + TotalOdds float32 +} + +type CreateTicket struct { + Outcomes []int64 + Amount Currency + TotalOdds float32 +} diff --git a/internal/domain/user.go b/internal/domain/user.go index 1e6b181..ea44cc8 100644 --- a/internal/domain/user.go +++ b/internal/domain/user.go @@ -1,12 +1,53 @@ package domain +import ( + "errors" + "time" +) + +var ( + ErrUserNotFound = errors.New("user not found") +) + type User struct { ID int64 FirstName string LastName string Email string PhoneNumber string - Password string - Role string - Verified bool + Password []byte + Role Role + // + EmailVerified bool + PhoneVerified bool + // + CreatedAt time.Time + UpdatedAt time.Time + // + SuspendedAt time.Time + Suspended bool +} +type RegisterUserReq struct { + FirstName string + LastName string + Email string + PhoneNumber string + Password string + //Role string + Otp string + ReferalCode string + // + OtpMedium OtpMedium +} +type ResetPasswordReq struct { + Email string + PhoneNumber string + Password string + Otp string + OtpMedium OtpMedium +} +type UpdateUserReq struct { + FirstName ValidString + LastName ValidString + Suspended ValidBool } diff --git a/internal/logger/logger.go b/internal/logger/logger.go index 2c7035f..043836c 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -11,8 +11,12 @@ var LogLevels = map[string]slog.Level{ "warn": slog.LevelWarn, "error": slog.LevelError, } +var Environment = map[string]string{ + "dev": "development", + "prod": "production", +} -func NewLogger(env string, lvl slog.Level, version string) *slog.Logger { +func NewLogger(env string, lvl slog.Level) *slog.Logger { var logHandler slog.Handler switch env { case "development": @@ -28,7 +32,6 @@ func NewLogger(env string, lvl slog.Level, version string) *slog.Logger { logger := slog.New(logHandler).With(slog.Group( "service_info", slog.String("env", env), - slog.String("version", version), ), ) diff --git a/internal/mocks/mock_email/email.go b/internal/mocks/mock_email/email.go new file mode 100644 index 0000000..18056a6 --- /dev/null +++ b/internal/mocks/mock_email/email.go @@ -0,0 +1,18 @@ +package mockemail + +import ( + "context" + "fmt" +) + +type MockEmail struct { +} + +func NewMockEmail() *MockEmail { + return &MockEmail{} +} + +func (m *MockEmail) SendEmailOTP(ctx context.Context, email string, otp string) error { + fmt.Println("MockEmail: Sending OTP to", email, "with OTP:", otp) + return nil +} diff --git a/internal/mocks/mock_sms/sms.go b/internal/mocks/mock_sms/sms.go new file mode 100644 index 0000000..150b6d3 --- /dev/null +++ b/internal/mocks/mock_sms/sms.go @@ -0,0 +1,19 @@ +package mocksms + +import ( + "context" + "fmt" +) + +type MockSMS struct { +} + +func NewMockSMS() *MockSMS { + return &MockSMS{} +} +func (m *MockSMS) SendSMSOTP(ctx context.Context, phoneNumber, otp string) error { + fmt.Println("MockSMS: Sending OTP to", phoneNumber, "with OTP:", otp) + return nil +} + +// func (m *MockSms){} diff --git a/internal/repository/auth.go b/internal/repository/auth.go new file mode 100644 index 0000000..99739e9 --- /dev/null +++ b/internal/repository/auth.go @@ -0,0 +1,77 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + + dbgen "github.com/SamuelTariku/FortuneBet-Backend/gen/db" + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/authentication" + "github.com/jackc/pgx/v5/pgtype" +) + +func (s *Store) GetUserByEmailPhone(ctx context.Context, email, phone string) (domain.User, error) { + user, err := s.queries.GetUserByEmailPhone(ctx, dbgen.GetUserByEmailPhoneParams{ + Email: pgtype.Text{ + String: email, + Valid: true, + }, + PhoneNumber: pgtype.Text{ + String: phone, + Valid: true, + }, + }) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return domain.User{}, authentication.ErrUserNotFound + } + return domain.User{}, err + } + return domain.User{ + ID: user.ID, + FirstName: user.FirstName, + LastName: user.LastName, + Email: user.Email.String, + PhoneNumber: user.PhoneNumber.String, + Password: user.Password, + Role: domain.Role(user.Role), + }, nil + +} + +func (s *Store) CreateRefreshToken(ctx context.Context, rt domain.RefreshToken) error { + return s.queries.CreateRefreshToken(ctx, dbgen.CreateRefreshTokenParams{ + UserID: rt.UserID, + Token: rt.Token, + CreatedAt: pgtype.Timestamptz{ + Time: rt.CreatedAt, + Valid: true, + }, + ExpiresAt: pgtype.Timestamptz{ + Time: rt.ExpiresAt, + Valid: true, + }, + Revoked: rt.Revoked, + }) + +} +func (s *Store) GetRefreshToken(ctx context.Context, token string) (domain.RefreshToken, error) { + rf, err := s.queries.GetRefreshToken(ctx, token) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return domain.RefreshToken{}, authentication.ErrRefreshTokenNotFound + } + return domain.RefreshToken{}, err + } + return domain.RefreshToken{ + Token: rf.Token, + UserID: rf.UserID, + CreatedAt: rf.CreatedAt.Time, + ExpiresAt: rf.ExpiresAt.Time, + Revoked: rf.Revoked, + }, nil +} +func (s *Store) RevokeRefreshToken(ctx context.Context, token string) error { + return s.queries.RevokeRefreshToken(ctx, token) +} diff --git a/internal/repository/bet.go b/internal/repository/bet.go new file mode 100644 index 0000000..b3c4cc3 --- /dev/null +++ b/internal/repository/bet.go @@ -0,0 +1,96 @@ +package repository + +import ( + "context" + + dbgen "github.com/SamuelTariku/FortuneBet-Backend/gen/db" + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" + "github.com/jackc/pgx/v5/pgtype" +) + +func convertDBBet(bet dbgen.Bet) domain.Bet { + return domain.Bet{ + ID: bet.ID, + Amount: domain.Currency(bet.Amount), + TotalOdds: bet.TotalOdds, + Status: domain.BetStatus(bet.Status), + FullName: bet.FullName, + PhoneNumber: bet.PhoneNumber, + BranchID: domain.ValidInt64{ + Value: bet.BranchID.Int64, + Valid: bet.BranchID.Valid, + }, + UserID: domain.ValidInt64{ + Value: bet.UserID.Int64, + Valid: bet.UserID.Valid, + }, + IsShopBet: bet.IsShopBet, + } +} + +func convertCreateBet(bet domain.CreateBet) dbgen.CreateBetParams { + return dbgen.CreateBetParams{ + Amount: int64(bet.Amount), + TotalOdds: bet.TotalOdds, + Status: int32(bet.Status), + FullName: bet.FullName, + PhoneNumber: bet.PhoneNumber, + BranchID: pgtype.Int8{ + Int64: bet.BranchID.Value, + Valid: bet.BranchID.Valid, + }, + UserID: pgtype.Int8{ + Int64: bet.UserID.Value, + Valid: bet.UserID.Valid, + }, + IsShopBet: bet.IsShopBet, + } +} + +func (s *Store) CreateBet(ctx context.Context, bet domain.CreateBet) (domain.Bet, error) { + + newBet, err := s.queries.CreateBet(ctx, convertCreateBet(bet)) + if err != nil { + return domain.Bet{}, err + } + return convertDBBet(newBet), err + +} + +func (s *Store) GetBetByID(ctx context.Context, id int64) (domain.Bet, error) { + bet, err := s.queries.GetBetByID(ctx, id) + if err != nil { + return domain.Bet{}, err + } + + return convertDBBet(bet), nil +} + +func (s *Store) GetAllBets(ctx context.Context) ([]domain.Bet, error) { + bets, err := s.queries.GetAllBets(ctx) + + if err != nil { + return nil, err + } + + var result []domain.Bet + for _, bet := range bets { + result = append(result, convertDBBet(bet)) + } + + return result, nil +} + +func (s *Store) UpdateCashOut(ctx context.Context, id int64, cashedOut bool) error { + err := s.queries.UpdateCashOut(ctx, dbgen.UpdateCashOutParams{ + ID: id, + CashedOut: pgtype.Bool{ + Bool: cashedOut, + }, + }) + return err +} + +func (s *Store) DeleteBet(ctx context.Context, id int64) error { + return s.queries.DeleteBet(ctx, id) +} diff --git a/internal/repository/otp.go b/internal/repository/otp.go new file mode 100644 index 0000000..aaa4c10 --- /dev/null +++ b/internal/repository/otp.go @@ -0,0 +1,60 @@ +package repository + +import ( + "context" + "database/sql" + + dbgen "github.com/SamuelTariku/FortuneBet-Backend/gen/db" + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" + "github.com/jackc/pgx/v5/pgtype" +) + +func (s *Store) CreateOtp(ctx context.Context, otp domain.Otp) error { + return s.queries.CreateOtp(ctx, dbgen.CreateOtpParams{ + SentTo: otp.SentTo, + Medium: string(otp.Medium), + OtpFor: string(otp.For), + Otp: otp.Otp, + ExpiresAt: pgtype.Timestamptz{ + Time: otp.ExpiresAt, + Valid: true, + }, + CreatedAt: pgtype.Timestamptz{ + Time: otp.CreatedAt, + Valid: true, + }, + }) +} +func (s *Store) GetOtp(ctx context.Context, sentTo string, sentfor domain.OtpFor, medium domain.OtpMedium) (domain.Otp, error) { + row, err := s.queries.GetOtp(ctx, dbgen.GetOtpParams{ + SentTo: sentTo, + Medium: string(medium), + OtpFor: string(sentfor), + }) + if err != nil { + if err == sql.ErrNoRows { + return domain.Otp{}, domain.ErrOtpNotFound + } + return domain.Otp{}, err + } + return domain.Otp{ + ID: row.ID, + SentTo: row.SentTo, + Medium: domain.OtpMedium(row.Medium), + For: domain.OtpFor(row.OtpFor), + Otp: row.Otp, + Used: row.Used, + UsedAt: row.UsedAt.Time, + CreatedAt: row.CreatedAt.Time, + ExpiresAt: row.ExpiresAt.Time, + }, nil +} +func (s *Store) MarkOtpAsUsed(ctx context.Context, otp domain.Otp) error { + return s.queries.MarkOtpAsUsed(ctx, dbgen.MarkOtpAsUsedParams{ + ID: otp.ID, + UsedAt: pgtype.Timestamptz{ + Time: otp.UsedAt, + Valid: true, + }, + }) +} diff --git a/internal/repository/ticket.go b/internal/repository/ticket.go new file mode 100644 index 0000000..b2ba5b7 --- /dev/null +++ b/internal/repository/ticket.go @@ -0,0 +1,68 @@ +package repository + +import ( + "context" + + dbgen "github.com/SamuelTariku/FortuneBet-Backend/gen/db" + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" + "github.com/jackc/pgx/v5/pgtype" +) + +func convertDBTicket(ticket dbgen.Ticket) domain.Ticket { + return domain.Ticket{ + ID: ticket.ID, + Amount: domain.Currency(ticket.Amount.Int64), + TotalOdds: ticket.TotalOdds, + } +} + +func convertCreateTicket(ticket domain.CreateTicket) dbgen.CreateTicketParams { + return dbgen.CreateTicketParams{ + Amount: pgtype.Int8{ + Int64: int64(ticket.Amount), + }, + TotalOdds: ticket.TotalOdds, + } +} + +func (s *Store) CreateTicket(ctx context.Context, ticket domain.CreateTicket) (domain.Ticket, error) { + + newTicket, err := s.queries.CreateTicket(ctx, convertCreateTicket(ticket)) + if err != nil { + return domain.Ticket{}, err + } + return convertDBTicket(newTicket), err + +} + +func (s *Store) GetTicketByID(ctx context.Context, id int64) (domain.Ticket, error) { + ticket, err := s.queries.GetTicketByID(ctx, id) + if err != nil { + return domain.Ticket{}, err + } + + return convertDBTicket(ticket), nil +} + +func (s *Store) GetAllTickets(ctx context.Context) ([]domain.Ticket, error) { + tickets, err := s.queries.GetAllTickets(ctx) + + if err != nil { + return nil, err + } + + var result []domain.Ticket + for _, ticket := range tickets { + result = append(result, convertDBTicket(ticket)) + } + + return result, nil +} + +func (s *Store) DeleteOldTickets(ctx context.Context) error { + return s.queries.DeleteOldTickets(ctx) +} + +func (s *Store) DeleteTicket(ctx context.Context, id int64) error { + return s.queries.DeleteTicket(ctx, id) +} diff --git a/internal/repository/user.go b/internal/repository/user.go index b19798f..2177b1e 100644 --- a/internal/repository/user.go +++ b/internal/repository/user.go @@ -2,47 +2,84 @@ package repository import ( "context" + "database/sql" + "errors" + "time" dbgen "github.com/SamuelTariku/FortuneBet-Backend/gen/db" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" + "github.com/jackc/pgx/v5/pgtype" ) -func (s *Store) CreateUser(ctx context.Context, firstName, lastName, email, phoneNumber, password, role string, verified bool) (domain.User, error) { - user, err := s.queries.CreateUser(ctx, dbgen.CreateUserParams{ - FirstName: firstName, - LastName: lastName, - Email: email, - PhoneNumber: phoneNumber, - Password: password, - Role: role, +func (s *Store) CreateUser(ctx context.Context, user domain.User, usedOtpId int64) (domain.User, error) { + err := s.queries.MarkOtpAsUsed(ctx, dbgen.MarkOtpAsUsedParams{ + ID: usedOtpId, + UsedAt: pgtype.Timestamptz{ + Time: time.Now(), + Valid: true, + }, + }) + if err != nil { + return domain.User{}, err + } + userRes, err := s.queries.CreateUser(ctx, dbgen.CreateUserParams{ + FirstName: user.FirstName, + LastName: user.LastName, + Email: pgtype.Text{ + String: user.Email, + Valid: user.Email != "", + }, + PhoneNumber: pgtype.Text{ + String: user.PhoneNumber, + Valid: user.PhoneNumber != "", + }, + Password: user.Password, + Role: string(user.Role), + EmailVerified: user.EmailVerified, + PhoneVerified: user.PhoneVerified, + CreatedAt: pgtype.Timestamptz{ + Time: time.Now(), + Valid: true, + }, + UpdatedAt: pgtype.Timestamptz{ + Time: time.Now(), + Valid: true, + }, }) if err != nil { return domain.User{}, err } return domain.User{ - ID: user.ID, - FirstName: user.FirstName, - LastName: user.LastName, - Email: user.Email, - PhoneNumber: user.PhoneNumber, - Password: user.Password, - Role: user.Role, + ID: userRes.ID, + FirstName: userRes.FirstName, + LastName: userRes.LastName, + Email: userRes.Email.String, + PhoneNumber: userRes.PhoneNumber.String, + Role: domain.Role(userRes.Role), }, nil - } func (s *Store) GetUserByID(ctx context.Context, id int64) (domain.User, error) { user, err := s.queries.GetUserByID(ctx, id) if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return domain.User{}, domain.ErrUserNotFound + } return domain.User{}, err } return domain.User{ - ID: user.ID, - FirstName: user.FirstName, - LastName: user.LastName, - Email: user.Email, - PhoneNumber: user.PhoneNumber, - Password: user.Password, - Role: user.Role, + ID: user.ID, + FirstName: user.FirstName, + LastName: user.LastName, + Email: user.Email.String, + PhoneNumber: user.PhoneNumber.String, + Role: domain.Role(user.Role), + EmailVerified: user.EmailVerified, + Password: user.Password, + PhoneVerified: user.PhoneVerified, + CreatedAt: user.CreatedAt.Time, + UpdatedAt: user.UpdatedAt.Time, + SuspendedAt: user.SuspendedAt.Time, + Suspended: user.Suspended, }, nil } func (s *Store) GetAllUsers(ctx context.Context) ([]domain.User, error) { @@ -50,32 +87,125 @@ func (s *Store) GetAllUsers(ctx context.Context) ([]domain.User, error) { if err != nil { return nil, err } - var result []domain.User - for _, user := range users { - result = append(result, domain.User{ + userList := make([]domain.User, len(users)) + for i, user := range users { + userList[i] = domain.User{ ID: user.ID, FirstName: user.FirstName, LastName: user.LastName, - Email: user.Email, - PhoneNumber: user.PhoneNumber, - Password: user.Password, - Role: user.Role, - }) + Email: user.Email.String, + PhoneNumber: user.PhoneNumber.String, + Role: domain.Role(user.Role), + } } - return result, nil + return userList, nil } -func (s *Store) UpdateUser(ctx context.Context, id int64, firstName, lastName, email, phoneNumber, password, role string, verified bool) error { +func (s *Store) UpdateUser(ctx context.Context, user domain.UpdateUserReq) error { err := s.queries.UpdateUser(ctx, dbgen.UpdateUserParams{ - ID: id, - FirstName: firstName, - LastName: lastName, - Email: email, - PhoneNumber: phoneNumber, - Password: password, - Role: role, + // ID: user.ID, + // FirstName: user.FirstName, + // LastName: user.LastName, + // Email: user.Email, + // PhoneNumber: user.PhoneNumber, + }) - return err + if err != nil { + return err + } + return nil } func (s *Store) DeleteUser(ctx context.Context, id int64) error { - return s.queries.DeleteUser(ctx, id) + err := s.queries.DeleteUser(ctx, id) + if err != nil { + return err + } + return nil +} +func (s *Store) CheckPhoneEmailExist(ctx context.Context, phoneNum, email string) (bool, bool, error) { + + row, err := s.queries.CheckPhoneEmailExist(ctx, dbgen.CheckPhoneEmailExistParams{ + PhoneNumber: pgtype.Text{ + String: phoneNum, + Valid: phoneNum != "", + }, + Email: pgtype.Text{ + String: email, + + Valid: email != "", + }, + }) + + if err != nil { + return false, false, err + } + return row.EmailExists, row.PhoneExists, nil +} + +func (s *Store) GetUserByEmail(ctx context.Context, email string) (domain.User, error) { + user, err := s.queries.GetUserByEmail(ctx, pgtype.Text{ + String: email, + Valid: true, + }) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return domain.User{}, domain.ErrUserNotFound + } + return domain.User{}, err + } + return domain.User{ + ID: user.ID, + FirstName: user.FirstName, + LastName: user.LastName, + Email: user.Email.String, + PhoneNumber: user.PhoneNumber.String, + Role: domain.Role(user.Role), + }, nil +} +func (s *Store) GetUserByPhone(ctx context.Context, phoneNum string) (domain.User, error) { + user, err := s.queries.GetUserByPhone(ctx, pgtype.Text{ + String: phoneNum, + Valid: true, + }) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return domain.User{}, domain.ErrUserNotFound + } + return domain.User{}, err + } + return domain.User{ + ID: user.ID, + FirstName: user.FirstName, + LastName: user.LastName, + Email: user.Email.String, + PhoneNumber: user.PhoneNumber.String, + Role: domain.Role(user.Role), + }, nil +} + +func (s *Store) UpdatePassword(ctx context.Context, identifier string, password []byte, usedOtpId int64) error { + err := s.queries.MarkOtpAsUsed(ctx, dbgen.MarkOtpAsUsedParams{ + ID: usedOtpId, + UsedAt: pgtype.Timestamptz{ + Time: time.Now(), + Valid: true, + }, + }) + if err != nil { + return err + } + err = s.queries.UpdatePassword(ctx, dbgen.UpdatePasswordParams{ + Password: password, + Email: pgtype.Text{ + String: identifier, + Valid: true, + }, + PhoneNumber: pgtype.Text{ + String: identifier, + Valid: true, + }, + }) + if err != nil { + return err + } + return nil } diff --git a/internal/services/authentication/impl.go b/internal/services/authentication/impl.go new file mode 100644 index 0000000..ea8de4d --- /dev/null +++ b/internal/services/authentication/impl.go @@ -0,0 +1,122 @@ +package authentication + +import ( + "context" + "crypto/rand" + "encoding/base32" + "errors" + "time" + + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" + "golang.org/x/crypto/bcrypt" +) + +var ( + ErrInvalidPassword = errors.New("incorrect password") + ErrUserNotFound = errors.New("user not found") + ErrExpiredToken = errors.New("token expired") + ErrRefreshTokenNotFound = errors.New("refresh token not found") // i.e login again +) + +type LoginSuccess struct { + UserId int64 + Role domain.Role + RfToken string +} + +func (s *Service) Login(ctx context.Context, email, phone string, password string) (LoginSuccess, error) { + user, err := s.userStore.GetUserByEmailPhone(ctx, email, phone) + if err != nil { + return LoginSuccess{}, err + } + err = matchPassword(password, user.Password) + if err != nil { + return LoginSuccess{}, err + } + + refreshToken, err := generateRefreshToken() + if err != nil { + return LoginSuccess{}, err + } + err = s.tokenStore.CreateRefreshToken(ctx, domain.RefreshToken{ + Token: refreshToken, + UserID: user.ID, + CreatedAt: time.Now(), + ExpiresAt: time.Now().Add(time.Duration(s.RefreshExpiry) * time.Second), + }) + if err != nil { + return LoginSuccess{}, err + } + return LoginSuccess{ + UserId: user.ID, + Role: user.Role, + RfToken: refreshToken, + }, nil +} + +func (s *Service) RefreshToken(ctx context.Context, refToken string) (string, error) { + + token, err := s.tokenStore.GetRefreshToken(ctx, refToken) + if err != nil { + return "", err + } + if token.Revoked { + return "", ErrRefreshTokenNotFound + } + if token.ExpiresAt.Before(time.Now()) { + return "", ErrExpiredToken + } + + newRefToken, err := generateRefreshToken() + if err != nil { + return "", err + } + + err = s.tokenStore.CreateRefreshToken(ctx, domain.RefreshToken{ + Token: newRefToken, + UserID: token.UserID, + CreatedAt: time.Now(), + ExpiresAt: time.Now().Add(time.Duration(s.RefreshExpiry) * time.Second), + }) + if err != nil { + return "", err + } + return newRefToken, nil +} +func (s *Service) Logout(ctx context.Context, refToken string) error { + token, err := s.tokenStore.GetRefreshToken(ctx, refToken) + if err != nil { + return err + } + if token.Revoked { + return ErrRefreshTokenNotFound + } + if token.ExpiresAt.Before(time.Now()) { + return ErrExpiredToken + } + + return s.tokenStore.RevokeRefreshToken(ctx, refToken) +} + +func matchPassword(plaintextPassword string, hash []byte) error { + err := bcrypt.CompareHashAndPassword(hash, []byte(plaintextPassword)) + if err != nil { + switch { + case errors.Is(err, bcrypt.ErrMismatchedHashAndPassword): + return ErrInvalidPassword + default: + return err + } + } + + return err +} +func generateRefreshToken() (string, error) { + randomBytes := make([]byte, 32) + _, err := rand.Read(randomBytes) + if err != nil { + return "", err + } + plaintext := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(randomBytes) + return plaintext, nil +} diff --git a/internal/services/authentication/port.go b/internal/services/authentication/port.go new file mode 100644 index 0000000..d177dbe --- /dev/null +++ b/internal/services/authentication/port.go @@ -0,0 +1,16 @@ +package authentication + +import ( + "context" + + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" +) + +type UserStore interface { + GetUserByEmailPhone(ctx context.Context, email, phone string) (domain.User, error) +} +type TokenStore interface { + CreateRefreshToken(ctx context.Context, rt domain.RefreshToken) error + GetRefreshToken(ctx context.Context, token string) (domain.RefreshToken, error) + RevokeRefreshToken(ctx context.Context, token string) error +} diff --git a/internal/services/authentication/service.go b/internal/services/authentication/service.go new file mode 100644 index 0000000..577e9da --- /dev/null +++ b/internal/services/authentication/service.go @@ -0,0 +1,28 @@ +package authentication + +// type EmailPhone struct { +// Email ValidString +// PhoneNumber ValidString +// Password ValidString +// } +type ValidString struct { + Value string + Valid bool +} +type Tokens struct { + AccessToken string + RefreshToken string +} +type Service struct { + userStore UserStore + tokenStore TokenStore + RefreshExpiry int +} + +func NewService(userStore UserStore, tokenStore TokenStore, RefreshExpiry int) *Service { + return &Service{ + userStore: userStore, + tokenStore: tokenStore, + RefreshExpiry: RefreshExpiry, + } +} diff --git a/internal/services/bet/port.go b/internal/services/bet/port.go new file mode 100644 index 0000000..1061b45 --- /dev/null +++ b/internal/services/bet/port.go @@ -0,0 +1,15 @@ +package bet + +import ( + "context" + + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" +) + +type BetStore interface { + CreateBet(ctx context.Context, bet domain.CreateBet) (domain.Bet, error) + GetBetByID(ctx context.Context, id int64) (domain.Bet, error) + GetAllBets(ctx context.Context) ([]domain.Bet, error) + UpdateCashOut(ctx context.Context, id int64, cashedOut bool) error + DeleteBet(ctx context.Context, id int64) error +} diff --git a/internal/services/bet/service.go b/internal/services/bet/service.go new file mode 100644 index 0000000..58b9cc5 --- /dev/null +++ b/internal/services/bet/service.go @@ -0,0 +1,35 @@ +package bet + +import ( + "context" + + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" +) + +type Service struct { + betStore BetStore +} + +func NewService(betStore BetStore) *Service { + return &Service{ + betStore: betStore, + } +} + +func (s *Service) CreateBet(ctx context.Context, bet domain.CreateBet) (domain.Bet, error) { + return s.betStore.CreateBet(ctx, bet) +} +func (s *Service) GetBetByID(ctx context.Context, id int64) (domain.Bet, error) { + return s.betStore.GetBetByID(ctx, id) +} +func (s *Service) GetAllBets(ctx context.Context) ([]domain.Bet, error) { + return s.betStore.GetAllBets(ctx) +} + +func (s *Service) UpdateCashOut(ctx context.Context, id int64, cashedOut bool) error { + return s.betStore.UpdateCashOut(ctx, id, cashedOut) +} + +func (s *Service) DeleteBet(ctx context.Context, id int64) error { + return s.betStore.DeleteBet(ctx, id) +} diff --git a/internal/services/sportsbook/events.go b/internal/services/sportsbook/events.go new file mode 100644 index 0000000..9dd0eb7 --- /dev/null +++ b/internal/services/sportsbook/events.go @@ -0,0 +1 @@ +package sportsbook diff --git a/internal/services/sportsbook/odds.go b/internal/services/sportsbook/odds.go new file mode 100644 index 0000000..9dd0eb7 --- /dev/null +++ b/internal/services/sportsbook/odds.go @@ -0,0 +1 @@ +package sportsbook diff --git a/internal/services/sportsbook/service.go b/internal/services/sportsbook/service.go new file mode 100644 index 0000000..9dd0eb7 --- /dev/null +++ b/internal/services/sportsbook/service.go @@ -0,0 +1 @@ +package sportsbook diff --git a/internal/services/ticket/port.go b/internal/services/ticket/port.go new file mode 100644 index 0000000..042d27a --- /dev/null +++ b/internal/services/ticket/port.go @@ -0,0 +1,15 @@ +package ticket + +import ( + "context" + + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" +) + +type TicketStore interface { + CreateTicket(ctx context.Context, ticket domain.CreateTicket) (domain.Ticket, error) + GetTicketByID(ctx context.Context, id int64) (domain.Ticket, error) + GetAllTickets(ctx context.Context) ([]domain.Ticket, error) + DeleteOldTickets(ctx context.Context) error + DeleteTicket(ctx context.Context, id int64) error +} diff --git a/internal/services/ticket/service.go b/internal/services/ticket/service.go new file mode 100644 index 0000000..5779ce4 --- /dev/null +++ b/internal/services/ticket/service.go @@ -0,0 +1,30 @@ +package ticket + +import ( + "context" + + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" +) + +type Service struct { + ticketStore TicketStore +} + +func NewService(ticketStore TicketStore) *Service { + return &Service{ + ticketStore: ticketStore, + } +} + +func (s *Service) CreateTicket(ctx context.Context, ticket domain.CreateTicket) (domain.Ticket, error) { + return s.ticketStore.CreateTicket(ctx, ticket) +} +func (s *Service) GetTicketByID(ctx context.Context, id int64) (domain.Ticket, error) { + return s.ticketStore.GetTicketByID(ctx, id) +} +func (s *Service) GetAllTickets(ctx context.Context) ([]domain.Ticket, error) { + return s.ticketStore.GetAllTickets(ctx) +} +func (s *Service) DeleteTicket(ctx context.Context, id int64) error { + return s.ticketStore.DeleteTicket(ctx, id) +} diff --git a/internal/services/user/common.go b/internal/services/user/common.go new file mode 100644 index 0000000..9adf8e4 --- /dev/null +++ b/internal/services/user/common.go @@ -0,0 +1,44 @@ +package user + +import ( + "context" + "time" + + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" + "golang.org/x/crypto/bcrypt" +) + +func (s *Service) SendOtp(ctx context.Context, sentTo string, otpFor domain.OtpFor, medium domain.OtpMedium) error { + otpCode := "123456" // Generate OTP code + + otp := domain.Otp{ + SentTo: sentTo, + Medium: medium, + For: otpFor, + Otp: otpCode, + Used: false, + CreatedAt: time.Now(), + ExpiresAt: time.Now().Add(OtpExpiry), + } + + err := s.otpStore.CreateOtp(ctx, otp) + if err != nil { + return err + } + + switch medium { + case domain.OtpMediumSms: + return s.smsGateway.SendSMSOTP(ctx, sentTo, otpCode) + case domain.OtpMediumEmail: + return s.emailGateway.SendEmailOTP(ctx, sentTo, otpCode) + } + return nil +} +func hashPassword(plaintextPassword string) ([]byte, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(plaintextPassword), 12) + if err != nil { + return []byte{}, err + } + + return hash, nil +} diff --git a/internal/services/user/port.go b/internal/services/user/port.go index 34700ad..aaf502d 100644 --- a/internal/services/user/port.go +++ b/internal/services/user/port.go @@ -7,9 +7,24 @@ import ( ) type UserStore interface { - CreateUser(ctx context.Context, CfirstName, lastName, email, phoneNumber, password, role string, verified bool) (domain.User, error) + CreateUser(ctx context.Context, user domain.User, usedOtpId int64) (domain.User, error) GetUserByID(ctx context.Context, id int64) (domain.User, error) GetAllUsers(ctx context.Context) ([]domain.User, error) - UpdateUser(ctx context.Context, id int64, firstName, lastName, email, phoneNumber, password, role string, verified bool) error + UpdateUser(ctx context.Context, user domain.UpdateUserReq) error DeleteUser(ctx context.Context, id int64) error + CheckPhoneEmailExist(ctx context.Context, phoneNum, email string) (bool, bool, error) + GetUserByEmail(ctx context.Context, email string) (domain.User, error) + GetUserByPhone(ctx context.Context, phoneNum string) (domain.User, error) + // + UpdatePassword(ctx context.Context, identifier string, password []byte, usedOtpId int64) error // identifier verified email or phone +} +type SmsGateway interface { + SendSMSOTP(ctx context.Context, phoneNumber, otp string) error +} +type EmailGateway interface { + SendEmailOTP(ctx context.Context, email string, otp string) error +} +type OtpStore interface { + CreateOtp(ctx context.Context, otp domain.Otp) error + GetOtp(ctx context.Context, sentTo string, sentfor domain.OtpFor, medium domain.OtpMedium) (domain.Otp, error) } diff --git a/internal/services/user/register.go b/internal/services/user/register.go new file mode 100644 index 0000000..7966254 --- /dev/null +++ b/internal/services/user/register.go @@ -0,0 +1,78 @@ +package user + +import ( + "context" + "time" + + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" +) + +func (s *Service) CheckPhoneEmailExist(ctx context.Context, phoneNum, email string) (bool, bool, error) { // email,phone,error + return s.userStore.CheckPhoneEmailExist(ctx, phoneNum, email) +} +func (s *Service) SendRegisterCode(ctx context.Context, medium domain.OtpMedium, sentTo string) error { + var err error + // check if user exists + switch medium { + case domain.OtpMediumEmail: + _, err = s.userStore.GetUserByEmail(ctx, sentTo) + case domain.OtpMediumSms: + _, err = s.userStore.GetUserByPhone(ctx, sentTo) + } + + if err != nil && err != domain.ErrUserNotFound { + + return err + } + + // send otp based on the medium + return s.SendOtp(ctx, sentTo, domain.OtpRegister, medium) +} +func (s *Service) RegisterUser(ctx context.Context, registerReq domain.RegisterUserReq) (domain.User, error) { // normal + // get otp + + var sentTo string + if registerReq.OtpMedium == domain.OtpMediumEmail { + sentTo = registerReq.Email + } else { + sentTo = registerReq.PhoneNumber + } + // + otp, err := s.otpStore.GetOtp( + ctx, sentTo, + domain.OtpRegister, registerReq.OtpMedium) + if err != nil { + return domain.User{}, err + } + // verify otp + if otp.Used { + return domain.User{}, domain.ErrOtpAlreadyUsed + } + if time.Now().After(otp.ExpiresAt) { + return domain.User{}, domain.ErrOtpExpired + } + if otp.Otp != registerReq.Otp { + return domain.User{}, domain.ErrInvalidOtp + } + + hashedPassword, err := hashPassword(registerReq.Password) + if err != nil { + return domain.User{}, err + } + userR := domain.User{ + FirstName: registerReq.FirstName, + LastName: registerReq.LastName, + Email: registerReq.Email, + PhoneNumber: registerReq.PhoneNumber, + Password: hashedPassword, + Role: "user", + EmailVerified: registerReq.OtpMedium == domain.OtpMediumEmail, + PhoneVerified: registerReq.OtpMedium == domain.OtpMediumSms, + } + // create the user and mark otp as used + user, err := s.userStore.CreateUser(ctx, userR, otp.ID) + if err != nil { + return domain.User{}, err + } + return user, nil +} diff --git a/internal/services/user/reset.go b/internal/services/user/reset.go new file mode 100644 index 0000000..70309a8 --- /dev/null +++ b/internal/services/user/reset.go @@ -0,0 +1,63 @@ +package user + +import ( + "context" + "time" + + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" +) + +func (s *Service) SendResetCode(ctx context.Context, medium domain.OtpMedium, sentTo string) error { + + var err error + // check if user exists + switch medium { + case domain.OtpMediumEmail: + _, err = s.userStore.GetUserByEmail(ctx, sentTo) + case domain.OtpMediumSms: + _, err = s.userStore.GetUserByPhone(ctx, sentTo) + } + + if err != nil { + return err + } + + return s.SendOtp(ctx, sentTo, domain.OtpReset, medium) + +} + +func (s *Service) ResetPassword(ctx context.Context, resetReq domain.ResetPasswordReq) error { + var sentTo string + if resetReq.OtpMedium == domain.OtpMediumEmail { + sentTo = resetReq.Email + } else { + sentTo = resetReq.PhoneNumber + } + otp, err := s.otpStore.GetOtp( + ctx, sentTo, + domain.OtpReset, resetReq.OtpMedium) + if err != nil { + return err + } + // + if otp.Used { + return domain.ErrOtpAlreadyUsed + } + if time.Now().After(otp.ExpiresAt) { + return domain.ErrOtpExpired + } + if otp.Otp != resetReq.Otp { + return domain.ErrInvalidOtp + } + // hash password + hashedPassword, err := hashPassword(resetReq.Password) + if err != nil { + return err + } + // reset pass and mark otp as used + err = s.userStore.UpdatePassword(ctx, sentTo, hashedPassword, otp.ID) + if err != nil { + return err + } + return nil +} diff --git a/internal/services/user/service.go b/internal/services/user/service.go index d427b66..cfa93fd 100644 --- a/internal/services/user/service.go +++ b/internal/services/user/service.go @@ -1,33 +1,29 @@ package user import ( - "context" + "time" +) - "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" +const ( + OtpExpiry = 5 * time.Minute ) type Service struct { - userStore UserStore + userStore UserStore + otpStore OtpStore + smsGateway SmsGateway + emailGateway EmailGateway } -func NewService(userStore UserStore) *Service { +func NewService( + userStore UserStore, + otpStore OtpStore, smsGateway SmsGateway, + emailGateway EmailGateway, +) *Service { return &Service{ - userStore: userStore, + userStore: userStore, + otpStore: otpStore, + smsGateway: smsGateway, + emailGateway: emailGateway, } } - -func (s *Service) CreateUser(ctx context.Context, firstName, lastName, email, phoneNumber, password, role string, verified bool) (domain.User, error) { - return s.userStore.CreateUser(ctx, firstName, lastName, email, phoneNumber, password, role, verified) -} -func (s *Service) GetUserByID(ctx context.Context, id int64) (domain.User, error) { - return s.userStore.GetUserByID(ctx, id) -} -func (s *Service) GetAllUsers(ctx context.Context) ([]domain.User, error) { - return s.userStore.GetAllUsers(ctx) -} -func (s *Service) UpdateUser(ctx context.Context, id int64, firstName, lastName, email, phoneNumber, password, role string, verified bool) error { - return s.userStore.UpdateUser(ctx, id, firstName, lastName, email, phoneNumber, password, role, verified) -} -func (s *Service) DeleteUser(ctx context.Context, id int64) error { - return s.userStore.DeleteUser(ctx, id) -} diff --git a/internal/services/user/user.go b/internal/services/user/user.go new file mode 100644 index 0000000..5b65b94 --- /dev/null +++ b/internal/services/user/user.go @@ -0,0 +1,16 @@ +package user + +import ( + "context" + + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" +) + +func (s *Service) UpdateUser(ctx context.Context, user domain.UpdateUserReq) error { + // update user + return s.userStore.UpdateUser(ctx, user) + +} +func (s *Service) GetUserByID(ctx context.Context, id int64) (domain.User, error) { + return s.userStore.GetUserByID(ctx, id) +} diff --git a/internal/web_server/app.go b/internal/web_server/app.go index 710ec37..c27e08b 100644 --- a/internal/web_server/app.go +++ b/internal/web_server/app.go @@ -4,19 +4,41 @@ import ( "fmt" "log/slog" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/authentication" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/bet" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/ticket" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/user" + jwtutil "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/jwt" + customvalidator "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/validator" "log/slog" + notificationservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/notfication" "github.com/bytedance/sonic" "github.com/gofiber/fiber/v2" ) type App struct { - fiber *fiber.App + fiber *fiber.App + logger *slog.Logger NotidicationStore notificationservice.NotificationStore - port int + port int + authSvc *authentication.Service + userSvc *user.Service + ticketSvc *ticket.Service + betSvc *bet.Service + validator *customvalidator.CustomValidator + JwtConfig jwtutil.JwtConfig Logger *slog.Logger } -func NewApp(port int, logger *slog.Logger, notidicationStore notificationservice.NotificationStore) *App { +func NewApp( + port int, validator *customvalidator.CustomValidator, + authSvc *authentication.Service, + logger *slog.Logger, + JwtConfig jwtutil.JwtConfig, + userSvc *user.Service, + ticketSvc *ticket.Service, + betSvc *bet.Service, +, notidicationStore notificationservice.NotificationStore) *App { app := fiber.New(fiber.Config{ CaseSensitive: true, DisableHeaderNormalizing: true, @@ -24,8 +46,15 @@ func NewApp(port int, logger *slog.Logger, notidicationStore notificationservice JSONDecoder: sonic.Unmarshal, }) s := &App{ - fiber: app, - port: port, + fiber: app, + port: port, + authSvc: authSvc, + validator: validator, + logger: logger, + JwtConfig: JwtConfig, + userSvc: userSvc, + ticketSvc: ticketSvc, + betSvc: betSvc, NotidicationStore: notidicationStore, Logger: logger, } diff --git a/internal/web_server/app_routes.go b/internal/web_server/app_routes.go deleted file mode 100644 index da6618c..0000000 --- a/internal/web_server/app_routes.go +++ /dev/null @@ -1,35 +0,0 @@ -package httpserver - -import ( - "context" - - "github.com/gofiber/fiber/v2" - "github.com/gofiber/websocket/v2" -) - -func (a *App) initAppRoutes() { - // a.fiber.Group("/users", users.CreateAccount(a.userAPI)) - - a.fiber.Get("/ws/:recipientID", func(c *fiber.Ctx) error { - if websocket.IsWebSocketUpgrade(c) { - c.Locals("allowed", true) - return c.Next() - } - return fiber.ErrUpgradeRequired - }, websocket.New(func(c *websocket.Conn) { - recipientID := c.Params("recipientID") - a.NotidicationStore.ConnectWebSocket(context.Background(), recipientID, c) - - defer a.NotidicationStore.DisconnectWebSocket(recipientID) - - for { - _, _, err := c.ReadMessage() - if err != nil { - if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { - a.Logger.Error("WebSocket error", "recipientID", recipientID, "error", err) - } - return - } - } - })) -} diff --git a/internal/web_server/handlers/auth_handler.go b/internal/web_server/handlers/auth_handler.go new file mode 100644 index 0000000..0022827 --- /dev/null +++ b/internal/web_server/handlers/auth_handler.go @@ -0,0 +1,182 @@ +package handlers + +import ( + "errors" + "log/slog" + + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/authentication" + jwtutil "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/jwt" + "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/response" + customvalidator "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/validator" + "github.com/gofiber/fiber/v2" +) + +type loginCustomerReq struct { + Email string `json:"email" example:"john.doe@example.com"` + PhoneNumber string `json:"phone_number" example:"1234567890"` + Password string `json:"password" example:"password123"` +} + +type loginCustomerRes struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` +} + +// LoginCustomer godoc +// @Summary Login customer +// @Description Login customer +// @Tags auth +// @Accept json +// @Produce json +// @Param login body loginCustomerReq true "Login customer" +// @Success 200 {object} loginCustomerRes +// @Failure 400 {object} response.APIResponse +// @Failure 401 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Router /auth/login [post] +func LoginCustomer( + logger *slog.Logger, authSvc *authentication.Service, + validator *customvalidator.CustomValidator, JwtConfig jwtutil.JwtConfig) fiber.Handler { + return func(c *fiber.Ctx) error { + var req loginCustomerReq + if err := c.BodyParser(&req); err != nil { + logger.Error("Login failed", "error", err) + response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", nil, nil) + } + valErrs, ok := validator.Validate(c, req) + if !ok { + + response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", valErrs, nil) + return nil + } + successRes, err := authSvc.Login(c.Context(), req.Email, req.PhoneNumber, req.Password) + if err != nil { + logger.Info("Login failed", "error", err) + if errors.Is(err, authentication.ErrInvalidPassword) { + response.WriteJSON(c, fiber.StatusUnauthorized, "Invalid password or not registered", nil, nil) + return nil + } + if errors.Is(err, authentication.ErrUserNotFound) { + response.WriteJSON(c, fiber.StatusUnauthorized, "Invalid password or not registered", nil, nil) + return nil + } + logger.Error("Login failed", "error", err) + response.WriteJSON(c, fiber.StatusInternalServerError, "Internal server error", nil, nil) + return nil + + } + accessToken, err := jwtutil.CreateJwt(successRes.UserId, successRes.Role, JwtConfig.JwtAccessKey, JwtConfig.JwtAccessExpiry) + res := loginCustomerRes{ + AccessToken: accessToken, + RefreshToken: successRes.RfToken, + } + return response.WriteJSON(c, fiber.StatusOK, "Login successful", res, nil) + } +} + +type refreshToken struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` +} + +// RefreshToken godoc +// @Summary Refresh token +// @Description Refresh token +// @Tags auth +// @Accept json +// @Produce json +// @Param refresh body refreshToken true "tokens" +// @Success 200 {object} loginCustomerRes +// @Failure 400 {object} response.APIResponse +// @Failure 401 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Router /auth/refresh [post] +func RefreshToken(logger *slog.Logger, authSvc *authentication.Service, + validator *customvalidator.CustomValidator, JwtConfig jwtutil.JwtConfig) fiber.Handler { + return func(c *fiber.Ctx) error { + var req refreshToken + if err := c.BodyParser(&req); err != nil { + response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", nil, nil) + } + valErrs, ok := validator.Validate(c, req) + if !ok { + response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", valErrs, nil) + return nil + } + rf, err := authSvc.RefreshToken(c.Context(), req.RefreshToken) + if err != nil { + logger.Info("Refresh token failed", "error", err) + if errors.Is(err, authentication.ErrExpiredToken) { + response.WriteJSON(c, fiber.StatusUnauthorized, "The refresh token has expired", nil, nil) + return nil + } + if errors.Is(err, authentication.ErrRefreshTokenNotFound) { + response.WriteJSON(c, fiber.StatusUnauthorized, "Refresh token not found", nil, nil) + return nil + } + logger.Error("Refresh token failed", "error", err) + response.WriteJSON(c, fiber.StatusInternalServerError, "Internal server error", nil, nil) + return nil + } + accessToken, err := jwtutil.CreateJwt(0, "", JwtConfig.JwtAccessKey, JwtConfig.JwtAccessExpiry) + if err != nil { + logger.Error("Create jwt failed", "error", err) + response.WriteJSON(c, fiber.StatusInternalServerError, "Internal server error", nil, nil) + return nil + } + + res := loginCustomerRes{ + AccessToken: accessToken, + RefreshToken: rf, + } + return response.WriteJSON(c, fiber.StatusOK, "refresh successful", res, nil) + } +} + +type logoutReq struct { + RefreshToken string `json:"refresh_token"` +} + +// LogOutCustomer godoc +// @Summary Logout customer +// @Description Logout customer +// @Tags auth +// @Accept json +// @Produce json +// @Param logout body logoutReq true "Logout customer" +// @Success 200 {object} response.APIResponse +// @Failure 400 {object} response.APIResponse +// @Failure 401 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Router /auth/logout [post] +func LogOutCustomer( + logger *slog.Logger, authSvc *authentication.Service, + validator *customvalidator.CustomValidator) fiber.Handler { + return func(c *fiber.Ctx) error { + var req logoutReq + if err := c.BodyParser(&req); err != nil { + response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", nil, nil) + } + valErrs, ok := validator.Validate(c, req) + if !ok { + response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", valErrs, nil) + return nil + } + err := authSvc.Logout(c.Context(), req.RefreshToken) + if err != nil { + logger.Info("Logout failed", "error", err) + if errors.Is(err, authentication.ErrExpiredToken) { + response.WriteJSON(c, fiber.StatusUnauthorized, "The refresh token has expired", nil, nil) + return nil + } + if errors.Is(err, authentication.ErrRefreshTokenNotFound) { + response.WriteJSON(c, fiber.StatusUnauthorized, "Refresh token not found", nil, nil) + return nil + } + logger.Error("Logout failed", "error", err) + response.WriteJSON(c, fiber.StatusInternalServerError, "Internal server error", nil, nil) + return nil + } + return response.WriteJSON(c, fiber.StatusOK, "Logout successful", nil, nil) + } +} diff --git a/internal/web_server/handlers/bet_handler.go b/internal/web_server/handlers/bet_handler.go new file mode 100644 index 0000000..fb49a6f --- /dev/null +++ b/internal/web_server/handlers/bet_handler.go @@ -0,0 +1,266 @@ +package handlers + +import ( + "log/slog" + "strconv" + + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/bet" + "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/response" + customvalidator "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/validator" + "github.com/gofiber/fiber/v2" +) + +type CreateBetReq struct { + Outcomes []int64 `json:"outcomes"` + Amount float32 `json:"amount" example:"100.0"` + TotalOdds float32 `json:"total_odds" example:"4.22"` + Status domain.BetStatus `json:"status" example:"1"` + FullName string `json:"full_name" example:"John"` + PhoneNumber string `json:"phone_number" example:"1234567890"` + IsShopBet bool `json:"is_shop_bet" example:"false"` +} + +type BetRes struct { + ID int64 `json:"id" example:"1"` + Outcomes []domain.Outcome `json:"outcomes"` + Amount float32 `json:"amount" example:"100.0"` + TotalOdds float32 `json:"total_odds" example:"4.22"` + Status domain.BetStatus `json:"status" example:"1"` + FullName string `json:"full_name" example:"John"` + PhoneNumber string `json:"phone_number" example:"1234567890"` + BranchID int64 `json:"branch_id" example:"2"` + UserID int64 `json:"user_id" example:"2"` + IsShopBet bool `json:"is_shop_bet" example:"false"` +} + +func convertBet(bet domain.Bet) BetRes { + return BetRes{ + ID: bet.ID, + Outcomes: bet.Outcomes, + Amount: bet.Amount.Float64(), + TotalOdds: bet.TotalOdds, + Status: bet.Status, + FullName: bet.FullName, + PhoneNumber: bet.PhoneNumber, + BranchID: bet.BranchID.Value, + UserID: bet.UserID.Value, + } +} + +// CreateBet godoc +// @Summary Create a bet +// @Description Creates a bet +// @Tags bet +// @Accept json +// @Produce json +// @Param createBet body CreateBetReq true "Creates bet" +// @Success 200 {object} BetRes +// @Failure 400 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Router /bet [post] +func CreateBet(logger *slog.Logger, betSvc *bet.Service, validator *customvalidator.CustomValidator) fiber.Handler { + return func(c *fiber.Ctx) error { + + // TODO: Check the token, and find the role and get the branch id from there + + // TODO Reduce amount from the branch wallet + + var isShopBet bool = true + var branchID int64 = 1 + var userID int64 + + var req CreateBetReq + + if err := c.BodyParser(&req); err != nil { + logger.Error("CreateBetReq failed", "error", err) + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "Invalid request", + }) + } + + valErrs, ok := validator.Validate(c, req) + if !ok { + response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", valErrs, nil) + return nil + } + + // TODO Validate Outcomes Here and make sure they didn't expire + + bet, err := betSvc.CreateBet(c.Context(), domain.CreateBet{ + Outcomes: req.Outcomes, + Amount: domain.Currency(req.Amount), + TotalOdds: req.TotalOdds, + Status: req.Status, + FullName: req.FullName, + PhoneNumber: req.PhoneNumber, + + BranchID: domain.ValidInt64{ + Value: branchID, + Valid: isShopBet, + }, + UserID: domain.ValidInt64{ + Value: userID, + Valid: !isShopBet, + }, + IsShopBet: req.IsShopBet, + }) + + if err != nil { + logger.Error("CreateBetReq failed", "error", err) + return response.WriteJSON(c, fiber.StatusInternalServerError, "Internal Server Error", err, nil) + } + + res := convertBet(bet) + + return response.WriteJSON(c, fiber.StatusOK, "Bet Created", res, nil) + } +} + +// GetAllBet godoc +// @Summary Gets all bets +// @Description Gets all the bets +// @Tags bet +// @Accept json +// @Produce json +// @Success 200 {array} BetRes +// @Failure 400 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Router /bet [get] +func GetAllBet(logger *slog.Logger, betSvc *bet.Service, validator *customvalidator.CustomValidator) fiber.Handler { + return func(c *fiber.Ctx) error { + bets, err := betSvc.GetAllBets(c.Context()) + + if err != nil { + logger.Error("Failed to get bets", "error", err) + return response.WriteJSON(c, fiber.StatusInternalServerError, "Failed to retrieve bets", err, nil) + } + + var res []BetRes + for _, bet := range bets { + res = append(res, convertBet(bet)) + } + + return response.WriteJSON(c, fiber.StatusOK, "All Bets Retrieved", res, nil) + } +} + +// GetBetByID godoc +// @Summary Gets bet by id +// @Description Gets a single bet by id +// @Tags bet +// @Accept json +// @Produce json +// @Param id path int true "Bet ID" +// @Success 200 {object} BetRes +// @Failure 400 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Router /bet/{id} [get] +func GetBetByID(logger *slog.Logger, betSvc *bet.Service, validator *customvalidator.CustomValidator) fiber.Handler { + return func(c *fiber.Ctx) error { + betID := c.Params("id") + id, err := strconv.ParseInt(betID, 10, 64) + + if err != nil { + logger.Error("Invalid bet ID", "betID", betID, "error", err) + return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid bet ID", err, nil) + } + + bet, err := betSvc.GetBetByID(c.Context(), id) + + if err != nil { + logger.Error("Failed to get bet by ID", "betID", id, "error", err) + return response.WriteJSON(c, fiber.StatusInternalServerError, "Failed to retrieve bet", err, nil) + } + + res := convertBet(bet) + + return response.WriteJSON(c, fiber.StatusOK, "Bet retrieved successfully", res, nil) + + } +} + +type UpdateCashOutReq struct { + CashedOut bool +} + +// UpdateCashOut godoc +// @Summary Updates the cashed out field +// @Description Updates the cashed out field +// @Tags bet +// @Accept json +// @Produce json +// @Param id path int true "Bet ID" +// @Param updateCashOut body UpdateCashOutReq true "Updates Cashed Out" +// @Success 200 {object} response.APIResponse +// @Failure 400 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Router /bet/{id} [patch] +func UpdateCashOut(logger *slog.Logger, betSvc *bet.Service, + validator *customvalidator.CustomValidator) fiber.Handler { + return func(c *fiber.Ctx) error { + betID := c.Params("id") + id, err := strconv.ParseInt(betID, 10, 64) + + if err != nil { + logger.Error("Invalid bet ID", "betID", betID, "error", err) + return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid bet ID", err, nil) + } + + var req UpdateCashOutReq + if err := c.BodyParser(&req); err != nil { + logger.Error("UpdateCashOutReq failed", "error", err) + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "Invalid request", + }) + } + + valErrs, ok := validator.Validate(c, req) + if !ok { + response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", valErrs, nil) + return nil + } + + err = betSvc.UpdateCashOut(c.Context(), id, req.CashedOut) + + if err != nil { + logger.Error("Failed to update cash out bet", "betID", id, "error", err) + return response.WriteJSON(c, fiber.StatusInternalServerError, "Failed to update cash out bet", err, nil) + } + + return response.WriteJSON(c, fiber.StatusOK, "Bet updated successfully", nil, nil) + } +} + +// DeleteBet godoc +// @Summary Deletes bet by id +// @Description Deletes bet by id +// @Tags bet +// @Accept json +// @Produce json +// @Param id path int true "Bet ID" +// @Success 200 {object} response.APIResponse +// @Failure 400 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Router /bet/{id} [delete] +func DeleteBet(logger *slog.Logger, betSvc *bet.Service, + validator *customvalidator.CustomValidator) fiber.Handler { + return func(c *fiber.Ctx) error { + betID := c.Params("id") + id, err := strconv.ParseInt(betID, 10, 64) + + if err != nil { + logger.Error("Invalid bet ID", "betID", betID, "error", err) + return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid bet ID", err, nil) + } + + err = betSvc.DeleteBet(c.Context(), id) + + if err != nil { + logger.Error("Failed to delete by ID", "betID", id, "error", err) + return response.WriteJSON(c, fiber.StatusInternalServerError, "Failed to delete bet", err, nil) + } + + return response.WriteJSON(c, fiber.StatusOK, "Bet removed successfully", nil, nil) + } +} diff --git a/internal/web_server/handlers/ticket_handler.go b/internal/web_server/handlers/ticket_handler.go new file mode 100644 index 0000000..696b738 --- /dev/null +++ b/internal/web_server/handlers/ticket_handler.go @@ -0,0 +1,153 @@ +package handlers + +import ( + "log/slog" + "strconv" + + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/ticket" + "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/response" + customvalidator "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/validator" + "github.com/gofiber/fiber/v2" +) + +type CreateTicketReq struct { + Outcomes []int64 `json:"outcomes"` + Amount float32 `json:"amount" example:"100.0"` + TotalOdds float32 `json:"total_odds" example:"4.22"` +} +type CreateTicketRes struct { + FastCode int64 `json:"fast_code" example:"1234"` +} + +// CreateTicket godoc +// @Summary Create a temporary ticket +// @Description Creates a temporary ticket +// @Tags ticket +// @Accept json +// @Produce json +// @Param createTicket body CreateTicketReq true "Creates ticket" +// @Success 200 {object} CreateTicketRes +// @Failure 400 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Router /ticket [post] +func CreateTicket(logger *slog.Logger, ticketSvc *ticket.Service, + validator *customvalidator.CustomValidator) fiber.Handler { + return func(c *fiber.Ctx) error { + var req CreateTicketReq + if err := c.BodyParser(&req); err != nil { + logger.Error("CreateTicketReq failed", "error", err) + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "Invalid request", + }) + } + + valErrs, ok := validator.Validate(c, req) + if !ok { + response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", valErrs, nil) + return nil + } + + // TODO Validate Outcomes Here and make sure they didn't expire + + ticket, err := ticketSvc.CreateTicket(c.Context(), domain.CreateTicket{ + Outcomes: req.Outcomes, + Amount: domain.Currency(req.Amount), + TotalOdds: req.TotalOdds, + }) + if err != nil { + logger.Error("CreateTicketReq failed", "error", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Internal server error", + }) + } + res := CreateTicketRes{ + FastCode: ticket.ID, + } + return response.WriteJSON(c, fiber.StatusOK, "Ticket Created", res, nil) + } +} + +type TicketRes struct { + ID int64 `json:"id" example:"1"` + Outcomes []domain.Outcome `json:"outcomes"` + Amount float32 `json:"amount" example:"100.0"` + TotalOdds float32 `json:"total_odds" example:"4.22"` +} + +// GetTicketByID godoc +// @Summary Get ticket by ID +// @Description Retrieve ticket details by ticket ID +// @Tags ticket +// @Accept json +// @Produce json +// @Param id path int true "Ticket ID" +// @Success 200 {object} TicketRes +// @Failure 400 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Router /ticket/{id} [get] +func GetTicketByID(logger *slog.Logger, ticketSvc *ticket.Service, + validator *customvalidator.CustomValidator) fiber.Handler { + return func(c *fiber.Ctx) error { + ticketID := c.Params("id") + + id, err := strconv.ParseInt(ticketID, 10, 64) + if err != nil { + logger.Error("Invalid ticket ID", "ticketID", ticketID, "error", err) + return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid ticket ID", err, nil) + } + + ticket, err := ticketSvc.GetTicketByID(c.Context(), id) + + if err != nil { + logger.Error("Failed to get ticket by ID", "ticketID", id, "error", err) + return response.WriteJSON(c, fiber.StatusInternalServerError, "Failed to retrieve ticket", err, nil) + } + + res := TicketRes{ + ID: ticket.ID, + Outcomes: ticket.Outcomes, + Amount: ticket.Amount.Float64(), + TotalOdds: ticket.TotalOdds, + } + + return response.WriteJSON(c, fiber.StatusOK, "Ticket retrieved successfully", res, nil) + + } +} + +// GetAllTickets godoc +// @Summary Get all tickets +// @Description Retrieve all tickets +// @Tags ticket +// @Accept json +// @Produce json +// @Success 200 {array} TicketRes +// @Failure 400 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Router /ticket [get] +func GetAllTickets(logger *slog.Logger, ticketSvc *ticket.Service, + validator *customvalidator.CustomValidator) fiber.Handler { + return func(c *fiber.Ctx) error { + tickets, err := ticketSvc.GetAllTickets(c.Context()) + + if err != nil { + logger.Error("Failed to get tickets", "error", err) + return response.WriteJSON(c, fiber.StatusInternalServerError, "Failed to retrieve tickets", err, nil) + } + + var res []TicketRes + + for _, ticket := range tickets { + res = append(res, TicketRes{ + ID: ticket.ID, + Outcomes: ticket.Outcomes, + Amount: ticket.Amount.Float64(), + TotalOdds: ticket.TotalOdds, + }) + } + + return response.WriteJSON(c, fiber.StatusOK, "All Tickets retrieved", res, nil) + + } +} diff --git a/internal/web_server/handlers/user.go b/internal/web_server/handlers/user.go new file mode 100644 index 0000000..139eb09 --- /dev/null +++ b/internal/web_server/handlers/user.go @@ -0,0 +1,365 @@ +package handlers + +import ( + "errors" + "log/slog" + "time" + + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/user" + "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/response" + customvalidator "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/validator" + + "github.com/gofiber/fiber/v2" +) + +type CheckPhoneEmailExistReq struct { + Email string `json:"email" example:"john.doe@example.com"` + PhoneNumber string `json:"phone_number" example:"1234567890"` +} +type CheckPhoneEmailExistRes struct { + EmailExist bool `json:"email_exist"` + PhoneNumberExist bool `json:"phone_number_exist"` +} + +// CheckPhoneEmailExist godoc +// @Summary Check if phone number or email exist +// @Description Check if phone number or email exist +// @Tags user +// @Accept json +// @Produce json +// @Param checkPhoneEmailExist body CheckPhoneEmailExistReq true "Check phone number or email exist" +// @Success 200 {object} CheckPhoneEmailExistRes +// @Failure 400 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Router /user/checkPhoneEmailExist [post] +func CheckPhoneEmailExist(logger *slog.Logger, userSvc *user.Service, + validator *customvalidator.CustomValidator) fiber.Handler { + return func(c *fiber.Ctx) error { + var req CheckPhoneEmailExistReq + if err := c.BodyParser(&req); err != nil { + logger.Error("CheckPhoneEmailExist failed", "error", err) + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "Invalid request", + }) + } + valErrs, ok := validator.Validate(c, req) + if !ok { + response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", valErrs, nil) + return nil + } + emailExist, phoneExist, err := userSvc.CheckPhoneEmailExist(c.Context(), req.PhoneNumber, req.Email) + if err != nil { + logger.Error("CheckPhoneEmailExist failed", "error", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Internal server error", + }) + } + res := CheckPhoneEmailExistRes{ + EmailExist: emailExist, + PhoneNumberExist: phoneExist, + } + return response.WriteJSON(c, fiber.StatusOK, "Check Success", res, nil) + } +} + +type RegisterCodeReq struct { + Email string `json:"email" example:"john.doe@example.com"` + PhoneNumber string `json:"phone_number" example:"1234567890"` +} + +// SendRegisterCode godoc +// @Summary Send register code +// @Description Send register code +// @Tags user +// @Accept json +// @Produce json +// @Param registerCode body RegisterCodeReq true "Send register code" +// @Success 200 {object} response.APIResponse +// @Failure 400 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Router /user/sendRegisterCode [post] +func SendRegisterCode(logger *slog.Logger, userSvc *user.Service, + validator *customvalidator.CustomValidator) fiber.Handler { + return func(c *fiber.Ctx) error { + var req RegisterCodeReq + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "Invalid request", + }) + } + valErrs, ok := validator.Validate(c, req) + if !ok { + response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", valErrs, nil) + return nil + } + var sentTo string + var medium domain.OtpMedium + if req.Email != "" { + sentTo = req.Email + medium = domain.OtpMediumEmail + } + if req.PhoneNumber != "" { + sentTo = req.PhoneNumber + medium = domain.OtpMediumSms + } + if err := userSvc.SendRegisterCode(c.Context(), medium, sentTo); err != nil { + logger.Error("SendRegisterCode failed", "error", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Internal server error", + }) + } + return response.WriteJSON(c, fiber.StatusOK, "Code sent successfully", nil, nil) + } +} + +type RegisterUserReq struct { + FirstName string `json:"first_name" example:"John"` + LastName string `json:"last_name" example:"Doe"` + Email string `json:"email" example:"john.doe@example.com"` + PhoneNumber string `json:"phone_number" example:"1234567890"` + Password string `json:"password" example:"password123"` + //Role string + Otp string `json:"otp" example:"123456"` + ReferalCode string `json:"referal_code" example:"ABC123"` + // + +} + +// RegisterUser godoc +// @Summary Register user +// @Description Register user +// @Tags user +// @Accept json +// @Produce json +// @Param registerUser body RegisterUserReq true "Register user" +// @Success 200 {object} response.APIResponse +// @Failure 400 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Router /user/register [post] +func RegisterUser(logger *slog.Logger, userSvc *user.Service, + validator *customvalidator.CustomValidator) fiber.Handler { + return func(c *fiber.Ctx) error { + var req RegisterUserReq + if err := c.BodyParser(&req); err != nil { + logger.Error("RegisterUser failed", "error", err) + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "Invalid request", + }) + } + valErrs, ok := validator.Validate(c, req) + if !ok { + response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", valErrs, nil) + return nil + } + user := domain.RegisterUserReq{ + FirstName: req.FirstName, + LastName: req.LastName, + Email: req.Email, + PhoneNumber: req.PhoneNumber, + Password: req.Password, + Otp: req.Otp, + ReferalCode: req.ReferalCode, + OtpMedium: domain.OtpMediumEmail, + } + medium, err := getMedium(req.Email, req.PhoneNumber) + if err != nil { + logger.Error("RegisterUser failed", "error", err) + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": err.Error(), + }) + } + user.OtpMedium = medium + if _, err := userSvc.RegisterUser(c.Context(), user); err != nil { + if errors.Is(err, domain.ErrOtpAlreadyUsed) { + return response.WriteJSON(c, fiber.StatusBadRequest, "Otp already used", nil, nil) + } + if errors.Is(err, domain.ErrOtpExpired) { + return response.WriteJSON(c, fiber.StatusBadRequest, "Otp expired", nil, nil) + } + if errors.Is(err, domain.ErrInvalidOtp) { + return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid otp", nil, nil) + } + if errors.Is(err, domain.ErrOtpNotFound) { + return response.WriteJSON(c, fiber.StatusBadRequest, "User already exist", nil, nil) + } + logger.Error("RegisterUser failed", "error", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Internal server error", + }) + } + return response.WriteJSON(c, fiber.StatusOK, "Registration successful", nil, nil) + } +} + +type ResetCodeReq struct { + Email string `json:"email" example:"john.doe@example.com"` + PhoneNumber string `json:"phone_number" example:"1234567890"` +} + +// SendResetCode godoc +// @Summary Send reset code +// @Description Send reset code +// @Tags user +// @Accept json +// @Produce json +// @Param resetCode body ResetCodeReq true "Send reset code" +// @Success 200 {object} response.APIResponse +// @Failure 400 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Router /user/sendResetCode [post] +func SendResetCode(logger *slog.Logger, userSvc *user.Service, + validator *customvalidator.CustomValidator) fiber.Handler { + return func(c *fiber.Ctx) error { + var req ResetCodeReq + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "Invalid request", + }) + } + valErrs, ok := validator.Validate(c, req) + if !ok { + response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", valErrs, nil) + return nil + } + var sentTo string + var medium domain.OtpMedium + if req.Email != "" { + sentTo = req.Email + medium = domain.OtpMediumEmail + } + if req.PhoneNumber != "" { + sentTo = req.PhoneNumber + medium = domain.OtpMediumSms + } + if err := userSvc.SendResetCode(c.Context(), medium, sentTo); err != nil { + logger.Error("SendResetCode failed", "error", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Internal server error", + }) + } + return response.WriteJSON(c, fiber.StatusOK, "Code sent successfully", nil, nil) + + } +} + +type ResetPasswordReq struct { + Email string + PhoneNumber string + Password string + Otp string +} + +// ResetPassword godoc +// @Summary Reset password +// @Description Reset password +// @Tags user +// @Accept json +// @Produce json +// @Param resetPassword body ResetPasswordReq true "Reset password" +// @Success 200 {object} response.APIResponse +// @Failure 400 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Router /user/resetPassword [post] +func ResetPassword(logger *slog.Logger, userSvc *user.Service, + validator *customvalidator.CustomValidator) fiber.Handler { + return func(c *fiber.Ctx) error { + var req ResetPasswordReq + if err := c.BodyParser(&req); err != nil { + logger.Error("ResetPassword failed", "error", err) + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "Invalid request", + }) + } + valErrs, ok := validator.Validate(c, req) + if !ok { + response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", valErrs, nil) + return nil + } + user := domain.ResetPasswordReq{ + Email: req.Email, + PhoneNumber: req.PhoneNumber, + Password: req.Password, + Otp: req.Otp, + } + medium, err := getMedium(req.Email, req.PhoneNumber) + if err != nil { + logger.Error("ResetPassword failed", "error", err) + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": err.Error(), + }) + } + user.OtpMedium = medium + if err := userSvc.ResetPassword(c.Context(), user); err != nil { + logger.Error("ResetPassword failed", "error", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Internal server error", + }) + } + return response.WriteJSON(c, fiber.StatusOK, "Password reset successful", nil, nil) + } +} + +type UserProfileRes struct { + ID int64 `json:"id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Email string `json:"email"` + PhoneNumber string `json:"phone_number"` + Role domain.Role `json:"role"` + EmailVerified bool `json:"email_verified"` + PhoneVerified bool `json:"phone_verified"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + SuspendedAt time.Time `json:"suspended_at"` + Suspended bool `json:"suspended"` +} + +// UserProfile godoc +// @Summary Get user profile +// @Description Get user profile +// @Tags user +// @Accept json +// @Produce json +// @Success 200 {object} UserProfileRes +// @Failure 400 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Security Bearer +// @Router /user/profile [get] +func UserProfile(logger *slog.Logger, userSvc *user.Service) fiber.Handler { + return func(c *fiber.Ctx) error { + userId := c.Locals("user_id").(int64) + user, err := userSvc.GetUserByID(c.Context(), userId) + if err != nil { + logger.Error("GetUserProfile failed", "error", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Internal server error", + }) + } + + res := UserProfileRes{ + ID: user.ID, + FirstName: user.FirstName, + LastName: user.LastName, + Email: user.Email, + PhoneNumber: user.PhoneNumber, + Role: user.Role, + EmailVerified: user.EmailVerified, + PhoneVerified: user.PhoneVerified, + CreatedAt: user.CreatedAt, + UpdatedAt: user.UpdatedAt, + SuspendedAt: user.SuspendedAt, + Suspended: user.Suspended, + } + return response.WriteJSON(c, fiber.StatusOK, "User profile retrieved successfully", res, nil) + } +} +func getMedium(email, phoneNumber string) (domain.OtpMedium, error) { + if email != "" { + return domain.OtpMediumEmail, nil + } + if phoneNumber != "" { + return domain.OtpMediumSms, nil + } + return "", errors.New("both email and phone number are empty") +} diff --git a/internal/web_server/jwt/jwt.go b/internal/web_server/jwt/jwt.go new file mode 100644 index 0000000..530eb12 --- /dev/null +++ b/internal/web_server/jwt/jwt.go @@ -0,0 +1,60 @@ +package jwtutil + +import ( + "errors" + "time" + + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" + "github.com/golang-jwt/jwt/v5" +) + +// type UserToken struct { +// UserId string +// } +var ( + ErrExpiredToken = errors.New("token expired") + ErrMalformedToken = errors.New("token malformed") + ErrTokenNotExpired = errors.New("token not expired") + ErrRefreshTokenNotFound = errors.New("refresh token not found") // i.e login again +) + +type UserClaim struct { + jwt.RegisteredClaims + UserId int64 + Role domain.Role +} +type JwtConfig struct { + JwtAccessKey string + JwtAccessExpiry int +} + +func CreateJwt(userId int64, Role domain.Role, key string, expiry int) (string, error) { + token := jwt.NewWithClaims(jwt.SigningMethodHS256, UserClaim{RegisteredClaims: jwt.RegisteredClaims{Issuer: "github.com/lafetz/snippitstash", + IssuedAt: jwt.NewNumericDate(time.Now()), + Audience: jwt.ClaimStrings{"fortune.com"}, + NotBefore: jwt.NewNumericDate(time.Now()), + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(expiry) * time.Second))}, + UserId: userId, + Role: Role, + }) + jwtToken, err := token.SignedString([]byte(key)) // + return jwtToken, err +} +func ParseJwt(jwtToken string, key string) (*UserClaim, error) { + token, err := jwt.ParseWithClaims(jwtToken, &UserClaim{}, func(token *jwt.Token) (interface{}, error) { + return []byte(key), nil + }) + if err != nil { + if errors.Is(err, jwt.ErrTokenExpired) { + return nil, ErrExpiredToken + } + if errors.Is(err, jwt.ErrTokenMalformed) { + return nil, ErrMalformedToken + } + return nil, err + } + if claims, ok := token.Claims.(*UserClaim); ok && token.Valid { + return claims, nil + } + return nil, err +} diff --git a/internal/web_server/jwt/jwt_test.go b/internal/web_server/jwt/jwt_test.go new file mode 100644 index 0000000..bffe0c5 --- /dev/null +++ b/internal/web_server/jwt/jwt_test.go @@ -0,0 +1,89 @@ +package jwtutil + +// func TestCreateJwt(t *testing.T) { +// // Define a user to test +// user := &domain.User{ +// ID: 123, +// } + +// // Secret key used for signing the JWT +// secretKey := "secret" + +// // Token expiry time (in seconds) +// expiry := 3600 // 1 hour + +// // Call CreateJwt function +// tokenString, err := CreateJwt(user, secretKey, expiry) + +// // Assertions +// assert.NoError(t, err, "Error should be nil when creating a JWT") +// assert.NotEmpty(t, tokenString, "Token string should not be empty") + +// // Parse the token back and verify its claims +// claims, err := ParseJwt(tokenString, secretKey) +// assert.NoError(t, err, "Error should be nil when parsing the JWT") +// assert.Equal(t, strconv.Itoa(int(user.ID)), claims.UserId, "User ID should match") +// assert.Equal(t, "github.com/lafetz/snippitstash", claims.Issuer, "Issuer should match") +// assert.True(t, claims.ExpiresAt.Time.After(time.Now()), "Token should not be expired yet") +// expectedExpiryTime := time.Now().Add(time.Duration(expiry) * time.Second) +// // Allow for a small margin of error due to the time delay in generating the token +// assert.True(t, claims.ExpiresAt.Time.Before(expectedExpiryTime.Add(1*time.Second)), +// "Token expiry time should be within the expected range") +// assert.True(t, claims.ExpiresAt.Time.After(expectedExpiryTime.Add(-1*time.Second)), +// "Token expiry time should be within the expected range") +// } +// func TestParseJwt(t *testing.T) { +// // Define a user to test +// user := &domain.User{ +// ID: 123, +// } + +// // Secret key used for signing the JWT +// secretKey := "secret" + +// // Token expiry time (in seconds) +// expiry := 3600 // 1 hour + +// // Generate a token using the CreateJwt function +// tokenString, err := CreateJwt(user, secretKey, expiry) +// assert.NoError(t, err, "Error should be nil when creating a JWT") +// assert.NotEmpty(t, tokenString, "Token string should not be empty") + +// // Now, we will parse the token +// claims, err := ParseJwt(tokenString, secretKey) +// assert.NoError(t, err, "Error should be nil when parsing the JWT") +// assert.NotNil(t, claims, "Claims should not be nil") + +// // Verify that the claims match the user and other values +// assert.Equal(t, strconv.Itoa(int(user.ID)), claims.UserId, "User ID should match") +// assert.Equal(t, "github.com/lafetz/snippitstash", claims.Issuer, "Issuer should match") +// assert.Equal(t, "fortune.com", claims.Audience[0], "Audience should match") +// assert.True(t, claims.ExpiresAt.Time.After(time.Now()), "Token should not be expired yet") + +// // Ensure the parsing fails when using an invalid token +// invalidToken := tokenString + "invalid" +// _, err = ParseJwt(invalidToken, secretKey) +// assert.Error(t, err, "Parsing an invalid token should return an error") +// } +// func TestParseJwte(t *testing.T) { +// // Define user and key +// user := &domain.User{ID: 1} +// key := "secretkey" + +// // Test valid token (not expired) +// validJwt, err := CreateJwt(user, key, 4) // Set expiry to 10 seconds +// assert.NoError(t, err) + +// // Test if the token is parsed correctly +// claims, err := ParseJwt(validJwt, key) +// assert.NoError(t, err) +// assert.Equal(t, "1", claims.UserId) + +// // Wait for token to expire +// time.Sleep(5 * time.Second) // Wait longer than the expiry time to test expiration + +// // Test expired token +// _, err = ParseJwt(validJwt, key) + +// assert.Error(t, jwt.ErrTokenExpired) // Expect an error because the token should be expired +// } diff --git a/internal/web_server/middleware.go b/internal/web_server/middleware.go new file mode 100644 index 0000000..4f337fb --- /dev/null +++ b/internal/web_server/middleware.go @@ -0,0 +1,43 @@ +package httpserver + +import ( + "errors" + "strings" + + jwtutil "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/jwt" + "github.com/gofiber/fiber/v2" +) + +func (a *App) authMiddleware(c *fiber.Ctx) error { + + authHeader := c.Get("Authorization") + if authHeader == "" { + return fiber.NewError(fiber.StatusUnauthorized, "Authorization header missing") + } + + if !strings.HasPrefix(authHeader, "Bearer ") { + return fiber.NewError(fiber.StatusUnauthorized, "Invalid authorization header format") + } + + accessToken := strings.TrimPrefix(authHeader, "Bearer ") + c.Locals("access_token", accessToken) + claim, err := jwtutil.ParseJwt(accessToken, a.JwtConfig.JwtAccessKey) + if err != nil { + if errors.Is(err, jwtutil.ErrExpiredToken) { + return fiber.NewError(fiber.StatusUnauthorized, "Access token expired") + } + return fiber.NewError(fiber.StatusUnauthorized, "Invalid access token") + } + + refreshToken := c.Get("Refresh-Token") + if refreshToken == "" { + + // refreshToken = c.Cookies("refresh_token", "") + + // return fiber.NewError(fiber.StatusUnauthorized, "Refresh token missing") + } + c.Locals("user_id", claim.UserId) + c.Locals("role", claim.Role) + c.Locals("refresh_token", refreshToken) + return c.Next() +} diff --git a/internal/web_server/response/res.go b/internal/web_server/response/res.go new file mode 100644 index 0000000..593758d --- /dev/null +++ b/internal/web_server/response/res.go @@ -0,0 +1,47 @@ +package response + +import ( + "time" + + "github.com/gofiber/fiber/v2" +) + +type Status string + +const ( + Error Status = "error" + Success Status = "success" +) + +type APIResponse struct { + Status Status `json:"status"` + Message string `json:"message"` + Data interface{} `json:"data,omitempty"` + Metadata interface{} `json:"metadata,omitempty"` + Timestamp time.Time `json:"timestamp"` +} + +func NewAPIResponse( + status Status, message string, + data interface{}, metadata interface{}, +) APIResponse { + + return APIResponse{ + Status: status, + Message: message, + Data: data, + Metadata: metadata, + Timestamp: time.Now(), + } +} +func WriteJSON(c *fiber.Ctx, status int, message string, data, metadata interface{}) error { + var apiStatus Status + if status >= 200 && status <= 299 { + apiStatus = Success + } else { + apiStatus = Error + } + apiRes := NewAPIResponse(apiStatus, message, data, metadata) + + return c.Status(status).JSON(apiRes) +} diff --git a/internal/web_server/validator/validatord.go b/internal/web_server/validator/validatord.go index 47f2da9..16e13e9 100644 --- a/internal/web_server/validator/validatord.go +++ b/internal/web_server/validator/validatord.go @@ -1 +1,65 @@ -package validator +package customvalidator + +import ( + "strings" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" +) + +type CustomValidator struct { + validate *validator.Validate +} + +func NewCustomValidator(validate *validator.Validate) *CustomValidator { + + return &CustomValidator{ + validate: validate, + } +} + +func (v *CustomValidator) Validate(c *fiber.Ctx, input interface{}) (map[string]string, bool) { + err := v.validate.Struct(input) + if err != nil { + if validationErrors, ok := err.(validator.ValidationErrors); ok { + errors := ValidateModel(validationErrors) + return errors, false + } + } + return nil, true +} + +type ValidationErrorResponse struct { + StatusCode int `json:"statusCode"` + Errors interface{} `json:"errors"` +} + +func ValidateModel(err validator.ValidationErrors) map[string]string { + errors := make(map[string]string) + + for _, err := range err { + + errors[strings.ToLower(err.Field())] = errorMsgs(err.Tag(), err.Param()) + + } + return errors + +} + +func errorMsgs(tag string, value string) string { + switch tag { + case "required": + return "This field is required" + case "numeric": + return "must be numeric " + value + case "lte": + return "can not be greater than " + value + case "gte": + return "can not be less than " + value + case "len": + return "length should be equal to " + value + case "email": + return "must be a valid email address" + } + return "" +} diff --git a/makefile b/makefile index f8b2189..79017cf 100644 --- a/makefile +++ b/makefile @@ -30,7 +30,12 @@ migrations/up: .PHONY: swagger swagger: @swag init -g cmd/main.go - +.PHONY: db-up +db-up: + docker compose -f compose.db.yaml up +.PHONY: db-down +db-down: + docker compose -f compose.db.yaml down .PHONY: sqlc-gen sqlc-gen: @sqlc generate