This commit is contained in:
2026-03-23 18:55:27 +07:00
parent 6dc0322fe5
commit 3626c12319
47 changed files with 2741 additions and 0 deletions

32
pkg/cache/redis.go vendored Normal file
View File

@@ -0,0 +1,32 @@
package cache
import (
"context"
"fmt"
"history-api/pkg/config"
"github.com/redis/go-redis/v9"
)
var RI *redis.Client
func Connect() error {
connectionURI, err := config.GetConfig("REDIS_CONNECTION_URI")
if err != nil {
return err
}
rdb := redis.NewClient(&redis.Options{
Addr: connectionURI,
Password: "",
DB: 0,
})
if err := rdb.Ping(context.Background()).Err(); err != nil {
return fmt.Errorf("Could not connect to Redis: %v", err)
}
RI = rdb
return nil
}

36
pkg/config/config.go Normal file
View File

@@ -0,0 +1,36 @@
package config
import (
"errors"
"fmt"
"history-api/assets"
"os"
"strings"
"github.com/joho/godotenv"
)
func LoadEnv() error {
envData, err := assets.GetFileContent("resources/.env")
if err != nil {
return errors.New("error read .env file")
}
envMap, err := godotenv.Parse(strings.NewReader(envData))
if err != nil {
return errors.New("error parsing .env content")
}
for key, value := range envMap {
os.Setenv(key, value)
}
return nil
}
func GetConfig(config string) (string, error) {
var data string = os.Getenv(config)
if data == "" {
return "", fmt.Errorf("config (%s) dose not exit", config)
}
return data, nil
}

27
pkg/constant/role.go Normal file
View File

@@ -0,0 +1,27 @@
package constant
type Role string
const (
ADMIN Role = "ADMIN"
MOD Role = "MOD"
USER Role = "USER"
HISTORIAN Role = "HISTORIAN"
BANNED Role = "BANNED"
)
func (r Role) String() string {
return string(r)
}
func (r Role) Compare(other Role) bool {
return r == other
}
func CheckValidRole(r Role) bool {
return r == ADMIN || r == MOD || r == HISTORIAN || r == USER || r == BANNED
}
func (r Role) ToSlice() []string {
return []string{r.String()}
}

35
pkg/constant/status.go Normal file
View File

@@ -0,0 +1,35 @@
package constant
type StatusType int16
const (
StatusPending StatusType = 1
StatusApproved StatusType = 2
StatusRejected StatusType = 3
)
func (t StatusType) String() string {
switch t {
case StatusPending:
return "PENDING"
case StatusApproved:
return "APPROVED"
case StatusRejected:
return "REJECT"
default:
return "UNKNOWN"
}
}
func ParseStatusType(v int16) StatusType {
switch v {
case 1:
return StatusPending
case 2:
return StatusApproved
case 3:
return StatusRejected
default:
return 0
}
}

40
pkg/constant/token.go Normal file
View File

@@ -0,0 +1,40 @@
package constant
type TokenType int16
const (
TokenPasswordReset TokenType = 1
TokenEmailVerify TokenType = 2
TokenMagicLink TokenType = 3
TokenRefreshToken TokenType = 4
)
func (t TokenType) String() string {
switch t {
case TokenPasswordReset:
return "PASSWORD_RESET"
case TokenEmailVerify:
return "EMAIL_VERIFY"
case TokenMagicLink:
return "LOGIN_MAGIC_LINK"
case TokenRefreshToken:
return "REFRESH_TOKEN"
default:
return "UNKNOWN"
}
}
func ParseTokenType(v int16) TokenType {
switch v {
case 1:
return TokenPasswordReset
case 2:
return TokenEmailVerify
case 3:
return TokenMagicLink
case 4:
return TokenRefreshToken
default:
return 0
}
}

35
pkg/constant/verify.go Normal file
View File

@@ -0,0 +1,35 @@
package constant
type VerifyType int16
const (
VerifyIdCard VerifyType = 1
VerifyEducation VerifyType = 2
VerifyExpert VerifyType = 3
)
func (t VerifyType) String() string {
switch t {
case VerifyIdCard:
return "ID_CARD"
case VerifyEducation:
return "EDUCATION"
case VerifyExpert:
return "EXPERT"
default:
return "UNKNOWN"
}
}
func ParseVerifyType(v int16) VerifyType {
switch v {
case 1:
return VerifyIdCard
case 2:
return VerifyEducation
case 3:
return VerifyExpert
default:
return 0
}
}

32
pkg/convert/convert.go Normal file
View File

@@ -0,0 +1,32 @@
package convert
import "github.com/jackc/pgx/v5/pgtype"
func UUIDToString(v pgtype.UUID) string {
if v.Valid {
return v.String()
}
return ""
}
func TextToString(v pgtype.Text) string {
if v.Valid {
return v.String
}
return ""
}
func BoolVal(v pgtype.Bool) bool {
if v.Valid {
return v.Bool
}
return false
}
func TimeToPtr(v pgtype.Timestamptz) *string {
if v.Valid {
t := v.Time.Format("2006-01-02T15:04:05Z07:00")
return &t
}
return nil
}

