UPDATE: Auth module, User module
Some checks failed
Build and Release / release (push) Failing after 1m25s

This commit is contained in:
2026-03-30 00:27:57 +07:00
parent 92d44bb00c
commit f04441bf2a
59 changed files with 4246 additions and 521 deletions

View File

@@ -20,14 +20,15 @@ func NewAuthController(svc services.AuthService) *AuthController {
}
// Signin godoc
// @Summary Sign in an existing user
// @Description Authenticate user and return token data
// @Summary Sign in a user
// @Description Authenticate user credentials and return access/refresh tokens
// @Tags Auth
// @Accept json
// @Produce json
// @Param request body request.SignInDto true "Sign In request"
// @Param request body request.SignInDto true "Sign In credentials"
// @Success 200 {object} response.CommonResponse
// @Failure 400 {object} response.CommonResponse
// @Failure 401 {object} response.CommonResponse "Invalid credentials"
// @Failure 500 {object} response.CommonResponse
// @Router /auth/signin [post]
func (h *AuthController) Signin(c fiber.Ctx) error {
@@ -57,12 +58,12 @@ func (h *AuthController) Signin(c fiber.Ctx) error {
}
// Signup godoc
// @Summary Sign up a new user
// @Description Create a new user account
// @Summary Register a new user
// @Description Create a new user account in the system
// @Tags Auth
// @Accept json
// @Produce json
// @Param request body request.SignUpDto true "Sign Up request"
// @Param request body request.SignUpDto true "Sign Up details"
// @Success 200 {object} response.CommonResponse
// @Failure 400 {object} response.CommonResponse
// @Failure 500 {object} response.CommonResponse
@@ -94,13 +95,14 @@ func (h *AuthController) Signup(c fiber.Ctx) error {
}
// RefreshToken godoc
// @Summary Refresh access token
// @Description Get a new access token using the user's current session/refresh token
// @Summary Refresh session tokens
// @Description Generate a new access token using a valid refresh token from context
// @Tags Auth
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} response.CommonResponse
// @Failure 401 {object} response.CommonResponse "Unauthorized or expired refresh token"
// @Failure 500 {object} response.CommonResponse
// @Router /auth/refresh [post]
func (h *AuthController) RefreshToken(c fiber.Ctx) error {
@@ -120,3 +122,116 @@ func (h *AuthController) RefreshToken(c fiber.Ctx) error {
Data: res,
})
}
// VerifyToken godoc
// @Summary Verify a security token
// @Description Validate an OTP or email verification token
// @Tags Auth
// @Accept json
// @Produce json
// @Param request body request.VerifyTokenDto true "Token verification data"
// @Success 200 {object} response.CommonResponse
// @Failure 400 {object} response.CommonResponse
// @Failure 500 {object} response.CommonResponse
// @Router /auth/token/verify [post]
func (h *AuthController) VerifyToken(c fiber.Ctx) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
dto := &request.VerifyTokenDto{}
if err := validator.ValidateBodyDto(c, dto); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(response.CommonResponse{
Status: false,
Message: err.Error(),
})
}
res, err := h.service.VerifyToken(ctx, dto)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(response.CommonResponse{
Status: false,
Message: err.Error(),
})
}
return c.Status(fiber.StatusOK).JSON(response.CommonResponse{
Status: true,
Data: res,
})
}
// CreateToken godoc
// @Summary Generate a new verification token
// @Description Request a new token for specific actions like email confirmation
// @Tags Auth
// @Accept json
// @Produce json
// @Param request body request.CreateTokenDto true "Token creation request"
// @Success 200 {object} response.CommonResponse
// @Failure 400 {object} response.CommonResponse
// @Failure 500 {object} response.CommonResponse
// @Router /auth/token/create [post]
func (h *AuthController) CreateToken(c fiber.Ctx) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
dto := &request.CreateTokenDto{}
if err := validator.ValidateBodyDto(c, dto); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(response.CommonResponse{
Status: false,
Message: err.Error(),
})
}
err := h.service.CreateToken(ctx, dto)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(response.CommonResponse{
Status: false,
Message: err.Error(),
})
}
return c.Status(fiber.StatusOK).JSON(response.CommonResponse{
Status: true,
Data: nil,
Message: "Token created successfully",
})
}
// ForgotPassword godoc
// @Summary Handle forgotten password
// @Description Initiate password recovery process for a user
// @Tags Auth
// @Accept json
// @Produce json
// @Param request body request.ForgotPasswordDto true "Forgot Password request"
// @Success 200 {object} response.CommonResponse
// @Failure 400 {object} response.CommonResponse
// @Failure 500 {object} response.CommonResponse
// @Router /auth/forgot-password [post]
func (h *AuthController) ForgotPassword(c fiber.Ctx) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
dto := &request.ForgotPasswordDto{}
if err := validator.ValidateBodyDto(c, dto); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(response.CommonResponse{
Status: false,
Message: err.Error(),
})
}
err := h.service.ForgotPassword(ctx, dto)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(response.CommonResponse{
Status: false,
Message: err.Error(),
})
}
return c.Status(fiber.StatusOK).JSON(response.CommonResponse{
Status: true,
Data: nil,
Message: "Password reset successfully",
})
}

View File

@@ -0,0 +1,276 @@
package controllers
import (
"context"
"history-api/internal/dtos/request"
"history-api/internal/dtos/response"
"history-api/internal/services"
"history-api/pkg/validator"
"time"
"github.com/gofiber/fiber/v3"
)
type UserController struct {
service services.UserService
}
func NewUserController(svc services.UserService) *UserController {
return &UserController{service: svc}
}
// GetUserCurrent godoc
// @Summary Get current user profile
// @Description Retrieve the profile information of the currently authenticated user
// @Tags Users
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} response.CommonResponse
// @Failure 500 {object} response.CommonResponse
// @Router /users/current [get]
func (h *UserController) GetUserCurrent(c fiber.Ctx) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
res, err := h.service.GetUserCurrent(ctx, c.Locals("uid").(string))
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(response.CommonResponse{
Status: false,
Message: err.Error(),
})
}
return c.Status(fiber.StatusOK).JSON(response.CommonResponse{
Status: true,
Data: res,
})
}
// UpdateProfile godoc
// @Summary Update user profile
// @Description Update the profile details of the currently authenticated user
// @Tags Users
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "User ID"
// @Param request body request.UpdateProfileDto true "Update Profile request"
// @Success 200 {object} response.CommonResponse
// @Failure 400 {object} response.CommonResponse
// @Failure 500 {object} response.CommonResponse
// @Router /users/{id} [put]
func (h *UserController) UpdateProfile(c fiber.Ctx) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
dto := &request.UpdateProfileDto{}
if err := validator.ValidateBodyDto(c, dto); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(response.CommonResponse{
Status: false,
Message: err.Error(),
})
}
res, err := h.service.UpdateProfile(ctx, c.Locals("uid").(string), dto)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(response.CommonResponse{
Status: false,
Message: err.Error(),
})
}
return c.Status(fiber.StatusOK).JSON(response.CommonResponse{
Status: true,
Data: res,
})
}
// ChangePassword godoc
// @Summary Change user password
// @Description Update the password for the currently authenticated user
// @Tags Users
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "User ID"
// @Param request body request.ChangePasswordDto true "Change Password request"
// @Success 200 {object} response.CommonResponse
// @Failure 400 {object} response.CommonResponse
// @Failure 500 {object} response.CommonResponse
// @Router /users/{id}/password [patch]
func (h *UserController) ChangePassword(c fiber.Ctx) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
dto := &request.ChangePasswordDto{}
if err := validator.ValidateBodyDto(c, dto); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(response.CommonResponse{
Status: false,
Message: err.Error(),
})
}
err := h.service.ChangePassword(ctx, c.Locals("uid").(string), dto)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(response.CommonResponse{
Status: false,
Message: err.Error(),
})
}
return c.Status(fiber.StatusOK).JSON(response.CommonResponse{
Status: true,
Message: "Password changed successfully",
})
}
// RestoreUser godoc
// @Summary Restore a deleted user
// @Description Restore a soft-deleted user account (Admin/Mod only)
// @Tags Users
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "User ID"
// @Success 200 {object} response.CommonResponse
// @Failure 500 {object} response.CommonResponse
// @Router /users/{id}/restore [patch]
func (h *UserController) RestoreUser(c fiber.Ctx) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
userId := c.Params("id")
res, err := h.service.RestoreUser(ctx, userId)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(response.CommonResponse{
Status: false,
Message: err.Error(),
})
}
return c.Status(fiber.StatusOK).JSON(response.CommonResponse{
Status: true,
Data: res,
})
}
// DeleteUser godoc
// @Summary Delete a user
// @Description Soft delete a user account (Admin/Mod only)
// @Tags Users
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "User ID"
// @Success 200 {object} response.CommonResponse
// @Failure 500 {object} response.CommonResponse
// @Router /users/{id} [delete]
func (h *UserController) DeleteUser(c fiber.Ctx) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
userId := c.Params("id")
err := h.service.DeleteUser(ctx, userId)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(response.CommonResponse{
Status: false,
Message: err.Error(),
})
}
return c.Status(fiber.StatusOK).JSON(response.CommonResponse{
Status: true,
Message: "User deleted successfully",
})
}
// ChangeRoleUser godoc
// @Summary Change user role
// @Description Update the role of a user (Admin only)
// @Tags Users
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "User ID"
// @Param request body request.ChangeRoleDto true "Change Role request"
// @Success 200 {object} response.CommonResponse
// @Failure 400 {object} response.CommonResponse
// @Failure 500 {object} response.CommonResponse
// @Router /users/{id}/role [patch]
func (h *UserController) ChangeRoleUser(c fiber.Ctx) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
dto := &request.ChangeRoleDto{}
if err := validator.ValidateBodyDto(c, dto); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(response.CommonResponse{
Status: false,
Message: err.Error(),
})
}
user, err := h.service.ChangeRoleUser(ctx, dto)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(response.CommonResponse{
Status: false,
Message: err.Error(),
})
}
return c.Status(fiber.StatusOK).JSON(response.CommonResponse{
Status: true,
Data: user,
})
}
// GetUserById godoc
// @Summary Get user by ID
// @Description Retrieve details of a specific user (Admin/Mod only)
// @Tags Users
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "User ID"
// @Success 200 {object} response.CommonResponse
// @Failure 500 {object} response.CommonResponse
// @Router /users/{id} [get]
func (h *UserController) GetUserById(c fiber.Ctx) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
userId := c.Params("id")
res, err := h.service.GetUserByID(ctx, userId)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(response.CommonResponse{
Status: false,
Message: err.Error(),
})
}
return c.Status(fiber.StatusOK).JSON(response.CommonResponse{
Status: true,
Data: res,
})
}
// Search godoc
// @Summary Search users
// @Description Search and filter users with pagination (Admin/Mod only)
// @Tags Users
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param query query request.SearchUserDto false "Search Query"
// @Success 200 {object} response.CommonResponse
// @Failure 400 {object} response.CommonResponse
// @Failure 500 {object} response.CommonResponse
// @Router /users [get]
func (h *UserController) Search(c fiber.Ctx) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
dto := &request.SearchUserDto{}
if err := validator.ValidateQueryDto(c, dto); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(response.CommonResponse{
Status: false,
Message: err.Error(),
})
}
res, err := h.service.Search(ctx, dto)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(response.CommonResponse{
Status: false,
Message: err.Error(),
})
}
return c.Status(fiber.StatusOK).JSON(res)
}

