diff --git a/cmd/main.go b/cmd/main.go index 320c594..1797c8e 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -6,17 +6,33 @@ import ( "os" "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" - // httpserver "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server" - "github.com/joho/godotenv" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/authentication" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/user" + httpserver "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server" + 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" ) +// @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()) @@ -24,12 +40,25 @@ func main() { } db, _, err := repository.OpenDB(cfg.DbUrl) if err != nil { - fmt.Print(err) + fmt.Print("db", err) os.Exit(1) } + logger := customlogger.NewLogger(cfg.Env, cfg.LogLevel) store := repository.NewStore(db) - fmt.Println(store) + 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) + app := httpserver.NewApp(cfg.Port, v, authSvc, logger, jwtutil.JwtConfig{ + JwtAccessKey: cfg.JwtKey, + JwtAccessExpiry: cfg.AccessExpiry, + }, userSvc, + ) + logger.Info("Starting server", "port", cfg.Port) + if err := app.Run(); err != nil { + logger.Error("Failed to start server", "error", err) + os.Exit(1) + } - // app := httpserver.NewApp(cfg.Port) - // app.Run() } diff --git a/db/migrations/000001_fortune.up.sql b/db/migrations/000001_fortune.up.sql index 0001a4a..6526209 100644 --- a/db/migrations/000001_fortune.up.sql +++ b/db/migrations/000001_fortune.up.sql @@ -2,13 +2,62 @@ CREATE TABLE 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 +); +----------------------------------------------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/otp.sql b/db/query/otp.sql new file mode 100644 index 0000000..90aec56 --- /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, CURRENT_TIMESTAMP, $5); + +-- 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 = CURRENT_TIMESTAMP +WHERE id = $1; \ No newline at end of file diff --git a/db/query/user.sql b/db/query/user.sql index 1d356e9..c0f14c9 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, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) +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 = CURRENT_TIMESTAMP +WHERE id = $6; -- 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 = CURRENT_TIMESTAMP +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..6625028 --- /dev/null +++ b/docs/docs.go @@ -0,0 +1,714 @@ +// 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" + } + } + } + } + }, + "/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.Role": { + "type": "string", + "enum": [ + "admin", + "customer", + "super_admin", + "branch_manager", + "cashier" + ], + "x-enum-varnames": [ + "RoleAdmin", + "RoleCustomer", + "RoleSuperAdmin", + "RoleBranchManager", + "RoleCashier" + ] + }, + "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.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.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..76ae6c5 --- /dev/null +++ b/docs/swagger.json @@ -0,0 +1,688 @@ +{ + "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" + } + } + } + } + }, + "/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.Role": { + "type": "string", + "enum": [ + "admin", + "customer", + "super_admin", + "branch_manager", + "cashier" + ], + "x-enum-varnames": [ + "RoleAdmin", + "RoleCustomer", + "RoleSuperAdmin", + "RoleBranchManager", + "RoleCashier" + ] + }, + "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.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.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..166d41d --- /dev/null +++ b/docs/swagger.yaml @@ -0,0 +1,458 @@ +definitions: + domain.Role: + enum: + - admin + - customer + - super_admin + - branch_manager + - cashier + type: string + x-enum-varnames: + - RoleAdmin + - RoleCustomer + - RoleSuperAdmin + - RoleBranchManager + - RoleCashier + 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.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.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 + /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..27fb891 --- /dev/null +++ b/gen/db/auth.sql.go @@ -0,0 +1,97 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.26.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/models.go b/gen/db/models.go index 35776c9..6d4137d 100644 --- a/gen/db/models.go +++ b/gen/db/models.go @@ -8,6 +8,27 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +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 Bet struct { ID int64 Amount int64 @@ -31,14 +52,17 @@ type Ticket struct { } 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 + 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..0e93b5a --- /dev/null +++ b/gen/db/otp.sql.go @@ -0,0 +1,77 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.26.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, CURRENT_TIMESTAMP, $5) +` + +type CreateOtpParams struct { + SentTo string + Medium string + OtpFor string + Otp string + 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.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 = CURRENT_TIMESTAMP +WHERE id = $1 +` + +func (q *Queries) MarkOtpAsUsed(ctx context.Context, id int64) error { + _, err := q.db.Exec(ctx, MarkOtpAsUsed, id) + return err +} diff --git a/gen/db/user.sql.go b/gen/db/user.sql.go index c5b0e90..1e046cb 100644 --- a/gen/db/user.sql.go +++ b/gen/db/user.sql.go @@ -11,42 +11,81 @@ 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, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) +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 } -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, ) - 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 +93,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 +103,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 +151,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 +203,95 @@ 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 = CURRENT_TIMESTAMP +WHERE (email = $2 OR phone_number = $3) +` + +type UpdatePasswordParams struct { + Password []byte + Email pgtype.Text + PhoneNumber pgtype.Text +} + +func (q *Queries) UpdatePassword(ctx context.Context, arg UpdatePasswordParams) error { + _, err := q.db.Exec(ctx, UpdatePassword, arg.Password, arg.Email, arg.PhoneNumber) + 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 = CURRENT_TIMESTAMP +WHERE id = $6 ` 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 + 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.ID, ) return err } diff --git a/go.mod b/go.mod index 18620e4..2fb3275 100644 --- a/go.mod +++ b/go.mod @@ -4,33 +4,51 @@ 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/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/google/uuid v1.6.0 // 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/stretchr/testify v1.8.4 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/rogpeppe/go-internal v1.14.1 // 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 9d6be49..c86e5af 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,13 +15,44 @@ 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/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/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= @@ -25,54 +63,144 @@ 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/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/common.go b/internal/domain/common.go index e705f3f..1712d6a 100644 --- a/internal/domain/common.go +++ b/internal/domain/common.go @@ -26,4 +26,13 @@ func (m Currency) String() string { x := float32(m) x = x / 100 return fmt.Sprintf("$%.2f", x) + +} +type ValidString struct { + Value string + Valid bool +} +type ValidBool struct { + Value bool + Valid bool } 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..59a17a5 --- /dev/null +++ b/internal/domain/role.go @@ -0,0 +1,11 @@ +package domain + +type Role string + +const ( + RoleAdmin Role = "admin" + RoleCustomer Role = "customer" + RoleSuperAdmin Role = "super_admin" + RoleBranchManager Role = "branch_manager" + RoleCashier Role = "cashier" +) 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/otp.go b/internal/repository/otp.go new file mode 100644 index 0000000..598a5eb --- /dev/null +++ b/internal/repository/otp.go @@ -0,0 +1,50 @@ +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, + }, + }) +} +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, otp.ID) +} diff --git a/internal/repository/user.go b/internal/repository/user.go index b19798f..d2d9b78 100644 --- a/internal/repository/user.go +++ b/internal/repository/user.go @@ -2,47 +2,70 @@ package repository import ( "context" + "database/sql" + "errors" + "fmt" 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, usedOtpId) + 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, }) 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 +73,118 @@ 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) { + fmt.Printf("phoneNum: %s, email: %s\n", phoneNum, email) + row, err := s.queries.CheckPhoneEmailExist(ctx, dbgen.CheckPhoneEmailExistParams{ + PhoneNumber: pgtype.Text{ + String: phoneNum, + Valid: phoneNum != "", + }, + Email: pgtype.Text{ + String: email, + + Valid: email != "", + }, + }) + fmt.Printf("row: %+v\n", row) + 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, usedOtpId) + 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/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..f6dcf71 --- /dev/null +++ b/internal/services/user/register.go @@ -0,0 +1,79 @@ +package user + +import ( + "context" + "fmt" + "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 + fmt.Printf("registerReq: %+v\n", registerReq) + 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 b7da149..2ebd22e 100644 --- a/internal/web_server/app.go +++ b/internal/web_server/app.go @@ -2,17 +2,33 @@ package httpserver import ( "fmt" + "log/slog" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/authentication" + "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" "github.com/bytedance/sonic" "github.com/gofiber/fiber/v2" ) type App struct { - fiber *fiber.App - port int + fiber *fiber.App + logger *slog.Logger + port int + authSvc *authentication.Service + userSvc *user.Service + validator *customvalidator.CustomValidator + JwtConfig jwtutil.JwtConfig } -func NewApp(port int) *App { +func NewApp( + port int, validator *customvalidator.CustomValidator, + authSvc *authentication.Service, + logger *slog.Logger, + JwtConfig jwtutil.JwtConfig, + userSvc *user.Service, +) *App { app := fiber.New(fiber.Config{ CaseSensitive: true, DisableHeaderNormalizing: true, @@ -20,8 +36,13 @@ func NewApp(port int) *App { JSONDecoder: sonic.Unmarshal, }) s := &App{ - fiber: app, - port: port, + fiber: app, + port: port, + authSvc: authSvc, + validator: validator, + logger: logger, + JwtConfig: JwtConfig, + userSvc: userSvc, } s.initAppRoutes() diff --git a/internal/web_server/app_routes.go b/internal/web_server/app_routes.go deleted file mode 100644 index 8388c84..0000000 --- a/internal/web_server/app_routes.go +++ /dev/null @@ -1,5 +0,0 @@ -package httpserver - -func (a *App) initAppRoutes() { - // a.fiber.Group("/users", users.CreateAccount(a.userAPI)) -} 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/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/routes.go b/internal/web_server/routes.go new file mode 100644 index 0000000..c30622d --- /dev/null +++ b/internal/web_server/routes.go @@ -0,0 +1,38 @@ +package httpserver + +import ( + _ "github.com/SamuelTariku/FortuneBet-Backend/docs" + "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/handlers" + "github.com/gofiber/fiber/v2" + fiberSwagger "github.com/swaggo/fiber-swagger" +) + +func (a *App) initAppRoutes() { + a.fiber.Post("/auth/login", handlers.LoginCustomer(a.logger, a.authSvc, a.validator, a.JwtConfig)) + a.fiber.Post("/auth/refresh", a.authMiddleware, handlers.RefreshToken(a.logger, a.authSvc, a.validator, a.JwtConfig)) + a.fiber.Post("/auth/logout", a.authMiddleware, handlers.LogOutCustomer(a.logger, a.authSvc, a.validator)) + a.fiber.Get("/auth/test", a.authMiddleware, func(c *fiber.Ctx) error { + userId := c.Locals("user_id") + role := c.Locals("role") + refreshToken := c.Locals("refresh_token") + a.logger.Info("User ID: " + userId.(string)) + a.logger.Info("Role: " + role.(string)) + a.logger.Info("Refresh Token: " + refreshToken.(string)) + return c.SendString("Test endpoint") + }) + a.fiber.Post("/user/resetPassword", handlers.ResetPassword(a.logger, a.userSvc, a.validator)) + a.fiber.Post("/user/sendResetCode", handlers.SendResetCode(a.logger, a.userSvc, a.validator)) + a.fiber.Post("/user/register", handlers.RegisterUser(a.logger, a.userSvc, a.validator)) + a.fiber.Post("/user/sendRegisterCode", handlers.SendRegisterCode(a.logger, a.userSvc, a.validator)) + a.fiber.Post("/user/checkPhoneEmailExist", handlers.CheckPhoneEmailExist(a.logger, a.userSvc, a.validator)) + a.fiber.Get("/user/profile", a.authMiddleware, handlers.UserProfile(a.logger, a.userSvc)) + // Swagger + a.fiber.Get("/swagger/*", fiberSwagger.WrapHandler) +} + +///user/profile get +// @Router /user/resetPassword [post] +// @Router /user/sendResetCode [post] +// @Router /user/register [post] +// @Router /user/sendRegisterCode [post] +// @Router /user/checkPhoneEmailExist [post] 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 845b5e7..1cacc84 100644 --- a/makefile +++ b/makefile @@ -12,11 +12,11 @@ build: go build -ldflags="-s" -o ./bin/web ./ .PHONY: run run: - echo "Running Go application"; \ + @echo "Running Go application"; \ go run ./cmd/main.go .PHONY: air air: - echo "Running air"; \ + @echo "Running air"; \ air -c .air.toml .PHONY: migrations/up migrations/new: @@ -30,3 +30,9 @@ 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 \ No newline at end of file