From ca7aa9d67c86094d960b0f2827b3d49b9fd6e89c Mon Sep 17 00:00:00 2001 From: lafetz Date: Mon, 31 Mar 2025 00:25:50 +0300 Subject: [PATCH] fix registration and password reset --- cmd/main.go | 9 +- db/migrations/000001_fortune.up.sql | 39 +- db/query/otp.sql | 14 + db/query/user.sql | 40 +- docs/docs.go | 447 ++++++++++++++++++- docs/swagger.json | 447 ++++++++++++++++++- docs/swagger.yaml | 304 ++++++++++++- gen/db/auth.sql.go | 13 +- gen/db/models.go | 35 +- gen/db/otp.sql.go | 77 ++++ gen/db/user.sql.go | 218 +++++++-- internal/domain/otp.go | 12 +- internal/domain/user.go | 9 +- internal/mocks/mock_email/email.go | 18 + internal/mocks/mock_sms/sms.go | 19 + internal/repository/auth.go | 14 +- internal/repository/otp.go | 50 +++ internal/repository/user.go | 193 ++++++-- internal/services/user/common.go | 44 ++ internal/services/user/port.go | 1 - internal/services/user/register.go | 79 ++++ internal/services/user/reset.go | 63 +++ internal/services/user/service.go | 186 +------- internal/services/user/user.go | 16 + internal/web_server/app.go | 4 + internal/web_server/handlers/auth_handler.go | 5 +- internal/web_server/handlers/user.go | 365 +++++++++++++++ internal/web_server/jwt/jwt.go | 4 +- internal/web_server/middleware.go | 2 +- internal/web_server/routes.go | 14 + 30 files changed, 2409 insertions(+), 332 deletions(-) create mode 100644 db/query/otp.sql create mode 100644 gen/db/otp.sql.go create mode 100644 internal/mocks/mock_email/email.go create mode 100644 internal/mocks/mock_sms/sms.go create mode 100644 internal/repository/otp.go create mode 100644 internal/services/user/common.go create mode 100644 internal/services/user/register.go create mode 100644 internal/services/user/reset.go create mode 100644 internal/services/user/user.go create mode 100644 internal/web_server/handlers/user.go diff --git a/cmd/main.go b/cmd/main.go index 5bc6f41..1797c8e 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -7,8 +7,11 @@ import ( "github.com/SamuelTariku/FortuneBet-Backend/internal/config" customlogger "github.com/SamuelTariku/FortuneBet-Backend/internal/logger" + mockemail "github.com/SamuelTariku/FortuneBet-Backend/internal/mocks/mock_email" + mocksms "github.com/SamuelTariku/FortuneBet-Backend/internal/mocks/mock_sms" "github.com/SamuelTariku/FortuneBet-Backend/internal/repository" "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" @@ -44,10 +47,14 @@ func main() { store := repository.NewStore(db) v := customvalidator.NewCustomValidator(validator.New()) authSvc := authentication.NewService(store, store, cfg.RefreshExpiry) + mockSms := mocksms.NewMockSMS() + mockemail := mockemail.NewMockEmail() + userSvc := user.NewService(store, store, mockSms, mockemail) 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) diff --git a/db/migrations/000001_fortune.up.sql b/db/migrations/000001_fortune.up.sql index 7647214..a4e3bd0 100644 --- a/db/migrations/000001_fortune.up.sql +++ b/db/migrations/000001_fortune.up.sql @@ -4,18 +4,16 @@ CREATE TABLE users ( last_name VARCHAR(255) NOT NULL, email VARCHAR(255) UNIQUE , phone_number VARCHAR(20) UNIQUE, - password BYTEA NOT NULL, role VARCHAR(50) NOT NULL, + 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 NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE 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 AND phone_number IS NULL) OR - (email IS NULL AND phone_number IS NOT NULL) - ) + CHECK (email IS NOT NULL OR phone_number IS NOT NULL) ); CREATE TABLE refresh_tokens ( id BIGSERIAL PRIMARY KEY, @@ -26,19 +24,38 @@ CREATE TABLE refresh_tokens ( 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, created_at, updated_at) -VALUES ( +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', - '1234567890', + NULL, crypt('password123', gen_salt('bf'))::bytea, - 'user', + 'customer', + TRUE, + FALSE, CURRENT_TIMESTAMP, - CURRENT_TIMESTAMP + CURRENT_TIMESTAMP, + NULL, + FALSE ); \ 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 index 28a683c..6625028 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -44,7 +44,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/httpserver.loginCustomerReq" + "$ref": "#/definitions/handlers.loginCustomerReq" } } ], @@ -52,7 +52,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/httpserver.loginCustomerRes" + "$ref": "#/definitions/handlers.loginCustomerRes" } }, "400": { @@ -96,7 +96,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/httpserver.logoutReq" + "$ref": "#/definitions/handlers.logoutReq" } } ], @@ -148,7 +148,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/httpserver.refreshToken" + "$ref": "#/definitions/handlers.refreshToken" } } ], @@ -156,7 +156,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/httpserver.loginCustomerRes" + "$ref": "#/definitions/handlers.loginCustomerRes" } }, "400": { @@ -179,10 +179,439 @@ const docTemplate = `{ } } } + }, + "/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": { - "httpserver.loginCustomerReq": { + "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": { @@ -199,7 +628,7 @@ const docTemplate = `{ } } }, - "httpserver.loginCustomerRes": { + "handlers.loginCustomerRes": { "type": "object", "properties": { "access_token": { @@ -210,7 +639,7 @@ const docTemplate = `{ } } }, - "httpserver.logoutReq": { + "handlers.logoutReq": { "type": "object", "properties": { "refresh_token": { @@ -218,7 +647,7 @@ const docTemplate = `{ } } }, - "httpserver.refreshToken": { + "handlers.refreshToken": { "type": "object", "properties": { "access_token": { diff --git a/docs/swagger.json b/docs/swagger.json index 07db1f3..76ae6c5 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -36,7 +36,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/httpserver.loginCustomerReq" + "$ref": "#/definitions/handlers.loginCustomerReq" } } ], @@ -44,7 +44,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/httpserver.loginCustomerRes" + "$ref": "#/definitions/handlers.loginCustomerRes" } }, "400": { @@ -88,7 +88,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/httpserver.logoutReq" + "$ref": "#/definitions/handlers.logoutReq" } } ], @@ -140,7 +140,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/httpserver.refreshToken" + "$ref": "#/definitions/handlers.refreshToken" } } ], @@ -148,7 +148,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/httpserver.loginCustomerRes" + "$ref": "#/definitions/handlers.loginCustomerRes" } }, "400": { @@ -171,10 +171,439 @@ } } } + }, + "/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": { - "httpserver.loginCustomerReq": { + "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": { @@ -191,7 +620,7 @@ } } }, - "httpserver.loginCustomerRes": { + "handlers.loginCustomerRes": { "type": "object", "properties": { "access_token": { @@ -202,7 +631,7 @@ } } }, - "httpserver.logoutReq": { + "handlers.logoutReq": { "type": "object", "properties": { "refresh_token": { @@ -210,7 +639,7 @@ } } }, - "httpserver.refreshToken": { + "handlers.refreshToken": { "type": "object", "properties": { "access_token": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 81f777c..166d41d 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1,5 +1,116 @@ definitions: - httpserver.loginCustomerReq: + 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 @@ -11,19 +122,19 @@ definitions: example: "1234567890" type: string type: object - httpserver.loginCustomerRes: + handlers.loginCustomerRes: properties: access_token: type: string refresh_token: type: string type: object - httpserver.logoutReq: + handlers.logoutReq: properties: refresh_token: type: string type: object - httpserver.refreshToken: + handlers.refreshToken: properties: access_token: type: string @@ -73,14 +184,14 @@ paths: name: login required: true schema: - $ref: '#/definitions/httpserver.loginCustomerReq' + $ref: '#/definitions/handlers.loginCustomerReq' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/httpserver.loginCustomerRes' + $ref: '#/definitions/handlers.loginCustomerRes' "400": description: Bad Request schema: @@ -107,7 +218,7 @@ paths: name: logout required: true schema: - $ref: '#/definitions/httpserver.logoutReq' + $ref: '#/definitions/handlers.logoutReq' produces: - application/json responses: @@ -141,14 +252,14 @@ paths: name: refresh required: true schema: - $ref: '#/definitions/httpserver.refreshToken' + $ref: '#/definitions/handlers.refreshToken' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/httpserver.loginCustomerRes' + $ref: '#/definitions/handlers.loginCustomerRes' "400": description: Bad Request schema: @@ -164,6 +275,181 @@ paths: 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 diff --git a/gen/db/auth.sql.go b/gen/db/auth.sql.go index 7ad4b74..27fb891 100644 --- a/gen/db/auth.sql.go +++ b/gen/db/auth.sql.go @@ -55,13 +55,13 @@ func (q *Queries) GetRefreshToken(ctx context.Context, token string) (RefreshTok } const GetUserByEmailPhone = `-- name: GetUserByEmailPhone :one -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, 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 string - PhoneNumber string + Email pgtype.Text + PhoneNumber pgtype.Text } func (q *Queries) GetUserByEmailPhone(ctx context.Context, arg GetUserByEmailPhoneParams) (User, error) { @@ -73,11 +73,14 @@ func (q *Queries) GetUserByEmailPhone(ctx context.Context, arg GetUserByEmailPho &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 } diff --git a/gen/db/models.go b/gen/db/models.go index b32f097..a1465c2 100644 --- a/gen/db/models.go +++ b/gen/db/models.go @@ -8,6 +8,18 @@ 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 @@ -18,14 +30,17 @@ type RefreshToken struct { } type User struct { - ID int64 - FirstName string - LastName string - Email string - PhoneNumber string - Password []byte - Role string - Verified pgtype.Bool - CreatedAt pgtype.Timestamptz - UpdatedAt pgtype.Timestamptz + 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 c1b551e..eaa5f52 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 []byte - 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 []byte + 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/internal/domain/otp.go b/internal/domain/otp.go index cc3630f..a6904e4 100644 --- a/internal/domain/otp.go +++ b/internal/domain/otp.go @@ -1,6 +1,16 @@ package domain -import "time" +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 diff --git a/internal/domain/user.go b/internal/domain/user.go index ed38ae8..ea44cc8 100644 --- a/internal/domain/user.go +++ b/internal/domain/user.go @@ -1,6 +1,13 @@ package domain -import "time" +import ( + "errors" + "time" +) + +var ( + ErrUserNotFound = errors.New("user not found") +) type User struct { ID int64 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 index 9695fc9..99739e9 100644 --- a/internal/repository/auth.go +++ b/internal/repository/auth.go @@ -13,8 +13,14 @@ import ( func (s *Store) GetUserByEmailPhone(ctx context.Context, email, phone string) (domain.User, error) { user, err := s.queries.GetUserByEmailPhone(ctx, dbgen.GetUserByEmailPhoneParams{ - Email: email, - PhoneNumber: phone, + Email: pgtype.Text{ + String: email, + Valid: true, + }, + PhoneNumber: pgtype.Text{ + String: phone, + Valid: true, + }, }) if err != nil { if errors.Is(err, sql.ErrNoRows) { @@ -26,8 +32,8 @@ func (s *Store) GetUserByEmailPhone(ctx context.Context, email, phone string) (d ID: user.ID, FirstName: user.FirstName, LastName: user.LastName, - Email: user.Email, - PhoneNumber: user.PhoneNumber, + Email: user.Email.String, + PhoneNumber: user.PhoneNumber.String, Password: user.Password, Role: domain.Role(user.Role), }, nil 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 dbbc6ca..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/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 8773525..aaf502d 100644 --- a/internal/services/user/port.go +++ b/internal/services/user/port.go @@ -27,5 +27,4 @@ type EmailGateway interface { 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) - MarkUsed(ctx context.Context, id int64) 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 2172211..cfa93fd 100644 --- a/internal/services/user/service.go +++ b/internal/services/user/service.go @@ -1,24 +1,13 @@ package user import ( - "context" - "errors" "time" - - "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" - "golang.org/x/crypto/bcrypt" ) const ( OtpExpiry = 5 * time.Minute ) -var ( - ErrOtpAlreadyUsed = errors.New("otp already used") - ErrInvalidOtp = errors.New("invalid otp") - ErrOtpExpired = errors.New("otp expired") -) - type Service struct { userStore UserStore otpStore OtpStore @@ -27,179 +16,14 @@ type Service struct { } func NewService( - userStore UserStore, RefreshExpiry int, + userStore UserStore, otpStore OtpStore, smsGateway SmsGateway, emailGateway EmailGateway, ) *Service { return &Service{ - userStore: userStore, - otpStore: otpStore, + userStore: userStore, + otpStore: otpStore, + smsGateway: smsGateway, + emailGateway: emailGateway, } } - -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 { - return err - } - - // send otp based on the medium - return s.SendOtp(ctx, sentTo, domain.OtpReset, medium) -} -func (s *Service) RegisterUser(ctx context.Context, registerReq domain.RegisterUserReq) (domain.User, error) { // normal - // get otp - var sentTo string - if registerReq.OtpMedium == domain.OtpMediumEmail { - sentTo = registerReq.Email - } else { - sentTo = registerReq.PhoneNumber - } - // - otp, err := s.otpStore.GetOtp( - ctx, sentTo, - domain.OtpRegister, registerReq.OtpMedium) - if err != nil { - return domain.User{}, err - } - // verify otp - if otp.Used { - return domain.User{}, ErrOtpAlreadyUsed - } - if time.Now().After(otp.ExpiresAt) { - return domain.User{}, ErrOtpExpired - } - if otp.Otp != registerReq.Otp { - return domain.User{}, 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 -} - -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.OtpRegister, resetReq.OtpMedium) - if err != nil { - return err - } - // - if otp.Used { - return ErrOtpAlreadyUsed - } - if time.Now().After(otp.ExpiresAt) { - return ErrOtpExpired - } - if otp.Otp != resetReq.Otp { - return 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 -} -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 (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) -} - -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/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 811b973..2ebd22e 100644 --- a/internal/web_server/app.go +++ b/internal/web_server/app.go @@ -5,6 +5,7 @@ import ( "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" @@ -16,6 +17,7 @@ type App struct { logger *slog.Logger port int authSvc *authentication.Service + userSvc *user.Service validator *customvalidator.CustomValidator JwtConfig jwtutil.JwtConfig } @@ -25,6 +27,7 @@ func NewApp( authSvc *authentication.Service, logger *slog.Logger, JwtConfig jwtutil.JwtConfig, + userSvc *user.Service, ) *App { app := fiber.New(fiber.Config{ CaseSensitive: true, @@ -39,6 +42,7 @@ func NewApp( validator: validator, logger: logger, JwtConfig: JwtConfig, + userSvc: userSvc, } s.initAppRoutes() diff --git a/internal/web_server/handlers/auth_handler.go b/internal/web_server/handlers/auth_handler.go index 551b259..0022827 100644 --- a/internal/web_server/handlers/auth_handler.go +++ b/internal/web_server/handlers/auth_handler.go @@ -3,7 +3,6 @@ package handlers import ( "errors" "log/slog" - "strconv" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/authentication" jwtutil "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/jwt" @@ -66,7 +65,7 @@ func LoginCustomer( return nil } - accessToken, err := jwtutil.CreateJwt(strconv.Itoa(int(successRes.UserId)), successRes.Role, JwtConfig.JwtAccessKey, JwtConfig.JwtAccessExpiry) + accessToken, err := jwtutil.CreateJwt(successRes.UserId, successRes.Role, JwtConfig.JwtAccessKey, JwtConfig.JwtAccessExpiry) res := loginCustomerRes{ AccessToken: accessToken, RefreshToken: successRes.RfToken, @@ -119,7 +118,7 @@ func RefreshToken(logger *slog.Logger, authSvc *authentication.Service, response.WriteJSON(c, fiber.StatusInternalServerError, "Internal server error", nil, nil) return nil } - accessToken, err := jwtutil.CreateJwt("", "", JwtConfig.JwtAccessKey, JwtConfig.JwtAccessExpiry) + 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) 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 index a59e81f..530eb12 100644 --- a/internal/web_server/jwt/jwt.go +++ b/internal/web_server/jwt/jwt.go @@ -20,7 +20,7 @@ var ( type UserClaim struct { jwt.RegisteredClaims - UserId string + UserId int64 Role domain.Role } type JwtConfig struct { @@ -28,7 +28,7 @@ type JwtConfig struct { JwtAccessExpiry int } -func CreateJwt(userId string, Role domain.Role, key string, expiry int) (string, error) { +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"}, diff --git a/internal/web_server/middleware.go b/internal/web_server/middleware.go index e80689f..4f337fb 100644 --- a/internal/web_server/middleware.go +++ b/internal/web_server/middleware.go @@ -34,7 +34,7 @@ func (a *App) authMiddleware(c *fiber.Ctx) error { // refreshToken = c.Cookies("refresh_token", "") - return fiber.NewError(fiber.StatusUnauthorized, "Refresh token missing") + // return fiber.NewError(fiber.StatusUnauthorized, "Refresh token missing") } c.Locals("user_id", claim.UserId) c.Locals("role", claim.Role) diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index 4af9781..c30622d 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -20,5 +20,19 @@ func (a *App) initAppRoutes() { 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]