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

@@ -9,8 +9,8 @@ RUN go mod download
COPY . . COPY . .
RUN GOOS=linux GOARCH=amd64 CGO_ENABLED=0 \ RUN GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o history-api ./cmd/api
go build -trimpath -ldflags="-s -w" -o history-api ./cmd/history-api RUN GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o email-worker ./cmd/worker/email
FROM alpine:latest FROM alpine:latest
@@ -20,9 +20,10 @@ ENV TZ=Asia/Ho_Chi_Minh
WORKDIR /app WORKDIR /app
COPY --from=builder /app/history-api . COPY --from=builder /app/history-api .
COPY --from=builder /app/email-worker .
COPY data ./data COPY data ./data
RUN chmod +x ./history-api RUN chmod +x ./history-api ./email-worker
EXPOSE 3344 EXPOSE 3344

View File

@@ -1,6 +1,6 @@
DB_URL ?= postgres://history:secret@localhost:5432/history_map?sslmode=disable DB_URL ?= postgres://history:secret@localhost:5432/history_map?sslmode=disable
APP_DIR = cmd/history-api APP_DIR = cmd/api
MAIN_APP = ./cmd/history-api/ MAIN_APP = ./cmd/api/
MAIN_FILE = $(APP_DIR)/main.go MAIN_FILE = $(APP_DIR)/main.go
DOCS_DIR = docs DOCS_DIR = docs

View File

