From f04441bf2a7c3fcb574c44e5660f5d4c95f8e813 Mon Sep 17 00:00:00 2001 From: AzenKain Date: Mon, 30 Mar 2026 00:27:57 +0700 Subject: [PATCH] UPDATE: Auth module, User module --- Dockerfile | 7 +- Makefile | 4 +- assets/resources/email_verify.html | 33 + assets/resources/password_reset.html | 33 + cmd/{history-api => api}/main.go | 0 cmd/{history-api => api}/server.go | 14 +- cmd/worker/email/main.go | 111 +++ db/migrations/000001_users.up.sql | 5 - ...ties.down.sql => 000005_entities.down.sql} | 0 ...entities.up.sql => 000005_entities.up.sql} | 0 db/migrations/000005_tokens.down.sql | 1 - db/migrations/000005_tokens.up.sql | 25 - ...007_wiki.down.sql => 000006_wiki.down.sql} | 0 ...{000007_wiki.up.sql => 000006_wiki.up.sql} | 0 ...es.down.sql => 000007_geometries.down.sql} | 0 ...etries.up.sql => 000007_geometries.up.sql} | 0 db/query/roles.sql | 9 +- db/query/users.sql | 136 ++- db/schema.sql | 13 +- docker-compose.yml | 48 +- docs/docs.go | 839 +++++++++++++++++- docs/swagger.json | 839 +++++++++++++++++- docs/swagger.yaml | 514 ++++++++++- go.mod | 3 +- go.sum | 2 + internal/controllers/authController.go | 131 ++- internal/controllers/userController.go | 276 ++++++ internal/dtos/request/auth.go | 25 + internal/dtos/request/user.go | 52 +- internal/dtos/response/auth.go | 4 + internal/dtos/response/common.go | 19 +- internal/dtos/response/token.go | 11 - internal/gen/sqlc/models.go | 11 - internal/gen/sqlc/roles.sql.go | 42 +- internal/gen/sqlc/users.sql.go | 292 ++++-- internal/middlewares/jwtMiddleware.go | 88 +- internal/middlewares/roleMiddleware.go | 8 +- internal/models/role.go | 15 +- internal/models/token.go | 34 +- internal/repositories/roleRepository.go | 63 +- internal/repositories/tileRepository.go | 3 +- internal/repositories/tokenRepository.go | 79 ++ internal/repositories/userRepository.go | 201 ++++- internal/routes/authRoute.go | 8 +- internal/routes/userRoute.go | 65 ++ internal/services/authService.go | 192 +++- internal/services/userService.go | 278 +++++- pkg/cache/redis.go | 83 +- pkg/config/config.go | 8 + pkg/{constant => constants}/regex.go | 2 +- pkg/{constant => constants}/role.go | 7 +- pkg/constants/sream.go | 6 + pkg/{constant => constants}/status.go | 2 +- pkg/constants/task.go | 11 + pkg/constants/time.go | 13 + pkg/{constant => constants}/token.go | 21 +- pkg/{constant => constants}/verify.go | 2 +- pkg/convert/convert.go | 9 + pkg/email/email.go | 70 ++ 59 files changed, 4246 insertions(+), 521 deletions(-) create mode 100644 assets/resources/email_verify.html create mode 100644 assets/resources/password_reset.html rename cmd/{history-api => api}/main.go (100%) rename cmd/{history-api => api}/server.go (81%) create mode 100644 cmd/worker/email/main.go rename db/migrations/{000006_entities.down.sql => 000005_entities.down.sql} (100%) rename db/migrations/{000006_entities.up.sql => 000005_entities.up.sql} (100%) delete mode 100644 db/migrations/000005_tokens.down.sql delete mode 100644 db/migrations/000005_tokens.up.sql rename db/migrations/{000007_wiki.down.sql => 000006_wiki.down.sql} (100%) rename db/migrations/{000007_wiki.up.sql => 000006_wiki.up.sql} (100%) rename db/migrations/{000008_geometries.down.sql => 000007_geometries.down.sql} (100%) rename db/migrations/{000008_geometries.up.sql => 000007_geometries.up.sql} (100%) create mode 100644 internal/controllers/userController.go delete mode 100644 internal/dtos/response/token.go create mode 100644 internal/repositories/tokenRepository.go create mode 100644 internal/routes/userRoute.go rename pkg/{constant => constants}/regex.go (98%) rename pkg/{constant => constants}/role.go (87%) create mode 100644 pkg/constants/sream.go rename pkg/{constant => constants}/status.go (96%) create mode 100644 pkg/constants/task.go create mode 100644 pkg/constants/time.go rename pkg/{constant => constants}/token.go (64%) rename pkg/{constant => constants}/verify.go (96%) create mode 100644 pkg/email/email.go diff --git a/Dockerfile b/Dockerfile index e9fd58a..3a59168 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,8 +9,8 @@ RUN go mod download COPY . . -RUN GOOS=linux GOARCH=amd64 CGO_ENABLED=0 \ - 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 history-api ./cmd/api +RUN GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o email-worker ./cmd/worker/email FROM alpine:latest @@ -20,9 +20,10 @@ ENV TZ=Asia/Ho_Chi_Minh WORKDIR /app COPY --from=builder /app/history-api . +COPY --from=builder /app/email-worker . COPY data ./data -RUN chmod +x ./history-api +RUN chmod +x ./history-api ./email-worker EXPOSE 3344 diff --git a/Makefile b/Makefile index 3c42e40..f1ea654 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ DB_URL ?= postgres://history:secret@localhost:5432/history_map?sslmode=disable -APP_DIR = cmd/history-api -MAIN_APP = ./cmd/history-api/ +APP_DIR = cmd/api +MAIN_APP = ./cmd/api/ MAIN_FILE = $(APP_DIR)/main.go DOCS_DIR = docs diff --git a/assets/resources/email_verify.html b/assets/resources/email_verify.html new file mode 100644 index 0000000..24afff9 --- /dev/null +++ b/assets/resources/email_verify.html @@ -0,0 +1,33 @@ + + + + + + Email Verification + + + +
+
+

Welcome!

+
+
+

Thank you for registering. Please use the OTP below to verify your email address. This code will expire in a few minutes.

+
{{OTP_CODE}}
+

If you did not make this request, please safely ignore this email.

+
+ +
+ + \ No newline at end of file diff --git a/assets/resources/password_reset.html b/assets/resources/password_reset.html new file mode 100644 index 0000000..dc7f7a2 --- /dev/null +++ b/assets/resources/password_reset.html @@ -0,0 +1,33 @@ + + + + + + Password Reset + + + +
+
+

Password Reset

+
+
+

We received a request to reset the password for your account. Here is your secure OTP:

+
{{OTP_CODE}}
+

Warning: Never share this code with anyone. If you didn't request a password reset, please ignore this email or contact support immediately.