32
pkg/database/db.go Normal file
View File

@@ -0,0 +1,32 @@
package database
import (
"context"
"history-api/pkg/config"
"github.com/jackc/pgx/v5/pgxpool"
)
func Connect() (*pgxpool.Pool, error) {
ctx := context.Background()
connectionURI, err := config.GetConfig("PGX_CONNECTION_URI")
if err != nil {
return nil, err
}
poolConfig, err := pgxpool.ParseConfig(connectionURI)
if err != nil {
return nil, err
}
pool, err := pgxpool.NewWithConfig(ctx, poolConfig)
if err != nil {
return nil, err
}
if err := pool.Ping(ctx); err != nil {
return nil, err
}
return pool, nil
}

13
pkg/dtos/request/auth.go Normal file
View File

@@ -0,0 +1,13 @@
package request
type SignUpDto struct {
Password string `json:"password" validate:"required"`
DiscordUserId string `json:"discord_user_id" validate:"required"`
Username string `json:"username" validate:"required"`
}
type LoginDto struct {
Username string `json:"username" validate:"required"`
Password string `json:"password" validate:"required"`
}

21
pkg/dtos/request/media.go Normal file
View File

@@ -0,0 +1,21 @@
package request
type PreSignedDto struct {
FileName string `json:"fileName" validate:"required"`
ContentType string `json:"contentType" validate:"required"`
}
type PreSignedCompleteDto struct {
FileName string `json:"fileName" validate:"required"`
MediaId string `json:"mediaId" validate:"required"`
PublicUrl string `json:"publicUrl" validate:"required"`
}
type SearchMediaDto struct {
MediaId string `query:"media_id" validate:"omitempty"`
FileName string `query:"file_name" 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"`
}

26
pkg/dtos/request/user.go Normal file
View File

@@ -0,0 +1,26 @@
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 UpdateUserDto struct {
Password *string `json:"password" validate:"omitempty"`
DiscordUserId *string `json:"discord_user_id" validate:"omitempty"`
Role *[]constant.Role `json:"role" validate:"omitempty"`
}
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"`
}

View File

@@ -0,0 +1,6 @@
package response
type AuthResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
}

View File

@@ -0,0 +1,19 @@
package response
import (
"history-api/pkg/constant"
"github.com/golang-jwt/jwt/v5"
)
type CommonResponse struct {
Status bool `json:"status"`
Data any `json:"data"`
Message string `json:"message"`
}
type JWTClaims struct {
UId string `json:"uid"`
Roles []constant.Role `json:"roles"`
jwt.RegisteredClaims
}

View File

@@ -0,0 +1,9 @@
package response
type PreSignedResponse struct {
UploadUrl string `json:"uploadUrl"`
PublicUrl string `json:"publicUrl"`
FileName string `json:"fileName"`
MediaId string `json:"mediaId"`
SignedHeaders map[string]string `json:"signedHeaders"`
}

14
pkg/dtos/response/role.go Normal file
View File

@@ -0,0 +1,14 @@
package response
type RoleSimpleResponse struct {
ID string `json:"id"`
Name string `json:"name"`
}
type RoleResponse struct {
ID string `json:"id"`
Name string `json:"name"`
IsDeleted bool `json:"is_deleted"`
CreatedAt *string `json:"created_at"`
UpdatedAt *string `json:"updated_at"`
}

View File

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

15
pkg/dtos/response/user.go Normal file
View File

@@ -0,0 +1,15 @@
package response
type UserResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
AvatarUrl string `json:"avatar_url"`
IsActive bool `json:"is_active"`
IsVerified bool `json:"is_verified"`
TokenVersion int32 `json:"token_version"`
IsDeleted bool `json:"is_deleted"`
CreatedAt *string `json:"created_at"`
UpdatedAt *string `json:"updated_at"`
Roles []*RoleSimpleResponse `json:"roles"`
}

17
pkg/log/log.go Normal file
View File