View File

@@ -1,11 +1,36 @@
package request
import "history-api/pkg/constants"
type SignUpDto struct {
Email string `json:"email" validate:"required,min=5,max=255,email"`
Password string `json:"password" validate:"required,min=8,max=64"`
DisplayName string `json:"display_name" validate:"required,min=2,max=50"`
TokenID string `json:"token_id" validate:"required,uuid"`
}
type SignInDto struct {
Email string `json:"email" validate:"required,min=5,max=255,email"`
Password string `json:"password" validate:"required,min=8,max=64"`
}
type CreateTokenDto struct {
Email string `json:"email" validate:"required,email"`
TokenType constants.TokenType `json:"token_type" validate:"required,oneof=1 2 3 4"`
}
type VerifyTokenDto struct {
Email string `json:"email" validate:"required,email"`
TokenType constants.TokenType `json:"token_type" validate:"required,oneof=1 2 3 4"`
Token string `json:"token" validate:"required,len=6,numeric"`
}
type ForgotPasswordDto struct {
TokenID string `json:"token_id" validate:"required,uuid"`
Email string `json:"email" validate:"required,min=5,max=255,email"`
NewPassword string `json:"new_password" validate:"required,min=8,max=64"`
}
type SigninWith3rdDto struct {
Provider string `json:"provider" validate:"required,oneof=google github facebook"`
AccessToken string `json:"access_token" validate:"required"`
}

View File

@@ -1,26 +1,42 @@
package request
import "history-api/pkg/constant"
type CreateUserDto struct {
Username string `json:"username" validate:"required"`
Password string `json:"password" validate:"required"`
DiscordUserId string `json:"discord_user_id" validate:"required"`
Role []constant.Role `json:"role" validate:"required"`
type UpdateProfileDto struct {
DisplayName string `json:"display_name" validate:"omitempty,min=2,max=50"`
FullName string `json:"full_name" validate:"omitempty,min=2,max=100"`
AvatarUrl string `json:"avatar_url" validate:"omitempty,url"`
Bio string `json:"bio" validate:"omitempty,max=255"`
Location string `json:"location" validate:"omitempty,max=100"`
Website string `json:"website" validate:"omitempty,url"`
CountryCode string `json:"country_code" validate:"omitempty,len=2"`
Phone string `json:"phone" validate:"omitempty,min=8,max=20"`
}
type UpdateUserDto struct {
Password *string `json:"password" validate:"omitempty"`
DiscordUserId *string `json:"discord_user_id" validate:"omitempty"`
Role *[]constant.Role `json:"role" validate:"omitempty"`
type ChangePasswordDto struct {
OldPassword string `json:"old_password" validate:"required,min=8,max=64"`
NewPassword string `json:"new_password" validate:"required,min=8,max=64,nefield=OldPassword"`
}
type ChangeRoleDto struct {
UserID string `json:"user_id" validate:"required,uuid"`
Roles []string `json:"role_ids" validate:"required,min=1,dive,required,uuid"`
}
type GetAllUserDto struct {
CursorPaginationDto
IsDeleted *bool `json:"is_deleted" query:"is_deleted" validate:"omitempty"`
RoleIDs []string `json:"role_ids" query:"role_ids" validate:"omitempty,dive,uuid"`
}
type CursorPaginationDto struct {
Cursor string `json:"cursor" query:"cursor" validate:"omitempty,uuid"`
Limit int `json:"limit" query:"limit" validate:"required,min=1,max=100"`
Sort string `json:"sort" query:"sort" validate:"omitempty,oneof=created_at updated_at email display_name"`
Order string `json:"order" query:"order" validate:"omitempty,oneof=asc desc"`
}
type SearchUserDto struct {
Username *string `query:"username" validate:"omitempty"`
DiscordUserId *string `query:"discord_user_id" validate:"omitempty"`
Role *[]constant.Role `query:"role" validate:"omitempty"`
SortBy string `query:"sort_by" default:"created_at" validate:"oneof=created_at updated_at"`
Order string `query:"order" default:"desc" validate:"oneof=asc desc"`
Page int `query:"page" default:"1" validate:"min=1"`
Limit int `query:"limit" default:"10" validate:"min=1,max=100"`
CursorPaginationDto
Search string `json:"search" query:"search" validate:"omitempty,min=2,max=200"`
IsDeleted *bool `json:"is_deleted" query:"is_deleted" validate:"omitempty"`
RoleIDs []string `json:"role_ids" query:"role_ids" validate:"omitempty,dive,uuid"`
}

View File

@@ -4,3 +4,7 @@ type AuthResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
}
type VerifyTokenResponse struct {
TokenID string `json:"token_id"`
}

View File

@@ -1,7 +1,7 @@
package response
import (
"history-api/pkg/constant"
"history-api/pkg/constants"
"github.com/golang-jwt/jwt/v5"
)
@@ -13,7 +13,18 @@ type CommonResponse struct {
}
type JWTClaims struct {
UId string `json:"uid"`
Roles []constant.Role `json:"roles"`
UId string `json:"uid"`
Roles []constants.Role `json:"roles"`
TokenVersion int32 `json:"token_version"`
jwt.RegisteredClaims
}
}
type PaginatedResponse struct {
Data any `json:"data"`
Status bool `json:"status"`
Message string `json:"message"`
Pagination struct {
NextCursor string `json:"next_cursor"`
HasMore bool `json:"has_more"`
} `json:"pagination"`
}

View File

@@ -1,11 +0,0 @@
package response
import "time"
type TokenResponse struct {
ID string `json:"id"`
UserID string `json:"user_id"`
TokenType int16 `json:"token_type"`
ExpiresAt *time.Time `json:"expires_at"`
CreatedAt *time.Time `json:"created_at"`
}

View File