+
+ +
+ + \ No newline at end of file diff --git a/cmd/history-api/main.go b/cmd/api/main.go similarity index 100% rename from cmd/history-api/main.go rename to cmd/api/main.go diff --git a/cmd/history-api/server.go b/cmd/api/server.go similarity index 81% rename from cmd/history-api/server.go rename to cmd/api/server.go index 80443d2..9af0d35 100644 --- a/cmd/history-api/server.go +++ b/cmd/api/server.go @@ -35,10 +35,10 @@ func NewHttpServer() *FiberServer { }), } cfg := swagger.Config{ - BasePath: "/", + BasePath: "/", FileContent: docs.SwaggerJSON, - Path: "swagger", - Title: "Swagger API Docs", + Path: "swagger", + Title: "Swagger API Docs", } server.App.Use(swagger.New(cfg)) @@ -64,17 +64,21 @@ func (s *FiberServer) SetupServer(sqlPg sqlc.DBTX, sqlTile *sql.DB, redis cache. userRepo := repositories.NewUserRepository(sqlPg, redis) roleRepo := repositories.NewRoleRepository(sqlPg, redis) tileRepo := repositories.NewTileRepository(sqlTile, redis) + tokenRepo := repositories.NewTokenRepository(redis) // service setup - authService := services.NewAuthService(userRepo, roleRepo) + authService := services.NewAuthService(userRepo, roleRepo, tokenRepo, redis) + userService := services.NewUserService(userRepo, roleRepo) tileService := services.NewTileService(tileRepo) // controller setup authController := controllers.NewAuthController(authService) + userController := controllers.NewUserController(userService) tileController := controllers.NewTileController(tileService) // 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.NotFoundRoute(s.App) } diff --git a/cmd/worker/email/main.go b/cmd/worker/email/main.go new file mode 100644 index 0000000..109ec85 --- /dev/null +++ b/cmd/worker/email/main.go @@ -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() +} diff --git a/db/migrations/000001_users.up.sql b/db/migrations/000001_users.up.sql index 31dd238..16852de 100644 --- a/db/migrations/000001_users.up.sql +++ b/db/migrations/000001_users.up.sql @@ -6,7 +6,6 @@ CREATE TABLE IF NOT EXISTS users ( password_hash TEXT, google_id VARCHAR(255) UNIQUE, auth_provider VARCHAR(50) NOT NULL DEFAULT 'local', - is_verified BOOLEAN NOT NULL DEFAULT false, is_deleted BOOLEAN NOT NULL DEFAULT false, token_version INT NOT NULL DEFAULT 1, refresh_token TEXT, @@ -22,10 +21,6 @@ CREATE INDEX idx_users_email_active ON users (email) WHERE is_deleted = false; -CREATE INDEX idx_users_verified -ON users (is_verified) -WHERE is_deleted = false; - CREATE OR REPLACE FUNCTION update_updated_at() RETURNS TRIGGER AS $$ BEGIN diff --git a/db/migrations/000006_entities.down.sql b/db/migrations/000005_entities.down.sql similarity index 100% rename from db/migrations/000006_entities.down.sql rename to db/migrations/000005_entities.down.sql diff --git a/db/migrations/000006_entities.up.sql b/db/migrations/000005_entities.up.sql similarity index 100% rename from db/migrations/000006_entities.up.sql rename to db/migrations/000005_entities.up.sql diff --git a/db/migrations/000005_tokens.down.sql b/db/migrations/000005_tokens.down.sql deleted file mode 100644 index 1eed838..0000000 --- a/db/migrations/000005_tokens.down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE IF EXISTS user_tokens; \ No newline at end of file diff --git a/db/migrations/000005_tokens.up.sql b/db/migrations/000005_tokens.up.sql deleted file mode 100644 index 792e052..0000000 --- a/db/migrations/000005_tokens.up.sql +++ /dev/null @@ -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; \ No newline at end of file diff --git a/db/migrations/000007_wiki.down.sql b/db/migrations/000006_wiki.down.sql similarity index 100% rename from db/migrations/000007_wiki.down.sql rename to db/migrations/000006_wiki.down.sql diff --git a/db/migrations/000007_wiki.up.sql b/db/migrations/000006_wiki.up.sql similarity index 100% rename from db/migrations/000007_wiki.up.sql rename to db/migrations/000006_wiki.up.sql diff --git a/db/migrations/000008_geometries.down.sql b/db/migrations/000007_geometries.down.sql similarity index 100% rename from db/migrations/000008_geometries.down.sql rename to db/migrations/000007_geometries.down.sql diff --git a/db/migrations/000008_geometries.up.sql b/db/migrations/000007_geometries.up.sql similarity index 100% rename from db/migrations/000008_geometries.up.sql rename to db/migrations/000007_geometries.up.sql diff --git a/db/query/roles.sql b/db/query/roles.sql index 99b2818..9af1e55 100644 --- a/db/query/roles.sql +++ b/db/query/roles.sql @@ -11,11 +11,14 @@ WHERE name = $1 AND is_deleted = false; SELECT id, name, is_deleted, created_at, updated_at FROM roles 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 INSERT INTO user_roles (user_id, role_id) -SELECT $1, r.id -FROM roles r -WHERE r.name = $2 +SELECT $1, unnest($2::uuid[]) ON CONFLICT DO NOTHING; -- name: RemoveUserRole :exec diff --git a/db/query/users.sql b/db/query/users.sql index d853325..cee4375 100644 --- a/db/query/users.sql +++ b/db/query/users.sql @@ -3,17 +3,14 @@ INSERT INTO users ( email, password_hash, google_id, - auth_provider, - is_verified + auth_provider ) VALUES ( - $1, $2, $3, $4, $5 + $1, $2, $3, $4 ) ON CONFLICT (email) DO UPDATE SET google_id = EXCLUDED.google_id, - auth_provider = EXCLUDED.auth_provider, - is_verified = users.is_verified OR EXCLUDED.is_verified, - updated_at = now() + auth_provider = EXCLUDED.auth_provider RETURNING *; -- name: CreateUserProfile :one @@ -55,12 +52,6 @@ SET WHERE id = $1 AND is_deleted = false; --- name: VerifyUser :exec -UPDATE users -SET - is_verified = true -WHERE id = $1 - AND is_deleted = false; -- name: DeleteUser :exec UPDATE users @@ -79,7 +70,6 @@ SELECT u.id, u.email, u.password_hash, - u.is_verified, u.token_version, u.refresh_token, u.is_deleted, @@ -116,6 +106,47 @@ SELECT FROM users u 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 SELECT token_version FROM users @@ -131,7 +162,6 @@ SELECT u.id, u.email, u.password_hash, - u.is_verified, u.token_version, u.is_deleted, u.created_at, @@ -170,7 +200,59 @@ SELECT u.id, u.email, u.password_hash, - u.is_verified, + u.token_version, + u.refresh_token, + u.is_deleted, + u.created_at, + u.updated_at, + + -- profile JSON + ( + SELECT json_build_object( + 'display_name', p.display_name, + 'full_name', p.full_name, + 'avatar_url', p.avatar_url, + 'bio', p.bio, + 'location', p.location, + 'website', p.website, + 'country_code', p.country_code, + 'phone', p.phone + ) + FROM user_profiles p + WHERE p.user_id = u.id + ) AS profile, + + -- roles JSON + ( + SELECT COALESCE( + json_agg(json_build_object('id', r.id, 'name', r.name)), + '[]' + )::json + FROM user_roles ur + JOIN roles r ON ur.role_id = r.id + WHERE ur.user_id = u.id + ) AS roles + +FROM users u +WHERE + (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.refresh_token, u.is_deleted, @@ -203,4 +285,26 @@ SELECT ) AS roles FROM users u -WHERE u.is_deleted = false; \ No newline at end of file +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'); \ No newline at end of file diff --git a/db/schema.sql b/db/schema.sql index ea1c5b6..d748acf 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -4,7 +4,6 @@ CREATE TABLE IF NOT EXISTS users ( password_hash TEXT, google_id VARCHAR(255) UNIQUE, auth_provider VARCHAR(50) NOT NULL DEFAULT 'local', - is_verified BOOLEAN NOT NULL DEFAULT false, is_deleted BOOLEAN NOT NULL DEFAULT false, token_version INT NOT NULL DEFAULT 1, refresh_token TEXT, @@ -49,14 +48,4 @@ CREATE TABLE IF NOT EXISTS user_verifications ( reviewed_by UUID REFERENCES users(id), reviewed_at TIMESTAMPTZ, 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() -); +); \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 26f5130..c124977 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,44 +13,43 @@ services: volumes: - pg_data:/var/lib/postgresql/data 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 - timeout: 3s + timeout: 5s retries: 5 networks: - history-api-project cache: - image: redis:8.6.1-alpine + image: redis:8.6.2-alpine container_name: history_redis restart: unless-stopped networks: - history-api-project migrate: - build: - context: . - dockerfile_inline: | - FROM migrate/migrate - COPY db/migrations /migrations + image: migrate/migrate container_name: history_migrate + volumes: + - ./db/migrations:/migrations:ro depends_on: db: condition: service_healthy env_file: - ./assets/resources/.env - entrypoint: + entrypoint: - sh - -c - | - 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 - /migrate -path /migrations -database "$$DB_URL" up || \ - (/migrate -path /migrations -database "$$DB_URL" force 8 && \ - /migrate -path /migrations -database "$$DB_URL" up) + DB_URL="postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?sslmode=disable" + + echo "Running migrations..." + # We skip the 'version' check loop because 'depends_on' + # with 'service_healthy' already handles the wait. + /migrate -path /migrations -database "$$DB_URL" up networks: - history-api-project + app: build: . container_name: history_app @@ -58,13 +57,32 @@ services: depends_on: migrate: condition: service_completed_successfully + db: + condition: service_healthy cache: condition: service_started + env_file: + - ./assets/resources/.env ports: - "3344:3344" networks: - 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: pg_data: diff --git a/docs/docs.go b/docs/docs.go index 1567f16..5ceb3c9 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -24,14 +24,9 @@ const docTemplate = `{ "host": "{{.Host}}", "basePath": "{{.BasePath}}", "paths": { - "/auth/refresh": { + "/auth/forgot-password": { "post": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Get a new access token using the user's current session/refresh token", + "description": "Initiate password recovery process for a user", "consumes": [ "application/json" ], @@ -41,44 +36,15 @@ const docTemplate = `{ "tags": [ "Auth" ], - "summary": "Refresh access token", - "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", + "summary": "Handle forgotten password", "parameters": [ { - "description": "Sign In request", + "description": "Forgot Password request", "name": "request", "in": "body", "required": true, "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": { - "description": "Create a new user account", + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Generate a new access token using a valid refresh token from context", "consumes": [ "application/json" ], @@ -116,10 +87,97 @@ const docTemplate = `{ "tags": [ "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": [ { - "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", "in": "body", "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": { "get": { "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": { + "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": { "type": "object", "required": [ @@ -260,7 +925,8 @@ const docTemplate = `{ "required": [ "display_name", "email", - "password" + "password", + "token_id" ], "properties": { "display_name": { @@ -277,6 +943,75 @@ const docTemplate = `{ "type": "string", "maxLength": 64, "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" } } + }, + "history-api_pkg_constants.TokenType": { + "type": "integer", + "format": "int32", + "enum": [ + 1, + 2, + 3, + 4 + ], + "x-enum-varnames": [ + "TokenPasswordReset", + "TokenEmailVerify", + "TokenMagicLink", + "TokenRefreshToken" + ] } }, "securityDefinitions": { diff --git a/docs/swagger.json b/docs/swagger.json index 73f841f..fd7d4db 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -22,14 +22,9 @@ "host": "history-api.kain.id.vn", "basePath": "/", "paths": { - "/auth/refresh": { + "/auth/forgot-password": { "post": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Get a new access token using the user's current session/refresh token", + "description": "Initiate password recovery process for a user", "consumes": [ "application/json" ], @@ -39,44 +34,15 @@ "tags": [ "Auth" ], - "summary": "Refresh access token", - "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", + "summary": "Handle forgotten password", "parameters": [ { - "description": "Sign In request", + "description": "Forgot Password request", "name": "request", "in": "body", "required": true, "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": { - "description": "Create a new user account", + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Generate a new access token using a valid refresh token from context", "consumes": [ "application/json" ], @@ -114,10 +85,97 @@ "tags": [ "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": [ { - "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", "in": "body", "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": { "get": { "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": { + "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": { "type": "object", "required": [ @@ -258,7 +923,8 @@ "required": [ "display_name", "email", - "password" + "password", + "token_id" ], "properties": { "display_name": { @@ -275,6 +941,75 @@ "type": "string", "maxLength": 64, "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" } } + }, + "history-api_pkg_constants.TokenType": { + "type": "integer", + "format": "int32", + "enum": [ + 1, + 2, + 3, + 4 + ], + "x-enum-varnames": [ + "TokenPasswordReset", + "TokenEmailVerify", + "TokenMagicLink", + "TokenRefreshToken" + ] } }, "securityDefinitions": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 413965c..1bb1c25 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1,5 +1,65 @@ basePath: / 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: properties: email: @@ -28,10 +88,59 @@ definitions: maxLength: 64 minLength: 8 type: string + token_id: + type: string required: - display_name - email - 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 history-api_internal_dtos_response.CommonResponse: properties: @@ -41,6 +150,19 @@ definitions: status: type: boolean 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 info: contact: @@ -55,12 +177,18 @@ info: title: History API version: "1.0" paths: - /auth/refresh: + /auth/forgot-password: post: consumes: - application/json - description: Get a new access token using the user's current session/refresh - token + description: Initiate password recovery process for a user + parameters: + - description: Forgot Password request + in: body + name: request + required: true + schema: + $ref: '#/definitions/history-api_internal_dtos_request.ForgotPasswordDto' produces: - application/json responses: @@ -68,22 +196,49 @@ paths: 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: 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": description: Internal Server Error schema: $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' security: - BearerAuth: [] - summary: Refresh access token + summary: Refresh session tokens tags: - Auth /auth/signin: post: consumes: - application/json - description: Authenticate user and return token data + description: Authenticate user credentials and return access/refresh tokens parameters: - - description: Sign In request + - description: Sign In credentials in: body name: request required: true @@ -100,20 +255,24 @@ paths: 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' - summary: Sign in an existing user + summary: Sign in a user tags: - Auth /auth/signup: post: consumes: - application/json - description: Create a new user account + description: Create a new user account in the system parameters: - - description: Sign Up request + - description: Sign Up details in: body name: request required: true @@ -134,7 +293,67 @@ paths: description: Internal Server Error schema: $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: - Auth /tiles/{z}/{x}/{y}: @@ -193,6 +412,281 @@ paths: summary: Get tile metadata tags: - 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: - https - http diff --git a/go.mod b/go.mod index 512fde3..f31df9b 100644 --- a/go.mod +++ b/go.mod @@ -10,11 +10,13 @@ require ( github.com/gofiber/contrib/v3/zerolog v1.0.1 github.com/gofiber/fiber/v3 v3.1.0 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/joho/godotenv v1.5.1 github.com/redis/go-redis/v9 v9.18.0 github.com/rs/zerolog v1.34.0 github.com/swaggo/swag v1.16.6 + github.com/wneessen/go-mail v0.7.2 golang.org/x/crypto v0.49.0 ) @@ -49,7 +51,6 @@ require ( github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/gofiber/schema v1.7.0 // 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/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect diff --git a/go.sum b/go.sum index 3fe0709..ec8fe3c 100644 --- a/go.sum +++ b/go.sum @@ -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/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI= 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/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= diff --git a/internal/controllers/authController.go b/internal/controllers/authController.go index 7b2df17..cdbf25f 100644 --- a/internal/controllers/authController.go +++ b/internal/controllers/authController.go @@ -20,14 +20,15 @@ func NewAuthController(svc services.AuthService) *AuthController { } // Signin godoc -// @Summary Sign in an existing user -// @Description Authenticate user and return token data +// @Summary Sign in a user +// @Description Authenticate user credentials and return access/refresh tokens // @Tags Auth // @Accept json // @Produce json -// @Param request body request.SignInDto true "Sign In request" +// @Param request body request.SignInDto true "Sign In credentials" // @Success 200 {object} response.CommonResponse // @Failure 400 {object} response.CommonResponse +// @Failure 401 {object} response.CommonResponse "Invalid credentials" // @Failure 500 {object} response.CommonResponse // @Router /auth/signin [post] func (h *AuthController) Signin(c fiber.Ctx) error { @@ -57,12 +58,12 @@ func (h *AuthController) Signin(c fiber.Ctx) error { } // Signup godoc -// @Summary Sign up a new user -// @Description Create a new user account +// @Summary Register a new user +// @Description Create a new user account in the system // @Tags Auth // @Accept json // @Produce json -// @Param request body request.SignUpDto true "Sign Up request" +// @Param request body request.SignUpDto true "Sign Up details" // @Success 200 {object} response.CommonResponse // @Failure 400 {object} response.CommonResponse // @Failure 500 {object} response.CommonResponse @@ -94,13 +95,14 @@ func (h *AuthController) Signup(c fiber.Ctx) error { } // RefreshToken godoc -// @Summary Refresh access token -// @Description Get a new access token using the user's current session/refresh token +// @Summary Refresh session tokens +// @Description Generate a new access token using a valid refresh token from context // @Tags Auth // @Accept json // @Produce json // @Security BearerAuth // @Success 200 {object} response.CommonResponse +// @Failure 401 {object} response.CommonResponse "Unauthorized or expired refresh token" // @Failure 500 {object} response.CommonResponse // @Router /auth/refresh [post] func (h *AuthController) RefreshToken(c fiber.Ctx) error { @@ -120,3 +122,116 @@ func (h *AuthController) RefreshToken(c fiber.Ctx) error { Data: res, }) } + +// VerifyToken godoc +// @Summary Verify a security token +// @Description Validate an OTP or email verification token +// @Tags Auth +// @Accept json +// @Produce json +// @Param request body request.VerifyTokenDto true "Token verification data" +// @Success 200 {object} response.CommonResponse +// @Failure 400 {object} response.CommonResponse +// @Failure 500 {object} response.CommonResponse +// @Router /auth/token/verify [post] +func (h *AuthController) VerifyToken(c fiber.Ctx) error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + dto := &request.VerifyTokenDto{} + + if err := validator.ValidateBodyDto(c, dto); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(response.CommonResponse{ + Status: false, + Message: err.Error(), + }) + } + + res, err := h.service.VerifyToken(ctx, dto) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(response.CommonResponse{ + Status: false, + Message: err.Error(), + }) + } + + return c.Status(fiber.StatusOK).JSON(response.CommonResponse{ + Status: true, + Data: res, + }) +} + +// CreateToken godoc +// @Summary Generate a new verification token +// @Description Request a new token for specific actions like email confirmation +// @Tags Auth +// @Accept json +// @Produce json +// @Param request body request.CreateTokenDto true "Token creation request" +// @Success 200 {object} response.CommonResponse +// @Failure 400 {object} response.CommonResponse +// @Failure 500 {object} response.CommonResponse +// @Router /auth/token/create [post] +func (h *AuthController) CreateToken(c fiber.Ctx) error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + dto := &request.CreateTokenDto{} + + if err := validator.ValidateBodyDto(c, dto); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(response.CommonResponse{ + Status: false, + Message: err.Error(), + }) + } + + err := h.service.CreateToken(ctx, dto) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(response.CommonResponse{ + Status: false, + Message: err.Error(), + }) + } + + return c.Status(fiber.StatusOK).JSON(response.CommonResponse{ + Status: true, + Data: nil, + Message: "Token created successfully", + }) +} + +// ForgotPassword godoc +// @Summary Handle forgotten password +// @Description Initiate password recovery process for a user +// @Tags Auth +// @Accept json +// @Produce json +// @Param request body request.ForgotPasswordDto true "Forgot Password request" +// @Success 200 {object} response.CommonResponse +// @Failure 400 {object} response.CommonResponse +// @Failure 500 {object} response.CommonResponse +// @Router /auth/forgot-password [post] +func (h *AuthController) ForgotPassword(c fiber.Ctx) error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + dto := &request.ForgotPasswordDto{} + + if err := validator.ValidateBodyDto(c, dto); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(response.CommonResponse{ + Status: false, + Message: err.Error(), + }) + } + + err := h.service.ForgotPassword(ctx, dto) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(response.CommonResponse{ + Status: false, + Message: err.Error(), + }) + } + + return c.Status(fiber.StatusOK).JSON(response.CommonResponse{ + Status: true, + Data: nil, + Message: "Password reset successfully", + }) +} \ No newline at end of file diff --git a/internal/controllers/userController.go b/internal/controllers/userController.go new file mode 100644 index 0000000..fc887f6 --- /dev/null +++ b/internal/controllers/userController.go @@ -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) +} \ No newline at end of file diff --git a/internal/dtos/request/auth.go b/internal/dtos/request/auth.go index 296eeb0..c1a2858 100644 --- a/internal/dtos/request/auth.go +++ b/internal/dtos/request/auth.go @@ -1,11 +1,36 @@ package request +import "history-api/pkg/constants" + type SignUpDto struct { Email string `json:"email" validate:"required,min=5,max=255,email"` Password string `json:"password" validate:"required,min=8,max=64"` DisplayName string `json:"display_name" validate:"required,min=2,max=50"` + TokenID string `json:"token_id" validate:"required,uuid"` } type SignInDto struct { Email string `json:"email" validate:"required,min=5,max=255,email"` Password string `json:"password" validate:"required,min=8,max=64"` } + +type CreateTokenDto struct { + Email string `json:"email" validate:"required,email"` + TokenType constants.TokenType `json:"token_type" validate:"required,oneof=1 2 3 4"` +} + +type VerifyTokenDto struct { + Email string `json:"email" validate:"required,email"` + TokenType constants.TokenType `json:"token_type" validate:"required,oneof=1 2 3 4"` + Token string `json:"token" validate:"required,len=6,numeric"` +} + +type ForgotPasswordDto struct { + TokenID string `json:"token_id" validate:"required,uuid"` + Email string `json:"email" validate:"required,min=5,max=255,email"` + NewPassword string `json:"new_password" validate:"required,min=8,max=64"` +} + +type SigninWith3rdDto struct { + Provider string `json:"provider" validate:"required,oneof=google github facebook"` + AccessToken string `json:"access_token" validate:"required"` +} diff --git a/internal/dtos/request/user.go b/internal/dtos/request/user.go index 0f2620a..8d5e769 100644 --- a/internal/dtos/request/user.go +++ b/internal/dtos/request/user.go @@ -1,26 +1,42 @@ package request -import "history-api/pkg/constant" - -type CreateUserDto struct { - Username string `json:"username" validate:"required"` - Password string `json:"password" validate:"required"` - DiscordUserId string `json:"discord_user_id" validate:"required"` - Role []constant.Role `json:"role" validate:"required"` +type UpdateProfileDto struct { + DisplayName string `json:"display_name" validate:"omitempty,min=2,max=50"` + FullName string `json:"full_name" validate:"omitempty,min=2,max=100"` + AvatarUrl string `json:"avatar_url" validate:"omitempty,url"` + Bio string `json:"bio" validate:"omitempty,max=255"` + Location string `json:"location" validate:"omitempty,max=100"` + Website string `json:"website" validate:"omitempty,url"` + CountryCode string `json:"country_code" validate:"omitempty,len=2"` + Phone string `json:"phone" validate:"omitempty,min=8,max=20"` } -type UpdateUserDto struct { - Password *string `json:"password" validate:"omitempty"` - DiscordUserId *string `json:"discord_user_id" validate:"omitempty"` - Role *[]constant.Role `json:"role" validate:"omitempty"` +type ChangePasswordDto struct { + OldPassword string `json:"old_password" validate:"required,min=8,max=64"` + NewPassword string `json:"new_password" validate:"required,min=8,max=64,nefield=OldPassword"` +} + +type ChangeRoleDto struct { + UserID string `json:"user_id" validate:"required,uuid"` + Roles []string `json:"role_ids" validate:"required,min=1,dive,required,uuid"` +} + +type GetAllUserDto struct { + CursorPaginationDto + IsDeleted *bool `json:"is_deleted" query:"is_deleted" validate:"omitempty"` + RoleIDs []string `json:"role_ids" query:"role_ids" validate:"omitempty,dive,uuid"` +} + +type CursorPaginationDto struct { + Cursor string `json:"cursor" query:"cursor" validate:"omitempty,uuid"` + Limit int `json:"limit" query:"limit" validate:"required,min=1,max=100"` + Sort string `json:"sort" query:"sort" validate:"omitempty,oneof=created_at updated_at email display_name"` + Order string `json:"order" query:"order" validate:"omitempty,oneof=asc desc"` } type SearchUserDto struct { - Username *string `query:"username" validate:"omitempty"` - DiscordUserId *string `query:"discord_user_id" validate:"omitempty"` - Role *[]constant.Role `query:"role" validate:"omitempty"` - SortBy string `query:"sort_by" default:"created_at" validate:"oneof=created_at updated_at"` - Order string `query:"order" default:"desc" validate:"oneof=asc desc"` - Page int `query:"page" default:"1" validate:"min=1"` - Limit int `query:"limit" default:"10" validate:"min=1,max=100"` + CursorPaginationDto + Search string `json:"search" query:"search" validate:"omitempty,min=2,max=200"` + IsDeleted *bool `json:"is_deleted" query:"is_deleted" validate:"omitempty"` + RoleIDs []string `json:"role_ids" query:"role_ids" validate:"omitempty,dive,uuid"` } diff --git a/internal/dtos/response/auth.go b/internal/dtos/response/auth.go index 916dfdd..96cc3ce 100644 --- a/internal/dtos/response/auth.go +++ b/internal/dtos/response/auth.go @@ -4,3 +4,7 @@ type AuthResponse struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` } + +type VerifyTokenResponse struct { + TokenID string `json:"token_id"` +} diff --git a/internal/dtos/response/common.go b/internal/dtos/response/common.go index 6d1d155..18d733b 100644 --- a/internal/dtos/response/common.go +++ b/internal/dtos/response/common.go @@ -1,7 +1,7 @@ package response import ( - "history-api/pkg/constant" + "history-api/pkg/constants" "github.com/golang-jwt/jwt/v5" ) @@ -13,7 +13,18 @@ type CommonResponse struct { } type JWTClaims struct { - UId string `json:"uid"` - Roles []constant.Role `json:"roles"` + UId string `json:"uid"` + Roles []constants.Role `json:"roles"` + TokenVersion int32 `json:"token_version"` jwt.RegisteredClaims -} \ No newline at end of file +} + +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"` +} diff --git a/internal/dtos/response/token.go b/internal/dtos/response/token.go deleted file mode 100644 index 136b918..0000000 --- a/internal/dtos/response/token.go +++ /dev/null @@ -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"` -} diff --git a/internal/gen/sqlc/models.go b/internal/gen/sqlc/models.go index 3017d03..dcfd4e6 100644 --- a/internal/gen/sqlc/models.go +++ b/internal/gen/sqlc/models.go @@ -22,7 +22,6 @@ type User struct { PasswordHash pgtype.Text `json:"password_hash"` GoogleID pgtype.Text `json:"google_id"` AuthProvider string `json:"auth_provider"` - IsVerified bool `json:"is_verified"` IsDeleted bool `json:"is_deleted"` TokenVersion int32 `json:"token_version"` RefreshToken pgtype.Text `json:"refresh_token"` @@ -49,16 +48,6 @@ type UserRole struct { RoleID pgtype.UUID `json:"role_id"` } -type UserToken struct { - ID pgtype.UUID `json:"id"` - UserID pgtype.UUID `json:"user_id"` - Token string `json:"token"` - IsDeleted bool `json:"is_deleted"` - TokenType int16 `json:"token_type"` - ExpiresAt pgtype.Timestamptz `json:"expires_at"` - CreatedAt pgtype.Timestamptz `json:"created_at"` -} - type UserVerification struct { ID pgtype.UUID `json:"id"` UserID pgtype.UUID `json:"user_id"` diff --git a/internal/gen/sqlc/roles.sql.go b/internal/gen/sqlc/roles.sql.go index 2ea42ef..c53c1a7 100644 --- a/internal/gen/sqlc/roles.sql.go +++ b/internal/gen/sqlc/roles.sql.go @@ -13,19 +13,17 @@ import ( const addUserRole = `-- name: AddUserRole :exec INSERT INTO user_roles (user_id, role_id) -SELECT $1, r.id -FROM roles r -WHERE r.name = $2 +SELECT $1, unnest($2::uuid[]) ON CONFLICT DO NOTHING ` type AddUserRoleParams struct { - UserID pgtype.UUID `json:"user_id"` - Name string `json:"name"` + UserID pgtype.UUID `json:"user_id"` + Column2 []pgtype.UUID `json:"column_2"` } func (q *Queries) AddUserRole(ctx context.Context, arg AddUserRoleParams) error { - _, err := q.db.Exec(ctx, addUserRole, arg.UserID, arg.Name) + _, err := q.db.Exec(ctx, addUserRole, arg.UserID, arg.Column2) return err } @@ -129,6 +127,38 @@ func (q *Queries) GetRoles(ctx context.Context) ([]Role, error) { return items, nil } +const getRolesByIDs = `-- name: GetRolesByIDs :many +SELECT id, name, is_deleted, created_at, updated_at +FROM roles +WHERE id = ANY($1::uuid[]) AND is_deleted = false +` + +func (q *Queries) GetRolesByIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([]Role, error) { + rows, err := q.db.Query(ctx, getRolesByIDs, dollar_1) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Role{} + for rows.Next() { + var i Role + if err := rows.Scan( + &i.ID, + &i.Name, + &i.IsDeleted, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const removeAllRolesFromUser = `-- name: RemoveAllRolesFromUser :exec DELETE FROM user_roles WHERE user_id = $1 diff --git a/internal/gen/sqlc/users.sql.go b/internal/gen/sqlc/users.sql.go index aa75c7a..36be411 100644 --- a/internal/gen/sqlc/users.sql.go +++ b/internal/gen/sqlc/users.sql.go @@ -77,7 +77,6 @@ SELECT u.id, u.email, u.password_hash, - u.is_verified, u.token_version, u.is_deleted, u.created_at, @@ -116,7 +115,6 @@ type GetUserByEmailRow struct { ID pgtype.UUID `json:"id"` Email string `json:"email"` PasswordHash pgtype.Text `json:"password_hash"` - IsVerified bool `json:"is_verified"` TokenVersion int32 `json:"token_version"` IsDeleted bool `json:"is_deleted"` CreatedAt pgtype.Timestamptz `json:"created_at"` @@ -132,7 +130,6 @@ func (q *Queries) GetUserByEmail(ctx context.Context, email string) (GetUserByEm &i.ID, &i.Email, &i.PasswordHash, - &i.IsVerified, &i.TokenVersion, &i.IsDeleted, &i.CreatedAt, @@ -148,7 +145,6 @@ SELECT u.id, u.email, u.password_hash, - u.is_verified, u.token_version, u.refresh_token, u.is_deleted, @@ -190,7 +186,6 @@ type GetUserByIDRow struct { ID pgtype.UUID `json:"id"` Email string `json:"email"` PasswordHash pgtype.Text `json:"password_hash"` - IsVerified bool `json:"is_verified"` TokenVersion int32 `json:"token_version"` RefreshToken pgtype.Text `json:"refresh_token"` IsDeleted bool `json:"is_deleted"` @@ -207,7 +202,79 @@ func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (GetUserByIDR &i.ID, &i.Email, &i.PasswordHash, - &i.IsVerified, + &i.TokenVersion, + &i.RefreshToken, + &i.IsDeleted, + &i.CreatedAt, + &i.UpdatedAt, + &i.Profile, + &i.Roles, + ) + return i, err +} + +const getUserByIDWithoutDeleted = `-- name: GetUserByIDWithoutDeleted :one +SELECT + u.id, + u.email, + u.password_hash, + u.token_version, + u.refresh_token, + u.is_deleted, + u.created_at, + u.updated_at, + + -- profile JSON + ( + SELECT json_build_object( + 'display_name', p.display_name, + 'full_name', p.full_name, + 'avatar_url', p.avatar_url, + 'bio', p.bio, + 'location', p.location, + 'website', p.website, + 'country_code', p.country_code, + 'phone', p.phone + ) + FROM user_profiles p + WHERE p.user_id = u.id + ) AS profile, + + -- roles JSON + ( + SELECT COALESCE( + json_agg(json_build_object('id', r.id, 'name', r.name)), + '[]' + )::json + FROM user_roles ur + JOIN roles r ON ur.role_id = r.id + WHERE ur.user_id = u.id + ) AS roles + +FROM users u +WHERE u.id = $1 +` + +type GetUserByIDWithoutDeletedRow struct { + ID pgtype.UUID `json:"id"` + Email string `json:"email"` + PasswordHash pgtype.Text `json:"password_hash"` + TokenVersion int32 `json:"token_version"` + RefreshToken pgtype.Text `json:"refresh_token"` + IsDeleted bool `json:"is_deleted"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + Profile []byte `json:"profile"` + Roles []byte `json:"roles"` +} + +func (q *Queries) GetUserByIDWithoutDeleted(ctx context.Context, id pgtype.UUID) (GetUserByIDWithoutDeletedRow, error) { + row := q.db.QueryRow(ctx, getUserByIDWithoutDeleted, id) + var i GetUserByIDWithoutDeletedRow + err := row.Scan( + &i.ID, + &i.Email, + &i.PasswordHash, &i.TokenVersion, &i.RefreshToken, &i.IsDeleted, @@ -224,7 +291,127 @@ SELECT u.id, u.email, u.password_hash, - u.is_verified, + u.token_version, + u.refresh_token, + u.is_deleted, + u.created_at, + u.updated_at, + + -- profile JSON + ( + SELECT json_build_object( + 'display_name', p.display_name, + 'full_name', p.full_name, + 'avatar_url', p.avatar_url, + 'bio', p.bio, + 'location', p.location, + 'website', p.website, + 'country_code', p.country_code, + 'phone', p.phone + ) + FROM user_profiles p + WHERE p.user_id = u.id + ) AS profile, + + -- roles JSON + ( + SELECT COALESCE( + json_agg(json_build_object('id', r.id, 'name', r.name)), + '[]' + )::json + FROM user_roles ur + JOIN roles r ON ur.role_id = r.id + WHERE ur.user_id = u.id + ) AS roles + +FROM users u +WHERE + ($1::uuid IS NULL OR u.id > $1::uuid) + AND ($2::boolean IS NULL OR u.is_deleted = $2::boolean) + AND ( + $3::uuid[] IS NULL OR + EXISTS ( + SELECT 1 FROM user_roles ur2 + WHERE ur2.user_id = u.id AND ur2.role_id = ANY($3::uuid[]) + ) + ) +ORDER BY u.id ASC +LIMIT $4 +` + +type GetUsersParams struct { + Cursor pgtype.UUID `json:"cursor"` + IsDeleted pgtype.Bool `json:"is_deleted"` + RoleIds []pgtype.UUID `json:"role_ids"` + Limit int32 `json:"limit"` +} + +type GetUsersRow struct { + ID pgtype.UUID `json:"id"` + Email string `json:"email"` + PasswordHash pgtype.Text `json:"password_hash"` + TokenVersion int32 `json:"token_version"` + RefreshToken pgtype.Text `json:"refresh_token"` + IsDeleted bool `json:"is_deleted"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + Profile []byte `json:"profile"` + Roles []byte `json:"roles"` +} + +func (q *Queries) GetUsers(ctx context.Context, arg GetUsersParams) ([]GetUsersRow, error) { + rows, err := q.db.Query(ctx, getUsers, + arg.Cursor, + arg.IsDeleted, + arg.RoleIds, + arg.Limit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetUsersRow{} + for rows.Next() { + var i GetUsersRow + if err := rows.Scan( + &i.ID, + &i.Email, + &i.PasswordHash, + &i.TokenVersion, + &i.RefreshToken, + &i.IsDeleted, + &i.CreatedAt, + &i.UpdatedAt, + &i.Profile, + &i.Roles, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const restoreUser = `-- name: RestoreUser :exec +UPDATE users +SET + is_deleted = false +WHERE id = $1 +` + +func (q *Queries) RestoreUser(ctx context.Context, id pgtype.UUID) error { + _, err := q.db.Exec(ctx, restoreUser, id) + return err +} + +const searchUsers = `-- name: SearchUsers :many +SELECT + u.id, + u.email, + u.password_hash, u.token_version, u.refresh_token, u.is_deleted, @@ -257,14 +444,44 @@ SELECT ) AS roles FROM users u -WHERE u.is_deleted = false +WHERE + ($1::uuid IS NULL OR u.id > $1::uuid) + + AND ($2::boolean IS NULL OR u.is_deleted = $2::boolean) + AND ( + $3::uuid[] IS NULL OR + EXISTS ( + SELECT 1 FROM user_roles ur2 + WHERE ur2.user_id = u.id AND ur2.role_id = ANY($3::uuid[]) + ) + ) + + AND ($4::uuid IS NULL OR u.id = $4::uuid) + AND ( + $5::text IS NULL OR + u.email ILIKE '%' || $5::text || '%' OR + EXISTS ( + SELECT 1 FROM user_profiles p + WHERE p.user_id = u.id AND p.display_name ILIKE '%' || $5::text || '%' + ) + ) +ORDER BY u.id ASC +LIMIT $6 ` -type GetUsersRow struct { +type SearchUsersParams struct { + Cursor pgtype.UUID `json:"cursor"` + IsDeleted pgtype.Bool `json:"is_deleted"` + RoleIds []pgtype.UUID `json:"role_ids"` + SearchID pgtype.UUID `json:"search_id"` + SearchText pgtype.Text `json:"search_text"` + Limit int32 `json:"limit"` +} + +type SearchUsersRow struct { ID pgtype.UUID `json:"id"` Email string `json:"email"` PasswordHash pgtype.Text `json:"password_hash"` - IsVerified bool `json:"is_verified"` TokenVersion int32 `json:"token_version"` RefreshToken pgtype.Text `json:"refresh_token"` IsDeleted bool `json:"is_deleted"` @@ -274,20 +491,26 @@ type GetUsersRow struct { Roles []byte `json:"roles"` } -func (q *Queries) GetUsers(ctx context.Context) ([]GetUsersRow, error) { - rows, err := q.db.Query(ctx, getUsers) +func (q *Queries) SearchUsers(ctx context.Context, arg SearchUsersParams) ([]SearchUsersRow, error) { + rows, err := q.db.Query(ctx, searchUsers, + arg.Cursor, + arg.IsDeleted, + arg.RoleIds, + arg.SearchID, + arg.SearchText, + arg.Limit, + ) if err != nil { return nil, err } defer rows.Close() - items := []GetUsersRow{} + items := []SearchUsersRow{} for rows.Next() { - var i GetUsersRow + var i SearchUsersRow if err := rows.Scan( &i.ID, &i.Email, &i.PasswordHash, - &i.IsVerified, &i.TokenVersion, &i.RefreshToken, &i.IsDeleted, @@ -306,18 +529,6 @@ func (q *Queries) GetUsers(ctx context.Context) ([]GetUsersRow, error) { return items, nil } -const restoreUser = `-- name: RestoreUser :exec -UPDATE users -SET - is_deleted = false -WHERE id = $1 -` - -func (q *Queries) RestoreUser(ctx context.Context, id pgtype.UUID) error { - _, err := q.db.Exec(ctx, restoreUser, id) - return err -} - const updateTokenVersion = `-- name: UpdateTokenVersion :exec UPDATE users SET token_version = $2 @@ -432,18 +643,15 @@ INSERT INTO users ( email, password_hash, google_id, - auth_provider, - is_verified + auth_provider ) VALUES ( - $1, $2, $3, $4, $5 + $1, $2, $3, $4 ) ON CONFLICT (email) DO UPDATE SET google_id = EXCLUDED.google_id, - auth_provider = EXCLUDED.auth_provider, - is_verified = users.is_verified OR EXCLUDED.is_verified, - updated_at = now() -RETURNING id, email, password_hash, google_id, auth_provider, is_verified, is_deleted, token_version, refresh_token, created_at, updated_at + auth_provider = EXCLUDED.auth_provider +RETURNING id, email, password_hash, google_id, auth_provider, is_deleted, token_version, refresh_token, created_at, updated_at ` type UpsertUserParams struct { @@ -451,7 +659,6 @@ type UpsertUserParams struct { PasswordHash pgtype.Text `json:"password_hash"` GoogleID pgtype.Text `json:"google_id"` AuthProvider string `json:"auth_provider"` - IsVerified bool `json:"is_verified"` } func (q *Queries) UpsertUser(ctx context.Context, arg UpsertUserParams) (User, error) { @@ -460,7 +667,6 @@ func (q *Queries) UpsertUser(ctx context.Context, arg UpsertUserParams) (User, e arg.PasswordHash, arg.GoogleID, arg.AuthProvider, - arg.IsVerified, ) var i User err := row.Scan( @@ -469,7 +675,6 @@ func (q *Queries) UpsertUser(ctx context.Context, arg UpsertUserParams) (User, e &i.PasswordHash, &i.GoogleID, &i.AuthProvider, - &i.IsVerified, &i.IsDeleted, &i.TokenVersion, &i.RefreshToken, @@ -478,16 +683,3 @@ func (q *Queries) UpsertUser(ctx context.Context, arg UpsertUserParams) (User, e ) return i, err } - -const verifyUser = `-- name: VerifyUser :exec -UPDATE users -SET - is_verified = true -WHERE id = $1 - AND is_deleted = false -` - -func (q *Queries) VerifyUser(ctx context.Context, id pgtype.UUID) error { - _, err := q.db.Exec(ctx, verifyUser, id) - return err -} diff --git a/internal/middlewares/jwtMiddleware.go b/internal/middlewares/jwtMiddleware.go index ffd0f85..89204cc 100644 --- a/internal/middlewares/jwtMiddleware.go +++ b/internal/middlewares/jwtMiddleware.go @@ -2,16 +2,18 @@ package middlewares import ( "history-api/internal/dtos/response" + "history-api/internal/repositories" "history-api/pkg/config" - "history-api/pkg/constant" + "history-api/pkg/constants" "slices" jwtware "github.com/gofiber/contrib/v3/jwt" "github.com/gofiber/fiber/v3" "github.com/gofiber/fiber/v3/extractors" + "github.com/jackc/pgx/v5/pgtype" ) -func JwtAccess() fiber.Handler { +func JwtAccess(userRepo repositories.UserRepository) fiber.Handler { jwtSecret, err := config.GetConfig("JWT_SECRET") if err != nil { return nil @@ -20,13 +22,13 @@ func JwtAccess() fiber.Handler { return jwtware.New(jwtware.Config{ SigningKey: jwtware.SigningKey{Key: []byte(jwtSecret)}, ErrorHandler: jwtError, - SuccessHandler: jwtSuccess, + SuccessHandler: jwtSuccess(userRepo), Extractor: extractors.FromAuthHeader("Bearer"), Claims: &response.JWTClaims{}, }) } -func JwtRefresh() fiber.Handler { +func JwtRefresh(userRepo repositories.UserRepository) fiber.Handler { jwtRefreshSecret, err := config.GetConfig("JWT_REFRESH_SECRET") if err != nil { return nil @@ -35,41 +37,61 @@ func JwtRefresh() fiber.Handler { return jwtware.New(jwtware.Config{ SigningKey: jwtware.SigningKey{Key: []byte(jwtRefreshSecret)}, ErrorHandler: jwtError, - SuccessHandler: jwtSuccess, + SuccessHandler: jwtSuccess(userRepo), Extractor: extractors.FromAuthHeader("Bearer"), Claims: &response.JWTClaims{}, }) } -func jwtSuccess(c fiber.Ctx) error { - user := jwtware.FromContext(c) - unauthorized := func() error { - return c.Status(fiber.StatusUnauthorized).JSON(response.CommonResponse{ - Status: false, - Message: "Invalid or missing token", - }) +func jwtSuccess(userRepo repositories.UserRepository) fiber.Handler { + return func(c fiber.Ctx) error { + user := jwtware.FromContext(c) + + unauthorized := func() error { + return c.Status(fiber.StatusUnauthorized).JSON(response.CommonResponse{ + Status: false, + Message: "Invalid or missing token", + }) + } + + if user == nil { + return unauthorized() + } + + claims, ok := user.Claims.(*response.JWTClaims) + if !ok { + return unauthorized() + } + + if slices.Contains(claims.Roles, constants.BANNED) { + return c.Status(fiber.StatusForbidden).JSON(response.CommonResponse{ + Status: false, + Message: "User account is banned", + }) + } + + var pgID pgtype.UUID + err := pgID.Scan(claims.UId) + if err != nil { + return unauthorized() + } + tokenVersion, err := userRepo.GetTokenVersion(c.Context(), pgID) + if err != nil { + return unauthorized() + } + + if tokenVersion != claims.TokenVersion { + return c.Status(fiber.StatusUnauthorized).JSON(response.CommonResponse{ + Status: false, + Message: "Token has been invalidated", + }) + } + + c.Locals("uid", claims.UId) + c.Locals("user_claims", claims) + + return c.Next() } - - if user == nil { - return unauthorized() - } - - claims, ok := user.Claims.(*response.JWTClaims) - if !ok { - return unauthorized() - } - - if slices.Contains(claims.Roles, constant.BANNED) { - return c.Status(fiber.StatusForbidden).JSON(response.CommonResponse{ - Status: false, - Message: "User account is banned", - }) - } - - c.Locals("uid", claims.UId) - c.Locals("user_claims", claims) - - return c.Next() } func jwtError(c fiber.Ctx, err error) error { if err.Error() == "Missing or malformed JWT" { diff --git a/internal/middlewares/roleMiddleware.go b/internal/middlewares/roleMiddleware.go index 3d1faa4..2576d86 100644 --- a/internal/middlewares/roleMiddleware.go +++ b/internal/middlewares/roleMiddleware.go @@ -2,13 +2,13 @@ package middlewares import ( "history-api/internal/dtos/response" - "history-api/pkg/constant" + "history-api/pkg/constants" "slices" "github.com/gofiber/fiber/v3" ) -func getRoles(c fiber.Ctx) ([]constant.Role, error) { +func getRoles(c fiber.Ctx) ([]constants.Role, error) { claimsVal := c.Locals("user_claims") if claimsVal == nil { return nil, fiber.ErrUnauthorized @@ -22,7 +22,7 @@ func getRoles(c fiber.Ctx) ([]constant.Role, error) { return claims.Roles, nil } -func RequireAnyRole(required ...constant.Role) fiber.Handler { +func RequireAnyRole(required ...constants.Role) fiber.Handler { return func(c fiber.Ctx) error { userRoles, err := getRoles(c) if err != nil { @@ -43,7 +43,7 @@ func RequireAnyRole(required ...constant.Role) fiber.Handler { } } -func RequireAllRoles(required ...constant.Role) fiber.Handler { +func RequireAllRoles(required ...constants.Role) fiber.Handler { return func(c fiber.Ctx) error { userRoles, err := getRoles(c) if err != nil { diff --git a/internal/models/role.go b/internal/models/role.go index 168406e..c6053e6 100644 --- a/internal/models/role.go +++ b/internal/models/role.go @@ -2,7 +2,7 @@ package models import ( "history-api/internal/dtos/response" - "history-api/pkg/constant" + "history-api/pkg/constants" "time" ) @@ -44,6 +44,13 @@ func (r *RoleEntity) ToResponse() *response.RoleResponse { } } +func (r *RoleEntity) ToRoleSimple() *RoleSimple { + return &RoleSimple{ + ID: r.ID, + Name: r.Name, + } +} + func RolesEntityToResponse(rs []*RoleEntity) []*response.RoleResponse { out := make([]*response.RoleResponse, len(rs)) for i := range rs { @@ -52,10 +59,10 @@ func RolesEntityToResponse(rs []*RoleEntity) []*response.RoleResponse { return out } -func RolesEntityToRoleConstant(rs []*RoleSimple) []constant.Role { - out := make([]constant.Role, len(rs)) +func RolesEntityToRoleConstant(rs []*RoleSimple) []constants.Role { + out := make([]constants.Role, len(rs)) for i := range rs { - data, ok := constant.ParseRole(rs[i].Name) + data, ok := constants.ParseRole(rs[i].Name) if !ok { continue } diff --git a/internal/models/token.go b/internal/models/token.go index bc51f2b..b728168 100644 --- a/internal/models/token.go +++ b/internal/models/token.go @@ -1,35 +1,9 @@ package models -import ( - "history-api/internal/dtos/response" - "history-api/pkg/convert" - - "github.com/jackc/pgx/v5/pgtype" -) +import "history-api/pkg/constants" type TokenEntity struct { - ID pgtype.UUID `json:"id"` - UserID pgtype.UUID `json:"user_id"` - Token string `json:"token"` - TokenType int16 `json:"token_type"` - ExpiresAt pgtype.Timestamptz `json:"expires_at"` - CreatedAt pgtype.Timestamptz `json:"created_at"` -} - -func (t *TokenEntity) ToResponse() *response.TokenResponse { - return &response.TokenResponse{ - ID: convert.UUIDToString(t.ID), - UserID: convert.UUIDToString(t.UserID), - TokenType: t.TokenType, - ExpiresAt: convert.TimeToPtr(t.ExpiresAt), - CreatedAt: convert.TimeToPtr(t.CreatedAt), - } -} - -func TokensEntityToResponse(ts []*TokenEntity) []*response.TokenResponse { - out := make([]*response.TokenResponse, len(ts)) - for i := range ts { - out[i] = ts[i].ToResponse() - } - return out + Email string `json:"email"` + Token string `json:"token"` + TokenType constants.TokenType `json:"token_type"` } diff --git a/internal/repositories/roleRepository.go b/internal/repositories/roleRepository.go index 29522ad..c34c5a0 100644 --- a/internal/repositories/roleRepository.go +++ b/internal/repositories/roleRepository.go @@ -2,19 +2,22 @@ package repositories import ( "context" + "crypto/md5" + "encoding/json" "fmt" - "time" "github.com/jackc/pgx/v5/pgtype" "history-api/internal/gen/sqlc" "history-api/internal/models" "history-api/pkg/cache" + "history-api/pkg/constants" "history-api/pkg/convert" ) type RoleRepository interface { GetByID(ctx context.Context, id pgtype.UUID) (*models.RoleEntity, error) + GetByIDs(ctx context.Context, ids []string) ([]*models.RoleEntity, error) GetByname(ctx context.Context, name string) (*models.RoleEntity, error) All(ctx context.Context) ([]*models.RoleEntity, error) Create(ctx context.Context, name string) (*models.RoleEntity, error) @@ -39,6 +42,56 @@ func NewRoleRepository(db sqlc.DBTX, c cache.Cache) RoleRepository { } } +func (r *roleRepository) generateQueryKey(prefix string, params any) string { + b, _ := json.Marshal(params) + hash := fmt.Sprintf("%x", md5.Sum(b)) + return fmt.Sprintf("%s:%s", prefix, hash) +} + +func (r *roleRepository) getByIDsWithFallback(ctx context.Context, ids []string) ([]*models.RoleEntity, error) { + if len(ids) == 0 { + return []*models.RoleEntity{}, nil + } + keys := make([]string, len(ids)) + for i, id := range ids { + keys[i] = fmt.Sprintf("role:id:%s", id) + } + raws := r.c.MGet(ctx, keys...) + + var roles []*models.RoleEntity + missingRolesToCache := make(map[string]any) + + for i, b := range raws { + if len(b) > 0 { + var u models.RoleEntity + if err := json.Unmarshal(b, &u); err == nil { + roles = append(roles, &u) + } + } else { + pgId := pgtype.UUID{} + err := pgId.Scan(ids[i]) + if err != nil { + continue + } + dbRole, err := r.GetByID(ctx, pgId) + if err == nil && dbRole != nil { + roles = append(roles, dbRole) + missingRolesToCache[keys[i]] = dbRole + } + } + } + + if len(missingRolesToCache) > 0 { + _ = r.c.MSet(ctx, missingRolesToCache, constants.NormalCacheDuration) + } + + return roles, nil +} + +func (r *roleRepository) GetByIDs(ctx context.Context, ids []string) ([]*models.RoleEntity, error) { + return r.getByIDsWithFallback(ctx, ids) +} + func (r *roleRepository) GetByID(ctx context.Context, id pgtype.UUID) (*models.RoleEntity, error) { cacheId := fmt.Sprintf("role:id:%s", convert.UUIDToString(id)) var role models.RoleEntity @@ -59,7 +112,7 @@ func (r *roleRepository) GetByID(ctx context.Context, id pgtype.UUID) (*models.R CreatedAt: convert.TimeToPtr(row.CreatedAt), UpdatedAt: convert.TimeToPtr(row.UpdatedAt), } - _ = r.c.Set(ctx, cacheId, role, 5*time.Minute) + _ = r.c.Set(ctx, cacheId, role, constants.NormalCacheDuration) return &role, nil } @@ -83,7 +136,7 @@ func (r *roleRepository) GetByname(ctx context.Context, name string) (*models.Ro UpdatedAt: convert.TimeToPtr(row.UpdatedAt), } - _ = r.c.Set(ctx, cacheId, role, 5*time.Minute) + _ = r.c.Set(ctx, cacheId, role, constants.NormalCacheDuration) return &role, nil } @@ -104,7 +157,7 @@ func (r *roleRepository) Create(ctx context.Context, name string) (*models.RoleE fmt.Sprintf("role:name:%s", name): role, fmt.Sprintf("role:id:%s", convert.UUIDToString(row.ID)): role, } - _ = r.c.MSet(ctx, mapCache, 5*time.Minute) + _ = r.c.MSet(ctx, mapCache, constants.NormalCacheDuration) return &role, nil } @@ -125,7 +178,7 @@ func (r *roleRepository) Update(ctx context.Context, params sqlc.UpdateRoleParam fmt.Sprintf("role:name:%s", row.Name): role, fmt.Sprintf("role:id:%s", convert.UUIDToString(row.ID)): role, } - _ = r.c.MSet(ctx, mapCache, 5*time.Minute) + _ = r.c.MSet(ctx, mapCache, constants.NormalCacheDuration) return &role, nil } diff --git a/internal/repositories/tileRepository.go b/internal/repositories/tileRepository.go index b84adf8..04b8a75 100644 --- a/internal/repositories/tileRepository.go +++ b/internal/repositories/tileRepository.go @@ -5,6 +5,7 @@ import ( "database/sql" "fmt" "history-api/pkg/cache" + "history-api/pkg/constants" "time" ) @@ -50,7 +51,7 @@ func (r *tileRepository) GetMetadata(ctx context.Context) (map[string]string, er metadata[name] = value } - _ = r.c.Set(ctx, cacheId, metadata, 10*time.Minute) + _ = r.c.Set(ctx, cacheId, metadata, constants.NormalCacheDuration) return metadata, nil } diff --git a/internal/repositories/tokenRepository.go b/internal/repositories/tokenRepository.go new file mode 100644 index 0000000..e1a85c2 --- /dev/null +++ b/internal/repositories/tokenRepository.go @@ -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 +} diff --git a/internal/repositories/userRepository.go b/internal/repositories/userRepository.go index fd5c34b..0793088 100644 --- a/internal/repositories/userRepository.go +++ b/internal/repositories/userRepository.go @@ -2,21 +2,25 @@ package repositories import ( "context" + "crypto/md5" + "encoding/json" "fmt" - "time" "github.com/jackc/pgx/v5/pgtype" "history-api/internal/gen/sqlc" "history-api/internal/models" "history-api/pkg/cache" + "history-api/pkg/constants" "history-api/pkg/convert" ) type UserRepository interface { GetByID(ctx context.Context, id pgtype.UUID) (*models.UserEntity, error) + GetByIDWithoutDeleted(ctx context.Context, id pgtype.UUID) (*models.UserEntity, error) GetByEmail(ctx context.Context, email string) (*models.UserEntity, error) - All(ctx context.Context) ([]*models.UserEntity, error) + All(ctx context.Context, params sqlc.GetUsersParams) ([]*models.UserEntity, error) + Search(ctx context.Context, params sqlc.SearchUsersParams) ([]*models.UserEntity, error) UpsertUser(ctx context.Context, params sqlc.UpsertUserParams) (*models.UserEntity, error) CreateProfile(ctx context.Context, params sqlc.CreateUserProfileParams) (*models.UserProfileSimple, error) UpdateProfile(ctx context.Context, params sqlc.UpdateUserProfileParams) (*models.UserEntity, error) @@ -24,7 +28,6 @@ type UserRepository interface { UpdateRefreshToken(ctx context.Context, params sqlc.UpdateUserRefreshTokenParams) error GetTokenVersion(ctx context.Context, id pgtype.UUID) (int32, error) UpdateTokenVersion(ctx context.Context, params sqlc.UpdateTokenVersionParams) error - Verify(ctx context.Context, id pgtype.UUID) error Delete(ctx context.Context, id pgtype.UUID) error Restore(ctx context.Context, id pgtype.UUID) error } @@ -41,6 +44,52 @@ func NewUserRepository(db sqlc.DBTX, c cache.Cache) UserRepository { } } +func (r *userRepository) generateQueryKey(prefix string, params any) string { + b, _ := json.Marshal(params) + hash := fmt.Sprintf("%x", md5.Sum(b)) + return fmt.Sprintf("%s:%s", prefix, hash) +} + +func (r *userRepository) getByIDsWithFallback(ctx context.Context, ids []string) ([]*models.UserEntity, error) { + if len(ids) == 0 { + return []*models.UserEntity{}, nil + } + keys := make([]string, len(ids)) + for i, id := range ids { + keys[i] = fmt.Sprintf("user:id:%s", id) + } + raws := r.c.MGet(ctx, keys...) + + var users []*models.UserEntity + missingUsersToCache := make(map[string]any) + + for i, b := range raws { + if len(b) > 0 { + var u models.UserEntity + if err := json.Unmarshal(b, &u); err == nil { + users = append(users, &u) + } + } else { + pgId := pgtype.UUID{} + err := pgId.Scan(ids[i]) + if err != nil { + continue + } + dbUser, err := r.GetByID(ctx, pgId) + if err == nil && dbUser != nil { + users = append(users, dbUser) + missingUsersToCache[keys[i]] = dbUser + } + } + } + + if len(missingUsersToCache) > 0 { + _ = r.c.MSet(ctx, missingUsersToCache, constants.NormalCacheDuration) + } + + return users, nil +} + func (r *userRepository) GetByID(ctx context.Context, id pgtype.UUID) (*models.UserEntity, error) { cacheId := fmt.Sprintf("user:id:%s", convert.UUIDToString(id)) var user models.UserEntity @@ -58,7 +107,6 @@ func (r *userRepository) GetByID(ctx context.Context, id pgtype.UUID) (*models.U ID: convert.UUIDToString(row.ID), Email: row.Email, PasswordHash: convert.TextToString(row.PasswordHash), - IsVerified: row.IsVerified, TokenVersion: row.TokenVersion, IsDeleted: row.IsDeleted, CreatedAt: convert.TimeToPtr(row.CreatedAt), @@ -73,7 +121,43 @@ func (r *userRepository) GetByID(ctx context.Context, id pgtype.UUID) (*models.U return nil, err } - _ = r.c.Set(ctx, cacheId, user, 5*time.Minute) + _ = r.c.Set(ctx, cacheId, user, constants.NormalCacheDuration) + + return &user, nil +} + +func (r *userRepository) GetByIDWithoutDeleted(ctx context.Context, id pgtype.UUID) (*models.UserEntity, error) { + cacheId := fmt.Sprintf("user:deleted:id:%s", convert.UUIDToString(id)) + var user models.UserEntity + err := r.c.Get(ctx, cacheId, &user) + if err == nil { + return &user, nil + } + + row, err := r.q.GetUserByID(ctx, id) + if err != nil { + return nil, err + } + + user = models.UserEntity{ + ID: convert.UUIDToString(row.ID), + Email: row.Email, + PasswordHash: convert.TextToString(row.PasswordHash), + TokenVersion: row.TokenVersion, + IsDeleted: row.IsDeleted, + CreatedAt: convert.TimeToPtr(row.CreatedAt), + UpdatedAt: convert.TimeToPtr(row.UpdatedAt), + } + + if err := user.ParseRoles(row.Roles); err != nil { + return nil, err + } + + if err := user.ParseProfile(row.Profile); err != nil { + return nil, err + } + + _ = r.c.Set(ctx, cacheId, user, constants.NormalCacheDuration) return &user, nil } @@ -96,7 +180,6 @@ func (r *userRepository) GetByEmail(ctx context.Context, email string) (*models. ID: convert.UUIDToString(row.ID), Email: row.Email, PasswordHash: convert.TextToString(row.PasswordHash), - IsVerified: row.IsVerified, TokenVersion: row.TokenVersion, IsDeleted: row.IsDeleted, CreatedAt: convert.TimeToPtr(row.CreatedAt), @@ -111,7 +194,7 @@ func (r *userRepository) GetByEmail(ctx context.Context, email string) (*models. return nil, err } - _ = r.c.Set(ctx, cacheId, user, 5*time.Minute) + _ = r.c.Set(ctx, cacheId, user, constants.NormalCacheDuration) return &user, nil } @@ -121,12 +204,17 @@ func (r *userRepository) UpsertUser(ctx context.Context, params sqlc.UpsertUserP if err != nil { return nil, err } + go func() { + bgCtx := context.Background() + + _ = r.c.DelByPattern(bgCtx, "user:all*") + _ = r.c.DelByPattern(bgCtx, "user:search*") + }() return &models.UserEntity{ ID: convert.UUIDToString(row.ID), Email: row.Email, PasswordHash: convert.TextToString(row.PasswordHash), - IsVerified: row.IsVerified, TokenVersion: row.TokenVersion, IsDeleted: row.IsDeleted, CreatedAt: convert.TimeToPtr(row.CreatedAt), @@ -161,7 +249,7 @@ func (r *userRepository) UpdateProfile(ctx context.Context, params sqlc.UpdateUs fmt.Sprintf("user:email:%s", user.Email): user, fmt.Sprintf("user:id:%s", user.ID): user, } - _ = r.c.MSet(ctx, mapCache, 5*time.Minute) + _ = r.c.MSet(ctx, mapCache, constants.NormalCacheDuration) return user, nil } @@ -183,19 +271,27 @@ func (r *userRepository) CreateProfile(ctx context.Context, params sqlc.CreateUs }, nil } -func (r *userRepository) All(ctx context.Context) ([]*models.UserEntity, error) { - rows, err := r.q.GetUsers(ctx) +func (r *userRepository) All(ctx context.Context, params sqlc.GetUsersParams) ([]*models.UserEntity, error) { + queryKey := r.generateQueryKey("user:all", params) + + var cachedIDs []string + if err := r.c.Get(ctx, queryKey, &cachedIDs); err == nil && len(cachedIDs) > 0 { + return r.getByIDsWithFallback(ctx, cachedIDs) + } + rows, err := r.q.GetUsers(ctx, params) if err != nil { return nil, err } var users []*models.UserEntity + var ids []string + usersToCache := make(map[string]any) + for _, row := range rows { user := &models.UserEntity{ ID: convert.UUIDToString(row.ID), Email: row.Email, PasswordHash: convert.TextToString(row.PasswordHash), - IsVerified: row.IsVerified, TokenVersion: row.TokenVersion, IsDeleted: row.IsDeleted, CreatedAt: convert.TimeToPtr(row.CreatedAt), @@ -205,44 +301,75 @@ func (r *userRepository) All(ctx context.Context) ([]*models.UserEntity, error) if err := user.ParseRoles(row.Roles); err != nil { return nil, err } - if err := user.ParseProfile(row.Profile); err != nil { return nil, err } users = append(users, user) + ids = append(ids, user.ID) + + usersToCache[fmt.Sprintf("user:id:%s", user.ID)] = user + } + + if len(usersToCache) > 0 { + _ = r.c.MSet(ctx, usersToCache, constants.NormalCacheDuration) + } + + if len(ids) > 0 { + _ = r.c.Set(ctx, queryKey, ids, constants.ListCacheDuration) } return users, nil } -func (r *userRepository) Verify(ctx context.Context, id pgtype.UUID) error { - user, err := r.GetByID(ctx, id) - if err != nil { - return err +func (r *userRepository) Search(ctx context.Context, params sqlc.SearchUsersParams) ([]*models.UserEntity, error) { + queryKey := r.generateQueryKey("user:search", params) + + var cachedIDs []string + if err := r.c.Get(ctx, queryKey, &cachedIDs); err == nil && len(cachedIDs) > 0 { + return r.getByIDsWithFallback(ctx, cachedIDs) } - err = r.q.VerifyUser(ctx, id) + rows, err := r.q.SearchUsers(ctx, params) if err != nil { - return err - } - err = r.q.UpdateTokenVersion(ctx, sqlc.UpdateTokenVersionParams{ - ID: id, - TokenVersion: user.TokenVersion + 1, - }) - if err != nil { - return err + return nil, err } - user.IsVerified = true - user.TokenVersion += 1 + var users []*models.UserEntity + var ids []string + usersToCache := make(map[string]any) - mapCache := map[string]any{ - fmt.Sprintf("user:email:%s", user.Email): user, - fmt.Sprintf("user:id:%s", user.ID): user, + for _, row := range rows { + user := &models.UserEntity{ + ID: convert.UUIDToString(row.ID), + Email: row.Email, + PasswordHash: convert.TextToString(row.PasswordHash), + TokenVersion: row.TokenVersion, + IsDeleted: row.IsDeleted, + CreatedAt: convert.TimeToPtr(row.CreatedAt), + UpdatedAt: convert.TimeToPtr(row.UpdatedAt), + } + + if err := user.ParseRoles(row.Roles); err != nil { + return nil, err + } + if err := user.ParseProfile(row.Profile); err != nil { + return nil, err + } + + users = append(users, user) + ids = append(ids, user.ID) + usersToCache[fmt.Sprintf("user:id:%s", user.ID)] = user } - _ = r.c.MSet(ctx, mapCache, 5*time.Minute) - return nil + + if len(usersToCache) > 0 { + _ = r.c.MSet(ctx, usersToCache, constants.NormalCacheDuration) + } + if len(ids) > 0 { + _ = r.c.Set(ctx, queryKey, ids, constants.ListCacheDuration) + } + + return users, nil } func (r *userRepository) Delete(ctx context.Context, id pgtype.UUID) error { @@ -288,7 +415,7 @@ func (r *userRepository) GetTokenVersion(ctx context.Context, id pgtype.UUID) (i return 0, err } - _ = r.c.Set(ctx, cacheId, raw, 5*time.Minute) + _ = r.c.Set(ctx, cacheId, raw, constants.NormalCacheDuration) return raw, nil } @@ -299,7 +426,7 @@ func (r *userRepository) UpdateTokenVersion(ctx context.Context, params sqlc.Upd } cacheId := fmt.Sprintf("user:token:%s", convert.UUIDToString(params.ID)) - _ = r.c.Set(ctx, cacheId, params.TokenVersion, 5*time.Minute) + _ = r.c.Set(ctx, cacheId, params.TokenVersion, constants.NormalCacheDuration) return nil } @@ -328,7 +455,7 @@ func (r *userRepository) UpdatePassword(ctx context.Context, params sqlc.UpdateU fmt.Sprintf("user:token:%s", user.ID): user.TokenVersion, } - _ = r.c.MSet(ctx, mapCache, 5*time.Minute) + _ = r.c.MSet(ctx, mapCache, constants.NormalCacheDuration) return nil } @@ -349,6 +476,6 @@ func (r *userRepository) UpdateRefreshToken(ctx context.Context, params sqlc.Upd fmt.Sprintf("user:token:%s", user.ID): user.TokenVersion, } - _ = r.c.MSet(ctx, mapCache, 5*time.Minute) + _ = r.c.MSet(ctx, mapCache, constants.NormalCacheDuration) return nil } diff --git a/internal/routes/authRoute.go b/internal/routes/authRoute.go index 1ebee53..a34f6c6 100644 --- a/internal/routes/authRoute.go +++ b/internal/routes/authRoute.go @@ -3,13 +3,17 @@ package routes import ( "history-api/internal/controllers" "history-api/internal/middlewares" + "history-api/internal/repositories" "github.com/gofiber/fiber/v3" ) -func AuthRoutes(app *fiber.App, controller *controllers.AuthController) { +func AuthRoutes(app *fiber.App, controller *controllers.AuthController, userRepo repositories.UserRepository) { route := app.Group("/auth") route.Post("/signin", controller.Signin) route.Post("/signup", controller.Signup) - route.Post("/refresh", middlewares.JwtRefresh(), controller.RefreshToken) + route.Post("/refresh", middlewares.JwtRefresh(userRepo), controller.RefreshToken) + route.Post("/token/create", controller.CreateToken) + route.Post("/token/verify", controller.VerifyToken) + route.Post("/forgot-password", controller.ForgotPassword) } diff --git a/internal/routes/userRoute.go b/internal/routes/userRoute.go new file mode 100644 index 0000000..9d9e2cd --- /dev/null +++ b/internal/routes/userRoute.go @@ -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, + ) +} diff --git a/internal/services/authService.go b/internal/services/authService.go index 67ef754..663e948 100644 --- a/internal/services/authService.go +++ b/internal/services/authService.go @@ -2,19 +2,25 @@ package services import ( "context" + "crypto/rand" + "fmt" "history-api/internal/dtos/request" "history-api/internal/dtos/response" "history-api/internal/gen/sqlc" "history-api/internal/models" "history-api/internal/repositories" + "history-api/pkg/cache" "history-api/pkg/config" - "history-api/pkg/constant" + "history-api/pkg/constants" + "history-api/pkg/convert" + "math/big" "slices" "time" "github.com/gofiber/fiber/v3" "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" "golang.org/x/crypto/bcrypt" ) @@ -22,29 +28,35 @@ import ( type AuthService interface { Signin(ctx context.Context, dto *request.SignInDto) (*response.AuthResponse, error) Signup(ctx context.Context, dto *request.SignUpDto) (*response.AuthResponse, error) - ForgotPassword(ctx context.Context) error - VerifyToken(ctx context.Context) error - CreateToken(ctx context.Context) error - SigninWith3rd(ctx context.Context) error + ForgotPassword(ctx context.Context, dto *request.ForgotPasswordDto) error + VerifyToken(ctx context.Context, dto *request.VerifyTokenDto) (*response.VerifyTokenResponse, error) + CreateToken(ctx context.Context, dto *request.CreateTokenDto) error + SigninWith3rd(ctx context.Context, dto *request.SigninWith3rdDto) error RefreshToken(ctx context.Context, id string) (*response.AuthResponse, error) } type authService struct { - userRepo repositories.UserRepository - roleRepo repositories.RoleRepository + userRepo repositories.UserRepository + roleRepo repositories.RoleRepository + tokenRepo repositories.TokenRepository + c cache.Cache } func NewAuthService( userRepo repositories.UserRepository, roleRepo repositories.RoleRepository, + tokenRepo repositories.TokenRepository, + c cache.Cache, ) AuthService { return &authService{ - userRepo: userRepo, - roleRepo: roleRepo, + userRepo: userRepo, + roleRepo: roleRepo, + tokenRepo: tokenRepo, + c: c, } } -func (a *authService) genToken(Uid string, role []constant.Role) (*response.AuthResponse, error) { +func (a *authService) genToken(user *models.UserEntity) (*response.AuthResponse, error) { jwtSecret, err := config.GetConfig("JWT_SECRET") if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "missing JWT_SECRET in environment") @@ -59,18 +71,20 @@ func (a *authService) genToken(Uid string, role []constant.Role) (*response.Auth } claimsAccess := &response.JWTClaims{ - UId: Uid, - Roles: role, + UId: user.ID, + Roles: models.RolesEntityToRoleConstant(user.Roles), + TokenVersion: user.TokenVersion, RegisteredClaims: jwt.RegisteredClaims{ - ExpiresAt: jwt.NewNumericDate(time.Now().Add(30 * time.Minute)), + ExpiresAt: jwt.NewNumericDate(time.Now().Add(constants.AccessTokenDuration)), }, } claimsRefresh := &response.JWTClaims{ - UId: Uid, - Roles: role, + UId: user.ID, + Roles: models.RolesEntityToRoleConstant(user.Roles), + TokenVersion: user.TokenVersion, RegisteredClaims: jwt.RegisteredClaims{ - ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * 24 * time.Hour)), + ExpiresAt: jwt.NewNumericDate(time.Now().Add(constants.RefreshTokenDuration)), }, } @@ -102,11 +116,11 @@ func (a *authService) saveNewRefreshToken(ctx context.Context, params sqlc.Updat } func (a *authService) Signin(ctx context.Context, dto *request.SignInDto) (*response.AuthResponse, error) { - if !constant.EMAIL_REGEX.MatchString(dto.Email) { + if !constants.EMAIL_REGEX.MatchString(dto.Email) { return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid email") } - err := constant.ValidatePassword(dto.Password) + err := constants.ValidatePassword(dto.Password) if err != nil { return nil, fiber.NewError(fiber.StatusBadRequest, err.Error()) } @@ -120,13 +134,12 @@ func (a *authService) Signin(ctx context.Context, dto *request.SignInDto) (*resp return nil, fiber.NewError(fiber.StatusUnauthorized, "Invalid identity or password!") } - data, err := a.genToken(user.ID, models.RolesEntityToRoleConstant(user.Roles)) + data, err := a.genToken(user) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) } - var pgID pgtype.UUID - err = pgID.Scan(user.ID) + pgID, err := convert.StringToUUID(user.ID) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) } @@ -160,11 +173,11 @@ func (a *authService) RefreshToken(ctx context.Context, id string) (*response.Au } roles := models.RolesEntityToRoleConstant(user.Roles) - if slices.Contains(roles, constant.BANNED) { + if slices.Contains(roles, constants.BANNED) { return nil, fiber.NewError(fiber.StatusUnauthorized, "User is banned!") } - data, err := a.genToken(id, roles) + data, err := a.genToken(user) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) } @@ -187,14 +200,23 @@ func (a *authService) RefreshToken(ctx context.Context, id string) (*response.Au } func (a *authService) Signup(ctx context.Context, dto *request.SignUpDto) (*response.AuthResponse, error) { - if !constant.EMAIL_REGEX.MatchString(dto.Email) { + if !constants.EMAIL_REGEX.MatchString(dto.Email) { return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid email") } - err := constant.ValidatePassword(dto.Password) + err := constants.ValidatePassword(dto.Password) if err != nil { return nil, fiber.NewError(fiber.StatusBadRequest, err.Error()) } + ok, err := a.tokenRepo.CheckVerified(ctx, dto.Email, constants.TokenEmailVerify, dto.TokenID) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + + if !ok { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid or expired token") + } + user, err := a.userRepo.GetByEmail(ctx, dto.Email) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) @@ -202,6 +224,7 @@ func (a *authService) Signup(ctx context.Context, dto *request.SignUpDto) (*resp if user != nil { return nil, fiber.NewError(fiber.StatusBadRequest, "User already exists") } + hashed, err := bcrypt.GenerateFromPassword([]byte(dto.Password), bcrypt.DefaultCost) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) @@ -215,14 +238,13 @@ func (a *authService) Signup(ctx context.Context, dto *request.SignUpDto) (*resp String: string(hashed), Valid: len(hashed) != 0, }, - IsVerified: true, }, ) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) } - var userId pgtype.UUID - err = userId.Scan(user.ID) + + userId, err := convert.StringToUUID(user.ID) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) } @@ -239,19 +261,28 @@ func (a *authService) Signup(ctx context.Context, dto *request.SignUpDto) (*resp if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) } + role, err := a.roleRepo.GetByname(ctx, constants.USER.String()) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + + roleId, err := convert.StringToUUID(role.ID) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } err = a.roleRepo.AddUserRole( ctx, sqlc.AddUserRoleParams{ - UserID: userId, - Name: constant.USER.String(), + UserID: userId, + Column2: []pgtype.UUID{roleId}, }, ) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) } - data, err := a.genToken(user.ID, constant.USER.ToSlice()) + data, err := a.genToken(user) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) } @@ -273,22 +304,101 @@ func (a *authService) Signup(ctx context.Context, dto *request.SignUpDto) (*resp return data, nil } -// ForgotPassword implements [AuthService]. -func (a *authService) ForgotPassword(ctx context.Context) error { - panic("unimplemented") +func (a *authService) ForgotPassword(ctx context.Context, dto *request.ForgotPasswordDto) error { + ok, err := a.tokenRepo.CheckVerified(ctx, dto.Email, constants.TokenPasswordReset, dto.TokenID) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + if !ok { + return fiber.NewError(fiber.StatusBadRequest, "Invalid or expired token") + } + user, err := a.userRepo.GetByEmail(ctx, dto.Email) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + if user == nil { + return fiber.NewError(fiber.StatusBadRequest, "User not found") + } + + hashed, err := bcrypt.GenerateFromPassword([]byte(dto.NewPassword), bcrypt.DefaultCost) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + userId, err := convert.StringToUUID(user.ID) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + err = a.userRepo.UpdatePassword(ctx, sqlc.UpdateUserPasswordParams{ + ID: userId, + PasswordHash: pgtype.Text{ + String: string(hashed), + Valid: len(hashed) != 0, + }, + }) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + return nil } // SigninWith3rd implements [AuthService]. -func (a *authService) SigninWith3rd(ctx context.Context) error { +func (a *authService) SigninWith3rd(ctx context.Context, dto *request.SigninWith3rdDto) error { panic("unimplemented") } +func (a *authService) GenerateOTP() (string, error) { + max := big.NewInt(900000) + n, err := rand.Int(rand.Reader, max) + if err != nil { + return "", err + } + otp := n.Int64() + 100000 + return fmt.Sprintf("%06d", otp), nil +} -// CreateToken implements [AuthService]. -func (a *authService) CreateToken(ctx context.Context) error { - panic("unimplemented") +func (a *authService) CreateToken(ctx context.Context, dto *request.CreateTokenDto) error { + ok, err := a.tokenRepo.CheckCooldown(ctx, dto.Email, dto.TokenType) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + + if ok { + return fiber.NewError(fiber.StatusBadRequest, "Please wait before requesting another token") + } + + otp, err := a.GenerateOTP() + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + + token := &models.TokenEntity{ + Email: dto.Email, + Token: otp, + TokenType: dto.TokenType, + } + + err = a.tokenRepo.Create(ctx, token) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + + a.c.PublishTask(ctx, constants.StreamEmailName, constants.TaskTypeSendEmailOTP, token) + return nil } -// Verify implements [AuthService]. -func (a *authService) VerifyToken(ctx context.Context) error { - panic("unimplemented") +func (a *authService) VerifyToken(ctx context.Context, dto *request.VerifyTokenDto) (*response.VerifyTokenResponse, error) { + token, err := a.tokenRepo.Get(ctx, dto.Email, dto.TokenType) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + if token == nil || token.Token != dto.Token { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid token") + } + tokenId := uuid.New().String() + err = a.tokenRepo.CreateVerified(ctx, dto.Email, dto.TokenType, tokenId) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + return &response.VerifyTokenResponse{ + TokenID: tokenId, + }, nil } diff --git a/internal/services/userService.go b/internal/services/userService.go index c43a8d9..aad5ef9 100644 --- a/internal/services/userService.go +++ b/internal/services/userService.go @@ -4,22 +4,28 @@ import ( "context" "history-api/internal/dtos/request" "history-api/internal/dtos/response" + "history-api/internal/gen/sqlc" + "history-api/internal/models" "history-api/internal/repositories" + "history-api/pkg/convert" + + "github.com/gofiber/fiber/v3" + "github.com/jackc/pgx/v5/pgtype" + "golang.org/x/crypto/bcrypt" ) type UserService interface { //user - GetUserCurrent(ctx context.Context, dto *request.SignInDto) (*response.AuthResponse, error) - UpdateProfile(ctx context.Context, id string) (*response.UserResponse, error) - ChangePassword(ctx context.Context, id string) (*response.UserResponse, error) + GetUserCurrent(ctx context.Context, userId string) (*response.UserResponse, error) + UpdateProfile(ctx context.Context, userId string, dto *request.UpdateProfileDto) (*response.UserResponse, error) + ChangePassword(ctx context.Context, userId string, dto *request.ChangePasswordDto) error //admin - DeleteUser(ctx context.Context, id string) (*response.UserResponse, error) - ChangeRoleUser(ctx context.Context, id string) (*response.UserResponse, error) - RestoreUser(ctx context.Context, id string) (*response.UserResponse, error) - GetUserByID(ctx context.Context, id string) (*response.UserResponse, error) - Search(ctx context.Context, id string) ([]*response.UserResponse, error) - GetAllUser(ctx context.Context, id string) ([]*response.UserResponse, error) + DeleteUser(ctx context.Context, userId string) error + ChangeRoleUser(ctx context.Context, dto *request.ChangeRoleDto) (*response.UserResponse, error) + RestoreUser(ctx context.Context, userId string) (*response.UserResponse, error) + GetUserByID(ctx context.Context, userId string) (*response.UserResponse, error) + Search(ctx context.Context, dto *request.SearchUserDto) (*response.PaginatedResponse, error) } type userService struct { @@ -37,47 +43,241 @@ func NewUserService( } } -// ChangePassword implements [UserService]. -func (u *userService) ChangePassword(ctx context.Context, id string) (*response.UserResponse, error) { - panic("unimplemented") +func (u *userService) ChangePassword(ctx context.Context, userId string, dto *request.ChangePasswordDto) error { + pgID, err := convert.StringToUUID(userId) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + user, err := u.userRepo.GetByID(ctx, pgID) + if err != nil { + return fiber.NewError(fiber.StatusNotFound, err.Error()) + } + if user == nil { + return fiber.NewError(fiber.StatusNotFound, "User not found") + } + + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(dto.OldPassword)); err != nil { + return fiber.NewError(fiber.StatusUnauthorized, "Invalid identity or password!") + } + + hashPassword, err := bcrypt.GenerateFromPassword([]byte(dto.NewPassword), bcrypt.DefaultCost) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + + err = u.userRepo.UpdatePassword(ctx, sqlc.UpdateUserPasswordParams{ + ID: pgID, + PasswordHash: pgtype.Text{String: string(hashPassword), Valid: true}, + }) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + + return nil } -// ChangeRoleUser implements [UserService]. -func (u *userService) ChangeRoleUser(ctx context.Context, id string) (*response.UserResponse, error) { - panic("unimplemented") +func (u *userService) ChangeRoleUser(ctx context.Context, dto *request.ChangeRoleDto) (*response.UserResponse, error) { + userId, err := convert.StringToUUID(dto.UserID) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + + user, err := u.userRepo.GetByID(ctx, userId) + if err != nil { + return nil, fiber.NewError(fiber.StatusNotFound, err.Error()) + } + if user == nil { + return nil, fiber.NewError(fiber.StatusNotFound, "User not found") + } + + roleIdstr, err := u.roleRepo.GetByIDs(ctx, dto.Roles) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + user.Roles = make([]*models.RoleSimple, 0) + roleIdList := make([]pgtype.UUID, 0) + for _, role := range roleIdstr { + roleID, err := convert.StringToUUID(role.ID) + if err != nil { + continue + } + roleIdList = append(roleIdList, roleID) + user.Roles = append(user.Roles, role.ToRoleSimple()) + } + + err = u.roleRepo.RemoveAllRolesFromUser(ctx, userId) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + + err = u.roleRepo.AddUserRole(ctx, sqlc.AddUserRoleParams{ + UserID: userId, + Column2: roleIdList, + }) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + + return user.ToResponse(), nil + } -// DeleteUser implements [UserService]. -func (u *userService) DeleteUser(ctx context.Context, id string) (*response.UserResponse, error) { - panic("unimplemented") +func (u *userService) DeleteUser(ctx context.Context, userId string) error { + pgID, err := convert.StringToUUID(userId) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + user, err := u.userRepo.GetByID(ctx, pgID) + if err != nil { + return fiber.NewError(fiber.StatusNotFound, err.Error()) + } + if user == nil { + return fiber.NewError(fiber.StatusNotFound, "User not found") + } + err = u.userRepo.Delete(ctx, pgID) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + return nil } -// GetAllUser implements [UserService]. -func (u *userService) GetAllUser(ctx context.Context, id string) ([]*response.UserResponse, error) { - panic("unimplemented") +func (u *userService) UpdateProfile(ctx context.Context, userId string, dto *request.UpdateProfileDto) (*response.UserResponse, error) { + pgID, err := convert.StringToUUID(userId) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + user, err := u.userRepo.GetByID(ctx, pgID) + if err != nil { + return nil, fiber.NewError(fiber.StatusNotFound, err.Error()) + } + if user == nil { + return nil, fiber.NewError(fiber.StatusNotFound, "User not found") + } + + newUser, err := u.userRepo.UpdateProfile( + ctx, + sqlc.UpdateUserProfileParams{ + DisplayName: pgtype.Text{String: dto.DisplayName, Valid: len(dto.DisplayName) > 0}, + FullName: pgtype.Text{String: dto.FullName, Valid: len(dto.FullName) > 0}, + AvatarUrl: pgtype.Text{String: dto.AvatarUrl, Valid: len(dto.AvatarUrl) > 0}, + Bio: pgtype.Text{String: dto.Bio, Valid: len(dto.Bio) > 0}, + Location: pgtype.Text{String: dto.Location, Valid: len(dto.Location) > 0}, + Website: pgtype.Text{String: dto.Website, Valid: len(dto.Website) > 0}, + CountryCode: pgtype.Text{String: dto.CountryCode, Valid: len(dto.CountryCode) > 0}, + Phone: pgtype.Text{String: dto.Phone, Valid: len(dto.Phone) > 0}, + UserID: pgID, + }, + ) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + return newUser.ToResponse(), nil } -// GetUserByID implements [UserService]. -func (u *userService) GetUserByID(ctx context.Context, id string) (*response.UserResponse, error) { - panic("unimplemented") +func (u *userService) GetUserCurrent(ctx context.Context, userId string) (*response.UserResponse, error) { + pgID, err := convert.StringToUUID(userId) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + user, err := u.userRepo.GetByID(ctx, pgID) + if err != nil { + return nil, fiber.NewError(fiber.StatusNotFound, err.Error()) + } + return user.ToResponse(), nil } -// GetUserCurrent implements [UserService]. -func (u *userService) GetUserCurrent(ctx context.Context, dto *request.SignInDto) (*response.AuthResponse, error) { - panic("unimplemented") +func (u *userService) RestoreUser(ctx context.Context, userId string) (*response.UserResponse, error) { + pgID, err := convert.StringToUUID(userId) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + + user, err := u.userRepo.GetByIDWithoutDeleted(ctx, pgID) + if err != nil { + return nil, fiber.NewError(fiber.StatusNotFound, err.Error()) + } + if user == nil { + return nil, fiber.NewError(fiber.StatusNotFound, "User not found") + } + + err = u.userRepo.Restore(ctx, pgID) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + user.IsDeleted = false + return user.ToResponse(), nil } -// RestoreUser implements [UserService]. -func (u *userService) RestoreUser(ctx context.Context, id string) (*response.UserResponse, error) { - panic("unimplemented") +func (u *userService) Search(ctx context.Context, dto *request.SearchUserDto) (*response.PaginatedResponse, error) { + arg := sqlc.SearchUsersParams{ + Limit: int32(dto.Limit + 1), + } + + if dto.Cursor != "" { + pgID, err := convert.StringToUUID(dto.Cursor) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid cursor format") + } + arg.Cursor = pgID + } + + if dto.Search != "" { + pgID, err := convert.StringToUUID(dto.Search) + if err == nil { + arg.SearchID = pgID + } else { + arg.SearchText = pgtype.Text{String: dto.Search, Valid: true} + } + } + + if dto.IsDeleted != nil { + arg.IsDeleted = pgtype.Bool{Bool: *dto.IsDeleted, Valid: true} + } + if len(dto.RoleIDs) > 0 { + var pgRoleIDs []pgtype.UUID + for _, idStr := range dto.RoleIDs { + pgID, err := convert.StringToUUID(idStr) + if err != nil { + continue + } + pgRoleIDs = append(pgRoleIDs, pgID) + } + arg.RoleIds = pgRoleIDs + } + + rows, err := u.userRepo.Search(ctx, arg) + if err != nil { + return nil, err + } + + hasMore := false + var nextCursor string + + if len(rows) > dto.Limit { + hasMore = true + nextCursor = rows[dto.Limit-1].ID + rows = rows[:dto.Limit] + } + + res := &response.PaginatedResponse{ + Data: rows, + Status: true, + Message: "", + } + res.Pagination.HasMore = hasMore + res.Pagination.NextCursor = nextCursor + + return res, nil } -// Search implements [UserService]. -func (u *userService) Search(ctx context.Context, id string) ([]*response.UserResponse, error) { - panic("unimplemented") -} - -// UpdateProfile implements [UserService]. -func (u *userService) UpdateProfile(ctx context.Context, id string) (*response.UserResponse, error) { - panic("unimplemented") -} +func (u *userService) GetUserByID(ctx context.Context, userId string) (*response.UserResponse, error) { + pgID, err := convert.StringToUUID(userId) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + user, err := u.userRepo.GetByID(ctx, pgID) + if err != nil { + return nil, fiber.NewError(fiber.StatusNotFound, err.Error()) + } + return user.ToResponse(), nil +} \ No newline at end of file diff --git a/pkg/cache/redis.go b/pkg/cache/redis.go index d3df970..957ad00 100644 --- a/pkg/cache/redis.go +++ b/pkg/cache/redis.go @@ -5,18 +5,22 @@ import ( "encoding/json" "fmt" "history-api/pkg/config" + "history-api/pkg/constants" "time" "github.com/redis/go-redis/v9" ) type Cache interface { - Set(ctx context.Context, key string, value any, ttl time.Duration) error - Get(ctx context.Context, key string, dest any) error - Del(ctx context.Context, keys ...string) error - DelByPattern(ctx context.Context, pattern string) error - MGet(ctx context.Context, keys ...string) [][]byte - MSet(ctx context.Context, pairs map[string]any, ttl time.Duration) error + Set(ctx context.Context, key string, value any, ttl time.Duration) error + Get(ctx context.Context, key string, dest any) error + Del(ctx context.Context, keys ...string) error + DelByPattern(ctx context.Context, pattern string) error + MGet(ctx context.Context, keys ...string) [][]byte + 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 { @@ -49,33 +53,45 @@ func NewRedisClient() (Cache, error) { 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 { - if len(keys) == 0 { - return nil - } - return r.client.Del(ctx, keys...).Err() + if len(keys) == 0 { + return nil + } + return r.client.Del(ctx, keys...).Err() } func (r *RedisClient) DelByPattern(ctx context.Context, pattern string) error { - var cursor uint64 - for { - keys, nextCursor, err := r.client.Scan(ctx, cursor, pattern, 100).Result() - if err != nil { - return fmt.Errorf("error scanning keys with pattern %s: %v", pattern, err) - } + var cursor uint64 + for { + keys, nextCursor, err := r.client.Scan(ctx, cursor, pattern, 1000).Result() + if err != nil { + return fmt.Errorf("error scanning keys with pattern %s: %v", pattern, err) + } - if len(keys) > 0 { - if err := r.client.Del(ctx, keys...).Err(); err != nil { - return fmt.Errorf("error deleting keys during scan: %v", err) + if len(keys) > 0 { + if err := r.client.Unlink(ctx, keys...).Err(); err != nil { + return fmt.Errorf("error unlinking keys during scan: %v", err) } - } + } - cursor = nextCursor - if cursor == 0 { - break - } - } - return nil + cursor = nextCursor + if cursor == 0 { + break + } + } + return nil } func (r *RedisClient) Set(ctx context.Context, key string, value any, ttl time.Duration) error { @@ -121,6 +137,21 @@ func (r *RedisClient) MGet(ctx context.Context, keys ...string) [][]byte { 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) { raws := c.MGet(ctx, keys...) final := make([]T, 0) diff --git a/pkg/config/config.go b/pkg/config/config.go index dbccdbf..d5b73d1 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -34,3 +34,11 @@ func GetConfig(config string) (string, error) { return data, nil } + +func GetConfigWithDefault(config, defaultValue string) string { + var data string = os.Getenv(config) + if data == "" { + return defaultValue + } + return data +} \ No newline at end of file diff --git a/pkg/constant/regex.go b/pkg/constants/regex.go similarity index 98% rename from pkg/constant/regex.go rename to pkg/constants/regex.go index c3c68b6..2c576c0 100644 --- a/pkg/constant/regex.go +++ b/pkg/constants/regex.go @@ -1,4 +1,4 @@ -package constant +package constants import ( "errors" diff --git a/pkg/constant/role.go b/pkg/constants/role.go similarity index 87% rename from pkg/constant/role.go rename to pkg/constants/role.go index 4bed768..ba1381d 100644 --- a/pkg/constant/role.go +++ b/pkg/constants/role.go @@ -1,4 +1,4 @@ -package constant +package constants type Role string @@ -18,10 +18,15 @@ func (r Role) Compare(other Role) bool { return r == other } +func (r Role) IsValid() bool { + return CheckValidRole(r) +} + func CheckValidRole(r Role) bool { return r == ADMIN || r == MOD || r == HISTORIAN || r == USER || r == BANNED } + func ParseRole(s string) (Role, bool) { r := Role(s) if CheckValidRole(r) { diff --git a/pkg/constants/sream.go b/pkg/constants/sream.go new file mode 100644 index 0000000..14aebf5 --- /dev/null +++ b/pkg/constants/sream.go @@ -0,0 +1,6 @@ +package constants + +const ( + StreamEmailName = "stream:email_tasks" + GroupEmailName = "email_workers_group" +) diff --git a/pkg/constant/status.go b/pkg/constants/status.go similarity index 96% rename from pkg/constant/status.go rename to pkg/constants/status.go index 9c4e8b9..e35ecaf 100644 --- a/pkg/constant/status.go +++ b/pkg/constants/status.go @@ -1,4 +1,4 @@ -package constant +package constants type StatusType int16 diff --git a/pkg/constants/task.go b/pkg/constants/task.go new file mode 100644 index 0000000..4f14926 --- /dev/null +++ b/pkg/constants/task.go @@ -0,0 +1,11 @@ +package constants + +type TaskType string + +const ( + TaskTypeSendEmailOTP TaskType = "SEND_EMAIL_OTP" +) + +func (t TaskType) String() string { + return string(t) +} \ No newline at end of file diff --git a/pkg/constants/time.go b/pkg/constants/time.go new file mode 100644 index 0000000..b248bcc --- /dev/null +++ b/pkg/constants/time.go @@ -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 +) diff --git a/pkg/constant/token.go b/pkg/constants/token.go similarity index 64% rename from pkg/constant/token.go rename to pkg/constants/token.go index e965359..76eaa37 100644 --- a/pkg/constant/token.go +++ b/pkg/constants/token.go @@ -1,4 +1,4 @@ -package constant +package constants 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 { switch v { case 1: @@ -37,4 +41,19 @@ func ParseTokenType(v int16) TokenType { default: 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 + } } \ No newline at end of file diff --git a/pkg/constant/verify.go b/pkg/constants/verify.go similarity index 96% rename from pkg/constant/verify.go rename to pkg/constants/verify.go index 462bd8c..23c70fe 100644 --- a/pkg/constant/verify.go +++ b/pkg/constants/verify.go @@ -1,4 +1,4 @@ -package constant +package constants type VerifyType int16 diff --git a/pkg/convert/convert.go b/pkg/convert/convert.go index 2eea37d..6841684 100644 --- a/pkg/convert/convert.go +++ b/pkg/convert/convert.go @@ -13,6 +13,15 @@ func UUIDToString(v pgtype.UUID) string { 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 { if v.Valid { return v.String diff --git a/pkg/email/email.go b/pkg/email/email.go new file mode 100644 index 0000000..24ed645 --- /dev/null +++ b/pkg/email/email.go @@ -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 +}