@@ -0,0 +1,17 @@
package log
import (
"os"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
func init() {
output := zerolog.ConsoleWriter{
Out: os.Stdout,
PartsOrder: []string{"level", "message"},
}
log.Logger = zerolog.New(output).With().Logger()
}

54
pkg/models/role.go Normal file
View File

@@ -0,0 +1,54 @@
package models
import (
"history-api/pkg/dtos/response"
"history-api/pkg/convert"
"github.com/jackc/pgx/v5/pgtype"
)
type RoleSimple struct {
ID pgtype.UUID `json:"id"`
Name string `json:"name"`
}
func (r *RoleSimple) ToResponse() *response.RoleSimpleResponse {
return &response.RoleSimpleResponse{
ID: convert.UUIDToString(r.ID),
Name: r.Name,
}
}
func RolesToResponse(rs []*RoleSimple) []*response.RoleSimpleResponse {
out := make([]*response.RoleSimpleResponse, len(rs))
for i := range rs {
out[i] = rs[i].ToResponse()
}
return out
}
type RoleEntity struct {
ID pgtype.UUID `json:"id"`
Name string `json:"name"`
IsDeleted pgtype.Bool `json:"is_deleted"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
func (r *RoleEntity) ToResponse() *response.RoleResponse {
return &response.RoleResponse{
ID: convert.UUIDToString(r.ID),
Name: r.Name,
IsDeleted: convert.BoolVal(r.IsDeleted),
CreatedAt: convert.TimeToPtr(r.CreatedAt),
UpdatedAt: convert.TimeToPtr(r.UpdatedAt),
}
}
func RolesEntityToResponse(rs []*RoleEntity) []*response.RoleResponse {
out := make([]*response.RoleResponse, len(rs))
for i := range rs {
out[i] = rs[i].ToResponse()
}
return out
}

35
pkg/models/token.go Normal file
View File

@@ -0,0 +1,35 @@
package models
import (
"history-api/pkg/convert"
"history-api/pkg/dtos/response"
"github.com/jackc/pgx/v5/pgtype"
)
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
}

58
pkg/models/user.go Normal file
View File

@@ -0,0 +1,58 @@
package models
import (
"encoding/json"
"history-api/pkg/convert"
"history-api/pkg/dtos/response"
"github.com/jackc/pgx/v5/pgtype"
)
type UserEntity struct {
ID pgtype.UUID `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
PasswordHash string `json:"password_hash"`
AvatarUrl pgtype.Text `json:"avatar_url"`
IsActive pgtype.Bool `json:"is_active"`
IsVerified pgtype.Bool `json:"is_verified"`
TokenVersion int32 `json:"token_version"`
RefreshToken pgtype.Text `json:"refresh_token"`
IsDeleted pgtype.Bool `json:"is_deleted"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
Roles []*RoleSimple `json:"roles"`
}
func (u *UserEntity) ParseRoles(data []byte) error {
if len(data) == 0 {
u.Roles = []*RoleSimple{}
return nil
}
return json.Unmarshal(data, &u.Roles)
}
func (u *UserEntity) ToResponse() *response.UserResponse {
return &response.UserResponse{
ID: convert.UUIDToString(u.ID),
Name: u.Name,
Email: u.Email,
AvatarUrl: convert.TextToString(u.AvatarUrl),
IsActive: convert.BoolVal(u.IsActive),
IsVerified: convert.BoolVal(u.IsVerified),
TokenVersion: u.TokenVersion,
IsDeleted: convert.BoolVal(u.IsDeleted),
CreatedAt: convert.TimeToPtr(u.CreatedAt),
UpdatedAt: convert.TimeToPtr(u.UpdatedAt),
Roles: RolesToResponse(u.Roles),
}
}
func UsersEntityToResponse(rs []*UserEntity) []*response.UserResponse {
out := make([]*response.UserResponse, len(rs))
for i := range rs {
out[i] = rs[i].ToResponse()
}
return out
}

View File

@@ -0,0 +1,82 @@
package validator
import (
"errors"
"reflect"
"strings"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v3"
)
var validate = validator.New()
func init() {
validate.RegisterTagNameFunc(func(fld reflect.StructField) string {
name := strings.SplitN(fld.Tag.Get("json"), ",", 2)[0]
if name == "-" {
return ""
}
if name == "" {
name = strings.SplitN(fld.Tag.Get("query"), ",", 2)[0]
}
return name
})
}
type ErrorResponse struct {
FailedField string `json:"failed_field"`
Tag string `json:"tag"`
Value string `json:"value"`
Message string `json:"message"`
}
func formatValidationError(err error) []ErrorResponse {
var validationErrors validator.ValidationErrors
var errorsList []ErrorResponse
if errors.As(err, &validationErrors) {
for _, fieldError := range validationErrors {
var element ErrorResponse
element.FailedField = fieldError.Field()
element.Tag = fieldError.Tag()
element.Value = fieldError.Param()
element.Message = "Field " + fieldError.Field() + " failed validation on tag '" + fieldError.Tag() + "'"
errorsList = append(errorsList, element)
}
}
return errorsList
}
func ValidateQueryDto(c fiber.Ctx, dto any) error {
if err := c.Bind().Query(dto); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "Failed to parse query parameters: " + err.Error(),
})
}
if err := validate.Struct(dto); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"errors": formatValidationError(err),
})
}
return nil
}
func ValidateBodyDto(c fiber.Ctx, dto any) error {
if err := c.Bind().Body(dto); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "Invalid request body: " + err.Error(),
})
}
if err := validate.Struct(dto); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"errors": formatValidationError(err),
})
}
return nil
}