@@ -22,7 +22,6 @@ type User struct {
PasswordHash pgtype.Text `json:"password_hash"`
GoogleID pgtype.Text `json:"google_id"`
AuthProvider string `json:"auth_provider"`
IsVerified bool `json:"is_verified"`
IsDeleted bool `json:"is_deleted"`
TokenVersion int32 `json:"token_version"`
RefreshToken pgtype.Text `json:"refresh_token"`
@@ -49,16 +48,6 @@ type UserRole struct {
RoleID pgtype.UUID `json:"role_id"`
}
type UserToken struct {
ID pgtype.UUID `json:"id"`
UserID pgtype.UUID `json:"user_id"`
Token string `json:"token"`
IsDeleted bool `json:"is_deleted"`
TokenType int16 `json:"token_type"`
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type UserVerification struct {
ID pgtype.UUID `json:"id"`
UserID pgtype.UUID `json:"user_id"`

View File

@@ -13,19 +13,17 @@ import (
const addUserRole = `-- name: AddUserRole :exec
INSERT INTO user_roles (user_id, role_id)
SELECT $1, r.id
FROM roles r
WHERE r.name = $2
SELECT $1, unnest($2::uuid[])
ON CONFLICT DO NOTHING
`
type AddUserRoleParams struct {
UserID pgtype.UUID `json:"user_id"`
Name string `json:"name"`
UserID pgtype.UUID `json:"user_id"`
Column2 []pgtype.UUID `json:"column_2"`
}
func (q *Queries) AddUserRole(ctx context.Context, arg AddUserRoleParams) error {
_, err := q.db.Exec(ctx, addUserRole, arg.UserID, arg.Name)
_, err := q.db.Exec(ctx, addUserRole, arg.UserID, arg.Column2)
return err
}
@@ -129,6 +127,38 @@ func (q *Queries) GetRoles(ctx context.Context) ([]Role, error) {
return items, nil
}
const getRolesByIDs = `-- name: GetRolesByIDs :many
SELECT id, name, is_deleted, created_at, updated_at
FROM roles
WHERE id = ANY($1::uuid[]) AND is_deleted = false
`
func (q *Queries) GetRolesByIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([]Role, error) {
rows, err := q.db.Query(ctx, getRolesByIDs, dollar_1)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Role{}
for rows.Next() {
var i Role
if err := rows.Scan(
&i.ID,
&i.Name,
&i.IsDeleted,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const removeAllRolesFromUser = `-- name: RemoveAllRolesFromUser :exec
DELETE FROM user_roles
WHERE user_id = $1

View File

@@ -77,7 +77,6 @@ SELECT
u.id,
u.email,
u.password_hash,
u.is_verified,
u.token_version,
u.is_deleted,
u.created_at,
@@ -116,7 +115,6 @@ type GetUserByEmailRow struct {
ID pgtype.UUID `json:"id"`
Email string `json:"email"`
PasswordHash pgtype.Text `json:"password_hash"`
IsVerified bool `json:"is_verified"`
TokenVersion int32 `json:"token_version"`
IsDeleted bool `json:"is_deleted"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
@@ -132,7 +130,6 @@ func (q *Queries) GetUserByEmail(ctx context.Context, email string) (GetUserByEm
&i.ID,
&i.Email,
&i.PasswordHash,
&i.IsVerified,
&i.TokenVersion,
&i.IsDeleted,
&i.CreatedAt,
@@ -148,7 +145,6 @@ SELECT
u.id,
u.email,
u.password_hash,
u.is_verified,
u.token_version,
u.refresh_token,
u.is_deleted,
@@ -190,7 +186,6 @@ type GetUserByIDRow struct {
ID pgtype.UUID `json:"id"`
Email string `json:"email"`
PasswordHash pgtype.Text `json:"password_hash"`
IsVerified bool `json:"is_verified"`
TokenVersion int32 `json:"token_version"`
RefreshToken pgtype.Text `json:"refresh_token"`
IsDeleted bool `json:"is_deleted"`
@@ -207,7 +202,79 @@ func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (GetUserByIDR
&i.ID,
&i.Email,
&i.PasswordHash,
&i.IsVerified,
&i.TokenVersion,
&i.RefreshToken,
&i.IsDeleted,
&i.CreatedAt,
&i.UpdatedAt,
&i.Profile,
&i.Roles,
)
return i, err
}
const getUserByIDWithoutDeleted = `-- name: GetUserByIDWithoutDeleted :one
SELECT
u.id,
u.email,
u.password_hash,
u.token_version,
u.refresh_token,
u.is_deleted,
u.created_at,
u.updated_at,
-- profile JSON
(
SELECT json_build_object(
'display_name', p.display_name,
'full_name', p.full_name,
'avatar_url', p.avatar_url,
'bio', p.bio,
'location', p.location,
'website', p.website,
'country_code', p.country_code,
'phone', p.phone
)
FROM user_profiles p
WHERE p.user_id = u.id
) AS profile,
-- roles JSON
(
SELECT COALESCE(
json_agg(json_build_object('id', r.id, 'name', r.name)),
'[]'
)::json
FROM user_roles ur
JOIN roles r ON ur.role_id = r.id
WHERE ur.user_id = u.id
) AS roles
FROM users u
WHERE u.id = $1
`
type GetUserByIDWithoutDeletedRow struct {
ID pgtype.UUID `json:"id"`
Email string `json:"email"`
PasswordHash pgtype.Text `json:"password_hash"`
TokenVersion int32 `json:"token_version"`
RefreshToken pgtype.Text `json:"refresh_token"`
IsDeleted bool `json:"is_deleted"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
Profile []byte `json:"profile"`
Roles []byte `json:"roles"`
}
func (q *Queries) GetUserByIDWithoutDeleted(ctx context.Context, id pgtype.UUID) (GetUserByIDWithoutDeletedRow, error) {
row := q.db.QueryRow(ctx, getUserByIDWithoutDeleted, id)
var i GetUserByIDWithoutDeletedRow
err := row.Scan(
&i.ID,
&i.Email,
&i.PasswordHash,
&i.TokenVersion,
&i.RefreshToken,
&i.IsDeleted,
@@ -224,7 +291,127 @@ SELECT
u.id,
u.email,
u.password_hash,
u.is_verified,
u.token_version,
u.refresh_token,
u.is_deleted,
u.created_at,
u.updated_at,
-- profile JSON
(
SELECT json_build_object(
'display_name', p.display_name,
'full_name', p.full_name,
'avatar_url', p.avatar_url,
'bio', p.bio,
'location', p.location,
'website', p.website,
'country_code', p.country_code,
'phone', p.phone
)
FROM user_profiles p
WHERE p.user_id = u.id
) AS profile,
-- roles JSON
(
SELECT COALESCE(
json_agg(json_build_object('id', r.id, 'name', r.name)),
'[]'
)::json
FROM user_roles ur
JOIN roles r ON ur.role_id = r.id
WHERE ur.user_id = u.id
) AS roles
FROM users u
WHERE
($1::uuid IS NULL OR u.id > $1::uuid)
AND ($2::boolean IS NULL OR u.is_deleted = $2::boolean)
AND (
$3::uuid[] IS NULL OR
EXISTS (
SELECT 1 FROM user_roles ur2
WHERE ur2.user_id = u.id AND ur2.role_id = ANY($3::uuid[])
)
)
ORDER BY u.id ASC
LIMIT $4
`
type GetUsersParams struct {
Cursor pgtype.UUID `json:"cursor"`
IsDeleted pgtype.Bool `json:"is_deleted"`
RoleIds []pgtype.UUID `json:"role_ids"`
Limit int32 `json:"limit"`
}
type GetUsersRow struct {
ID pgtype.UUID `json:"id"`
Email string `json:"email"`
PasswordHash pgtype.Text `json:"password_hash"`
TokenVersion int32 `json:"token_version"`
RefreshToken pgtype.Text `json:"refresh_token"`
IsDeleted bool `json:"is_deleted"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
Profile []byte `json:"profile"`
Roles []byte `json:"roles"`
}
func (q *Queries) GetUsers(ctx context.Context, arg GetUsersParams) ([]GetUsersRow, error) {
rows, err := q.db.Query(ctx, getUsers,
arg.Cursor,
arg.IsDeleted,
arg.RoleIds,
arg.Limit,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []GetUsersRow{}
for rows.Next() {
var i GetUsersRow
if err := rows.Scan(
&i.ID,
&i.Email,
&i.PasswordHash,
&i.TokenVersion,
&i.RefreshToken,
&i.IsDeleted,
&i.CreatedAt,
&i.UpdatedAt,
&i.Profile,
&i.Roles,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const restoreUser = `-- name: RestoreUser :exec
UPDATE users
SET
is_deleted = false
WHERE id = $1
`
func (q *Queries) RestoreUser(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, restoreUser, id)
return err
}
const searchUsers = `-- name: SearchUsers :many
SELECT
u.id,
u.email,
u.password_hash,
u.token_version,
u.refresh_token,
u.is_deleted,
@@ -257,14 +444,44 @@ SELECT
) AS roles
FROM users u
WHERE u.is_deleted = false
WHERE
($1::uuid IS NULL OR u.id > $1::uuid)
AND ($2::boolean IS NULL OR u.is_deleted = $2::boolean)
AND (
$3::uuid[] IS NULL OR
EXISTS (
SELECT 1 FROM user_roles ur2
WHERE ur2.user_id = u.id AND ur2.role_id = ANY($3::uuid[])
)
)
AND ($4::uuid IS NULL OR u.id = $4::uuid)
AND (
$5::text IS NULL OR
u.email ILIKE '%' || $5::text || '%' OR
EXISTS (
SELECT 1 FROM user_profiles p
WHERE p.user_id = u.id AND p.display_name ILIKE '%' || $5::text || '%'
)
)
ORDER BY u.id ASC
LIMIT $6
`
type GetUsersRow struct {
type SearchUsersParams struct {
Cursor pgtype.UUID `json:"cursor"`
IsDeleted pgtype.Bool `json:"is_deleted"`
RoleIds []pgtype.UUID `json:"role_ids"`
SearchID pgtype.UUID `json:"search_id"`
SearchText pgtype.Text `json:"search_text"`
Limit int32 `json:"limit"`
}
type SearchUsersRow struct {
ID pgtype.UUID `json:"id"`
Email string `json:"email"`
PasswordHash pgtype.Text `json:"password_hash"`
IsVerified bool `json:"is_verified"`
TokenVersion int32 `json:"token_version"`
RefreshToken pgtype.Text `json:"refresh_token"`
IsDeleted bool `json:"is_deleted"`
@@ -274,20 +491,26 @@ type GetUsersRow struct {
Roles []byte `json:"roles"`
}
func (q *Queries) GetUsers(ctx context.Context) ([]GetUsersRow, error) {
rows, err := q.db.Query(ctx, getUsers)
func (q *Queries) SearchUsers(ctx context.Context, arg SearchUsersParams) ([]SearchUsersRow, error) {
rows, err := q.db.Query(ctx, searchUsers,
arg.Cursor,
arg.IsDeleted,
arg.RoleIds,
arg.SearchID,
arg.SearchText,
arg.Limit,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []GetUsersRow{}
items := []SearchUsersRow{}
for rows.Next() {
var i GetUsersRow
var i SearchUsersRow
if err := rows.Scan(
&i.ID,
&i.Email,
&i.PasswordHash,
&i.IsVerified,
&i.TokenVersion,
&i.RefreshToken,
&i.IsDeleted,
@@ -306,18 +529,6 @@ func (q *Queries) GetUsers(ctx context.Context) ([]GetUsersRow, error) {
return items, nil
}
const restoreUser = `-- name: RestoreUser :exec
UPDATE users
SET
is_deleted = false
WHERE id = $1
`
func (q *Queries) RestoreUser(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, restoreUser, id)
return err
}
const updateTokenVersion = `-- name: UpdateTokenVersion :exec
UPDATE users
SET token_version = $2
@@ -432,18 +643,15 @@ INSERT INTO users (
email,
password_hash,
google_id,
auth_provider,
is_verified
auth_provider
) VALUES (
$1, $2, $3, $4, $5
$1, $2, $3, $4
)
ON CONFLICT (email)
DO UPDATE SET
google_id = EXCLUDED.google_id,
auth_provider = EXCLUDED.auth_provider,
is_verified = users.is_verified OR EXCLUDED.is_verified,
updated_at = now()
RETURNING id, email, password_hash, google_id, auth_provider, is_verified, is_deleted, token_version, refresh_token, created_at, updated_at
auth_provider = EXCLUDED.auth_provider
RETURNING id, email, password_hash, google_id, auth_provider, is_deleted, token_version, refresh_token, created_at, updated_at
`
type UpsertUserParams struct {
@@ -451,7 +659,6 @@ type UpsertUserParams struct {
PasswordHash pgtype.Text `json:"password_hash"`
GoogleID pgtype.Text `json:"google_id"`
AuthProvider string `json:"auth_provider"`
IsVerified bool `json:"is_verified"`
}
func (q *Queries) UpsertUser(ctx context.Context, arg UpsertUserParams) (User, error) {
@@ -460,7 +667,6 @@ func (q *Queries) UpsertUser(ctx context.Context, arg UpsertUserParams) (User, e
arg.PasswordHash,
arg.GoogleID,
arg.AuthProvider,
arg.IsVerified,
)
var i User
err := row.Scan(
@@ -469,7 +675,6 @@ func (q *Queries) UpsertUser(ctx context.Context, arg UpsertUserParams) (User, e
&i.PasswordHash,
&i.GoogleID,
&i.AuthProvider,
&i.IsVerified,
&i.IsDeleted,
&i.TokenVersion,
&i.RefreshToken,
@@ -478,16 +683,3 @@ func (q *Queries) UpsertUser(ctx context.Context, arg UpsertUserParams) (User, e
)
return i, err
}
const verifyUser = `-- name: VerifyUser :exec
UPDATE users
SET
is_verified = true
WHERE id = $1
AND is_deleted = false
`
func (q *Queries) VerifyUser(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, verifyUser, id)
return err
}

View File

@@ -2,16 +2,18 @@ package middlewares
import (
"history-api/internal/dtos/response"
"history-api/internal/repositories"
"history-api/pkg/config"
"history-api/pkg/constant"
"history-api/pkg/constants"
"slices"
jwtware "github.com/gofiber/contrib/v3/jwt"
"github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/extractors"
"github.com/jackc/pgx/v5/pgtype"
)
func JwtAccess() fiber.Handler {
func JwtAccess(userRepo repositories.UserRepository) fiber.Handler {
jwtSecret, err := config.GetConfig("JWT_SECRET")
if err != nil {
return nil
@@ -20,13 +22,13 @@ func JwtAccess() fiber.Handler {
return jwtware.New(jwtware.Config{
SigningKey: jwtware.SigningKey{Key: []byte(jwtSecret)},
ErrorHandler: jwtError,
SuccessHandler: jwtSuccess,
SuccessHandler: jwtSuccess(userRepo),
Extractor: extractors.FromAuthHeader("Bearer"),
Claims: &response.JWTClaims{},
})
}
func JwtRefresh() fiber.Handler {
func JwtRefresh(userRepo repositories.UserRepository) fiber.Handler {
jwtRefreshSecret, err := config.GetConfig("JWT_REFRESH_SECRET")
if err != nil {
return nil
@@ -35,41 +37,61 @@ func JwtRefresh() fiber.Handler {
return jwtware.New(jwtware.Config{
SigningKey: jwtware.SigningKey{Key: []byte(jwtRefreshSecret)},
ErrorHandler: jwtError,
SuccessHandler: jwtSuccess,
SuccessHandler: jwtSuccess(userRepo),
Extractor: extractors.FromAuthHeader("Bearer"),
Claims: &response.JWTClaims{},
})
}
func jwtSuccess(c fiber.Ctx) error {
user := jwtware.FromContext(c)
unauthorized := func() error {
return c.Status(fiber.StatusUnauthorized).JSON(response.CommonResponse{
Status: false,
Message: "Invalid or missing token",
})
func jwtSuccess(userRepo repositories.UserRepository) fiber.Handler {
return func(c fiber.Ctx) error {
user := jwtware.FromContext(c)
unauthorized := func() error {
return c.Status(fiber.StatusUnauthorized).JSON(response.CommonResponse{
Status: false,
Message: "Invalid or missing token",
})
}
if user == nil {
return unauthorized()
}
claims, ok := user.Claims.(*response.JWTClaims)
if !ok {
return unauthorized()
}
if slices.Contains(claims.Roles, constants.BANNED) {
return c.Status(fiber.StatusForbidden).JSON(response.CommonResponse{
Status: false,
Message: "User account is banned",
})
}
var pgID pgtype.UUID
err := pgID.Scan(claims.UId)
if err != nil {
return unauthorized()
}
tokenVersion, err := userRepo.GetTokenVersion(c.Context(), pgID)
if err != nil {
return unauthorized()
}
if tokenVersion != claims.TokenVersion {
return c.Status(fiber.StatusUnauthorized).JSON(response.CommonResponse{
Status: false,
Message: "Token has been invalidated",
})
}
c.Locals("uid", claims.UId)
c.Locals("user_claims", claims)
return c.Next()
}
if user == nil {
return unauthorized()
}
claims, ok := user.Claims.(*response.JWTClaims)
if !ok {
return unauthorized()
}
if slices.Contains(claims.Roles, constant.BANNED) {
return c.Status(fiber.StatusForbidden).JSON(response.CommonResponse{
Status: false,
Message: "User account is banned",
})
}
c.Locals("uid", claims.UId)
c.Locals("user_claims", claims)
return c.Next()
}
func jwtError(c fiber.Ctx, err error) error {
if err.Error() == "Missing or malformed JWT" {

View File

@@ -2,13 +2,13 @@ package middlewares
import (
"history-api/internal/dtos/response"
"history-api/pkg/constant"
"history-api/pkg/constants"
"slices"
"github.com/gofiber/fiber/v3"
)
func getRoles(c fiber.Ctx) ([]constant.Role, error) {
func getRoles(c fiber.Ctx) ([]constants.Role, error) {
claimsVal := c.Locals("user_claims")
if claimsVal == nil {
return nil, fiber.ErrUnauthorized
@@ -22,7 +22,7 @@ func getRoles(c fiber.Ctx) ([]constant.Role, error) {
return claims.Roles, nil
}
func RequireAnyRole(required ...constant.Role) fiber.Handler {
func RequireAnyRole(required ...constants.Role) fiber.Handler {
return func(c fiber.Ctx) error {
userRoles, err := getRoles(c)
if err != nil {
@@ -43,7 +43,7 @@ func RequireAnyRole(required ...constant.Role) fiber.Handler {
}
}
func RequireAllRoles(required ...constant.Role) fiber.Handler {
func RequireAllRoles(required ...constants.Role) fiber.Handler {
return func(c fiber.Ctx) error {
userRoles, err := getRoles(c)
if err != nil {

View File

@@ -2,7 +2,7 @@ package models
import (
"history-api/internal/dtos/response"
"history-api/pkg/constant"
"history-api/pkg/constants"
"time"
)
@@ -44,6 +44,13 @@ func (r *RoleEntity) ToResponse() *response.RoleResponse {
}
}
func (r *RoleEntity) ToRoleSimple() *RoleSimple {
return &RoleSimple{
ID: r.ID,
Name: r.Name,
}
}
func RolesEntityToResponse(rs []*RoleEntity) []*response.RoleResponse {
out := make([]*response.RoleResponse, len(rs))
for i := range rs {
@@ -52,10 +59,10 @@ func RolesEntityToResponse(rs []*RoleEntity) []*response.RoleResponse {
return out
}
func RolesEntityToRoleConstant(rs []*RoleSimple) []constant.Role {
out := make([]constant.Role, len(rs))
func RolesEntityToRoleConstant(rs []*RoleSimple) []constants.Role {
out := make([]constants.Role, len(rs))
for i := range rs {
data, ok := constant.ParseRole(rs[i].Name)
data, ok := constants.ParseRole(rs[i].Name)
if !ok {
continue
}

View File

@@ -1,35 +1,9 @@
package models
import (
"history-api/internal/dtos/response"
"history-api/pkg/convert"
"github.com/jackc/pgx/v5/pgtype"
)
import "history-api/pkg/constants"
type TokenEntity struct {
ID pgtype.UUID `json:"id"`
UserID pgtype.UUID `json:"user_id"`
Token string `json:"token"`
TokenType int16 `json:"token_type"`
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
func (t *TokenEntity) ToResponse() *response.TokenResponse {
return &response.TokenResponse{
ID: convert.UUIDToString(t.ID),
UserID: convert.UUIDToString(t.UserID),
TokenType: t.TokenType,
ExpiresAt: convert.TimeToPtr(t.ExpiresAt),
CreatedAt: convert.TimeToPtr(t.CreatedAt),
}
}
func TokensEntityToResponse(ts []*TokenEntity) []*response.TokenResponse {
out := make([]*response.TokenResponse, len(ts))
for i := range ts {
out[i] = ts[i].ToResponse()
}
return out
Email string `json:"email"`
Token string `json:"token"`
TokenType constants.TokenType `json:"token_type"`
}

View File

@@ -2,19 +2,22 @@ package repositories
import (
"context"
"crypto/md5"
"encoding/json"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgtype"
"history-api/internal/gen/sqlc"
"history-api/internal/models"
"history-api/pkg/cache"
"history-api/pkg/constants"
"history-api/pkg/convert"
)
type RoleRepository interface {
GetByID(ctx context.Context, id pgtype.UUID) (*models.RoleEntity, error)
GetByIDs(ctx context.Context, ids []string) ([]*models.RoleEntity, error)
GetByname(ctx context.Context, name string) (*models.RoleEntity, error)
All(ctx context.Context) ([]*models.RoleEntity, error)
Create(ctx context.Context, name string) (*models.RoleEntity, error)
@@ -39,6 +42,56 @@ func NewRoleRepository(db sqlc.DBTX, c cache.Cache) RoleRepository {
}
}
func (r *roleRepository) generateQueryKey(prefix string, params any) string {
b, _ := json.Marshal(params)
hash := fmt.Sprintf("%x", md5.Sum(b))
return fmt.Sprintf("%s:%s", prefix, hash)
}
func (r *roleRepository) getByIDsWithFallback(ctx context.Context, ids []string) ([]*models.RoleEntity, error) {
if len(ids) == 0 {
return []*models.RoleEntity{}, nil
}
keys := make([]string, len(ids))
for i, id := range ids {
keys[i] = fmt.Sprintf("role:id:%s", id)
}
raws := r.c.MGet(ctx, keys...)
var roles []*models.RoleEntity
missingRolesToCache := make(map[string]any)
for i, b := range raws {
if len(b) > 0 {
var u models.RoleEntity
if err := json.Unmarshal(b, &u); err == nil {
roles = append(roles, &u)
}
} else {
pgId := pgtype.UUID{}
err := pgId.Scan(ids[i])
if err != nil {
continue
}
dbRole, err := r.GetByID(ctx, pgId)
if err == nil && dbRole != nil {
roles = append(roles, dbRole)
missingRolesToCache[keys[i]] = dbRole
}
}
}
if len(missingRolesToCache) > 0 {
_ = r.c.MSet(ctx, missingRolesToCache, constants.NormalCacheDuration)
}
return roles, nil
}
func (r *roleRepository) GetByIDs(ctx context.Context, ids []string) ([]*models.RoleEntity, error) {
return r.getByIDsWithFallback(ctx, ids)
}
func (r *roleRepository) GetByID(ctx context.Context, id pgtype.UUID) (*models.RoleEntity, error) {
cacheId := fmt.Sprintf("role:id:%s", convert.UUIDToString(id))
var role models.RoleEntity
@@ -59,7 +112,7 @@ func (r *roleRepository) GetByID(ctx context.Context, id pgtype.UUID) (*models.R
CreatedAt: convert.TimeToPtr(row.CreatedAt),
UpdatedAt: convert.TimeToPtr(row.UpdatedAt),
}
_ = r.c.Set(ctx, cacheId, role, 5*time.Minute)
_ = r.c.Set(ctx, cacheId, role, constants.NormalCacheDuration)
return &role, nil
}
@@ -83,7 +136,7 @@ func (r *roleRepository) GetByname(ctx context.Context, name string) (*models.Ro
UpdatedAt: convert.TimeToPtr(row.UpdatedAt),
}
_ = r.c.Set(ctx, cacheId, role, 5*time.Minute)
_ = r.c.Set(ctx, cacheId, role, constants.NormalCacheDuration)
return &role, nil
}
@@ -104,7 +157,7 @@ func (r *roleRepository) Create(ctx context.Context, name string) (*models.RoleE
fmt.Sprintf("role:name:%s", name): role,
fmt.Sprintf("role:id:%s", convert.UUIDToString(row.ID)): role,
}
_ = r.c.MSet(ctx, mapCache, 5*time.Minute)
_ = r.c.MSet(ctx, mapCache, constants.NormalCacheDuration)
return &role, nil
}
@@ -125,7 +178,7 @@ func (r *roleRepository) Update(ctx context.Context, params sqlc.UpdateRoleParam
fmt.Sprintf("role:name:%s", row.Name): role,
fmt.Sprintf("role:id:%s", convert.UUIDToString(row.ID)): role,
}
_ = r.c.MSet(ctx, mapCache, 5*time.Minute)
_ = r.c.MSet(ctx, mapCache, constants.NormalCacheDuration)
return &role, nil
}

View File

@@ -5,6 +5,7 @@ import (
"database/sql"
"fmt"
"history-api/pkg/cache"
"history-api/pkg/constants"
"time"
)
@@ -50,7 +51,7 @@ func (r *tileRepository) GetMetadata(ctx context.Context) (map[string]string, er
metadata[name] = value
}
_ = r.c.Set(ctx, cacheId, metadata, 10*time.Minute)
_ = r.c.Set(ctx, cacheId, metadata, constants.NormalCacheDuration)
return metadata, nil
}

View File

@@ -0,0 +1,79 @@
package repositories
import (
"context"
"fmt"
"history-api/internal/models"
"history-api/pkg/cache"
"history-api/pkg/constants"
)
type TokenRepository interface {
CheckCooldown(ctx context.Context, email string, tokenType constants.TokenType) (bool, error)
Get(ctx context.Context, email string, tokenType constants.TokenType) (*models.TokenEntity, error)
Create(ctx context.Context, token *models.TokenEntity) error
Delete(ctx context.Context, email string, tokenType constants.TokenType) error
CheckVerified(ctx context.Context, email string, tokenType constants.TokenType, id string) (bool, error)
CreateVerified(ctx context.Context, email string, tokenType constants.TokenType, id string) error
DeleteVerified(ctx context.Context, email string, tokenType constants.TokenType, id string) error
}
type tokenRepository struct {
c cache.Cache
}
func NewTokenRepository(c cache.Cache) TokenRepository {
return &tokenRepository{
c: c,
}
}
func (t *tokenRepository) CreateVerified(ctx context.Context, email string, tokenType constants.TokenType, id string) error {
cacheKey := fmt.Sprintf("token:verified:%d:%s:%s", tokenType.Value(), email, id)
return t.c.Set(ctx, cacheKey, true, constants.TokenVerifiedDuration)
}
func (t *tokenRepository) DeleteVerified(ctx context.Context, email string, tokenType constants.TokenType, id string) error {
cacheKey := fmt.Sprintf("token:verified:%d:%s:%s", tokenType.Value(), email, id)
return t.c.Del(ctx, cacheKey)
}
func (t *tokenRepository) CheckCooldown(ctx context.Context, email string, tokenType constants.TokenType) (bool, error) {
cacheKey := fmt.Sprintf("token:cooldown:%d:%s", tokenType.Value(), email)
exists, err := t.c.Exists(ctx, cacheKey)
return exists, err
}
func (t *tokenRepository) CheckVerified(ctx context.Context, email string, tokenType constants.TokenType, id string) (bool, error) {
cacheKey := fmt.Sprintf("token:verified:%d:%s:%s", tokenType.Value(), email, id)
exists, err := t.c.Exists(ctx, cacheKey)
return exists, err
}
func (t *tokenRepository) Create(ctx context.Context, token *models.TokenEntity) error {
cacheKey := fmt.Sprintf("token:%d:%s", token.TokenType.Value(), token.Email)
err := t.c.Set(ctx, cacheKey, token, constants.TokenExpirationDuration)
if err != nil {
return err
}
cooldownKey := fmt.Sprintf("token:cooldown:%d:%s", token.TokenType.Value(), token.Email)
return t.c.Set(ctx, cooldownKey, true, constants.TokenCooldownDuration)
}
func (t *tokenRepository) Delete(ctx context.Context, email string, tokenType constants.TokenType) error {
cacheKey := fmt.Sprintf("token:%d:%s", tokenType.Value(), email)
cooldownKey := fmt.Sprintf("token:cooldown:%d:%s", tokenType.Value(), email)
_ = t.c.Del(ctx, cooldownKey)
return t.c.Del(ctx, cacheKey)
}
func (t *tokenRepository) Get(ctx context.Context, email string, tokenType constants.TokenType) (*models.TokenEntity, error) {
cacheKey := fmt.Sprintf("token:%d:%s", tokenType.Value(), email)
var token models.TokenEntity
err := t.c.Get(ctx, cacheKey, &token)
if err != nil {
return nil, err
}
return &token, nil
}

View File

@@ -2,21 +2,25 @@ package repositories
import (
"context"
"crypto/md5"
"encoding/json"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgtype"
"history-api/internal/gen/sqlc"
"history-api/internal/models"
"history-api/pkg/cache"
"history-api/pkg/constants"
"history-api/pkg/convert"
)
type UserRepository interface {
GetByID(ctx context.Context, id pgtype.UUID) (*models.UserEntity, error)
GetByIDWithoutDeleted(ctx context.Context, id pgtype.UUID) (*models.UserEntity, error)
GetByEmail(ctx context.Context, email string) (*models.UserEntity, error)
All(ctx context.Context) ([]*models.UserEntity, error)
All(ctx context.Context, params sqlc.GetUsersParams) ([]*models.UserEntity, error)
Search(ctx context.Context, params sqlc.SearchUsersParams) ([]*models.UserEntity, error)
UpsertUser(ctx context.Context, params sqlc.UpsertUserParams) (*models.UserEntity, error)
CreateProfile(ctx context.Context, params sqlc.CreateUserProfileParams) (*models.UserProfileSimple, error)
UpdateProfile(ctx context.Context, params sqlc.UpdateUserProfileParams) (*models.UserEntity, error)
@@ -24,7 +28,6 @@ type UserRepository interface {
UpdateRefreshToken(ctx context.Context, params sqlc.UpdateUserRefreshTokenParams) error
GetTokenVersion(ctx context.Context, id pgtype.UUID) (int32, error)
UpdateTokenVersion(ctx context.Context, params sqlc.UpdateTokenVersionParams) error
Verify(ctx context.Context, id pgtype.UUID) error
Delete(ctx context.Context, id pgtype.UUID) error
Restore(ctx context.Context, id pgtype.UUID) error
}
@@ -41,6 +44,52 @@ func NewUserRepository(db sqlc.DBTX, c cache.Cache) UserRepository {
}
}
func (r *userRepository) generateQueryKey(prefix string, params any) string {
b, _ := json.Marshal(params)
hash := fmt.Sprintf("%x", md5.Sum(b))
return fmt.Sprintf("%s:%s", prefix, hash)
}
func (r *userRepository) getByIDsWithFallback(ctx context.Context, ids []string) ([]*models.UserEntity, error) {
if len(ids) == 0 {
return []*models.UserEntity{}, nil
}
keys := make([]string, len(ids))
for i, id := range ids {
keys[i] = fmt.Sprintf("user:id:%s", id)
}
raws := r.c.MGet(ctx, keys...)
var users []*models.UserEntity
missingUsersToCache := make(map[string]any)
for i, b := range raws {
if len(b) > 0 {
var u models.UserEntity
if err := json.Unmarshal(b, &u); err == nil {
users = append(users, &u)
}
} else {
pgId := pgtype.UUID{}
err := pgId.Scan(ids[i])
if err != nil {
continue
}
dbUser, err := r.GetByID(ctx, pgId)
if err == nil && dbUser != nil {
users = append(users, dbUser)
missingUsersToCache[keys[i]] = dbUser
}
}
}
if len(missingUsersToCache) > 0 {
_ = r.c.MSet(ctx, missingUsersToCache, constants.NormalCacheDuration)
}
return users, nil
}
func (r *userRepository) GetByID(ctx context.Context, id pgtype.UUID) (*models.UserEntity, error) {
cacheId := fmt.Sprintf("user:id:%s", convert.UUIDToString(id))
var user models.UserEntity
@@ -58,7 +107,6 @@ func (r *userRepository) GetByID(ctx context.Context, id pgtype.UUID) (*models.U
ID: convert.UUIDToString(row.ID),
Email: row.Email,
PasswordHash: convert.TextToString(row.PasswordHash),
IsVerified: row.IsVerified,
TokenVersion: row.TokenVersion,
IsDeleted: row.IsDeleted,
CreatedAt: convert.TimeToPtr(row.CreatedAt),
@@ -73,7 +121,43 @@ func (r *userRepository) GetByID(ctx context.Context, id pgtype.UUID) (*models.U
return nil, err
}
_ = r.c.Set(ctx, cacheId, user, 5*time.Minute)
_ = r.c.Set(ctx, cacheId, user, constants.NormalCacheDuration)
return &user, nil
}
func (r *userRepository) GetByIDWithoutDeleted(ctx context.Context, id pgtype.UUID) (*models.UserEntity, error) {
cacheId := fmt.Sprintf("user:deleted:id:%s", convert.UUIDToString(id))
var user models.UserEntity
err := r.c.Get(ctx, cacheId, &user)
if err == nil {
return &user, nil
}
row, err := r.q.GetUserByID(ctx, id)
if err != nil {
return nil, err
}
user = models.UserEntity{
ID: convert.UUIDToString(row.ID),
Email: row.Email,
PasswordHash: convert.TextToString(row.PasswordHash),
TokenVersion: row.TokenVersion,
IsDeleted: row.IsDeleted,
CreatedAt: convert.TimeToPtr(row.CreatedAt),
UpdatedAt: convert.TimeToPtr(row.UpdatedAt),
}
if err := user.ParseRoles(row.Roles); err != nil {
return nil, err
}
if err := user.ParseProfile(row.Profile); err != nil {
return nil, err
}
_ = r.c.Set(ctx, cacheId, user, constants.NormalCacheDuration)
return &user, nil
}
@@ -96,7 +180,6 @@ func (r *userRepository) GetByEmail(ctx context.Context, email string) (*models.
ID: convert.UUIDToString(row.ID),
Email: row.Email,
PasswordHash: convert.TextToString(row.PasswordHash),
IsVerified: row.IsVerified,
TokenVersion: row.TokenVersion,
IsDeleted: row.IsDeleted,
CreatedAt: convert.TimeToPtr(row.CreatedAt),
@@ -111,7 +194,7 @@ func (r *userRepository) GetByEmail(ctx context.Context, email string) (*models.
return nil, err
}
_ = r.c.Set(ctx, cacheId, user, 5*time.Minute)
_ = r.c.Set(ctx, cacheId, user, constants.NormalCacheDuration)
return &user, nil
}
@@ -121,12 +204,17 @@ func (r *userRepository) UpsertUser(ctx context.Context, params sqlc.UpsertUserP
if err != nil {
return nil, err
}
go func() {
bgCtx := context.Background()
_ = r.c.DelByPattern(bgCtx, "user:all*")
_ = r.c.DelByPattern(bgCtx, "user:search*")
}()
return &models.UserEntity{
ID: convert.UUIDToString(row.ID),
Email: row.Email,
PasswordHash: convert.TextToString(row.PasswordHash),
IsVerified: row.IsVerified,
TokenVersion: row.TokenVersion,
IsDeleted: row.IsDeleted,
CreatedAt: convert.TimeToPtr(row.CreatedAt),
@@ -161,7 +249,7 @@ func (r *userRepository) UpdateProfile(ctx context.Context, params sqlc.UpdateUs
fmt.Sprintf("user:email:%s", user.Email): user,
fmt.Sprintf("user:id:%s", user.ID): user,
}
_ = r.c.MSet(ctx, mapCache, 5*time.Minute)
_ = r.c.MSet(ctx, mapCache, constants.NormalCacheDuration)
return user, nil
}
@@ -183,19 +271,27 @@ func (r *userRepository) CreateProfile(ctx context.Context, params sqlc.CreateUs
}, nil
}
func (r *userRepository) All(ctx context.Context) ([]*models.UserEntity, error) {
rows, err := r.q.GetUsers(ctx)
func (r *userRepository) All(ctx context.Context, params sqlc.GetUsersParams) ([]*models.UserEntity, error) {
queryKey := r.generateQueryKey("user:all", params)
var cachedIDs []string
if err := r.c.Get(ctx, queryKey, &cachedIDs); err == nil && len(cachedIDs) > 0 {
return r.getByIDsWithFallback(ctx, cachedIDs)
}
rows, err := r.q.GetUsers(ctx, params)
if err != nil {
return nil, err
}
var users []*models.UserEntity
var ids []string
usersToCache := make(map[string]any)
for _, row := range rows {
user := &models.UserEntity{
ID: convert.UUIDToString(row.ID),
Email: row.Email,
PasswordHash: convert.TextToString(row.PasswordHash),
IsVerified: row.IsVerified,
TokenVersion: row.TokenVersion,
IsDeleted: row.IsDeleted,
CreatedAt: convert.TimeToPtr(row.CreatedAt),
@@ -205,44 +301,75 @@ func (r *userRepository) All(ctx context.Context) ([]*models.UserEntity, error)
if err := user.ParseRoles(row.Roles); err != nil {
return nil, err
}
if err := user.ParseProfile(row.Profile); err != nil {
return nil, err
}
users = append(users, user)
ids = append(ids, user.ID)
usersToCache[fmt.Sprintf("user:id:%s", user.ID)] = user
}
if len(usersToCache) > 0 {
_ = r.c.MSet(ctx, usersToCache, constants.NormalCacheDuration)
}
if len(ids) > 0 {
_ = r.c.Set(ctx, queryKey, ids, constants.ListCacheDuration)
}
return users, nil
}
func (r *userRepository) Verify(ctx context.Context, id pgtype.UUID) error {
user, err := r.GetByID(ctx, id)
if err != nil {
return err
func (r *userRepository) Search(ctx context.Context, params sqlc.SearchUsersParams) ([]*models.UserEntity, error) {
queryKey := r.generateQueryKey("user:search", params)
var cachedIDs []string
if err := r.c.Get(ctx, queryKey, &cachedIDs); err == nil && len(cachedIDs) > 0 {
return r.getByIDsWithFallback(ctx, cachedIDs)
}
err = r.q.VerifyUser(ctx, id)
rows, err := r.q.SearchUsers(ctx, params)
if err != nil {
return err
}
err = r.q.UpdateTokenVersion(ctx, sqlc.UpdateTokenVersionParams{
ID: id,
TokenVersion: user.TokenVersion + 1,
})
if err != nil {
return err
return nil, err
}
user.IsVerified = true
user.TokenVersion += 1
var users []*models.UserEntity
var ids []string
usersToCache := make(map[string]any)
mapCache := map[string]any{
fmt.Sprintf("user:email:%s", user.Email): user,
fmt.Sprintf("user:id:%s", user.ID): user,
for _, row := range rows {
user := &models.UserEntity{
ID: convert.UUIDToString(row.ID),
Email: row.Email,
PasswordHash: convert.TextToString(row.PasswordHash),
TokenVersion: row.TokenVersion,
IsDeleted: row.IsDeleted,
CreatedAt: convert.TimeToPtr(row.CreatedAt),
UpdatedAt: convert.TimeToPtr(row.UpdatedAt),
}
if err := user.ParseRoles(row.Roles); err != nil {
return nil, err
}
if err := user.ParseProfile(row.Profile); err != nil {
return nil, err
}
users = append(users, user)
ids = append(ids, user.ID)
usersToCache[fmt.Sprintf("user:id:%s", user.ID)] = user
}
_ = r.c.MSet(ctx, mapCache, 5*time.Minute)
return nil
if len(usersToCache) > 0 {
_ = r.c.MSet(ctx, usersToCache, constants.NormalCacheDuration)
}
if len(ids) > 0 {
_ = r.c.Set(ctx, queryKey, ids, constants.ListCacheDuration)
}
return users, nil
}
func (r *userRepository) Delete(ctx context.Context, id pgtype.UUID) error {
@@ -288,7 +415,7 @@ func (r *userRepository) GetTokenVersion(ctx context.Context, id pgtype.UUID) (i
return 0, err
}
_ = r.c.Set(ctx, cacheId, raw, 5*time.Minute)
_ = r.c.Set(ctx, cacheId, raw, constants.NormalCacheDuration)
return raw, nil
}
@@ -299,7 +426,7 @@ func (r *userRepository) UpdateTokenVersion(ctx context.Context, params sqlc.Upd
}
cacheId := fmt.Sprintf("user:token:%s", convert.UUIDToString(params.ID))
_ = r.c.Set(ctx, cacheId, params.TokenVersion, 5*time.Minute)
_ = r.c.Set(ctx, cacheId, params.TokenVersion, constants.NormalCacheDuration)
return nil
}
@@ -328,7 +455,7 @@ func (r *userRepository) UpdatePassword(ctx context.Context, params sqlc.UpdateU
fmt.Sprintf("user:token:%s", user.ID): user.TokenVersion,
}
_ = r.c.MSet(ctx, mapCache, 5*time.Minute)
_ = r.c.MSet(ctx, mapCache, constants.NormalCacheDuration)
return nil
}
@@ -349,6 +476,6 @@ func (r *userRepository) UpdateRefreshToken(ctx context.Context, params sqlc.Upd
fmt.Sprintf("user:token:%s", user.ID): user.TokenVersion,
}
_ = r.c.MSet(ctx, mapCache, 5*time.Minute)
_ = r.c.MSet(ctx, mapCache, constants.NormalCacheDuration)
return nil
}

View File

@@ -3,13 +3,17 @@ package routes
import (
"history-api/internal/controllers"
"history-api/internal/middlewares"
"history-api/internal/repositories"
"github.com/gofiber/fiber/v3"
)
func AuthRoutes(app *fiber.App, controller *controllers.AuthController) {
func AuthRoutes(app *fiber.App, controller *controllers.AuthController, userRepo repositories.UserRepository) {
route := app.Group("/auth")
route.Post("/signin", controller.Signin)
route.Post("/signup", controller.Signup)
route.Post("/refresh", middlewares.JwtRefresh(), controller.RefreshToken)
route.Post("/refresh", middlewares.JwtRefresh(userRepo), controller.RefreshToken)
route.Post("/token/create", controller.CreateToken)
route.Post("/token/verify", controller.VerifyToken)
route.Post("/forgot-password", controller.ForgotPassword)
}

View File

@@ -0,0 +1,65 @@
package routes
import (
"history-api/internal/controllers"
"history-api/internal/middlewares"
"history-api/internal/repositories"
"history-api/pkg/constants"
"github.com/gofiber/fiber/v3"
)
func UserRoutes(app *fiber.App, controller *controllers.UserController, userRepo repositories.UserRepository) {
route := app.Group("/users")
route.Get(
"/",
middlewares.JwtAccess(userRepo),
middlewares.RequireAnyRole(constants.ADMIN, constants.MOD),
controller.Search,
)
route.Get(
"/:id",
middlewares.JwtAccess(userRepo),
middlewares.RequireAnyRole(constants.ADMIN, constants.MOD),
controller.Search,
)
route.Delete(
"/:id",
middlewares.JwtAccess(userRepo),
middlewares.RequireAnyRole(constants.ADMIN, constants.MOD),
controller.DeleteUser,
)
route.Patch(
"/:id/restore",
middlewares.JwtAccess(userRepo),
middlewares.RequireAnyRole(constants.ADMIN, constants.MOD),
controller.RestoreUser,
)
route.Patch(
"/:id/role",
middlewares.JwtAccess(userRepo),
middlewares.RequireAnyRole(constants.ADMIN),
controller.ChangeRoleUser,
)
route.Patch(
"/:id/password",
middlewares.JwtAccess(userRepo),
controller.ChangePassword,
)
route.Get(
"/current",
middlewares.JwtAccess(userRepo),
controller.GetUserCurrent,
)
route.Put(
"/:id",
middlewares.JwtAccess(userRepo),
controller.UpdateProfile,
)
}

View File

@@ -2,19 +2,25 @@ package services
import (
"context"
"crypto/rand"
"fmt"
"history-api/internal/dtos/request"
"history-api/internal/dtos/response"
"history-api/internal/gen/sqlc"
"history-api/internal/models"
"history-api/internal/repositories"
"history-api/pkg/cache"
"history-api/pkg/config"
"history-api/pkg/constant"
"history-api/pkg/constants"
"history-api/pkg/convert"
"math/big"
"slices"
"time"
"github.com/gofiber/fiber/v3"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"golang.org/x/crypto/bcrypt"
)
@@ -22,29 +28,35 @@ import (
type AuthService interface {
Signin(ctx context.Context, dto *request.SignInDto) (*response.AuthResponse, error)
Signup(ctx context.Context, dto *request.SignUpDto) (*response.AuthResponse, error)
ForgotPassword(ctx context.Context) error
VerifyToken(ctx context.Context) error
CreateToken(ctx context.Context) error
SigninWith3rd(ctx context.Context) error
ForgotPassword(ctx context.Context, dto *request.ForgotPasswordDto) error
VerifyToken(ctx context.Context, dto *request.VerifyTokenDto) (*response.VerifyTokenResponse, error)
CreateToken(ctx context.Context, dto *request.CreateTokenDto) error
SigninWith3rd(ctx context.Context, dto *request.SigninWith3rdDto) error
RefreshToken(ctx context.Context, id string) (*response.AuthResponse, error)
}
type authService struct {
userRepo repositories.UserRepository
roleRepo repositories.RoleRepository
userRepo repositories.UserRepository
roleRepo repositories.RoleRepository
tokenRepo repositories.TokenRepository
c cache.Cache
}
func NewAuthService(
userRepo repositories.UserRepository,
roleRepo repositories.RoleRepository,
tokenRepo repositories.TokenRepository,
c cache.Cache,
) AuthService {
return &authService{
userRepo: userRepo,
roleRepo: roleRepo,
userRepo: userRepo,
roleRepo: roleRepo,
tokenRepo: tokenRepo,
c: c,
}
}
func (a *authService) genToken(Uid string, role []constant.Role) (*response.AuthResponse, error) {
func (a *authService) genToken(user *models.UserEntity) (*response.AuthResponse, error) {
jwtSecret, err := config.GetConfig("JWT_SECRET")
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "missing JWT_SECRET in environment")
@@ -59,18 +71,20 @@ func (a *authService) genToken(Uid string, role []constant.Role) (*response.Auth
}
claimsAccess := &response.JWTClaims{
UId: Uid,
Roles: role,
UId: user.ID,
Roles: models.RolesEntityToRoleConstant(user.Roles),
TokenVersion: user.TokenVersion,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(30 * time.Minute)),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(constants.AccessTokenDuration)),
},
}
claimsRefresh := &response.JWTClaims{
UId: Uid,
Roles: role,
UId: user.ID,
Roles: models.RolesEntityToRoleConstant(user.Roles),
TokenVersion: user.TokenVersion,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * 24 * time.Hour)),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(constants.RefreshTokenDuration)),
},
}
@@ -102,11 +116,11 @@ func (a *authService) saveNewRefreshToken(ctx context.Context, params sqlc.Updat
}
func (a *authService) Signin(ctx context.Context, dto *request.SignInDto) (*response.AuthResponse, error) {
if !constant.EMAIL_REGEX.MatchString(dto.Email) {
if !constants.EMAIL_REGEX.MatchString(dto.Email) {
return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid email")
}
err := constant.ValidatePassword(dto.Password)
err := constants.ValidatePassword(dto.Password)
if err != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, err.Error())
}
@@ -120,13 +134,12 @@ func (a *authService) Signin(ctx context.Context, dto *request.SignInDto) (*resp
return nil, fiber.NewError(fiber.StatusUnauthorized, "Invalid identity or password!")
}
data, err := a.genToken(user.ID, models.RolesEntityToRoleConstant(user.Roles))
data, err := a.genToken(user)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
var pgID pgtype.UUID
err = pgID.Scan(user.ID)
pgID, err := convert.StringToUUID(user.ID)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
@@ -160,11 +173,11 @@ func (a *authService) RefreshToken(ctx context.Context, id string) (*response.Au
}
roles := models.RolesEntityToRoleConstant(user.Roles)
if slices.Contains(roles, constant.BANNED) {
if slices.Contains(roles, constants.BANNED) {
return nil, fiber.NewError(fiber.StatusUnauthorized, "User is banned!")
}
data, err := a.genToken(id, roles)
data, err := a.genToken(user)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
@@ -187,14 +200,23 @@ func (a *authService) RefreshToken(ctx context.Context, id string) (*response.Au
}
func (a *authService) Signup(ctx context.Context, dto *request.SignUpDto) (*response.AuthResponse, error) {
if !constant.EMAIL_REGEX.MatchString(dto.Email) {
if !constants.EMAIL_REGEX.MatchString(dto.Email) {
return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid email")
}
err := constant.ValidatePassword(dto.Password)
err := constants.ValidatePassword(dto.Password)
if err != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, err.Error())
}
ok, err := a.tokenRepo.CheckVerified(ctx, dto.Email, constants.TokenEmailVerify, dto.TokenID)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
if !ok {
return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid or expired token")
}
user, err := a.userRepo.GetByEmail(ctx, dto.Email)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error())
@@ -202,6 +224,7 @@ func (a *authService) Signup(ctx context.Context, dto *request.SignUpDto) (*resp
if user != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, "User already exists")
}
hashed, err := bcrypt.GenerateFromPassword([]byte(dto.Password), bcrypt.DefaultCost)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error())
@@ -215,14 +238,13 @@ func (a *authService) Signup(ctx context.Context, dto *request.SignUpDto) (*resp
String: string(hashed),
Valid: len(hashed) != 0,
},
IsVerified: true,
},
)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
var userId pgtype.UUID
err = userId.Scan(user.ID)
userId, err := convert.StringToUUID(user.ID)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
@@ -239,19 +261,28 @@ func (a *authService) Signup(ctx context.Context, dto *request.SignUpDto) (*resp
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
role, err := a.roleRepo.GetByname(ctx, constants.USER.String())
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
roleId, err := convert.StringToUUID(role.ID)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
err = a.roleRepo.AddUserRole(
ctx,
sqlc.AddUserRoleParams{
UserID: userId,
Name: constant.USER.String(),
UserID: userId,
Column2: []pgtype.UUID{roleId},
},
)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
data, err := a.genToken(user.ID, constant.USER.ToSlice())
data, err := a.genToken(user)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
@@ -273,22 +304,101 @@ func (a *authService) Signup(ctx context.Context, dto *request.SignUpDto) (*resp
return data, nil
}
// ForgotPassword implements [AuthService].
func (a *authService) ForgotPassword(ctx context.Context) error {
panic("unimplemented")
func (a *authService) ForgotPassword(ctx context.Context, dto *request.ForgotPasswordDto) error {
ok, err := a.tokenRepo.CheckVerified(ctx, dto.Email, constants.TokenPasswordReset, dto.TokenID)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
if !ok {
return fiber.NewError(fiber.StatusBadRequest, "Invalid or expired token")
}
user, err := a.userRepo.GetByEmail(ctx, dto.Email)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
if user == nil {
return fiber.NewError(fiber.StatusBadRequest, "User not found")
}
hashed, err := bcrypt.GenerateFromPassword([]byte(dto.NewPassword), bcrypt.DefaultCost)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
userId, err := convert.StringToUUID(user.ID)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
err = a.userRepo.UpdatePassword(ctx, sqlc.UpdateUserPasswordParams{
ID: userId,
PasswordHash: pgtype.Text{
String: string(hashed),
Valid: len(hashed) != 0,
},
})
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
return nil
}
// SigninWith3rd implements [AuthService].
func (a *authService) SigninWith3rd(ctx context.Context) error {
func (a *authService) SigninWith3rd(ctx context.Context, dto *request.SigninWith3rdDto) error {
panic("unimplemented")
}
func (a *authService) GenerateOTP() (string, error) {
max := big.NewInt(900000)
n, err := rand.Int(rand.Reader, max)
if err != nil {
return "", err
}
otp := n.Int64() + 100000
return fmt.Sprintf("%06d", otp), nil
}
// CreateToken implements [AuthService].
func (a *authService) CreateToken(ctx context.Context) error {
panic("unimplemented")
func (a *authService) CreateToken(ctx context.Context, dto *request.CreateTokenDto) error {
ok, err := a.tokenRepo.CheckCooldown(ctx, dto.Email, dto.TokenType)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
if ok {
return fiber.NewError(fiber.StatusBadRequest, "Please wait before requesting another token")
}
otp, err := a.GenerateOTP()
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
token := &models.TokenEntity{
Email: dto.Email,
Token: otp,
TokenType: dto.TokenType,
}
err = a.tokenRepo.Create(ctx, token)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
a.c.PublishTask(ctx, constants.StreamEmailName, constants.TaskTypeSendEmailOTP, token)
return nil
}
// Verify implements [AuthService].
func (a *authService) VerifyToken(ctx context.Context) error {
panic("unimplemented")
func (a *authService) VerifyToken(ctx context.Context, dto *request.VerifyTokenDto) (*response.VerifyTokenResponse, error) {
token, err := a.tokenRepo.Get(ctx, dto.Email, dto.TokenType)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
if token == nil || token.Token != dto.Token {
return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid token")
}
tokenId := uuid.New().String()
err = a.tokenRepo.CreateVerified(ctx, dto.Email, dto.TokenType, tokenId)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
return &response.VerifyTokenResponse{
TokenID: tokenId,
}, nil
}

View File

@@ -4,22 +4,28 @@ import (
"context"
"history-api/internal/dtos/request"
"history-api/internal/dtos/response"
"history-api/internal/gen/sqlc"
"history-api/internal/models"
"history-api/internal/repositories"
"history-api/pkg/convert"
"github.com/gofiber/fiber/v3"
"github.com/jackc/pgx/v5/pgtype"
"golang.org/x/crypto/bcrypt"
)
type UserService interface {
//user
GetUserCurrent(ctx context.Context, dto *request.SignInDto) (*response.AuthResponse, error)
UpdateProfile(ctx context.Context, id string) (*response.UserResponse, error)
ChangePassword(ctx context.Context, id string) (*response.UserResponse, error)
GetUserCurrent(ctx context.Context, userId string) (*response.UserResponse, error)
UpdateProfile(ctx context.Context, userId string, dto *request.UpdateProfileDto) (*response.UserResponse, error)
ChangePassword(ctx context.Context, userId string, dto *request.ChangePasswordDto) error
//admin
DeleteUser(ctx context.Context, id string) (*response.UserResponse, error)
ChangeRoleUser(ctx context.Context, id string) (*response.UserResponse, error)
RestoreUser(ctx context.Context, id string) (*response.UserResponse, error)
GetUserByID(ctx context.Context, id string) (*response.UserResponse, error)
Search(ctx context.Context, id string) ([]*response.UserResponse, error)
GetAllUser(ctx context.Context, id string) ([]*response.UserResponse, error)
DeleteUser(ctx context.Context, userId string) error
ChangeRoleUser(ctx context.Context, dto *request.ChangeRoleDto) (*response.UserResponse, error)
RestoreUser(ctx context.Context, userId string) (*response.UserResponse, error)
GetUserByID(ctx context.Context, userId string) (*response.UserResponse, error)
Search(ctx context.Context, dto *request.SearchUserDto) (*response.PaginatedResponse, error)
}
type userService struct {
@@ -37,47 +43,241 @@ func NewUserService(
}
}
// ChangePassword implements [UserService].
func (u *userService) ChangePassword(ctx context.Context, id string) (*response.UserResponse, error) {
panic("unimplemented")
func (u *userService) ChangePassword(ctx context.Context, userId string, dto *request.ChangePasswordDto) error {
pgID, err := convert.StringToUUID(userId)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
user, err := u.userRepo.GetByID(ctx, pgID)
if err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
if user == nil {
return fiber.NewError(fiber.StatusNotFound, "User not found")
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(dto.OldPassword)); err != nil {
return fiber.NewError(fiber.StatusUnauthorized, "Invalid identity or password!")
}
hashPassword, err := bcrypt.GenerateFromPassword([]byte(dto.NewPassword), bcrypt.DefaultCost)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
err = u.userRepo.UpdatePassword(ctx, sqlc.UpdateUserPasswordParams{
ID: pgID,
PasswordHash: pgtype.Text{String: string(hashPassword), Valid: true},
})
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
return nil
}
// ChangeRoleUser implements [UserService].
func (u *userService) ChangeRoleUser(ctx context.Context, id string) (*response.UserResponse, error) {
panic("unimplemented")
func (u *userService) ChangeRoleUser(ctx context.Context, dto *request.ChangeRoleDto) (*response.UserResponse, error) {
userId, err := convert.StringToUUID(dto.UserID)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
user, err := u.userRepo.GetByID(ctx, userId)
if err != nil {
return nil, fiber.NewError(fiber.StatusNotFound, err.Error())
}
if user == nil {
return nil, fiber.NewError(fiber.StatusNotFound, "User not found")
}
roleIdstr, err := u.roleRepo.GetByIDs(ctx, dto.Roles)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
user.Roles = make([]*models.RoleSimple, 0)
roleIdList := make([]pgtype.UUID, 0)
for _, role := range roleIdstr {
roleID, err := convert.StringToUUID(role.ID)
if err != nil {
continue
}
roleIdList = append(roleIdList, roleID)
user.Roles = append(user.Roles, role.ToRoleSimple())
}
err = u.roleRepo.RemoveAllRolesFromUser(ctx, userId)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
err = u.roleRepo.AddUserRole(ctx, sqlc.AddUserRoleParams{
UserID: userId,
Column2: roleIdList,
})
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
return user.ToResponse(), nil
}
// DeleteUser implements [UserService].
func (u *userService) DeleteUser(ctx context.Context, id string) (*response.UserResponse, error) {
panic("unimplemented")
func (u *userService) DeleteUser(ctx context.Context, userId string) error {
pgID, err := convert.StringToUUID(userId)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
user, err := u.userRepo.GetByID(ctx, pgID)
if err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
if user == nil {
return fiber.NewError(fiber.StatusNotFound, "User not found")
}
err = u.userRepo.Delete(ctx, pgID)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
return nil
}
// GetAllUser implements [UserService].
func (u *userService) GetAllUser(ctx context.Context, id string) ([]*response.UserResponse, error) {
panic("unimplemented")
func (u *userService) UpdateProfile(ctx context.Context, userId string, dto *request.UpdateProfileDto) (*response.UserResponse, error) {
pgID, err := convert.StringToUUID(userId)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
user, err := u.userRepo.GetByID(ctx, pgID)
if err != nil {
return nil, fiber.NewError(fiber.StatusNotFound, err.Error())
}
if user == nil {
return nil, fiber.NewError(fiber.StatusNotFound, "User not found")
}
newUser, err := u.userRepo.UpdateProfile(
ctx,
sqlc.UpdateUserProfileParams{
DisplayName: pgtype.Text{String: dto.DisplayName, Valid: len(dto.DisplayName) > 0},
FullName: pgtype.Text{String: dto.FullName, Valid: len(dto.FullName) > 0},
AvatarUrl: pgtype.Text{String: dto.AvatarUrl, Valid: len(dto.AvatarUrl) > 0},
Bio: pgtype.Text{String: dto.Bio, Valid: len(dto.Bio) > 0},
Location: pgtype.Text{String: dto.Location, Valid: len(dto.Location) > 0},
Website: pgtype.Text{String: dto.Website, Valid: len(dto.Website) > 0},
CountryCode: pgtype.Text{String: dto.CountryCode, Valid: len(dto.CountryCode) > 0},
Phone: pgtype.Text{String: dto.Phone, Valid: len(dto.Phone) > 0},
UserID: pgID,
},
)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
return newUser.ToResponse(), nil
}
// GetUserByID implements [UserService].
func (u *userService) GetUserByID(ctx context.Context, id string) (*response.UserResponse, error) {
panic("unimplemented")
func (u *userService) GetUserCurrent(ctx context.Context, userId string) (*response.UserResponse, error) {
pgID, err := convert.StringToUUID(userId)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
user, err := u.userRepo.GetByID(ctx, pgID)
if err != nil {
return nil, fiber.NewError(fiber.StatusNotFound, err.Error())
}
return user.ToResponse(), nil
}
// GetUserCurrent implements [UserService].
func (u *userService) GetUserCurrent(ctx context.Context, dto *request.SignInDto) (*response.AuthResponse, error) {
panic("unimplemented")
func (u *userService) RestoreUser(ctx context.Context, userId string) (*response.UserResponse, error) {
pgID, err := convert.StringToUUID(userId)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
user, err := u.userRepo.GetByIDWithoutDeleted(ctx, pgID)
if err != nil {
return nil, fiber.NewError(fiber.StatusNotFound, err.Error())
}
if user == nil {
return nil, fiber.NewError(fiber.StatusNotFound, "User not found")
}
err = u.userRepo.Restore(ctx, pgID)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
user.IsDeleted = false
return user.ToResponse(), nil
}
// RestoreUser implements [UserService].
func (u *userService) RestoreUser(ctx context.Context, id string) (*response.UserResponse, error) {
panic("unimplemented")
func (u *userService) Search(ctx context.Context, dto *request.SearchUserDto) (*response.PaginatedResponse, error) {
arg := sqlc.SearchUsersParams{
Limit: int32(dto.Limit + 1),
}
if dto.Cursor != "" {
pgID, err := convert.StringToUUID(dto.Cursor)
if err != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid cursor format")
}
arg.Cursor = pgID
}
if dto.Search != "" {
pgID, err := convert.StringToUUID(dto.Search)
if err == nil {
arg.SearchID = pgID
} else {
arg.SearchText = pgtype.Text{String: dto.Search, Valid: true}
}
}
if dto.IsDeleted != nil {
arg.IsDeleted = pgtype.Bool{Bool: *dto.IsDeleted, Valid: true}
}
if len(dto.RoleIDs) > 0 {
var pgRoleIDs []pgtype.UUID
for _, idStr := range dto.RoleIDs {
pgID, err := convert.StringToUUID(idStr)
if err != nil {
continue
}
pgRoleIDs = append(pgRoleIDs, pgID)
}
arg.RoleIds = pgRoleIDs
}
rows, err := u.userRepo.Search(ctx, arg)
if err != nil {
return nil, err
}
hasMore := false
var nextCursor string
if len(rows) > dto.Limit {
hasMore = true
nextCursor = rows[dto.Limit-1].ID
rows = rows[:dto.Limit]
}
res := &response.PaginatedResponse{
Data: rows,
Status: true,
Message: "",
}
res.Pagination.HasMore = hasMore
res.Pagination.NextCursor = nextCursor
return res, nil
}
// Search implements [UserService].
func (u *userService) Search(ctx context.Context, id string) ([]*response.UserResponse, error) {
panic("unimplemented")
}
// UpdateProfile implements [UserService].
func (u *userService) UpdateProfile(ctx context.Context, id string) (*response.UserResponse, error) {
panic("unimplemented")
}
func (u *userService) GetUserByID(ctx context.Context, userId string) (*response.UserResponse, error) {
pgID, err := convert.StringToUUID(userId)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
user, err := u.userRepo.GetByID(ctx, pgID)
if err != nil {
return nil, fiber.NewError(fiber.StatusNotFound, err.Error())
}
return user.ToResponse(), nil
}