@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Email Verification</title>
<style>
body { margin: 0; padding: 0; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background-color: #f4f7f6; color: #333333; }
.container { max-width: 600px; margin: 40px auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 4px 10px rgba(0,0,0,0.05); overflow: hidden; }
.header { background-color: #4F46E5; padding: 30px; text-align: center; color: #ffffff; }
.header h1 { margin: 0; font-size: 24px; font-weight: 600; }
.content { padding: 40px 30px; text-align: center; }
.content p { font-size: 16px; line-height: 1.6; color: #555555; margin-bottom: 25px; }
.otp-box { display: inline-block; background-color: #F3F4F6; padding: 15px 30px; border-radius: 6px; letter-spacing: 5px; font-size: 32px; font-weight: bold; color: #111827; margin-bottom: 25px;}
.footer { background-color: #f9fafb; padding: 20px; text-align: center; font-size: 13px; color: #9ca3af; border-top: 1px solid #eeeeee; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Welcome!</h1>
</div>
<div class="content">
<p>Thank you for registering. Please use the OTP below to verify your email address. This code will expire in a few minutes.</p>
<div class="otp-box">{{OTP_CODE}}</div>
<p>If you did not make this request, please safely ignore this email.</p>
</div>
<div class="footer">
&copy; 2026 Black Cat Studio. All rights reserved.
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Password Reset</title>
<style>
body { margin: 0; padding: 0; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background-color: #f4f7f6; color: #333333; }
.container { max-width: 600px; margin: 40px auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 4px 10px rgba(0,0,0,0.05); overflow: hidden; }
.header { background-color: #DC2626; padding: 30px; text-align: center; color: #ffffff; }
.header h1 { margin: 0; font-size: 24px; font-weight: 600; }
.content { padding: 40px 30px; text-align: center; }
.content p { font-size: 16px; line-height: 1.6; color: #555555; margin-bottom: 25px; }
.otp-box { display: inline-block; background-color: #FEF2F2; border: 1px dashed #F87171; padding: 15px 30px; border-radius: 6px; letter-spacing: 5px; font-size: 32px; font-weight: bold; color: #991B1B; margin-bottom: 25px;}
.footer { background-color: #f9fafb; padding: 20px; text-align: center; font-size: 13px; color: #9ca3af; border-top: 1px solid #eeeeee; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Password Reset</h1>
</div>
<div class="content">
<p>We received a request to reset the password for your account. Here is your secure OTP:</p>
<div class="otp-box">{{OTP_CODE}}</div>
<p><strong>Warning:</strong> Never share this code with anyone. If you didn't request a password reset, please ignore this email or contact support immediately.</p>
</div>
<div class="footer">
&copy; 2026 Black Cat Studio. All rights reserved.
</div>
</div>
</body>
</html>

View File

@@ -64,17 +64,21 @@ func (s *FiberServer) SetupServer(sqlPg sqlc.DBTX, sqlTile *sql.DB, redis cache.
userRepo := repositories.NewUserRepository(sqlPg, redis) userRepo := repositories.NewUserRepository(sqlPg, redis)
roleRepo := repositories.NewRoleRepository(sqlPg, redis) roleRepo := repositories.NewRoleRepository(sqlPg, redis)
tileRepo := repositories.NewTileRepository(sqlTile, redis) tileRepo := repositories.NewTileRepository(sqlTile, redis)
tokenRepo := repositories.NewTokenRepository(redis)
// service setup // service setup
authService := services.NewAuthService(userRepo, roleRepo) authService := services.NewAuthService(userRepo, roleRepo, tokenRepo, redis)
userService := services.NewUserService(userRepo, roleRepo)
tileService := services.NewTileService(tileRepo) tileService := services.NewTileService(tileRepo)
// controller setup // controller setup
authController := controllers.NewAuthController(authService) authController := controllers.NewAuthController(authService)
userController := controllers.NewUserController(userService)
tileController := controllers.NewTileController(tileService) tileController := controllers.NewTileController(tileService)
// route setup // route setup
routes.AuthRoutes(s.App, authController) routes.AuthRoutes(s.App, authController, userRepo)
routes.UserRoutes(s.App, userController, userRepo)
routes.TileRoutes(s.App, tileController) routes.TileRoutes(s.App, tileController)
routes.NotFoundRoute(s.App) routes.NotFoundRoute(s.App)
} }

111
cmd/worker/email/main.go Normal file
View File

@@ -0,0 +1,111 @@
package main
import (
"context"
"encoding/json"
"strconv"
"sync"
"time"
"history-api/internal/models"
"history-api/pkg/cache"
"history-api/pkg/config"
"history-api/pkg/constants"
"history-api/pkg/email"
_ "history-api/pkg/log"
"github.com/redis/go-redis/v9"
"github.com/rs/zerolog/log"
)
func runSingleWorker(ctx context.Context, rdb *redis.Client, consumerID int) {
consumerName := "worker-" + strconv.Itoa(consumerID)
log.Info().Str("worker", consumerName).Msg("Worker started and ready")
for {
entries, err := rdb.XReadGroup(ctx, &redis.XReadGroupArgs{
Group: constants.GroupEmailName,
Consumer: consumerName,
Streams: []string{constants.StreamEmailName, ">"},
Count: 1,
Block: 0,
}).Result()
if err != nil {
log.Error().Err(err).Str("worker", consumerName).Msg("Failed to read stream")
time.Sleep(2 * time.Second)
continue
}
for _, stream := range entries {
for _, message := range stream.Messages {
taskType := message.Values["task_type"].(string)
payloadStr := message.Values["payload"].(string)
if taskType == constants.TaskTypeSendEmailOTP.String() {
var data models.TokenEntity
if err := json.Unmarshal([]byte(payloadStr), &data); err != nil {
log.Error().Err(err).Msg("Failed to unmarshal payload")
continue
}
log.Info().
Str("worker", consumerName).
Str("email", data.Email).
Msg("Processing email task")
errSend := email.SendMailOTP(data.Email, data.Token, data.TokenType)
if errSend != nil {
log.Error().Err(errSend).Str("email", data.Email).Msg("Failed to send email")
continue
}
}
rdb.XAck(ctx, constants.StreamEmailName, constants.GroupEmailName, message.ID)
log.Info().Str("msg_id", message.ID).Msg("Task acknowledged")
}
}
}
}
func main() {
config.LoadEnv()
workerCountStr := config.GetConfigWithDefault("EMAIL_WORKER_COUNT", "1")
workerCount, err := strconv.Atoi(workerCountStr)
if err != nil || workerCount <= 0 {
workerCount = 1
}
cacheInterface, err := cache.NewRedisClient()
if err != nil {
log.Fatal().
Err(err).
Msg("Failed to connect to Redis")
}
rdb := cacheInterface.GetRawClient()
ctx := context.Background()
err = rdb.XGroupCreateMkStream(ctx, constants.StreamEmailName, constants.GroupEmailName, "$").Err()
if err != nil && err.Error() != "BUSYGROUP Consumer Group name already exists" {
log.Fatal().
Err(err).
Msg("Failed to create Redis Stream Group")
}
log.Info().
Int("worker_count", workerCount).
Msg("Starting email worker system")
var wg sync.WaitGroup
for i := 1; i <= workerCount; i++ {
wg.Go(func() {
runSingleWorker(ctx, rdb, i)
})
}
wg.Wait()
}

View File

@@ -6,7 +6,6 @@ CREATE TABLE IF NOT EXISTS users (
password_hash TEXT, password_hash TEXT,
google_id VARCHAR(255) UNIQUE, google_id VARCHAR(255) UNIQUE,
auth_provider VARCHAR(50) NOT NULL DEFAULT 'local', auth_provider VARCHAR(50) NOT NULL DEFAULT 'local',
is_verified BOOLEAN NOT NULL DEFAULT false,
is_deleted BOOLEAN NOT NULL DEFAULT false, is_deleted BOOLEAN NOT NULL DEFAULT false,
token_version INT NOT NULL DEFAULT 1, token_version INT NOT NULL DEFAULT 1,
refresh_token TEXT, refresh_token TEXT,
@@ -22,10 +21,6 @@ CREATE INDEX idx_users_email_active
ON users (email) ON users (email)
WHERE is_deleted = false; WHERE is_deleted = false;
CREATE INDEX idx_users_verified
ON users (is_verified)
WHERE is_deleted = false;
CREATE OR REPLACE FUNCTION update_updated_at() CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$ RETURNS TRIGGER AS $$
BEGIN BEGIN

View File

@@ -1 +0,0 @@
DROP TABLE IF EXISTS user_tokens;

View File

@@ -1,25 +0,0 @@
CREATE TABLE IF NOT EXISTS user_tokens (
id UUID PRIMARY KEY DEFAULT uuidv7(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token VARCHAR(255) NOT NULL UNIQUE,
token_type SMALLINT NOT NULL,
is_deleted BOOLEAN NOT NULL DEFAULT false,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_user_tokens_token
ON user_tokens(token)
WHERE is_deleted = false;
CREATE INDEX idx_user_tokens_user_id
ON user_tokens(user_id)
WHERE is_deleted = false;
CREATE INDEX idx_user_tokens_type
ON user_tokens(token_type)
WHERE is_deleted = false;
CREATE INDEX idx_user_tokens_expires_at
ON user_tokens(expires_at)
WHERE is_deleted = false;

View File

@@ -11,11 +11,14 @@ WHERE name = $1 AND is_deleted = false;
SELECT id, name, is_deleted, created_at, updated_at FROM roles SELECT id, name, is_deleted, created_at, updated_at FROM roles
WHERE id = $1 AND is_deleted = false; WHERE id = $1 AND is_deleted = false;
-- name: GetRolesByIDs :many
SELECT id, name, is_deleted, created_at, updated_at
FROM roles
WHERE id = ANY($1::uuid[]) AND is_deleted = false;
-- name: AddUserRole :exec -- name: AddUserRole :exec
INSERT INTO user_roles (user_id, role_id) INSERT INTO user_roles (user_id, role_id)
SELECT $1, r.id SELECT $1, unnest($2::uuid[])
FROM roles r
WHERE r.name = $2
ON CONFLICT DO NOTHING; ON CONFLICT DO NOTHING;
-- name: RemoveUserRole :exec -- name: RemoveUserRole :exec

View File

@@ -3,17 +3,14 @@ INSERT INTO users (
email, email,
password_hash, password_hash,
google_id, google_id,
auth_provider, auth_provider
is_verified
) VALUES ( ) VALUES (
$1, $2, $3, $4, $5 $1, $2, $3, $4
) )
ON CONFLICT (email) ON CONFLICT (email)
DO UPDATE SET DO UPDATE SET
google_id = EXCLUDED.google_id, google_id = EXCLUDED.google_id,
auth_provider = EXCLUDED.auth_provider, auth_provider = EXCLUDED.auth_provider
is_verified = users.is_verified OR EXCLUDED.is_verified,
updated_at = now()
RETURNING *; RETURNING *;
-- name: CreateUserProfile :one -- name: CreateUserProfile :one
@@ -55,12 +52,6 @@ SET
WHERE id = $1 WHERE id = $1
AND is_deleted = false; AND is_deleted = false;
-- name: VerifyUser :exec
UPDATE users
SET
is_verified = true
WHERE id = $1
AND is_deleted = false;
-- name: DeleteUser :exec -- name: DeleteUser :exec
UPDATE users UPDATE users
@@ -79,7 +70,6 @@ SELECT
u.id, u.id,
u.email, u.email,
u.password_hash, u.password_hash,
u.is_verified,
u.token_version, u.token_version,
u.refresh_token, u.refresh_token,
u.is_deleted, u.is_deleted,
@@ -116,6 +106,47 @@ SELECT
FROM users u FROM users u
WHERE u.id = $1 AND u.is_deleted = false; WHERE u.id = $1 AND u.is_deleted = false;
-- 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;
-- name: GetTokenVersion :one -- name: GetTokenVersion :one
SELECT token_version SELECT token_version
FROM users FROM users
@@ -131,7 +162,6 @@ SELECT
u.id, u.id,
u.email, u.email,
u.password_hash, u.password_hash,
u.is_verified,
u.token_version, u.token_version,
u.is_deleted, u.is_deleted,
u.created_at, u.created_at,
@@ -170,7 +200,59 @@ SELECT
u.id, u.id,
u.email, u.email,
u.password_hash, 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
(sqlc.narg('cursor')::uuid IS NULL OR u.id > sqlc.narg('cursor')::uuid)
AND (sqlc.narg('is_deleted')::boolean IS NULL OR u.is_deleted = sqlc.narg('is_deleted')::boolean)
AND (
sqlc.narg('role_ids')::uuid[] IS NULL OR
EXISTS (
SELECT 1 FROM user_roles ur2
WHERE ur2.user_id = u.id AND ur2.role_id = ANY(sqlc.narg('role_ids')::uuid[])
)
)
ORDER BY u.id ASC
LIMIT sqlc.arg('limit');
-- name: SearchUsers :many
SELECT
u.id,
u.email,
u.password_hash,
u.token_version, u.token_version,
u.refresh_token, u.refresh_token,
u.is_deleted, u.is_deleted,
@@ -203,4 +285,26 @@ SELECT
) AS roles ) AS roles
FROM users u FROM users u
WHERE u.is_deleted = false; WHERE
(sqlc.narg('cursor')::uuid IS NULL OR u.id > sqlc.narg('cursor')::uuid)
AND (sqlc.narg('is_deleted')::boolean IS NULL OR u.is_deleted = sqlc.narg('is_deleted')::boolean)
AND (
sqlc.narg('role_ids')::uuid[] IS NULL OR
EXISTS (
SELECT 1 FROM user_roles ur2
WHERE ur2.user_id = u.id AND ur2.role_id = ANY(sqlc.narg('role_ids')::uuid[])
)
)
AND (sqlc.narg('search_id')::uuid IS NULL OR u.id = sqlc.narg('search_id')::uuid)
AND (
sqlc.narg('search_text')::text IS NULL OR
u.email ILIKE '%' || sqlc.narg('search_text')::text || '%' OR
EXISTS (
SELECT 1 FROM user_profiles p
WHERE p.user_id = u.id AND p.display_name ILIKE '%' || sqlc.narg('search_text')::text || '%'
)
)
ORDER BY u.id ASC
LIMIT sqlc.arg('limit');

View File

@@ -4,7 +4,6 @@ CREATE TABLE IF NOT EXISTS users (
password_hash TEXT, password_hash TEXT,
google_id VARCHAR(255) UNIQUE, google_id VARCHAR(255) UNIQUE,
auth_provider VARCHAR(50) NOT NULL DEFAULT 'local', auth_provider VARCHAR(50) NOT NULL DEFAULT 'local',
is_verified BOOLEAN NOT NULL DEFAULT false,
is_deleted BOOLEAN NOT NULL DEFAULT false, is_deleted BOOLEAN NOT NULL DEFAULT false,
token_version INT NOT NULL DEFAULT 1, token_version INT NOT NULL DEFAULT 1,
refresh_token TEXT, refresh_token TEXT,
@@ -50,13 +49,3 @@ CREATE TABLE IF NOT EXISTS user_verifications (
reviewed_at TIMESTAMPTZ, reviewed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT now() created_at TIMESTAMPTZ DEFAULT now()
); );
CREATE TABLE IF NOT EXISTS user_tokens (
id UUID PRIMARY KEY DEFAULT uuidv7(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token VARCHAR(255) NOT NULL UNIQUE,
is_deleted BOOLEAN NOT NULL DEFAULT false,
token_type SMALLINT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);

View File

@@ -13,27 +13,25 @@ services:
volumes: volumes:
- pg_data:/var/lib/postgresql/data - pg_data:/var/lib/postgresql/data
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s interval: 5s
timeout: 3s timeout: 5s
retries: 5 retries: 5
networks: networks:
- history-api-project - history-api-project
cache: cache:
image: redis:8.6.1-alpine image: redis:8.6.2-alpine
container_name: history_redis container_name: history_redis
restart: unless-stopped restart: unless-stopped
networks: networks:
- history-api-project - history-api-project
migrate: migrate:
build: image: migrate/migrate
context: .
dockerfile_inline: |
FROM migrate/migrate
COPY db/migrations /migrations
container_name: history_migrate container_name: history_migrate
volumes:
- ./db/migrations:/migrations:ro
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
@@ -43,14 +41,15 @@ services:
- sh - sh
- -c - -c
- | - |
DB_URL="postgres://$${POSTGRES_USER}:$${POSTGRES_PASSWORD}@db:5432/$${POSTGRES_DB}?sslmode=disable" DB_URL="postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?sslmode=disable"
# Thêm ls để kiểm tra chắc chắn lúc chạy
ls /migrations echo "Running migrations..."
/migrate -path /migrations -database "$$DB_URL" up || \ # We skip the 'version' check loop because 'depends_on'
(/migrate -path /migrations -database "$$DB_URL" force 8 && \ # with 'service_healthy' already handles the wait.
/migrate -path /migrations -database "$$DB_URL" up) /migrate -path /migrations -database "$$DB_URL" up
networks: networks:
- history-api-project - history-api-project
app: app:
build: . build: .
container_name: history_app container_name: history_app
@@ -58,13 +57,32 @@ services:
depends_on: depends_on:
migrate: migrate:
condition: service_completed_successfully condition: service_completed_successfully
db:
condition: service_healthy
cache: cache:
condition: service_started condition: service_started
env_file:
- ./assets/resources/.env
ports: ports:
- "3344:3344" - "3344:3344"
networks: networks:
- history-api-project - history-api-project
worker:
build: .
container_name: history_worker
restart: unless-stopped
depends_on:
db:
condition: service_healthy
cache:
condition: service_started
env_file:
- ./assets/resources/.env
command: ["./email-worker"]
networks:
- history-api-project
volumes: volumes:
pg_data: pg_data:

View File

@@ -24,14 +24,9 @@ const docTemplate = `{
"host": "{{.Host}}", "host": "{{.Host}}",
"basePath": "{{.BasePath}}", "basePath": "{{.BasePath}}",
"paths": { "paths": {
"/auth/refresh": { "/auth/forgot-password": {
"post": { "post": {
"security": [ "description": "Initiate password recovery process for a user",
{
"BearerAuth": []
}
],
"description": "Get a new access token using the user's current session/refresh token",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@@ -41,44 +36,15 @@ const docTemplate = `{
"tags": [ "tags": [
"Auth" "Auth"
], ],
"summary": "Refresh access token", "summary": "Handle forgotten password",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
}
}
}
},
"/auth/signin": {
"post": {
"description": "Authenticate user and return token data",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Auth"
],
"summary": "Sign in an existing user",
"parameters": [ "parameters": [
{ {
"description": "Sign In request", "description": "Forgot Password request",
"name": "request", "name": "request",
"in": "body", "in": "body",
"required": true, "required": true,
"schema": { "schema": {
"$ref": "#/definitions/history-api_internal_dtos_request.SignInDto" "$ref": "#/definitions/history-api_internal_dtos_request.ForgotPasswordDto"
} }
} }
], ],
@@ -104,9 +70,14 @@ const docTemplate = `{
} }
} }
}, },
"/auth/signup": { "/auth/refresh": {
"post": { "post": {
"description": "Create a new user account", "security": [
{
"BearerAuth": []
}
],
"description": "Generate a new access token using a valid refresh token from context",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@@ -116,10 +87,97 @@ const docTemplate = `{
"tags": [ "tags": [
"Auth" "Auth"
], ],
"summary": "Sign up a new user", "summary": "Refresh session tokens",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
},
"401": {
"description": "Unauthorized or expired refresh token",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
}
}
}
},
"/auth/signin": {
"post": {
"description": "Authenticate user credentials and return access/refresh tokens",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Auth"
],
"summary": "Sign in a user",
"parameters": [ "parameters": [
{ {
"description": "Sign Up request", "description": "Sign In credentials",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_request.SignInDto"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
},
"401": {
"description": "Invalid credentials",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
}
}
}
},
"/auth/signup": {
"post": {
"description": "Create a new user account in the system",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Auth"
],
"summary": "Register a new user",
"parameters": [
{
"description": "Sign Up details",
"name": "request", "name": "request",
"in": "body", "in": "body",
"required": true, "required": true,
@@ -150,6 +208,98 @@ const docTemplate = `{
} }
} }
}, },
"/auth/token/create": {
"post": {
"description": "Request a new token for specific actions like email confirmation",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Auth"
],
"summary": "Generate a new verification token",
"parameters": [
{
"description": "Token creation request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_request.CreateTokenDto"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
}
}
}
},
"/auth/token/verify": {
"post": {
"description": "Validate an OTP or email verification token",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Auth"
],
"summary": "Verify a security token",
"parameters": [
{
"description": "Token verification data",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_request.VerifyTokenDto"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
}
}
}
},
"/tiles/metadata": { "/tiles/metadata": {
"get": { "get": {
"description": "Retrieve map metadata", "description": "Retrieve map metadata",
@@ -233,9 +383,524 @@ const docTemplate = `{
} }
} }
} }
},
"/users": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"description": "Search and filter users with pagination (Admin/Mod only)",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Users"
],
"summary": "Search users",
"parameters": [
{
"type": "string",
"name": "cursor",
"in": "query"
},
{
"type": "boolean",
"name": "is_deleted",
"in": "query"
},
{
"maximum": 100,
"minimum": 1,
"type": "integer",
"name": "limit",
"in": "query",
"required": true
},
{
"enum": [
"asc",
"desc"
],
"type": "string",
"name": "order",
"in": "query"
},
{
"type": "array",
"items": {
"type": "string"
},
"collectionFormat": "csv",
"name": "role_ids",
"in": "query"
},
{
"maxLength": 200,
"minLength": 2,
"type": "string",
"name": "search",
"in": "query"
},
{
"enum": [
"created_at",
"updated_at",
"email",
"display_name"
],
"type": "string",
"name": "sort",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
}
}
}
},
"/users/current": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"description": "Retrieve the profile information of the currently authenticated user",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Users"
],
"summary": "Get current user profile",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
}
}
}
},
"/users/{id}": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"description": "Retrieve details of a specific user (Admin/Mod only)",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Users"
],
"summary": "Get user by ID",
"parameters": [
{
"type": "string",
"description": "User ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
}
}
},
"put": {
"security": [
{
"BearerAuth": []
}
],
"description": "Update the profile details of the currently authenticated user",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Users"
],
"summary": "Update user profile",
"parameters": [
{
"type": "string",
"description": "User ID",
"name": "id",
"in": "path",
"required": true
},
{
"description": "Update Profile request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_request.UpdateProfileDto"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
}
}
},
"delete": {
"security": [
{
"BearerAuth": []
}
],
"description": "Soft delete a user account (Admin/Mod only)",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Users"
],
"summary": "Delete a user",
"parameters": [
{
"type": "string",
"description": "User ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
}
}
}
},
"/users/{id}/password": {
"patch": {
"security": [
{
"BearerAuth": []
}
],
"description": "Update the password for the currently authenticated user",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Users"
],
"summary": "Change user password",
"parameters": [
{
"type": "string",
"description": "User ID",
"name": "id",
"in": "path",
"required": true
},
{
"description": "Change Password request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_request.ChangePasswordDto"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
}
}
}
},
"/users/{id}/restore": {
"patch": {
"security": [
{
"BearerAuth": []
}
],
"description": "Restore a soft-deleted user account (Admin/Mod only)",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Users"
],
"summary": "Restore a deleted user",
"parameters": [
{
"type": "string",
"description": "User ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
}
}
}
},
"/users/{id}/role": {
"patch": {
"security": [
{
"BearerAuth": []
}
],
"description": "Update the role of a user (Admin only)",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Users"
],
"summary": "Change user role",
"parameters": [
{
"type": "string",
"description": "User ID",
"name": "id",
"in": "path",
"required": true
},
{
"description": "Change Role request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_request.ChangeRoleDto"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
}
}
}
} }
}, },
"definitions": { "definitions": {
"history-api_internal_dtos_request.ChangePasswordDto": {
"type": "object",
"required": [
"new_password",
"old_password"
],
"properties": {
"new_password": {
"type": "string",
"maxLength": 64,
"minLength": 8
},
"old_password": {
"type": "string",
"maxLength": 64,
"minLength": 8
}
}
},
"history-api_internal_dtos_request.ChangeRoleDto": {
"type": "object",
"required": [
"role_ids",
"user_id"
],
"properties": {
"role_ids": {
"type": "array",
"minItems": 1,
"items": {
"type": "string"
}
},
"user_id": {
"type": "string"
}
}
},
"history-api_internal_dtos_request.CreateTokenDto": {
"type": "object",
"required": [
"email",
"token_type"
],
"properties": {
"email": {
"type": "string"
},
"token_type": {
"enum": [
1,
2,
3,
4
],
"allOf": [
{
"$ref": "#/definitions/history-api_pkg_constants.TokenType"
}
]
}
}
},
"history-api_internal_dtos_request.ForgotPasswordDto": {
"type": "object",
"required": [
"email",
"new_password",
"token_id"
],
"properties": {
"email": {
"type": "string",
"maxLength": 255,
"minLength": 5
},
"new_password": {
"type": "string",
"maxLength": 64,
"minLength": 8
},
"token_id": {
"type": "string"
}
}
},
"history-api_internal_dtos_request.SignInDto": { "history-api_internal_dtos_request.SignInDto": {
"type": "object", "type": "object",
"required": [ "required": [
@@ -260,7 +925,8 @@ const docTemplate = `{
"required": [ "required": [
"display_name", "display_name",
"email", "email",
"password" "password",
"token_id"
], ],
"properties": { "properties": {
"display_name": { "display_name": {
@@ -277,6 +943,75 @@ const docTemplate = `{
"type": "string", "type": "string",
"maxLength": 64, "maxLength": 64,
"minLength": 8 "minLength": 8
},
"token_id": {
"type": "string"
}
}
},
"history-api_internal_dtos_request.UpdateProfileDto": {
"type": "object",
"properties": {
"avatar_url": {
"type": "string"
},
"bio": {
"type": "string",
"maxLength": 255
},
"country_code": {
"type": "string"
},
"display_name": {
"type": "string",
"maxLength": 50,
"minLength": 2
},
"full_name": {
"type": "string",
"maxLength": 100,
"minLength": 2
},
"location": {
"type": "string",
"maxLength": 100
},
"phone": {
"type": "string",
"maxLength": 20,
"minLength": 8
},
"website": {
"type": "string"
}
}
},
"history-api_internal_dtos_request.VerifyTokenDto": {
"type": "object",
"required": [
"email",
"token",
"token_type"
],
"properties": {
"email": {
"type": "string"
},
"token": {
"type": "string"
},
"token_type": {
"enum": [
1,
2,
3,
4
],
"allOf": [
{
"$ref": "#/definitions/history-api_pkg_constants.TokenType"
}
]
} }
} }
}, },
@@ -291,6 +1026,22 @@ const docTemplate = `{
"type": "boolean" "type": "boolean"
} }
} }
},
"history-api_pkg_constants.TokenType": {
"type": "integer",
"format": "int32",
"enum": [
1,
2,
3,
4
],
"x-enum-varnames": [
"TokenPasswordReset",
"TokenEmailVerify",
"TokenMagicLink",
"TokenRefreshToken"
]
} }
}, },
"securityDefinitions": { "securityDefinitions": {

View File

@@ -22,14 +22,9 @@
"host": "history-api.kain.id.vn", "host": "history-api.kain.id.vn",
"basePath": "/", "basePath": "/",
"paths": { "paths": {
"/auth/refresh": { "/auth/forgot-password": {
"post": { "post": {
"security": [ "description": "Initiate password recovery process for a user",
{
"BearerAuth": []
}
],
"description": "Get a new access token using the user's current session/refresh token",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@@ -39,44 +34,15 @@
"tags": [ "tags": [
"Auth" "Auth"
], ],
"summary": "Refresh access token", "summary": "Handle forgotten password",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
}
}
}
},
"/auth/signin": {
"post": {
"description": "Authenticate user and return token data",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Auth"
],
"summary": "Sign in an existing user",
"parameters": [ "parameters": [
{ {
"description": "Sign In request", "description": "Forgot Password request",
"name": "request", "name": "request",
"in": "body", "in": "body",
"required": true, "required": true,
"schema": { "schema": {
"$ref": "#/definitions/history-api_internal_dtos_request.SignInDto" "$ref": "#/definitions/history-api_internal_dtos_request.ForgotPasswordDto"
} }
} }
], ],
@@ -102,9 +68,14 @@
} }
} }
}, },
"/auth/signup": { "/auth/refresh": {
"post": { "post": {
"description": "Create a new user account", "security": [
{
"BearerAuth": []
}
],
"description": "Generate a new access token using a valid refresh token from context",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@@ -114,10 +85,97 @@
"tags": [ "tags": [
"Auth" "Auth"
], ],
"summary": "Sign up a new user", "summary": "Refresh session tokens",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
},
"401": {
"description": "Unauthorized or expired refresh token",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
}
}
}
},
"/auth/signin": {
"post": {
"description": "Authenticate user credentials and return access/refresh tokens",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Auth"
],
"summary": "Sign in a user",
"parameters": [ "parameters": [
{ {
"description": "Sign Up request", "description": "Sign In credentials",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_request.SignInDto"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
},
"401": {
"description": "Invalid credentials",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
}
}
}
},
"/auth/signup": {
"post": {
"description": "Create a new user account in the system",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Auth"
],
"summary": "Register a new user",
"parameters": [
{
"description": "Sign Up details",
"name": "request", "name": "request",
"in": "body", "in": "body",
"required": true, "required": true,
@@ -148,6 +206,98 @@
} }
} }
}, },
"/auth/token/create": {
"post": {
"description": "Request a new token for specific actions like email confirmation",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Auth"
],
"summary": "Generate a new verification token",
"parameters": [
{
"description": "Token creation request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_request.CreateTokenDto"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
}
}
}
},
"/auth/token/verify": {
"post": {
"description": "Validate an OTP or email verification token",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Auth"
],
"summary": "Verify a security token",
"parameters": [
{
"description": "Token verification data",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_request.VerifyTokenDto"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
}
}
}
},
"/tiles/metadata": { "/tiles/metadata": {
"get": { "get": {
"description": "Retrieve map metadata", "description": "Retrieve map metadata",
@@ -231,9 +381,524 @@
} }
} }
} }
},
"/users": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"description": "Search and filter users with pagination (Admin/Mod only)",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Users"
],
"summary": "Search users",
"parameters": [
{
"type": "string",
"name": "cursor",
"in": "query"
},
{
"type": "boolean",
"name": "is_deleted",
"in": "query"
},
{
"maximum": 100,
"minimum": 1,
"type": "integer",
"name": "limit",
"in": "query",
"required": true
},
{
"enum": [
"asc",
"desc"
],
"type": "string",
"name": "order",
"in": "query"
},
{
"type": "array",
"items": {
"type": "string"
},
"collectionFormat": "csv",
"name": "role_ids",
"in": "query"
},
{
"maxLength": 200,
"minLength": 2,
"type": "string",
"name": "search",
"in": "query"
},
{
"enum": [
"created_at",
"updated_at",
"email",
"display_name"
],
"type": "string",
"name": "sort",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
}
}
}
},
"/users/current": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"description": "Retrieve the profile information of the currently authenticated user",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Users"
],
"summary": "Get current user profile",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
}
}
}
},
"/users/{id}": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"description": "Retrieve details of a specific user (Admin/Mod only)",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Users"
],
"summary": "Get user by ID",
"parameters": [
{
"type": "string",
"description": "User ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
}
}
},
"put": {
"security": [
{
"BearerAuth": []
}
],
"description": "Update the profile details of the currently authenticated user",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Users"
],
"summary": "Update user profile",
"parameters": [
{
"type": "string",
"description": "User ID",
"name": "id",
"in": "path",
"required": true
},
{
"description": "Update Profile request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_request.UpdateProfileDto"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
}
}
},
"delete": {
"security": [
{
"BearerAuth": []
}
],
"description": "Soft delete a user account (Admin/Mod only)",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Users"
],
"summary": "Delete a user",
"parameters": [
{
"type": "string",
"description": "User ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
}
}
}
},
"/users/{id}/password": {
"patch": {
"security": [
{
"BearerAuth": []
}
],
"description": "Update the password for the currently authenticated user",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Users"
],
"summary": "Change user password",
"parameters": [
{
"type": "string",
"description": "User ID",
"name": "id",
"in": "path",
"required": true
},
{
"description": "Change Password request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_request.ChangePasswordDto"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
}
}
}
},
"/users/{id}/restore": {
"patch": {
"security": [
{
"BearerAuth": []
}
],
"description": "Restore a soft-deleted user account (Admin/Mod only)",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Users"
],
"summary": "Restore a deleted user",
"parameters": [
{
"type": "string",
"description": "User ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
}
}
}
},
"/users/{id}/role": {
"patch": {
"security": [
{
"BearerAuth": []
}
],
"description": "Update the role of a user (Admin only)",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Users"
],
"summary": "Change user role",
"parameters": [
{
"type": "string",
"description": "User ID",
"name": "id",
"in": "path",
"required": true
},
{
"description": "Change Role request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_request.ChangeRoleDto"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
}
}
}
} }
}, },
"definitions": { "definitions": {
"history-api_internal_dtos_request.ChangePasswordDto": {
"type": "object",
"required": [
"new_password",
"old_password"
],
"properties": {
"new_password": {
"type": "string",
"maxLength": 64,
"minLength": 8
},
"old_password": {
"type": "string",
"maxLength": 64,
"minLength": 8
}
}
},
"history-api_internal_dtos_request.ChangeRoleDto": {
"type": "object",
"required": [
"role_ids",
"user_id"
],
"properties": {
"role_ids": {
"type": "array",
"minItems": 1,
"items": {
"type": "string"
}
},
"user_id": {
"type": "string"
}
}
},
"history-api_internal_dtos_request.CreateTokenDto": {
"type": "object",
"required": [
"email",
"token_type"
],
"properties": {
"email": {
"type": "string"
},
"token_type": {
"enum": [
1,
2,
3,
4
],
"allOf": [
{
"$ref": "#/definitions/history-api_pkg_constants.TokenType"
}
]
}
}
},
"history-api_internal_dtos_request.ForgotPasswordDto": {
"type": "object",
"required": [
"email",
"new_password",
"token_id"
],
"properties": {
"email": {
"type": "string",
"maxLength": 255,
"minLength": 5
},
"new_password": {
"type": "string",
"maxLength": 64,
"minLength": 8
},
"token_id": {
"type": "string"
}
}
},
"history-api_internal_dtos_request.SignInDto": { "history-api_internal_dtos_request.SignInDto": {
"type": "object", "type": "object",
"required": [ "required": [
@@ -258,7 +923,8 @@
"required": [ "required": [
"display_name", "display_name",
"email", "email",
"password" "password",
"token_id"
], ],
"properties": { "properties": {
"display_name": { "display_name": {
@@ -275,6 +941,75 @@
"type": "string", "type": "string",
"maxLength": 64, "maxLength": 64,
"minLength": 8 "minLength": 8
},
"token_id": {
"type": "string"
}
}
},
"history-api_internal_dtos_request.UpdateProfileDto": {
"type": "object",
"properties": {
"avatar_url": {
"type": "string"
},
"bio": {
"type": "string",
"maxLength": 255
},
"country_code": {
"type": "string"
},
"display_name": {
"type": "string",
"maxLength": 50,
"minLength": 2
},
"full_name": {
"type": "string",
"maxLength": 100,
"minLength": 2
},
"location": {
"type": "string",
"maxLength": 100
},
"phone": {
"type": "string",
"maxLength": 20,
"minLength": 8
},
"website": {
"type": "string"
}
}
},
"history-api_internal_dtos_request.VerifyTokenDto": {
"type": "object",
"required": [
"email",
"token",
"token_type"
],
"properties": {
"email": {
"type": "string"
},
"token": {
"type": "string"
},
"token_type": {
"enum": [
1,
2,
3,
4
],
"allOf": [
{
"$ref": "#/definitions/history-api_pkg_constants.TokenType"
}
]
} }
} }
}, },
@@ -289,6 +1024,22 @@
"type": "boolean" "type": "boolean"
} }
} }
},
"history-api_pkg_constants.TokenType": {
"type": "integer",
"format": "int32",
"enum": [
1,
2,
3,
4
],
"x-enum-varnames": [
"TokenPasswordReset",
"TokenEmailVerify",
"TokenMagicLink",
"TokenRefreshToken"
]
} }
}, },
"securityDefinitions": { "securityDefinitions": {

View File

@@ -1,5 +1,65 @@
basePath: / basePath: /
definitions: definitions:
history-api_internal_dtos_request.ChangePasswordDto:
properties:
new_password:
maxLength: 64
minLength: 8
type: string
old_password:
maxLength: 64
minLength: 8
type: string
required:
- new_password
- old_password
type: object
history-api_internal_dtos_request.ChangeRoleDto:
properties:
role_ids:
items:
type: string
minItems: 1
type: array
user_id:
type: string
required:
- role_ids
- user_id
type: object
history-api_internal_dtos_request.CreateTokenDto:
properties:
email:
type: string
token_type:
allOf:
- $ref: '#/definitions/history-api_pkg_constants.TokenType'
enum:
- 1
- 2
- 3
- 4
required:
- email
- token_type
type: object
history-api_internal_dtos_request.ForgotPasswordDto:
properties:
email:
maxLength: 255
minLength: 5
type: string
new_password:
maxLength: 64
minLength: 8
type: string
token_id:
type: string
required:
- email
- new_password
- token_id
type: object
history-api_internal_dtos_request.SignInDto: history-api_internal_dtos_request.SignInDto:
properties: properties:
email: email:
@@ -28,10 +88,59 @@ definitions:
maxLength: 64 maxLength: 64
minLength: 8 minLength: 8
type: string type: string
token_id:
type: string
required: required:
- display_name - display_name
- email - email
- password - password
- token_id
type: object
history-api_internal_dtos_request.UpdateProfileDto:
properties:
avatar_url:
type: string
bio:
maxLength: 255
type: string
country_code:
type: string
display_name:
maxLength: 50
minLength: 2
type: string
full_name:
maxLength: 100
minLength: 2
type: string
location:
maxLength: 100
type: string
phone:
maxLength: 20
minLength: 8
type: string
website:
type: string
type: object
history-api_internal_dtos_request.VerifyTokenDto:
properties:
email:
type: string
token:
type: string
token_type:
allOf:
- $ref: '#/definitions/history-api_pkg_constants.TokenType'
enum:
- 1
- 2
- 3
- 4
required:
- email
- token
- token_type
type: object type: object
history-api_internal_dtos_response.CommonResponse: history-api_internal_dtos_response.CommonResponse:
properties: properties:
@@ -41,6 +150,19 @@ definitions:
status: status:
type: boolean type: boolean
type: object type: object
history-api_pkg_constants.TokenType:
enum:
- 1
- 2
- 3
- 4
format: int32
type: integer
x-enum-varnames:
- TokenPasswordReset
- TokenEmailVerify
- TokenMagicLink
- TokenRefreshToken
host: history-api.kain.id.vn host: history-api.kain.id.vn
info: info:
contact: contact:
@@ -55,12 +177,18 @@ info:
title: History API title: History API
version: "1.0" version: "1.0"
paths: paths:
/auth/refresh: /auth/forgot-password:
post: post:
consumes: consumes:
- application/json - application/json
description: Get a new access token using the user's current session/refresh description: Initiate password recovery process for a user
token parameters:
- description: Forgot Password request
in: body
name: request
required: true
schema:
$ref: '#/definitions/history-api_internal_dtos_request.ForgotPasswordDto'
produces: produces:
- application/json - application/json
responses: responses:
@@ -68,22 +196,49 @@ paths:
description: OK description: OK
schema: schema:
$ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/history-api_internal_dtos_response.CommonResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/history-api_internal_dtos_response.CommonResponse'
summary: Handle forgotten password
tags:
- Auth
/auth/refresh:
post:
consumes:
- application/json
description: Generate a new access token using a valid refresh token from context
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/history-api_internal_dtos_response.CommonResponse'
"401":
description: Unauthorized or expired refresh token
schema:
$ref: '#/definitions/history-api_internal_dtos_response.CommonResponse'
"500": "500":
description: Internal Server Error description: Internal Server Error
schema: schema:
$ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse'
security: security:
- BearerAuth: [] - BearerAuth: []
summary: Refresh access token summary: Refresh session tokens
tags: tags:
- Auth - Auth
/auth/signin: /auth/signin:
post: post:
consumes: consumes:
- application/json - application/json
description: Authenticate user and return token data description: Authenticate user credentials and return access/refresh tokens
parameters: parameters:
- description: Sign In request - description: Sign In credentials
in: body in: body
name: request name: request
required: true required: true
@@ -100,20 +255,24 @@ paths:
description: Bad Request description: Bad Request
schema: schema:
$ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse'
"401":
description: Invalid credentials
schema:
$ref: '#/definitions/history-api_internal_dtos_response.CommonResponse'
"500": "500":
description: Internal Server Error description: Internal Server Error
schema: schema:
$ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse'
summary: Sign in an existing user summary: Sign in a user
tags: tags:
- Auth - Auth
/auth/signup: /auth/signup:
post: post:
consumes: consumes:
- application/json - application/json
description: Create a new user account description: Create a new user account in the system
parameters: parameters:
- description: Sign Up request - description: Sign Up details
in: body in: body
name: request name: request
required: true required: true
@@ -134,7 +293,67 @@ paths:
description: Internal Server Error description: Internal Server Error
schema: schema:
$ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse'
summary: Sign up a new user summary: Register a new user
tags:
- Auth
/auth/token/create:
post:
consumes:
- application/json
description: Request a new token for specific actions like email confirmation
parameters:
- description: Token creation request
in: body
name: request
required: true
schema:
$ref: '#/definitions/history-api_internal_dtos_request.CreateTokenDto'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/history-api_internal_dtos_response.CommonResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/history-api_internal_dtos_response.CommonResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/history-api_internal_dtos_response.CommonResponse'
summary: Generate a new verification token
tags:
- Auth
/auth/token/verify:
post:
consumes:
- application/json
description: Validate an OTP or email verification token
parameters:
- description: Token verification data
in: body
name: request
required: true
schema:
$ref: '#/definitions/history-api_internal_dtos_request.VerifyTokenDto'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/history-api_internal_dtos_response.CommonResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/history-api_internal_dtos_response.CommonResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/history-api_internal_dtos_response.CommonResponse'
summary: Verify a security token
tags: tags:
- Auth - Auth
/tiles/{z}/{x}/{y}: /tiles/{z}/{x}/{y}:
@@ -193,6 +412,281 @@ paths:
summary: Get tile metadata summary: Get tile metadata
tags: tags:
- Tile - Tile
/users:
get:
consumes:
- application/json
description: Search and filter users with pagination (Admin/Mod only)
parameters:
- in: query
name: cursor
type: string
- in: query
name: is_deleted
type: boolean
- in: query
maximum: 100
minimum: 1
name: limit
required: true
type: integer
- enum:
- asc
- desc
in: query
name: order
type: string
- collectionFormat: csv
in: query
items:
type: string
name: role_ids
type: array
- in: query
maxLength: 200
minLength: 2
name: search
type: string
- enum:
- created_at
- updated_at
- email
- display_name
in: query
name: sort
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/history-api_internal_dtos_response.CommonResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/history-api_internal_dtos_response.CommonResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/history-api_internal_dtos_response.CommonResponse'
security:
- BearerAuth: []
summary: Search users
tags:
- Users
/users/{id}:
delete:
consumes:
- application/json
description: Soft delete a user account (Admin/Mod only)
parameters:
- description: User ID
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/history-api_internal_dtos_response.CommonResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/history-api_internal_dtos_response.CommonResponse'
security:
- BearerAuth: []
summary: Delete a user
tags:
- Users
get:
consumes:
- application/json
description: Retrieve details of a specific user (Admin/Mod only)
parameters:
- description: User ID
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/history-api_internal_dtos_response.CommonResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/history-api_internal_dtos_response.CommonResponse'
security:
- BearerAuth: []
summary: Get user by ID
tags:
- Users
put:
consumes:
- application/json
description: Update the profile details of the currently authenticated user
parameters:
- description: User ID
in: path
name: id
required: true
type: string
- description: Update Profile request
in: body
name: request
required: true
schema:
$ref: '#/definitions/history-api_internal_dtos_request.UpdateProfileDto'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/history-api_internal_dtos_response.CommonResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/history-api_internal_dtos_response.CommonResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/history-api_internal_dtos_response.CommonResponse'
security:
- BearerAuth: []
summary: Update user profile
tags:
- Users
/users/{id}/password:
patch:
consumes:
- application/json
description: Update the password for the currently authenticated user
parameters:
- description: User ID
in: path
name: id
required: true
type: string
- description: Change Password request
in: body
name: request
required: true
schema:
$ref: '#/definitions/history-api_internal_dtos_request.ChangePasswordDto'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/history-api_internal_dtos_response.CommonResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/history-api_internal_dtos_response.CommonResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/history-api_internal_dtos_response.CommonResponse'
security:
- BearerAuth: []
summary: Change user password
tags:
- Users
/users/{id}/restore:
patch:
consumes:
- application/json
description: Restore a soft-deleted user account (Admin/Mod only)
parameters:
- description: User ID
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/history-api_internal_dtos_response.CommonResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/history-api_internal_dtos_response.CommonResponse'
security:
- BearerAuth: []
summary: Restore a deleted user
tags:
- Users
/users/{id}/role:
patch:
consumes:
- application/json
description: Update the role of a user (Admin only)
parameters:
- description: User ID
in: path
name: id
required: true
type: string
- description: Change Role request
in: body
name: request
required: true
schema:
$ref: '#/definitions/history-api_internal_dtos_request.ChangeRoleDto'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/history-api_internal_dtos_response.CommonResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/history-api_internal_dtos_response.CommonResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/history-api_internal_dtos_response.CommonResponse'
security:
- BearerAuth: []
summary: Change user role
tags:
- Users
/users/current:
get:
consumes:
- application/json
description: Retrieve the profile information of the currently authenticated
user
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/history-api_internal_dtos_response.CommonResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/history-api_internal_dtos_response.CommonResponse'
security:
- BearerAuth: []
summary: Get current user profile
tags:
- Users
schemes: schemes:
- https - https
- http - http

3
go.mod
View File

@@ -10,11 +10,13 @@ require (
github.com/gofiber/contrib/v3/zerolog v1.0.1 github.com/gofiber/contrib/v3/zerolog v1.0.1
github.com/gofiber/fiber/v3 v3.1.0 github.com/gofiber/fiber/v3 v3.1.0
github.com/golang-jwt/jwt/v5 v5.3.1 github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.8.0 github.com/jackc/pgx/v5 v5.8.0
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/redis/go-redis/v9 v9.18.0 github.com/redis/go-redis/v9 v9.18.0
github.com/rs/zerolog v1.34.0 github.com/rs/zerolog v1.34.0
github.com/swaggo/swag v1.16.6 github.com/swaggo/swag v1.16.6
github.com/wneessen/go-mail v0.7.2
golang.org/x/crypto v0.49.0 golang.org/x/crypto v0.49.0
) )
@@ -49,7 +51,6 @@ require (
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/gofiber/schema v1.7.0 // indirect github.com/gofiber/schema v1.7.0 // indirect
github.com/gofiber/utils/v2 v2.0.2 // indirect github.com/gofiber/utils/v2 v2.0.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect

2
go.sum
View File

@@ -158,6 +158,8 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI= github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI=
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw= github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
github.com/wneessen/go-mail v0.7.2 h1:xxPnhZ6IZLSgxShebmZ6DPKh1b6OJcoHfzy7UjOkzS8=
github.com/wneessen/go-mail v0.7.2/go.mod h1:+TkW6QP3EVkgTEqHtVmnAE/1MRhmzb8Y9/W3pweuS+k=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=

View File

@@ -20,14 +20,15 @@ func NewAuthController(svc services.AuthService) *AuthController {
} }
// Signin godoc // Signin godoc
// @Summary Sign in an existing user // @Summary Sign in a user
// @Description Authenticate user and return token data // @Description Authenticate user credentials and return access/refresh tokens
// @Tags Auth // @Tags Auth
// @Accept json // @Accept json
// @Produce 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 // @Success 200 {object} response.CommonResponse
// @Failure 400 {object} response.CommonResponse // @Failure 400 {object} response.CommonResponse
// @Failure 401 {object} response.CommonResponse "Invalid credentials"
// @Failure 500 {object} response.CommonResponse // @Failure 500 {object} response.CommonResponse
// @Router /auth/signin [post] // @Router /auth/signin [post]
func (h *AuthController) Signin(c fiber.Ctx) error { func (h *AuthController) Signin(c fiber.Ctx) error {
@@ -57,12 +58,12 @@ func (h *AuthController) Signin(c fiber.Ctx) error {
} }
// Signup godoc // Signup godoc
// @Summary Sign up a new user // @Summary Register a new user
// @Description Create a new user account // @Description Create a new user account in the system
// @Tags Auth // @Tags Auth
// @Accept json // @Accept json
// @Produce 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 // @Success 200 {object} response.CommonResponse
// @Failure 400 {object} response.CommonResponse // @Failure 400 {object} response.CommonResponse
// @Failure 500 {object} response.CommonResponse // @Failure 500 {object} response.CommonResponse
@@ -94,13 +95,14 @@ func (h *AuthController) Signup(c fiber.Ctx) error {
} }
// RefreshToken godoc // RefreshToken godoc
// @Summary Refresh access token // @Summary Refresh session tokens
// @Description Get a new access token using the user's current session/refresh token // @Description Generate a new access token using a valid refresh token from context
// @Tags Auth // @Tags Auth
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Security BearerAuth // @Security BearerAuth
// @Success 200 {object} response.CommonResponse // @Success 200 {object} response.CommonResponse
// @Failure 401 {object} response.CommonResponse "Unauthorized or expired refresh token"
// @Failure 500 {object} response.CommonResponse // @Failure 500 {object} response.CommonResponse
// @Router /auth/refresh [post] // @Router /auth/refresh [post]
func (h *AuthController) RefreshToken(c fiber.Ctx) error { func (h *AuthController) RefreshToken(c fiber.Ctx) error {
@@ -120,3 +122,116 @@ func (h *AuthController) RefreshToken(c fiber.Ctx) error {
Data: res, 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 package request
import "history-api/pkg/constants"
type SignUpDto struct { type SignUpDto struct {
Email string `json:"email" validate:"required,min=5,max=255,email"` Email string `json:"email" validate:"required,min=5,max=255,email"`
Password string `json:"password" validate:"required,min=8,max=64"` Password string `json:"password" validate:"required,min=8,max=64"`
DisplayName string `json:"display_name" validate:"required,min=2,max=50"` DisplayName string `json:"display_name" validate:"required,min=2,max=50"`
TokenID string `json:"token_id" validate:"required,uuid"`
} }
type SignInDto struct { type SignInDto struct {
Email string `json:"email" validate:"required,min=5,max=255,email"` Email string `json:"email" validate:"required,min=5,max=255,email"`
Password string `json:"password" validate:"required,min=8,max=64"` 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 package request
import "history-api/pkg/constant" type UpdateProfileDto struct {
DisplayName string `json:"display_name" validate:"omitempty,min=2,max=50"`
type CreateUserDto struct { FullName string `json:"full_name" validate:"omitempty,min=2,max=100"`
Username string `json:"username" validate:"required"` AvatarUrl string `json:"avatar_url" validate:"omitempty,url"`
Password string `json:"password" validate:"required"` Bio string `json:"bio" validate:"omitempty,max=255"`
DiscordUserId string `json:"discord_user_id" validate:"required"` Location string `json:"location" validate:"omitempty,max=100"`
Role []constant.Role `json:"role" validate:"required"` 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 { type ChangePasswordDto struct {
Password *string `json:"password" validate:"omitempty"` OldPassword string `json:"old_password" validate:"required,min=8,max=64"`
DiscordUserId *string `json:"discord_user_id" validate:"omitempty"` NewPassword string `json:"new_password" validate:"required,min=8,max=64,nefield=OldPassword"`
Role *[]constant.Role `json:"role" validate:"omitempty"` }
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 { type SearchUserDto struct {
Username *string `query:"username" validate:"omitempty"` CursorPaginationDto
DiscordUserId *string `query:"discord_user_id" validate:"omitempty"` Search string `json:"search" query:"search" validate:"omitempty,min=2,max=200"`
Role *[]constant.Role `query:"role" validate:"omitempty"` IsDeleted *bool `json:"is_deleted" query:"is_deleted" validate:"omitempty"`
SortBy string `query:"sort_by" default:"created_at" validate:"oneof=created_at updated_at"` RoleIDs []string `json:"role_ids" query:"role_ids" validate:"omitempty,dive,uuid"`
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

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

View File

@@ -1,7 +1,7 @@
package response package response
import ( import (
"history-api/pkg/constant" "history-api/pkg/constants"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
) )
@@ -14,6 +14,17 @@ type CommonResponse struct {
type JWTClaims struct { type JWTClaims struct {
UId string `json:"uid"` UId string `json:"uid"`
Roles []constant.Role `json:"roles"` Roles []constants.Role `json:"roles"`
TokenVersion int32 `json:"token_version"`
jwt.RegisteredClaims 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"` PasswordHash pgtype.Text `json:"password_hash"`
GoogleID pgtype.Text `json:"google_id"` GoogleID pgtype.Text `json:"google_id"`
AuthProvider string `json:"auth_provider"` AuthProvider string `json:"auth_provider"`
IsVerified bool `json:"is_verified"`
IsDeleted bool `json:"is_deleted"` IsDeleted bool `json:"is_deleted"`
TokenVersion int32 `json:"token_version"` TokenVersion int32 `json:"token_version"`
RefreshToken pgtype.Text `json:"refresh_token"` RefreshToken pgtype.Text `json:"refresh_token"`
@@ -49,16 +48,6 @@ type UserRole struct {
RoleID pgtype.UUID `json:"role_id"` 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 { type UserVerification struct {
ID pgtype.UUID `json:"id"` ID pgtype.UUID `json:"id"`
UserID pgtype.UUID `json:"user_id"` UserID pgtype.UUID `json:"user_id"`

View File

@@ -13,19 +13,17 @@ import (
const addUserRole = `-- name: AddUserRole :exec const addUserRole = `-- name: AddUserRole :exec
INSERT INTO user_roles (user_id, role_id) INSERT INTO user_roles (user_id, role_id)
SELECT $1, r.id SELECT $1, unnest($2::uuid[])
FROM roles r
WHERE r.name = $2
ON CONFLICT DO NOTHING ON CONFLICT DO NOTHING
` `
type AddUserRoleParams struct { type AddUserRoleParams struct {
UserID pgtype.UUID `json:"user_id"` UserID pgtype.UUID `json:"user_id"`
Name string `json:"name"` Column2 []pgtype.UUID `json:"column_2"`
} }
func (q *Queries) AddUserRole(ctx context.Context, arg AddUserRoleParams) error { 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 return err
} }
@@ -129,6 +127,38 @@ func (q *Queries) GetRoles(ctx context.Context) ([]Role, error) {
return items, nil 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 const removeAllRolesFromUser = `-- name: RemoveAllRolesFromUser :exec
DELETE FROM user_roles DELETE FROM user_roles
WHERE user_id = $1 WHERE user_id = $1

View File

@@ -77,7 +77,6 @@ SELECT
u.id, u.id,
u.email, u.email,
u.password_hash, u.password_hash,
u.is_verified,
u.token_version, u.token_version,
u.is_deleted, u.is_deleted,
u.created_at, u.created_at,
@@ -116,7 +115,6 @@ type GetUserByEmailRow struct {
ID pgtype.UUID `json:"id"` ID pgtype.UUID `json:"id"`
Email string `json:"email"` Email string `json:"email"`
PasswordHash pgtype.Text `json:"password_hash"` PasswordHash pgtype.Text `json:"password_hash"`
IsVerified bool `json:"is_verified"`
TokenVersion int32 `json:"token_version"` TokenVersion int32 `json:"token_version"`
IsDeleted bool `json:"is_deleted"` IsDeleted bool `json:"is_deleted"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
@@ -132,7 +130,6 @@ func (q *Queries) GetUserByEmail(ctx context.Context, email string) (GetUserByEm
&i.ID, &i.ID,
&i.Email, &i.Email,
&i.PasswordHash, &i.PasswordHash,
&i.IsVerified,
&i.TokenVersion, &i.TokenVersion,
&i.IsDeleted, &i.IsDeleted,
&i.CreatedAt, &i.CreatedAt,
@@ -148,7 +145,6 @@ SELECT
u.id, u.id,
u.email, u.email,
u.password_hash, u.password_hash,
u.is_verified,
u.token_version, u.token_version,
u.refresh_token, u.refresh_token,
u.is_deleted, u.is_deleted,
@@ -190,7 +186,6 @@ type GetUserByIDRow struct {
ID pgtype.UUID `json:"id"` ID pgtype.UUID `json:"id"`
Email string `json:"email"` Email string `json:"email"`
PasswordHash pgtype.Text `json:"password_hash"` PasswordHash pgtype.Text `json:"password_hash"`
IsVerified bool `json:"is_verified"`
TokenVersion int32 `json:"token_version"` TokenVersion int32 `json:"token_version"`
RefreshToken pgtype.Text `json:"refresh_token"` RefreshToken pgtype.Text `json:"refresh_token"`
IsDeleted bool `json:"is_deleted"` IsDeleted bool `json:"is_deleted"`
@@ -207,7 +202,79 @@ func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (GetUserByIDR
&i.ID, &i.ID,
&i.Email, &i.Email,
&i.PasswordHash, &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.TokenVersion,
&i.RefreshToken, &i.RefreshToken,
&i.IsDeleted, &i.IsDeleted,
@@ -224,7 +291,127 @@ SELECT
u.id, u.id,
u.email, u.email,
u.password_hash, 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.token_version,
u.refresh_token, u.refresh_token,
u.is_deleted, u.is_deleted,
@@ -257,14 +444,44 @@ SELECT
) AS roles ) AS roles
FROM users u 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"` ID pgtype.UUID `json:"id"`
Email string `json:"email"` Email string `json:"email"`
PasswordHash pgtype.Text `json:"password_hash"` PasswordHash pgtype.Text `json:"password_hash"`
IsVerified bool `json:"is_verified"`
TokenVersion int32 `json:"token_version"` TokenVersion int32 `json:"token_version"`
RefreshToken pgtype.Text `json:"refresh_token"` RefreshToken pgtype.Text `json:"refresh_token"`
IsDeleted bool `json:"is_deleted"` IsDeleted bool `json:"is_deleted"`
@@ -274,20 +491,26 @@ type GetUsersRow struct {
Roles []byte `json:"roles"` Roles []byte `json:"roles"`
} }
func (q *Queries) GetUsers(ctx context.Context) ([]GetUsersRow, error) { func (q *Queries) SearchUsers(ctx context.Context, arg SearchUsersParams) ([]SearchUsersRow, error) {
rows, err := q.db.Query(ctx, getUsers) rows, err := q.db.Query(ctx, searchUsers,
arg.Cursor,
arg.IsDeleted,
arg.RoleIds,
arg.SearchID,
arg.SearchText,
arg.Limit,
)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
items := []GetUsersRow{} items := []SearchUsersRow{}
for rows.Next() { for rows.Next() {
var i GetUsersRow var i SearchUsersRow
if err := rows.Scan( if err := rows.Scan(
&i.ID, &i.ID,
&i.Email, &i.Email,
&i.PasswordHash, &i.PasswordHash,
&i.IsVerified,
&i.TokenVersion, &i.TokenVersion,
&i.RefreshToken, &i.RefreshToken,
&i.IsDeleted, &i.IsDeleted,
@@ -306,18 +529,6 @@ func (q *Queries) GetUsers(ctx context.Context) ([]GetUsersRow, error) {
return items, nil 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 const updateTokenVersion = `-- name: UpdateTokenVersion :exec
UPDATE users UPDATE users
SET token_version = $2 SET token_version = $2
@@ -432,18 +643,15 @@ INSERT INTO users (
email, email,
password_hash, password_hash,
google_id, google_id,
auth_provider, auth_provider
is_verified
) VALUES ( ) VALUES (
$1, $2, $3, $4, $5 $1, $2, $3, $4
) )
ON CONFLICT (email) ON CONFLICT (email)
DO UPDATE SET DO UPDATE SET
google_id = EXCLUDED.google_id, google_id = EXCLUDED.google_id,
auth_provider = EXCLUDED.auth_provider, auth_provider = EXCLUDED.auth_provider
is_verified = users.is_verified OR EXCLUDED.is_verified, RETURNING id, email, password_hash, google_id, auth_provider, is_deleted, token_version, refresh_token, created_at, updated_at
updated_at = now()
RETURNING id, email, password_hash, google_id, auth_provider, is_verified, is_deleted, token_version, refresh_token, created_at, updated_at
` `
type UpsertUserParams struct { type UpsertUserParams struct {
@@ -451,7 +659,6 @@ type UpsertUserParams struct {
PasswordHash pgtype.Text `json:"password_hash"` PasswordHash pgtype.Text `json:"password_hash"`
GoogleID pgtype.Text `json:"google_id"` GoogleID pgtype.Text `json:"google_id"`
AuthProvider string `json:"auth_provider"` AuthProvider string `json:"auth_provider"`
IsVerified bool `json:"is_verified"`
} }
func (q *Queries) UpsertUser(ctx context.Context, arg UpsertUserParams) (User, error) { 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.PasswordHash,
arg.GoogleID, arg.GoogleID,
arg.AuthProvider, arg.AuthProvider,
arg.IsVerified,
) )
var i User var i User
err := row.Scan( err := row.Scan(
@@ -469,7 +675,6 @@ func (q *Queries) UpsertUser(ctx context.Context, arg UpsertUserParams) (User, e
&i.PasswordHash, &i.PasswordHash,
&i.GoogleID, &i.GoogleID,
&i.AuthProvider, &i.AuthProvider,
&i.IsVerified,
&i.IsDeleted, &i.IsDeleted,
&i.TokenVersion, &i.TokenVersion,
&i.RefreshToken, &i.RefreshToken,
@@ -478,16 +683,3 @@ func (q *Queries) UpsertUser(ctx context.Context, arg UpsertUserParams) (User, e
) )
return i, err 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 ( import (
"history-api/internal/dtos/response" "history-api/internal/dtos/response"
"history-api/internal/repositories"
"history-api/pkg/config" "history-api/pkg/config"
"history-api/pkg/constant" "history-api/pkg/constants"
"slices" "slices"
jwtware "github.com/gofiber/contrib/v3/jwt" jwtware "github.com/gofiber/contrib/v3/jwt"
"github.com/gofiber/fiber/v3" "github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/extractors" "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") jwtSecret, err := config.GetConfig("JWT_SECRET")
if err != nil { if err != nil {
return nil return nil
@@ -20,13 +22,13 @@ func JwtAccess() fiber.Handler {
return jwtware.New(jwtware.Config{ return jwtware.New(jwtware.Config{
SigningKey: jwtware.SigningKey{Key: []byte(jwtSecret)}, SigningKey: jwtware.SigningKey{Key: []byte(jwtSecret)},
ErrorHandler: jwtError, ErrorHandler: jwtError,
SuccessHandler: jwtSuccess, SuccessHandler: jwtSuccess(userRepo),
Extractor: extractors.FromAuthHeader("Bearer"), Extractor: extractors.FromAuthHeader("Bearer"),
Claims: &response.JWTClaims{}, Claims: &response.JWTClaims{},
}) })
} }
func JwtRefresh() fiber.Handler { func JwtRefresh(userRepo repositories.UserRepository) fiber.Handler {
jwtRefreshSecret, err := config.GetConfig("JWT_REFRESH_SECRET") jwtRefreshSecret, err := config.GetConfig("JWT_REFRESH_SECRET")
if err != nil { if err != nil {
return nil return nil
@@ -35,14 +37,16 @@ func JwtRefresh() fiber.Handler {
return jwtware.New(jwtware.Config{ return jwtware.New(jwtware.Config{
SigningKey: jwtware.SigningKey{Key: []byte(jwtRefreshSecret)}, SigningKey: jwtware.SigningKey{Key: []byte(jwtRefreshSecret)},
ErrorHandler: jwtError, ErrorHandler: jwtError,
SuccessHandler: jwtSuccess, SuccessHandler: jwtSuccess(userRepo),
Extractor: extractors.FromAuthHeader("Bearer"), Extractor: extractors.FromAuthHeader("Bearer"),
Claims: &response.JWTClaims{}, Claims: &response.JWTClaims{},
}) })
} }
func jwtSuccess(c fiber.Ctx) error { func jwtSuccess(userRepo repositories.UserRepository) fiber.Handler {
return func(c fiber.Ctx) error {
user := jwtware.FromContext(c) user := jwtware.FromContext(c)
unauthorized := func() error { unauthorized := func() error {
return c.Status(fiber.StatusUnauthorized).JSON(response.CommonResponse{ return c.Status(fiber.StatusUnauthorized).JSON(response.CommonResponse{
Status: false, Status: false,
@@ -59,17 +63,35 @@ func jwtSuccess(c fiber.Ctx) error {
return unauthorized() return unauthorized()
} }
if slices.Contains(claims.Roles, constant.BANNED) { if slices.Contains(claims.Roles, constants.BANNED) {
return c.Status(fiber.StatusForbidden).JSON(response.CommonResponse{ return c.Status(fiber.StatusForbidden).JSON(response.CommonResponse{
Status: false, Status: false,
Message: "User account is banned", 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("uid", claims.UId)
c.Locals("user_claims", claims) c.Locals("user_claims", claims)
return c.Next() return c.Next()
}
} }
func jwtError(c fiber.Ctx, err error) error { func jwtError(c fiber.Ctx, err error) error {
if err.Error() == "Missing or malformed JWT" { if err.Error() == "Missing or malformed JWT" {

View File

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

View File

@@ -2,7 +2,7 @@ package models
import ( import (
"history-api/internal/dtos/response" "history-api/internal/dtos/response"
"history-api/pkg/constant" "history-api/pkg/constants"
"time" "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 { func RolesEntityToResponse(rs []*RoleEntity) []*response.RoleResponse {
out := make([]*response.RoleResponse, len(rs)) out := make([]*response.RoleResponse, len(rs))
for i := range rs { for i := range rs {
@@ -52,10 +59,10 @@ func RolesEntityToResponse(rs []*RoleEntity) []*response.RoleResponse {
return out return out
} }
func RolesEntityToRoleConstant(rs []*RoleSimple) []constant.Role { func RolesEntityToRoleConstant(rs []*RoleSimple) []constants.Role {
out := make([]constant.Role, len(rs)) out := make([]constants.Role, len(rs))
for i := range rs { for i := range rs {
data, ok := constant.ParseRole(rs[i].Name) data, ok := constants.ParseRole(rs[i].Name)
if !ok { if !ok {
continue continue
} }

View File

@@ -1,35 +1,9 @@
package models package models
import ( import "history-api/pkg/constants"
"history-api/internal/dtos/response"
"history-api/pkg/convert"
"github.com/jackc/pgx/v5/pgtype"
)
type TokenEntity struct { type TokenEntity struct {
ID pgtype.UUID `json:"id"` Email string `json:"email"`
UserID pgtype.UUID `json:"user_id"`
Token string `json:"token"` Token string `json:"token"`
TokenType int16 `json:"token_type"` TokenType constants.TokenType `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
} }

View File

@@ -2,19 +2,22 @@ package repositories
import ( import (
"context" "context"
"crypto/md5"
"encoding/json"
"fmt" "fmt"
"time"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"history-api/internal/gen/sqlc" "history-api/internal/gen/sqlc"
"history-api/internal/models" "history-api/internal/models"
"history-api/pkg/cache" "history-api/pkg/cache"
"history-api/pkg/constants"
"history-api/pkg/convert" "history-api/pkg/convert"
) )
type RoleRepository interface { type RoleRepository interface {
GetByID(ctx context.Context, id pgtype.UUID) (*models.RoleEntity, error) 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) GetByname(ctx context.Context, name string) (*models.RoleEntity, error)
All(ctx context.Context) ([]*models.RoleEntity, error) All(ctx context.Context) ([]*models.RoleEntity, error)
Create(ctx context.Context, name string) (*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) { func (r *roleRepository) GetByID(ctx context.Context, id pgtype.UUID) (*models.RoleEntity, error) {
cacheId := fmt.Sprintf("role:id:%s", convert.UUIDToString(id)) cacheId := fmt.Sprintf("role:id:%s", convert.UUIDToString(id))
var role models.RoleEntity 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), CreatedAt: convert.TimeToPtr(row.CreatedAt),
UpdatedAt: convert.TimeToPtr(row.UpdatedAt), 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 return &role, nil
} }
@@ -83,7 +136,7 @@ func (r *roleRepository) GetByname(ctx context.Context, name string) (*models.Ro
UpdatedAt: convert.TimeToPtr(row.UpdatedAt), 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 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:name:%s", name): role,
fmt.Sprintf("role:id:%s", convert.UUIDToString(row.ID)): 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 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:name:%s", row.Name): role,
fmt.Sprintf("role:id:%s", convert.UUIDToString(row.ID)): 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 return &role, nil
} }

View File

@@ -5,6 +5,7 @@ import (
"database/sql" "database/sql"
"fmt" "fmt"
"history-api/pkg/cache" "history-api/pkg/cache"
"history-api/pkg/constants"
"time" "time"
) )
@@ -50,7 +51,7 @@ func (r *tileRepository) GetMetadata(ctx context.Context) (map[string]string, er
metadata[name] = value metadata[name] = value
} }
_ = r.c.Set(ctx, cacheId, metadata, 10*time.Minute) _ = r.c.Set(ctx, cacheId, metadata, constants.NormalCacheDuration)
return metadata, nil 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 ( import (
"context" "context"
"crypto/md5"
"encoding/json"
"fmt" "fmt"
"time"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"history-api/internal/gen/sqlc" "history-api/internal/gen/sqlc"
"history-api/internal/models" "history-api/internal/models"
"history-api/pkg/cache" "history-api/pkg/cache"
"history-api/pkg/constants"
"history-api/pkg/convert" "history-api/pkg/convert"
) )
type UserRepository interface { type UserRepository interface {
GetByID(ctx context.Context, id pgtype.UUID) (*models.UserEntity, error) 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) 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) UpsertUser(ctx context.Context, params sqlc.UpsertUserParams) (*models.UserEntity, error)
CreateProfile(ctx context.Context, params sqlc.CreateUserProfileParams) (*models.UserProfileSimple, error) CreateProfile(ctx context.Context, params sqlc.CreateUserProfileParams) (*models.UserProfileSimple, error)
UpdateProfile(ctx context.Context, params sqlc.UpdateUserProfileParams) (*models.UserEntity, 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 UpdateRefreshToken(ctx context.Context, params sqlc.UpdateUserRefreshTokenParams) error
GetTokenVersion(ctx context.Context, id pgtype.UUID) (int32, error) GetTokenVersion(ctx context.Context, id pgtype.UUID) (int32, error)
UpdateTokenVersion(ctx context.Context, params sqlc.UpdateTokenVersionParams) 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 Delete(ctx context.Context, id pgtype.UUID) error
Restore(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) { func (r *userRepository) GetByID(ctx context.Context, id pgtype.UUID) (*models.UserEntity, error) {
cacheId := fmt.Sprintf("user:id:%s", convert.UUIDToString(id)) cacheId := fmt.Sprintf("user:id:%s", convert.UUIDToString(id))
var user models.UserEntity 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), ID: convert.UUIDToString(row.ID),
Email: row.Email, Email: row.Email,
PasswordHash: convert.TextToString(row.PasswordHash), PasswordHash: convert.TextToString(row.PasswordHash),
IsVerified: row.IsVerified,
TokenVersion: row.TokenVersion, TokenVersion: row.TokenVersion,
IsDeleted: row.IsDeleted, IsDeleted: row.IsDeleted,
CreatedAt: convert.TimeToPtr(row.CreatedAt), CreatedAt: convert.TimeToPtr(row.CreatedAt),
@@ -73,7 +121,43 @@ func (r *userRepository) GetByID(ctx context.Context, id pgtype.UUID) (*models.U
return nil, err 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 return &user, nil
} }
@@ -96,7 +180,6 @@ func (r *userRepository) GetByEmail(ctx context.Context, email string) (*models.
ID: convert.UUIDToString(row.ID), ID: convert.UUIDToString(row.ID),
Email: row.Email, Email: row.Email,
PasswordHash: convert.TextToString(row.PasswordHash), PasswordHash: convert.TextToString(row.PasswordHash),
IsVerified: row.IsVerified,
TokenVersion: row.TokenVersion, TokenVersion: row.TokenVersion,
IsDeleted: row.IsDeleted, IsDeleted: row.IsDeleted,
CreatedAt: convert.TimeToPtr(row.CreatedAt), CreatedAt: convert.TimeToPtr(row.CreatedAt),
@@ -111,7 +194,7 @@ func (r *userRepository) GetByEmail(ctx context.Context, email string) (*models.
return nil, err return nil, err
} }
_ = r.c.Set(ctx, cacheId, user, 5*time.Minute) _ = r.c.Set(ctx, cacheId, user, constants.NormalCacheDuration)
return &user, nil return &user, nil
} }
@@ -121,12 +204,17 @@ func (r *userRepository) UpsertUser(ctx context.Context, params sqlc.UpsertUserP
if err != nil { if err != nil {
return nil, err return nil, err
} }
go func() {
bgCtx := context.Background()
_ = r.c.DelByPattern(bgCtx, "user:all*")
_ = r.c.DelByPattern(bgCtx, "user:search*")
}()
return &models.UserEntity{ return &models.UserEntity{
ID: convert.UUIDToString(row.ID), ID: convert.UUIDToString(row.ID),
Email: row.Email, Email: row.Email,
PasswordHash: convert.TextToString(row.PasswordHash), PasswordHash: convert.TextToString(row.PasswordHash),
IsVerified: row.IsVerified,
TokenVersion: row.TokenVersion, TokenVersion: row.TokenVersion,
IsDeleted: row.IsDeleted, IsDeleted: row.IsDeleted,
CreatedAt: convert.TimeToPtr(row.CreatedAt), 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:email:%s", user.Email): user,
fmt.Sprintf("user:id:%s", user.ID): 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 return user, nil
} }
@@ -183,19 +271,27 @@ func (r *userRepository) CreateProfile(ctx context.Context, params sqlc.CreateUs
}, nil }, nil
} }
func (r *userRepository) All(ctx context.Context) ([]*models.UserEntity, error) { func (r *userRepository) All(ctx context.Context, params sqlc.GetUsersParams) ([]*models.UserEntity, error) {
rows, err := r.q.GetUsers(ctx) 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 { if err != nil {
return nil, err return nil, err
} }
var users []*models.UserEntity var users []*models.UserEntity
var ids []string
usersToCache := make(map[string]any)
for _, row := range rows { for _, row := range rows {
user := &models.UserEntity{ user := &models.UserEntity{
ID: convert.UUIDToString(row.ID), ID: convert.UUIDToString(row.ID),
Email: row.Email, Email: row.Email,
PasswordHash: convert.TextToString(row.PasswordHash), PasswordHash: convert.TextToString(row.PasswordHash),
IsVerified: row.IsVerified,
TokenVersion: row.TokenVersion, TokenVersion: row.TokenVersion,
IsDeleted: row.IsDeleted, IsDeleted: row.IsDeleted,
CreatedAt: convert.TimeToPtr(row.CreatedAt), 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 { if err := user.ParseRoles(row.Roles); err != nil {
return nil, err return nil, err
} }
if err := user.ParseProfile(row.Profile); err != nil { if err := user.ParseProfile(row.Profile); err != nil {
return nil, err return nil, err
} }
users = append(users, user) 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 return users, nil
} }
func (r *userRepository) Verify(ctx context.Context, id pgtype.UUID) error { func (r *userRepository) Search(ctx context.Context, params sqlc.SearchUsersParams) ([]*models.UserEntity, error) {
user, err := r.GetByID(ctx, id) queryKey := r.generateQueryKey("user:search", params)
if err != nil {
return err 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 { if err != nil {
return err return nil, err
}
err = r.q.UpdateTokenVersion(ctx, sqlc.UpdateTokenVersionParams{
ID: id,
TokenVersion: user.TokenVersion + 1,
})
if err != nil {
return err
} }
user.IsVerified = true var users []*models.UserEntity
user.TokenVersion += 1 var ids []string
usersToCache := make(map[string]any)
mapCache := map[string]any{ for _, row := range rows {
fmt.Sprintf("user:email:%s", user.Email): user, user := &models.UserEntity{
fmt.Sprintf("user:id:%s", user.ID): user, 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),
} }
_ = r.c.MSet(ctx, mapCache, 5*time.Minute)
return nil 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) Delete(ctx context.Context, id pgtype.UUID) error { 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 return 0, err
} }
_ = r.c.Set(ctx, cacheId, raw, 5*time.Minute) _ = r.c.Set(ctx, cacheId, raw, constants.NormalCacheDuration)
return raw, nil 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)) 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 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, 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 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, 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 return nil
} }

View File

@@ -3,13 +3,17 @@ package routes
import ( import (
"history-api/internal/controllers" "history-api/internal/controllers"
"history-api/internal/middlewares" "history-api/internal/middlewares"
"history-api/internal/repositories"
"github.com/gofiber/fiber/v3" "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 := app.Group("/auth")
route.Post("/signin", controller.Signin) route.Post("/signin", controller.Signin)
route.Post("/signup", controller.Signup) 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 ( import (
"context" "context"
"crypto/rand"
"fmt"
"history-api/internal/dtos/request" "history-api/internal/dtos/request"
"history-api/internal/dtos/response" "history-api/internal/dtos/response"
"history-api/internal/gen/sqlc" "history-api/internal/gen/sqlc"
"history-api/internal/models" "history-api/internal/models"
"history-api/internal/repositories" "history-api/internal/repositories"
"history-api/pkg/cache"
"history-api/pkg/config" "history-api/pkg/config"
"history-api/pkg/constant" "history-api/pkg/constants"
"history-api/pkg/convert"
"math/big"
"slices" "slices"
"time" "time"
"github.com/gofiber/fiber/v3" "github.com/gofiber/fiber/v3"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
@@ -22,29 +28,35 @@ import (
type AuthService interface { type AuthService interface {
Signin(ctx context.Context, dto *request.SignInDto) (*response.AuthResponse, error) Signin(ctx context.Context, dto *request.SignInDto) (*response.AuthResponse, error)
Signup(ctx context.Context, dto *request.SignUpDto) (*response.AuthResponse, error) Signup(ctx context.Context, dto *request.SignUpDto) (*response.AuthResponse, error)
ForgotPassword(ctx context.Context) error ForgotPassword(ctx context.Context, dto *request.ForgotPasswordDto) error
VerifyToken(ctx context.Context) error VerifyToken(ctx context.Context, dto *request.VerifyTokenDto) (*response.VerifyTokenResponse, error)
CreateToken(ctx context.Context) error CreateToken(ctx context.Context, dto *request.CreateTokenDto) error
SigninWith3rd(ctx context.Context) error SigninWith3rd(ctx context.Context, dto *request.SigninWith3rdDto) error
RefreshToken(ctx context.Context, id string) (*response.AuthResponse, error) RefreshToken(ctx context.Context, id string) (*response.AuthResponse, error)
} }
type authService struct { type authService struct {
userRepo repositories.UserRepository userRepo repositories.UserRepository
roleRepo repositories.RoleRepository roleRepo repositories.RoleRepository
tokenRepo repositories.TokenRepository
c cache.Cache
} }
func NewAuthService( func NewAuthService(
userRepo repositories.UserRepository, userRepo repositories.UserRepository,
roleRepo repositories.RoleRepository, roleRepo repositories.RoleRepository,
tokenRepo repositories.TokenRepository,
c cache.Cache,
) AuthService { ) AuthService {
return &authService{ return &authService{
userRepo: userRepo, userRepo: userRepo,
roleRepo: roleRepo, 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") jwtSecret, err := config.GetConfig("JWT_SECRET")
if err != nil { if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "missing JWT_SECRET in environment") 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{ claimsAccess := &response.JWTClaims{
UId: Uid, UId: user.ID,
Roles: role, Roles: models.RolesEntityToRoleConstant(user.Roles),
TokenVersion: user.TokenVersion,
RegisteredClaims: jwt.RegisteredClaims{ RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(30 * time.Minute)), ExpiresAt: jwt.NewNumericDate(time.Now().Add(constants.AccessTokenDuration)),
}, },
} }
claimsRefresh := &response.JWTClaims{ claimsRefresh := &response.JWTClaims{
UId: Uid, UId: user.ID,
Roles: role, Roles: models.RolesEntityToRoleConstant(user.Roles),
TokenVersion: user.TokenVersion,
RegisteredClaims: jwt.RegisteredClaims{ 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) { 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") return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid email")
} }
err := constant.ValidatePassword(dto.Password) err := constants.ValidatePassword(dto.Password)
if err != nil { if err != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, err.Error()) 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!") 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 { if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error())
} }
var pgID pgtype.UUID pgID, err := convert.StringToUUID(user.ID)
err = pgID.Scan(user.ID)
if err != nil { if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) 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) 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!") return nil, fiber.NewError(fiber.StatusUnauthorized, "User is banned!")
} }
data, err := a.genToken(id, roles) data, err := a.genToken(user)
if err != nil { if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) 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) { 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") return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid email")
} }
err := constant.ValidatePassword(dto.Password) err := constants.ValidatePassword(dto.Password)
if err != nil { if err != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, err.Error()) 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) user, err := a.userRepo.GetByEmail(ctx, dto.Email)
if err != nil { if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) 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 { if user != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, "User already exists") return nil, fiber.NewError(fiber.StatusBadRequest, "User already exists")
} }
hashed, err := bcrypt.GenerateFromPassword([]byte(dto.Password), bcrypt.DefaultCost) hashed, err := bcrypt.GenerateFromPassword([]byte(dto.Password), bcrypt.DefaultCost)
if err != nil { if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) 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), String: string(hashed),
Valid: len(hashed) != 0, Valid: len(hashed) != 0,
}, },
IsVerified: true,
}, },
) )
if err != nil { if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) 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 { if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) 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 { if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) 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( err = a.roleRepo.AddUserRole(
ctx, ctx,
sqlc.AddUserRoleParams{ sqlc.AddUserRoleParams{
UserID: userId, UserID: userId,
Name: constant.USER.String(), Column2: []pgtype.UUID{roleId},
}, },
) )
if err != nil { if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) 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 { if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) 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 return data, nil
} }
// ForgotPassword implements [AuthService]. func (a *authService) ForgotPassword(ctx context.Context, dto *request.ForgotPasswordDto) error {
func (a *authService) ForgotPassword(ctx context.Context) error { ok, err := a.tokenRepo.CheckVerified(ctx, dto.Email, constants.TokenPasswordReset, dto.TokenID)
panic("unimplemented") 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]. // SigninWith3rd implements [AuthService].
func (a *authService) SigninWith3rd(ctx context.Context) error { func (a *authService) SigninWith3rd(ctx context.Context, dto *request.SigninWith3rdDto) error {
panic("unimplemented") 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, dto *request.CreateTokenDto) error {
func (a *authService) CreateToken(ctx context.Context) error { ok, err := a.tokenRepo.CheckCooldown(ctx, dto.Email, dto.TokenType)
panic("unimplemented") 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, dto *request.VerifyTokenDto) (*response.VerifyTokenResponse, error) {
func (a *authService) VerifyToken(ctx context.Context) error { token, err := a.tokenRepo.Get(ctx, dto.Email, dto.TokenType)
panic("unimplemented") 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" "context"
"history-api/internal/dtos/request" "history-api/internal/dtos/request"
"history-api/internal/dtos/response" "history-api/internal/dtos/response"
"history-api/internal/gen/sqlc"
"history-api/internal/models"
"history-api/internal/repositories" "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 { type UserService interface {
//user //user
GetUserCurrent(ctx context.Context, dto *request.SignInDto) (*response.AuthResponse, error) GetUserCurrent(ctx context.Context, userId string) (*response.UserResponse, error)
UpdateProfile(ctx context.Context, id string) (*response.UserResponse, error) UpdateProfile(ctx context.Context, userId string, dto *request.UpdateProfileDto) (*response.UserResponse, error)
ChangePassword(ctx context.Context, id string) (*response.UserResponse, error) ChangePassword(ctx context.Context, userId string, dto *request.ChangePasswordDto) error
//admin //admin
DeleteUser(ctx context.Context, id string) (*response.UserResponse, error) DeleteUser(ctx context.Context, userId string) error
ChangeRoleUser(ctx context.Context, id string) (*response.UserResponse, error) ChangeRoleUser(ctx context.Context, dto *request.ChangeRoleDto) (*response.UserResponse, error)
RestoreUser(ctx context.Context, id string) (*response.UserResponse, error) RestoreUser(ctx context.Context, userId string) (*response.UserResponse, error)
GetUserByID(ctx context.Context, id string) (*response.UserResponse, error) GetUserByID(ctx context.Context, userId string) (*response.UserResponse, error)
Search(ctx context.Context, id string) ([]*response.UserResponse, error) Search(ctx context.Context, dto *request.SearchUserDto) (*response.PaginatedResponse, error)
GetAllUser(ctx context.Context, id string) ([]*response.UserResponse, error)
} }
type userService struct { type userService struct {
@@ -37,47 +43,241 @@ func NewUserService(
} }
} }
// ChangePassword implements [UserService]. func (u *userService) ChangePassword(ctx context.Context, userId string, dto *request.ChangePasswordDto) error {
func (u *userService) ChangePassword(ctx context.Context, id string) (*response.UserResponse, error) { pgID, err := convert.StringToUUID(userId)
panic("unimplemented") 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, dto *request.ChangeRoleDto) (*response.UserResponse, error) {
func (u *userService) ChangeRoleUser(ctx context.Context, id string) (*response.UserResponse, error) { userId, err := convert.StringToUUID(dto.UserID)
panic("unimplemented") 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, userId string) error {
func (u *userService) DeleteUser(ctx context.Context, id string) (*response.UserResponse, error) { pgID, err := convert.StringToUUID(userId)
panic("unimplemented") 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) UpdateProfile(ctx context.Context, userId string, dto *request.UpdateProfileDto) (*response.UserResponse, error) {
func (u *userService) GetAllUser(ctx context.Context, id string) ([]*response.UserResponse, error) { pgID, err := convert.StringToUUID(userId)
panic("unimplemented") 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) GetUserCurrent(ctx context.Context, userId string) (*response.UserResponse, error) {
func (u *userService) GetUserByID(ctx context.Context, id string) (*response.UserResponse, error) { pgID, err := convert.StringToUUID(userId)
panic("unimplemented") 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) RestoreUser(ctx context.Context, userId string) (*response.UserResponse, error) {
func (u *userService) GetUserCurrent(ctx context.Context, dto *request.SignInDto) (*response.AuthResponse, error) { pgID, err := convert.StringToUUID(userId)
panic("unimplemented") 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) Search(ctx context.Context, dto *request.SearchUserDto) (*response.PaginatedResponse, error) {
func (u *userService) RestoreUser(ctx context.Context, id string) (*response.UserResponse, error) { arg := sqlc.SearchUsersParams{
panic("unimplemented") 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) GetUserByID(ctx context.Context, userId string) (*response.UserResponse, error) {
func (u *userService) Search(ctx context.Context, id string) ([]*response.UserResponse, error) { pgID, err := convert.StringToUUID(userId)
panic("unimplemented") if err != nil {
} return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
// UpdateProfile implements [UserService]. user, err := u.userRepo.GetByID(ctx, pgID)
func (u *userService) UpdateProfile(ctx context.Context, id string) (*response.UserResponse, error) { if err != nil {
panic("unimplemented") return nil, fiber.NewError(fiber.StatusNotFound, err.Error())
}
return user.ToResponse(), nil
} }

37
pkg/cache/redis.go vendored
View File

@@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"history-api/pkg/config" "history-api/pkg/config"
"history-api/pkg/constants"
"time" "time"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
@@ -17,6 +18,9 @@ type Cache interface {
DelByPattern(ctx context.Context, pattern string) error DelByPattern(ctx context.Context, pattern string) error
MGet(ctx context.Context, keys ...string) [][]byte MGet(ctx context.Context, keys ...string) [][]byte
MSet(ctx context.Context, pairs map[string]any, ttl time.Duration) error MSet(ctx context.Context, pairs map[string]any, ttl time.Duration) error
Exists(ctx context.Context, key string) (bool, error)
GetRawClient() *redis.Client
PublishTask(ctx context.Context, streamName string, taskType constants.TaskType, payload any) error
} }
type RedisClient struct { type RedisClient struct {
@@ -49,6 +53,18 @@ func NewRedisClient() (Cache, error) {
return &RedisClient{client: rdb}, nil return &RedisClient{client: rdb}, nil
} }
func (r *RedisClient) GetRawClient() *redis.Client {
return r.client
}
func (r *RedisClient) Exists(ctx context.Context, key string) (bool, error) {
count, err := r.client.Exists(ctx, key).Result()
if err != nil {
return false, err
}
return count > 0, nil
}
func (r *RedisClient) Del(ctx context.Context, keys ...string) error { func (r *RedisClient) Del(ctx context.Context, keys ...string) error {
if len(keys) == 0 { if len(keys) == 0 {
return nil return nil
@@ -59,14 +75,14 @@ func (r *RedisClient) Del(ctx context.Context, keys ...string) error {
func (r *RedisClient) DelByPattern(ctx context.Context, pattern string) error { func (r *RedisClient) DelByPattern(ctx context.Context, pattern string) error {
var cursor uint64 var cursor uint64
for { for {
keys, nextCursor, err := r.client.Scan(ctx, cursor, pattern, 100).Result() keys, nextCursor, err := r.client.Scan(ctx, cursor, pattern, 1000).Result()
if err != nil { if err != nil {
return fmt.Errorf("error scanning keys with pattern %s: %v", pattern, err) return fmt.Errorf("error scanning keys with pattern %s: %v", pattern, err)
} }
if len(keys) > 0 { if len(keys) > 0 {
if err := r.client.Del(ctx, keys...).Err(); err != nil { if err := r.client.Unlink(ctx, keys...).Err(); err != nil {
return fmt.Errorf("error deleting keys during scan: %v", err) return fmt.Errorf("error unlinking keys during scan: %v", err)
} }
} }
@@ -121,6 +137,21 @@ func (r *RedisClient) MGet(ctx context.Context, keys ...string) [][]byte {
return results return results
} }
func (r *RedisClient) PublishTask(ctx context.Context, streamName string, taskType constants.TaskType, payload any) error {
payloadBytes, err := json.Marshal(payload)
if err != nil {
return err
}
return r.client.XAdd(ctx, &redis.XAddArgs{
Stream: streamName,
Values: map[string]interface{}{
"task_type": taskType.String(),
"payload": string(payloadBytes),
},
}).Err()
}
func GetMultiple[T any](ctx context.Context, c Cache, keys []string) ([]T, error) { func GetMultiple[T any](ctx context.Context, c Cache, keys []string) ([]T, error) {
raws := c.MGet(ctx, keys...) raws := c.MGet(ctx, keys...)
final := make([]T, 0) final := make([]T, 0)

View File

@@ -34,3 +34,11 @@ func GetConfig(config string) (string, error) {
return data, nil return data, nil
} }
func GetConfigWithDefault(config, defaultValue string) string {
var data string = os.Getenv(config)
if data == "" {
return defaultValue
}
return data
}

View File

@@ -1,4 +1,4 @@
package constant package constants
import ( import (
"errors" "errors"

View File

@@ -1,4 +1,4 @@
package constant package constants
type Role string type Role string
@@ -18,10 +18,15 @@ func (r Role) Compare(other Role) bool {
return r == other return r == other
} }
func (r Role) IsValid() bool {
return CheckValidRole(r)
}
func CheckValidRole(r Role) bool { func CheckValidRole(r Role) bool {
return r == ADMIN || r == MOD || r == HISTORIAN || r == USER || r == BANNED return r == ADMIN || r == MOD || r == HISTORIAN || r == USER || r == BANNED
} }
func ParseRole(s string) (Role, bool) { func ParseRole(s string) (Role, bool) {
r := Role(s) r := Role(s)
if CheckValidRole(r) { if CheckValidRole(r) {

6
pkg/constants/sream.go Normal file
View File

@@ -0,0 +1,6 @@
package constants
const (
StreamEmailName = "stream:email_tasks"
GroupEmailName = "email_workers_group"
)

View File

@@ -1,4 +1,4 @@
package constant package constants
type StatusType int16 type StatusType int16

11
pkg/constants/task.go Normal file
View File

@@ -0,0 +1,11 @@
package constants
type TaskType string
const (
TaskTypeSendEmailOTP TaskType = "SEND_EMAIL_OTP"
)
func (t TaskType) String() string {
return string(t)
}

13
pkg/constants/time.go Normal file
View File

@@ -0,0 +1,13 @@
package constants
import "time"
const (
TokenCooldownDuration = 1 * time.Minute
TokenExpirationDuration = 20 * time.Minute
NormalCacheDuration = 15 * time.Minute
ListCacheDuration = 10 * time.Minute
AccessTokenDuration = 15 * time.Minute
RefreshTokenDuration = 7 * 24 * time.Hour
TokenVerifiedDuration = 10 * time.Minute
)

View File

@@ -1,4 +1,4 @@
package constant package constants
type TokenType int16 type TokenType int16
@@ -24,6 +24,10 @@ func (t TokenType) String() string {
} }
} }
func (t TokenType) Value() int16 {
return int16(t)
}
func ParseTokenType(v int16) TokenType { func ParseTokenType(v int16) TokenType {
switch v { switch v {
case 1: case 1:
@@ -38,3 +42,18 @@ func ParseTokenType(v int16) TokenType {
return 0 return 0
} }
} }
func ParseTokenTypeFromString(s string) TokenType {
switch s {
case "PASSWORD_RESET":
return TokenPasswordReset
case "EMAIL_VERIFY":
return TokenEmailVerify
case "LOGIN_MAGIC_LINK":
return TokenMagicLink
case "REFRESH_TOKEN":
return TokenRefreshToken
default:
return 0
}
}

View File

@@ -1,4 +1,4 @@
package constant package constants
type VerifyType int16 type VerifyType int16

View File

@@ -13,6 +13,15 @@ func UUIDToString(v pgtype.UUID) string {
return "" return ""
} }
func StringToUUID(s string) (pgtype.UUID, error) {
var pgId pgtype.UUID
err := pgId.Scan(s)
if err != nil {
return pgtype.UUID{}, err
}
return pgId, nil
}
func TextToString(v pgtype.Text) string { func TextToString(v pgtype.Text) string {
if v.Valid { if v.Valid {
return v.String return v.String

70
pkg/email/email.go Normal file
View File

@@ -0,0 +1,70 @@
package email
import (
"fmt"
"history-api/assets"
"history-api/pkg/config"
"history-api/pkg/constants"
"strings"
"github.com/wneessen/go-mail"
)
func SendMailOTP(toEmail, otpCode string, tokenType constants.TokenType) error {
userSmtp, err := config.GetConfig("SMTP_USER")
if err != nil {
return err
}
passSmtp, err := config.GetConfig("SMTP_PASS")
if err != nil {
return err
}
var subject string
var templatePath string
switch tokenType {
case constants.TokenPasswordReset:
subject = "Your Password Reset Code"
templatePath = "resources/password_reset.html"
case constants.TokenEmailVerify:
subject = "Verify your email address"
templatePath = "resources/email_verify.html"
default:
return fmt.Errorf("invalid token type: %v", tokenType)
}
htmlTemplate, err := assets.GetFileContent(templatePath)
if err != nil {
return fmt.Errorf("failed to read email template: %s", err)
}
message := mail.NewMsg()
if err := message.From(userSmtp); err != nil {
return fmt.Errorf("failed to set From email address: %s", err)
}
if err := message.To(toEmail); err != nil {
return fmt.Errorf("failed to set To email address: %s", err)
}
finalHTML := strings.ReplaceAll(htmlTemplate, "{{OTP_CODE}}", otpCode)
message.Subject(subject)
message.SetBodyString(mail.TypeTextHTML, finalHTML)
client, err := mail.NewClient(
"smtp.gmail.com",
mail.WithSMTPAuth(mail.SMTPAuthAutoDiscover),
mail.WithTLSPortPolicy(mail.TLSMandatory),
mail.WithUsername(userSmtp),
mail.WithPassword(passSmtp),
)
if err != nil {
return fmt.Errorf("failed to create mail client: %s", err)
}
err = client.DialAndSend(message)
if err != nil {
return fmt.Errorf("failed to send mail: %s", err)
}
return nil
}