diff --git a/assets/resources/historian_approved.html b/assets/resources/historian_approved.html new file mode 100644 index 0000000..356164f --- /dev/null +++ b/assets/resources/historian_approved.html @@ -0,0 +1,71 @@ + + + + + Application Approved + + + + + + +
+ + + + + + + + + + + + + + + + +
+ 🎉 Application Approved +
+ Hello {{name}}, +
+ Your application to become a Historian has been + approved.

+ You can now access historian features and start contributing + content. +
+ + Go to Dashboard + +
+ © 2026 Black Cat Studio. All rights reserved. +
+
+ + diff --git a/assets/resources/historian_rejected.html b/assets/resources/historian_rejected.html new file mode 100644 index 0000000..1e6a84f --- /dev/null +++ b/assets/resources/historian_rejected.html @@ -0,0 +1,50 @@ + + + + + Application Rejected + + + + + + +
+ + + + + + + + + + + + + + + + +
+ ❌ Application Rejected +
+ Hello {{name}}, +
+ Unfortunately, your application to become a Historian has been + rejected. +

+ Reason:
+ {{reason}} +

+ You can update your information and reapply anytime. +
+ + Reapply + +
+ © 2026 Black Cat Studio. All rights reserved. +
+
+ + \ No newline at end of file diff --git a/cmd/api/server.go b/cmd/api/server.go index 7bf7350..cd37b2c 100644 --- a/cmd/api/server.go +++ b/cmd/api/server.go @@ -77,6 +77,7 @@ func (s *FiberServer) SetupServer(sqlPg sqlc.DBTX, sqlTile *sql.DB, redis cache. tileRepo := repositories.NewTileRepository(sqlTile, redis) tokenRepo := repositories.NewTokenRepository(redis) mediaRepo := repositories.NewMediaRepository(sqlPg, redis) + verificationRepo := repositories.NewVerificationRepository(sqlPg, redis) // service setup authService := services.NewAuthService(userRepo, roleRepo, tokenRepo, redis) @@ -84,19 +85,22 @@ func (s *FiberServer) SetupServer(sqlPg sqlc.DBTX, sqlTile *sql.DB, redis cache. roleService := services.NewRoleService(roleRepo) tileService := services.NewTileService(tileRepo) mediaService := services.NewMediaService(mediaRepo, tokenRepo, sclient, redis) + verificationService := services.NewVerificationService(verificationRepo, mediaRepo, userRepo, roleRepo, redis) // controller setup authController := controllers.NewAuthController(authService, oauth) - userController := controllers.NewUserController(userService, mediaService) + userController := controllers.NewUserController(userService, mediaService, verificationService) tileController := controllers.NewTileController(tileService) roleController := controllers.NewRoleController(roleService) mediaController := controllers.NewMediaController(mediaService) + verificationController := controllers.NewVerificationController(verificationService) // route setup routes.AuthRoutes(s.App, authController, userRepo) routes.UserRoutes(s.App, userController, userRepo) routes.MediaRoutes(s.App, mediaController, userRepo) routes.RoleRoutes(s.App, roleController, userRepo) + routes.VerificationRoutes(s.App, verificationController, userRepo) routes.TileRoutes(s.App, tileController) routes.NotFoundRoute(s.App) } diff --git a/cmd/worker/email/main.go b/cmd/worker/email/main.go index 36eb92d..33862ab 100644 --- a/cmd/worker/email/main.go +++ b/cmd/worker/email/main.go @@ -49,7 +49,7 @@ func runSingleWorker(ctx context.Context, rdb *redis.Client, consumerID int) { rdb.XAck(ctx, constants.StreamEmailName, constants.GroupEmailName, message.ID) continue } - + if taskType == constants.TaskTypeSendEmailOTP.String() { var data models.TokenEntity if err := json.Unmarshal([]byte(payloadStr), &data); err != nil { @@ -62,7 +62,26 @@ func runSingleWorker(ctx context.Context, rdb *redis.Client, consumerID int) { Str("email", data.Email). Msg("Processing email task") - errSend := email.SendMailOTP(data.Email, data.Token, data.TokenType) + errSend := email.SendMailOTP(&data) + if errSend != nil { + log.Error().Err(errSend).Str("email", data.Email).Msg("Failed to send email") + continue + } + } + + if taskType == constants.TaskTypeNotifyHistorianReview.String() { + var data models.UserVerificationStorageEntity + 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.SendHistorianReviewMail(&data) if errSend != nil { log.Error().Err(errSend).Str("email", data.Email).Msg("Failed to send email") continue diff --git a/db/migrations/000002_profiles.up.sql b/db/migrations/000002_profiles.up.sql index dd93458..5ee55ff 100644 --- a/db/migrations/000002_profiles.up.sql +++ b/db/migrations/000002_profiles.up.sql @@ -20,7 +20,7 @@ ON user_profiles USING gin (display_name gin_trgm_ops); CREATE INDEX idx_user_profiles_country ON user_profiles(country_code); -CREATE UNIQUE INDEX idx_user_profiles_phone +CREATE INDEX idx_user_profiles_phone ON user_profiles(phone); CREATE TRIGGER trigger_user_profiles_updated_at diff --git a/db/migrations/000005_verifications.up.sql b/db/migrations/000005_verifications.up.sql index 5d506cb..08aa94b 100644 --- a/db/migrations/000005_verifications.up.sql +++ b/db/migrations/000005_verifications.up.sql @@ -6,6 +6,7 @@ CREATE TABLE IF NOT EXISTS user_verifications ( is_deleted BOOLEAN NOT NULL DEFAULT false, status SMALLINT NOT NULL DEFAULT 1, reviewed_by UUID REFERENCES users(id), + review_note TEXT, reviewed_at TIMESTAMPTZ, created_at TIMESTAMPTZ DEFAULT now() ); diff --git a/db/query/users.sql b/db/query/users.sql index 8242791..8c897be 100644 --- a/db/query/users.sql +++ b/db/query/users.sql @@ -242,8 +242,8 @@ WHERE ) ) AND (sqlc.narg('auth_provider')::text IS NULL OR u.auth_provider = sqlc.narg('auth_provider')::text) - AND (sqlc.narg('created_from')::timestamp IS NULL OR u.created_at >= sqlc.narg('created_from')::timestamp) - AND (sqlc.narg('created_to')::timestamp IS NULL OR u.created_at <= sqlc.narg('created_to')::timestamp) + AND (sqlc.narg('created_from')::timestamptz IS NULL OR u.created_at >= sqlc.narg('created_from')::timestamptz) + AND (sqlc.narg('created_to')::timestamptz IS NULL OR u.created_at <= sqlc.narg('created_to')::timestamptz) AND ( sqlc.narg('search_text')::text IS NULL OR u.id::text ILIKE '%' || sqlc.narg('search_text')::text || '%' OR @@ -289,8 +289,8 @@ WHERE ) ) AND (sqlc.narg('auth_provider')::text IS NULL OR u.auth_provider = sqlc.narg('auth_provider')::text) - AND (sqlc.narg('created_from')::timestamp IS NULL OR u.created_at >= sqlc.narg('created_from')::timestamp) - AND (sqlc.narg('created_to')::timestamp IS NULL OR u.created_at <= sqlc.narg('created_to')::timestamp) + AND (sqlc.narg('created_from')::timestamptz IS NULL OR u.created_at >= sqlc.narg('created_from')::timestamptz) + AND (sqlc.narg('created_to')::timestamptz IS NULL OR u.created_at <= sqlc.narg('created_to')::timestamptz) AND ( sqlc.narg('search_text')::text IS NULL OR u.id::text ILIKE '%' || sqlc.narg('search_text')::text || '%' OR diff --git a/db/query/verification.sql b/db/query/verification.sql index f659442..726450e 100644 --- a/db/query/verification.sql +++ b/db/query/verification.sql @@ -15,6 +15,7 @@ SELECT uv.is_deleted, uv.status, uv.reviewed_by, + uv.review_note, uv.reviewed_at, uv.created_at, ( @@ -48,7 +49,8 @@ SELECT uv.is_deleted, uv.status, uv.reviewed_by, - uv.reviewed_at, + uv.reviewed_at, + uv.review_note, uv.created_at, ( SELECT COALESCE( @@ -77,7 +79,8 @@ ORDER BY uv.created_at DESC; UPDATE user_verifications SET status = $2, - reviewed_by = $3, + review_note = $3, + reviewed_by = $4, reviewed_at = now() WHERE id = $1 AND is_deleted = false; @@ -97,13 +100,10 @@ INSERT INTO verification_medias ( SELECT $1, unnest($2::uuid[]) ON CONFLICT DO NOTHING; --- name: BulkDeleteVerificationByMediaId :exec +-- name: BulkDeleteVerificationMediaByMediaId :many DELETE FROM verification_medias -WHERE media_id = $1; - --- name: DeleteVerificationMedias :exec -DELETE FROM verification_medias -WHERE verification_id = $1 AND media_id = ANY($2::uuid[]); +WHERE media_id = $1 +RETURNING verification_id; -- name: SearchUserVerifications :many SELECT @@ -113,7 +113,8 @@ SELECT uv.content, uv.is_deleted, uv.status, - uv.reviewed_by, + uv.reviewed_by, + uv.review_note, uv.reviewed_at, uv.created_at, ( @@ -139,11 +140,17 @@ FROM user_verifications uv WHERE uv.is_deleted = false AND (sqlc.narg('user_ids')::uuid[] IS NULL OR uv.user_id = ANY(sqlc.narg('user_ids')::uuid[])) - AND (sqlc.narg('verify_types')::text[] IS NULL OR uv.verify_type = ANY(sqlc.narg('verify_types')::text[])) - AND (sqlc.narg('statuses')::text[] IS NULL OR uv.status = ANY(sqlc.narg('statuses')::text[])) + AND ( + sqlc.narg('verify_types')::smallint[] IS NULL + OR uv.verify_type = ANY(sqlc.narg('verify_types')::smallint[]) + ) + AND ( + sqlc.narg('statuses')::smallint[] IS NULL + OR uv.status = ANY(sqlc.narg('statuses')::smallint[]) + ) AND (sqlc.narg('reviewed_by')::uuid IS NULL OR uv.reviewed_by = sqlc.narg('reviewed_by')::uuid) - AND (sqlc.narg('created_after')::timestamptz IS NULL OR uv.created_at >= sqlc.narg('created_after')::timestamptz) - AND (sqlc.narg('created_before')::timestamptz IS NULL OR uv.created_at <= sqlc.narg('created_before')::timestamptz) + AND (sqlc.narg('created_from')::timestamptz IS NULL OR uv.created_at >= sqlc.narg('created_from')::timestamptz) + AND (sqlc.narg('created_to')::timestamptz IS NULL OR uv.created_at <= sqlc.narg('created_to')::timestamptz) AND ( sqlc.narg('search_text')::text IS NULL OR uv.id::text ILIKE '%' || sqlc.narg('search_text')::text || '%' OR @@ -166,11 +173,17 @@ FROM user_verifications uv WHERE uv.is_deleted = false AND (sqlc.narg('user_ids')::uuid[] IS NULL OR uv.user_id = ANY(sqlc.narg('user_ids')::uuid[])) - AND (sqlc.narg('verify_types')::text[] IS NULL OR uv.verify_type = ANY(sqlc.narg('verify_types')::text[])) - AND (sqlc.narg('statuses')::text[] IS NULL OR uv.status = ANY(sqlc.narg('statuses')::text[])) + AND ( + sqlc.narg('verify_types')::smallint[] IS NULL + OR uv.verify_type = ANY(sqlc.narg('verify_types')::smallint[]) + ) + AND ( + sqlc.narg('statuses')::smallint[] IS NULL + OR uv.status = ANY(sqlc.narg('statuses')::smallint[]) + ) AND (sqlc.narg('reviewed_by')::uuid IS NULL OR uv.reviewed_by = sqlc.narg('reviewed_by')::uuid) - AND (sqlc.narg('created_after')::timestamptz IS NULL OR uv.created_at >= sqlc.narg('created_after')::timestamptz) - AND (sqlc.narg('created_before')::timestamptz IS NULL OR uv.created_at <= sqlc.narg('created_before')::timestamptz) + AND (sqlc.narg('created_from')::timestamptz IS NULL OR uv.created_at >= sqlc.narg('created_from')::timestamptz) + AND (sqlc.narg('created_to')::timestamptz IS NULL OR uv.created_at <= sqlc.narg('created_to')::timestamptz) AND ( sqlc.narg('search_text')::text IS NULL OR uv.id::text ILIKE '%' || sqlc.narg('search_text')::text || '%' OR diff --git a/db/schema.sql b/db/schema.sql index 1adc4d1..32638ab 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -59,6 +59,7 @@ CREATE TABLE IF NOT EXISTS user_verifications ( is_deleted BOOLEAN NOT NULL DEFAULT false, status SMALLINT NOT NULL DEFAULT 1, reviewed_by UUID REFERENCES users(id), + review_note TEXT, reviewed_at TIMESTAMPTZ, created_at TIMESTAMPTZ DEFAULT now() ); diff --git a/docs/docs.go b/docs/docs.go index da7bb5d..073a1ac 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -399,8 +399,322 @@ const docTemplate = `{ } } }, + "/historian/application": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Get list of historian applications with filters", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Historian Application" + ], + "summary": "Search historian applications", + "parameters": [ + { + "type": "string", + "name": "created_from", + "in": "query" + }, + { + "type": "string", + "name": "created_to", + "in": "query" + }, + { + "maximum": 100, + "minimum": 1, + "type": "integer", + "name": "limit", + "in": "query" + }, + { + "enum": [ + "asc", + "desc" + ], + "type": "string", + "name": "order", + "in": "query" + }, + { + "minimum": 1, + "type": "integer", + "name": "page", + "in": "query" + }, + { + "type": "string", + "name": "reviewed_by", + "in": "query" + }, + { + "maxLength": 200, + "minLength": 2, + "type": "string", + "name": "search", + "in": "query" + }, + { + "enum": [ + "id", + "created_at", + "reviewed_at", + "status" + ], + "type": "string", + "name": "sort", + "in": "query" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "csv", + "name": "statuses", + "in": "query" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "csv", + "name": "user_ids", + "in": "query" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "csv", + "name": "verify_types", + "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" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Submit application to become historian", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Historian Application" + ], + "summary": "Create historian application", + "parameters": [ + { + "description": "Application data", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_request.CreateUserVerificationDto" + } + } + ], + "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" + } + } + } + } + }, + "/historian/application/{id}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Get historian application detail", + "produces": [ + "application/json" + ], + "tags": [ + "Historian Application" + ], + "summary": "Get application by ID", + "parameters": [ + { + "type": "string", + "description": "Verification 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" + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Delete historian application", + "produces": [ + "application/json" + ], + "tags": [ + "Historian Application" + ], + "summary": "Delete application", + "parameters": [ + { + "type": "string", + "description": "Verification 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" + } + } + } + } + }, + "/historian/application/{id}/status": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Approve or reject historian application", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Historian Application" + ], + "summary": "Update application status", + "parameters": [ + { + "type": "string", + "description": "Verification ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Status update", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_request.UpdateVerificationStatusDto" + } + } + ], + "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" + } + } + } + } + }, "/media": { "get": { + "security": [ + { + "BearerAuth": [] + } + ], "description": "Search media with filters, pagination", "consumes": [ "application/json" @@ -414,21 +728,73 @@ const docTemplate = `{ "summary": "Search media", "parameters": [ { + "maximum": 100, + "minimum": 1, "type": "integer", - "description": "Page number", - "name": "page", - "in": "query" - }, - { - "type": "integer", - "description": "Items per page", "name": "limit", "in": "query" }, { + "minimum": 0, + "type": "integer", + "name": "max_size", + "in": "query" + }, + { + "maxLength": 100, "type": "string", - "description": "Search keyword", - "name": "keyword", + "name": "mime_type", + "in": "query" + }, + { + "minimum": 0, + "type": "integer", + "name": "min_size", + "in": "query" + }, + { + "enum": [ + "asc", + "desc" + ], + "type": "string", + "name": "order", + "in": "query" + }, + { + "minimum": 1, + "type": "integer", + "name": "page", + "in": "query" + }, + { + "maxLength": 200, + "minLength": 2, + "type": "string", + "name": "search", + "in": "query" + }, + { + "enum": [ + "id", + "created_at", + "updated_at", + "size", + "original_name", + "storage_key", + "mime_type" + ], + "type": "string", + "name": "sort", + "in": "query" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "csv", + "name": "user_ids", "in": "query" } ], @@ -518,15 +884,19 @@ const docTemplate = `{ "parameters": [ { "type": "string", - "description": "File name", - "name": "filename", + "name": "content_type", "in": "query", "required": true }, { "type": "string", - "description": "Content type", - "name": "contentType", + "name": "fileName", + "in": "query", + "required": true + }, + { + "type": "integer", + "name": "size", "in": "query", "required": true } @@ -573,11 +943,13 @@ const docTemplate = `{ "summary": "Confirm presigned upload", "parameters": [ { - "type": "string", - "description": "Storage key", - "name": "key", - "in": "query", - "required": true + "description": "Request body", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_request.PreSignedCompleteDto" + } } ], "responses": { @@ -653,6 +1025,11 @@ const docTemplate = `{ }, "/media/{id}": { "get": { + "security": [ + { + "BearerAuth": [] + } + ], "description": "Retrieve a media file by its ID", "consumes": [ "application/json" @@ -1218,6 +1595,44 @@ const docTemplate = `{ } } }, + "/users/{id}/application": { + "get": { + "description": "Retrieve media list by specific user ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Get user's media by user 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" + } + } + } + } + }, "/users/{id}/media": { "get": { "description": "Retrieve media list by specific user ID", @@ -1480,6 +1895,34 @@ const docTemplate = `{ } } }, + "history-api_internal_dtos_request.CreateUserVerificationDto": { + "type": "object", + "required": [ + "content", + "verify_type" + ], + "properties": { + "content": { + "type": "string", + "minLength": 10 + }, + "media_ids": { + "type": "array", + "items": { + "type": "string" + } + }, + "verify_type": { + "type": "string", + "enum": [ + "ID_CARD", + "EDUCATION", + "EXPERT", + "OTHER" + ] + } + } + }, "history-api_internal_dtos_request.ForgotPasswordDto": { "type": "object", "required": [ @@ -1517,6 +1960,17 @@ const docTemplate = `{ } } }, + "history-api_internal_dtos_request.PreSignedCompleteDto": { + "type": "object", + "required": [ + "token_id" + ], + "properties": { + "token_id": { + "type": "string" + } + } + }, "history-api_internal_dtos_request.SignInDto": { "type": "object", "required": [ @@ -1602,6 +2056,28 @@ const docTemplate = `{ } } }, + "history-api_internal_dtos_request.UpdateVerificationStatusDto": { + "type": "object", + "required": [ + "review_note", + "status" + ], + "properties": { + "review_note": { + "type": "string", + "maxLength": 3000, + "minLength": 5 + }, + "status": { + "type": "string", + "enum": [ + "PENDING", + "APPROVED", + "REJECTED" + ] + } + } + }, "history-api_internal_dtos_request.VerifyTokenDto": { "type": "object", "required": [ diff --git a/docs/swagger.json b/docs/swagger.json index 22652ef..20fe6f4 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -392,8 +392,322 @@ } } }, + "/historian/application": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Get list of historian applications with filters", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Historian Application" + ], + "summary": "Search historian applications", + "parameters": [ + { + "type": "string", + "name": "created_from", + "in": "query" + }, + { + "type": "string", + "name": "created_to", + "in": "query" + }, + { + "maximum": 100, + "minimum": 1, + "type": "integer", + "name": "limit", + "in": "query" + }, + { + "enum": [ + "asc", + "desc" + ], + "type": "string", + "name": "order", + "in": "query" + }, + { + "minimum": 1, + "type": "integer", + "name": "page", + "in": "query" + }, + { + "type": "string", + "name": "reviewed_by", + "in": "query" + }, + { + "maxLength": 200, + "minLength": 2, + "type": "string", + "name": "search", + "in": "query" + }, + { + "enum": [ + "id", + "created_at", + "reviewed_at", + "status" + ], + "type": "string", + "name": "sort", + "in": "query" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "csv", + "name": "statuses", + "in": "query" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "csv", + "name": "user_ids", + "in": "query" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "csv", + "name": "verify_types", + "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" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Submit application to become historian", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Historian Application" + ], + "summary": "Create historian application", + "parameters": [ + { + "description": "Application data", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_request.CreateUserVerificationDto" + } + } + ], + "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" + } + } + } + } + }, + "/historian/application/{id}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Get historian application detail", + "produces": [ + "application/json" + ], + "tags": [ + "Historian Application" + ], + "summary": "Get application by ID", + "parameters": [ + { + "type": "string", + "description": "Verification 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" + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Delete historian application", + "produces": [ + "application/json" + ], + "tags": [ + "Historian Application" + ], + "summary": "Delete application", + "parameters": [ + { + "type": "string", + "description": "Verification 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" + } + } + } + } + }, + "/historian/application/{id}/status": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Approve or reject historian application", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Historian Application" + ], + "summary": "Update application status", + "parameters": [ + { + "type": "string", + "description": "Verification ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Status update", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_request.UpdateVerificationStatusDto" + } + } + ], + "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" + } + } + } + } + }, "/media": { "get": { + "security": [ + { + "BearerAuth": [] + } + ], "description": "Search media with filters, pagination", "consumes": [ "application/json" @@ -407,21 +721,73 @@ "summary": "Search media", "parameters": [ { + "maximum": 100, + "minimum": 1, "type": "integer", - "description": "Page number", - "name": "page", - "in": "query" - }, - { - "type": "integer", - "description": "Items per page", "name": "limit", "in": "query" }, { + "minimum": 0, + "type": "integer", + "name": "max_size", + "in": "query" + }, + { + "maxLength": 100, "type": "string", - "description": "Search keyword", - "name": "keyword", + "name": "mime_type", + "in": "query" + }, + { + "minimum": 0, + "type": "integer", + "name": "min_size", + "in": "query" + }, + { + "enum": [ + "asc", + "desc" + ], + "type": "string", + "name": "order", + "in": "query" + }, + { + "minimum": 1, + "type": "integer", + "name": "page", + "in": "query" + }, + { + "maxLength": 200, + "minLength": 2, + "type": "string", + "name": "search", + "in": "query" + }, + { + "enum": [ + "id", + "created_at", + "updated_at", + "size", + "original_name", + "storage_key", + "mime_type" + ], + "type": "string", + "name": "sort", + "in": "query" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "csv", + "name": "user_ids", "in": "query" } ], @@ -511,15 +877,19 @@ "parameters": [ { "type": "string", - "description": "File name", - "name": "filename", + "name": "content_type", "in": "query", "required": true }, { "type": "string", - "description": "Content type", - "name": "contentType", + "name": "fileName", + "in": "query", + "required": true + }, + { + "type": "integer", + "name": "size", "in": "query", "required": true } @@ -566,11 +936,13 @@ "summary": "Confirm presigned upload", "parameters": [ { - "type": "string", - "description": "Storage key", - "name": "key", - "in": "query", - "required": true + "description": "Request body", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_request.PreSignedCompleteDto" + } } ], "responses": { @@ -646,6 +1018,11 @@ }, "/media/{id}": { "get": { + "security": [ + { + "BearerAuth": [] + } + ], "description": "Retrieve a media file by its ID", "consumes": [ "application/json" @@ -1211,6 +1588,44 @@ } } }, + "/users/{id}/application": { + "get": { + "description": "Retrieve media list by specific user ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Get user's media by user 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" + } + } + } + } + }, "/users/{id}/media": { "get": { "description": "Retrieve media list by specific user ID", @@ -1473,6 +1888,34 @@ } } }, + "history-api_internal_dtos_request.CreateUserVerificationDto": { + "type": "object", + "required": [ + "content", + "verify_type" + ], + "properties": { + "content": { + "type": "string", + "minLength": 10 + }, + "media_ids": { + "type": "array", + "items": { + "type": "string" + } + }, + "verify_type": { + "type": "string", + "enum": [ + "ID_CARD", + "EDUCATION", + "EXPERT", + "OTHER" + ] + } + } + }, "history-api_internal_dtos_request.ForgotPasswordDto": { "type": "object", "required": [ @@ -1510,6 +1953,17 @@ } } }, + "history-api_internal_dtos_request.PreSignedCompleteDto": { + "type": "object", + "required": [ + "token_id" + ], + "properties": { + "token_id": { + "type": "string" + } + } + }, "history-api_internal_dtos_request.SignInDto": { "type": "object", "required": [ @@ -1595,6 +2049,28 @@ } } }, + "history-api_internal_dtos_request.UpdateVerificationStatusDto": { + "type": "object", + "required": [ + "review_note", + "status" + ], + "properties": { + "review_note": { + "type": "string", + "maxLength": 3000, + "minLength": 5 + }, + "status": { + "type": "string", + "enum": [ + "PENDING", + "APPROVED", + "REJECTED" + ] + } + } + }, "history-api_internal_dtos_request.VerifyTokenDto": { "type": "object", "required": [ diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 3e4cc7e..bcf061d 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -43,6 +43,26 @@ definitions: - email - token_type type: object + history-api_internal_dtos_request.CreateUserVerificationDto: + properties: + content: + minLength: 10 + type: string + media_ids: + items: + type: string + type: array + verify_type: + enum: + - ID_CARD + - EDUCATION + - EXPERT + - OTHER + type: string + required: + - content + - verify_type + type: object history-api_internal_dtos_request.ForgotPasswordDto: properties: email: @@ -69,6 +89,13 @@ definitions: required: - media_ids type: object + history-api_internal_dtos_request.PreSignedCompleteDto: + properties: + token_id: + type: string + required: + - token_id + type: object history-api_internal_dtos_request.SignInDto: properties: email: @@ -132,6 +159,22 @@ definitions: website: type: string type: object + history-api_internal_dtos_request.UpdateVerificationStatusDto: + properties: + review_note: + maxLength: 3000 + minLength: 5 + type: string + status: + enum: + - PENDING + - APPROVED + - REJECTED + type: string + required: + - review_note + - status + type: object history-api_internal_dtos_request.VerifyTokenDto: properties: email: @@ -451,6 +494,204 @@ paths: summary: Verify a security token tags: - Auth + /historian/application: + get: + consumes: + - application/json + description: Get list of historian applications with filters + parameters: + - in: query + name: created_from + type: string + - in: query + name: created_to + type: string + - in: query + maximum: 100 + minimum: 1 + name: limit + type: integer + - enum: + - asc + - desc + in: query + name: order + type: string + - in: query + minimum: 1 + name: page + type: integer + - in: query + name: reviewed_by + type: string + - in: query + maxLength: 200 + minLength: 2 + name: search + type: string + - enum: + - id + - created_at + - reviewed_at + - status + in: query + name: sort + type: string + - collectionFormat: csv + in: query + items: + type: string + name: statuses + type: array + - collectionFormat: csv + in: query + items: + type: string + name: user_ids + type: array + - collectionFormat: csv + in: query + items: + type: string + name: verify_types + type: array + 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 historian applications + tags: + - Historian Application + post: + consumes: + - application/json + description: Submit application to become historian + parameters: + - description: Application data + in: body + name: body + required: true + schema: + $ref: '#/definitions/history-api_internal_dtos_request.CreateUserVerificationDto' + 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: Create historian application + tags: + - Historian Application + /historian/application/{id}: + delete: + description: Delete historian application + parameters: + - description: Verification 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 application + tags: + - Historian Application + get: + description: Get historian application detail + parameters: + - description: Verification 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 application by ID + tags: + - Historian Application + /historian/application/{id}/status: + put: + consumes: + - application/json + description: Approve or reject historian application + parameters: + - description: Verification ID + in: path + name: id + required: true + type: string + - description: Status update + in: body + name: body + required: true + schema: + $ref: '#/definitions/history-api_internal_dtos_request.UpdateVerificationStatusDto' + 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 application status + tags: + - Historian Application /media: delete: consumes: @@ -484,18 +725,55 @@ paths: - application/json description: Search media with filters, pagination parameters: - - description: Page number - in: query - name: page - type: integer - - description: Items per page - in: query + - in: query + maximum: 100 + minimum: 1 name: limit type: integer - - description: Search keyword - in: query - name: keyword + - in: query + minimum: 0 + name: max_size + type: integer + - in: query + maxLength: 100 + name: mime_type type: string + - in: query + minimum: 0 + name: min_size + type: integer + - enum: + - asc + - desc + in: query + name: order + type: string + - in: query + minimum: 1 + name: page + type: integer + - in: query + maxLength: 200 + minLength: 2 + name: search + type: string + - enum: + - id + - created_at + - updated_at + - size + - original_name + - storage_key + - mime_type + in: query + name: sort + type: string + - collectionFormat: csv + in: query + items: + type: string + name: user_ids + type: array produces: - application/json responses: @@ -511,6 +789,8 @@ paths: description: Internal Server Error schema: $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' + security: + - BearerAuth: [] summary: Search media tags: - Media @@ -562,6 +842,8 @@ paths: description: Internal Server Error schema: $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' + security: + - BearerAuth: [] summary: Get media by ID tags: - Media @@ -571,16 +853,18 @@ paths: - application/json description: Generate a presigned URL for direct upload to storage parameters: - - description: File name - in: query - name: filename + - in: query + name: content_type required: true type: string - - description: Content type - in: query - name: contentType + - in: query + name: fileName required: true type: string + - in: query + name: size + required: true + type: integer produces: - application/json responses: @@ -607,11 +891,12 @@ paths: - application/json description: Confirm that upload via presigned URL is completed parameters: - - description: Storage key - in: query - name: key + - description: Request body + in: body + name: data required: true - type: string + schema: + $ref: '#/definitions/history-api_internal_dtos_request.PreSignedCompleteDto' produces: - application/json responses: @@ -935,6 +1220,31 @@ paths: summary: Update user profile tags: - Users + /users/{id}/application: + get: + consumes: + - application/json + description: Retrieve media list by specific user ID + 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' + summary: Get user's media by user ID + tags: + - Users /users/{id}/media: get: consumes: diff --git a/internal/controllers/authController.go b/internal/controllers/authController.go index 6504a41..626f6b4 100644 --- a/internal/controllers/authController.go +++ b/internal/controllers/authController.go @@ -8,6 +8,7 @@ import ( "history-api/internal/dtos/response" "history-api/internal/models" "history-api/internal/services" + "history-api/pkg/config" "history-api/pkg/validator" "strings" "time" @@ -137,7 +138,6 @@ func (h *AuthController) Signup(c fiber.Ctx) error { }) } - func (h *AuthController) getRefreshToken(c fiber.Ctx) string { auth := c.Get("Authorization") if auth != "" { @@ -326,9 +326,10 @@ func (h *AuthController) GoogleLogin(c fiber.Ctx) error { state := uuid.New().String() + feUrl := config.GetConfigWithDefault("FRONTEND_URL", "http://localhost:3000") redirect := c.Query("redirect") if redirect == "" { - redirect = "http://localhost:3000" + redirect = feUrl } data := models.OAuthState{ @@ -443,10 +444,10 @@ func (h *AuthController) GoogleCallback(c fiber.Ctx) error { "https://localhost:3001": true, "http://localhost:5500": true, } - + feUrl := config.GetConfigWithDefault("FRONTEND_URL", "http://localhost:3000") redirectURL := data.RedirectURL if !allowed[redirectURL] { - redirectURL = "http://localhost:3000" + redirectURL = feUrl } return c.Redirect().To(redirectURL) diff --git a/internal/controllers/mediaController.go b/internal/controllers/mediaController.go index 5dfbff9..c2326f7 100644 --- a/internal/controllers/mediaController.go +++ b/internal/controllers/mediaController.go @@ -25,6 +25,7 @@ func NewMediaController(svc services.MediaService) *MediaController { // @Tags Media // @Accept json // @Produce json +// @Security BearerAuth // @Param id path string true "Media ID" // @Success 200 {object} response.CommonResponse // @Failure 500 {object} response.CommonResponse @@ -52,9 +53,8 @@ func (m *MediaController) GetMediaByID(c fiber.Ctx) error { // @Tags Media // @Accept json // @Produce json -// @Param page query int false "Page number" -// @Param limit query int false "Items per page" -// @Param keyword query string false "Search keyword" +// @Security BearerAuth +// @Param query query request.SearchMediaDto false "Search Query" // @Success 200 {object} response.PaginatedResponse // @Failure 400 {object} response.CommonResponse // @Failure 500 {object} response.CommonResponse @@ -220,9 +220,7 @@ func (m *MediaController) UploadServerSide(c fiber.Ctx) error { // @Accept json // @Produce json // @Security BearerAuth -// @Param fileName query string true "File name" -// @Param content_type query string true "Content type" -// @Param size query int true "File size" +// @Param query query request.PreSignedDto false "PreSigned" // @Success 200 {object} response.CommonResponse // @Failure 400 {object} response.CommonResponse // @Failure 500 {object} response.CommonResponse @@ -255,7 +253,7 @@ func (m *MediaController) GeneratePresignedURL(c fiber.Ctx) error { // @Accept json // @Produce json // @Security BearerAuth -// @Param data body PreSignedCompleteDto true "Request body" +// @Param data body request.PreSignedCompleteDto true "Request body" // @Success 200 {object} response.CommonResponse // @Failure 400 {object} response.CommonResponse // @Failure 500 {object} response.CommonResponse diff --git a/internal/controllers/userController.go b/internal/controllers/userController.go index a90a59f..75e6a58 100644 --- a/internal/controllers/userController.go +++ b/internal/controllers/userController.go @@ -14,12 +14,18 @@ import ( type UserController struct { service services.UserService mediaService services.MediaService + verificationService services.VerificationService } -func NewUserController(svc services.UserService, mediaSvc services.MediaService) *UserController { +func NewUserController( + svc services.UserService, + mediaSvc services.MediaService, + verificationSvc services.VerificationService, +) *UserController { return &UserController{ service: svc, mediaService: mediaSvc, + verificationService: verificationSvc, } } @@ -106,6 +112,33 @@ func (h *UserController) GetMediaByUserID(c fiber.Ctx) error { }) } +// GetApplicationUserID godoc +// @Summary Get user's media by user ID +// @Description Retrieve media list by specific user ID +// @Tags Users +// @Accept json +// @Produce json +// @Param id path string true "User ID" +// @Success 200 {object} response.CommonResponse +// @Failure 500 {object} response.CommonResponse +// @Router /users/{id}/application [get] +func (h *UserController) GetVerificationByUserID(c fiber.Ctx) error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + userId := c.Params("id") + res, err := h.verificationService.GetVerificationByUserID(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, + }) +} + // UpdateProfile godoc // @Summary Update user profile // @Description Update the profile details of the currently authenticated user diff --git a/internal/controllers/verificationController.go b/internal/controllers/verificationController.go new file mode 100644 index 0000000..c5434c7 --- /dev/null +++ b/internal/controllers/verificationController.go @@ -0,0 +1,186 @@ +package controllers + +import ( + "context" + "history-api/internal/dtos/response" + "history-api/internal/dtos/request" + "history-api/internal/services" + "history-api/pkg/validator" + "time" + + "github.com/gofiber/fiber/v3" +) + +type VerificationController struct { + service services.VerificationService +} + +func NewVerificationController(svc services.VerificationService) *VerificationController { + return &VerificationController{service: svc} +} + +// @Summary Get application by ID +// @Description Get historian application detail +// @Tags Historian Application +// @Produce json +// @Param id path string true "Verification ID" +// @Success 200 {object} response.CommonResponse +// @Failure 500 {object} response.CommonResponse +// @Security BearerAuth +// @Router /historian/application/{id} [get] +func (m *VerificationController) GetVerificationByID(c fiber.Ctx) error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + verificationId := c.Params("id") + res, err := m.service.GetVerificationByID(ctx, verificationId) + 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, + }) +} + +// @Summary Search historian applications +// @Description Get list of historian applications with filters +// @Tags Historian Application +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param query query request.SearchUserVerificationDto false "Search Query" +// @Success 200 {object} response.CommonResponse +// @Failure 400 {object} response.CommonResponse +// @Failure 500 {object} response.CommonResponse +// @Router /historian/application [get] +func (m *VerificationController) SearchVerification(c fiber.Ctx) error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + dto := &request.SearchUserVerificationDto{} + if err := validator.ValidateQueryDto(c, dto); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(response.CommonResponse{ + Status: false, + Message: err.Error(), + }) + } + res, err := m.service.SearchVerification(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) +} + +// @Summary Delete application +// @Description Delete historian application +// @Tags Historian Application +// @Produce json +// @Param id path string true "Verification ID" +// @Success 200 {object} response.CommonResponse +// @Failure 500 {object} response.CommonResponse +// @Security BearerAuth +// @Router /historian/application/{id} [delete] +func (m *VerificationController) DeleteVerification(c fiber.Ctx) error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + claimsVal := c.Locals("user_claims") + if claimsVal == nil { + return c.Status(fiber.StatusUnauthorized).JSON(response.CommonResponse{ + Status: false, + Message: "Unauthorized", + }) + } + + claims, ok := claimsVal.(*response.JWTClaims) + if !ok { + return c.Status(fiber.StatusUnauthorized).JSON(response.CommonResponse{ + Status: false, + Message: "Invalid user claims", + }) + } + + verificationId := c.Params("id") + err := m.service.DeleteVerification(ctx, claims, verificationId) + 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: "Verification deleted successfully", + }) +} + +// @Summary Create historian application +// @Description Submit application to become historian +// @Tags Historian Application +// @Accept json +// @Produce json +// @Param body body request.CreateUserVerificationDto true "Application data" +// @Success 200 {object} response.CommonResponse +// @Failure 400 {object} response.CommonResponse +// @Failure 500 {object} response.CommonResponse +// @Security BearerAuth +// @Router /historian/application [post] +func (m *VerificationController) CreateVerification(c fiber.Ctx) error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + dto := &request.CreateUserVerificationDto{} + if err := validator.ValidateBodyDto(c, dto); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(response.CommonResponse{ + Status: false, + Message: err.Error(), + }) + } + res, err := m.service.CreateVerification(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(res) +} + +// @Summary Update application status +// @Description Approve or reject historian application +// @Tags Historian Application +// @Accept json +// @Produce json +// @Param id path string true "Verification ID" +// @Param body body request.UpdateVerificationStatusDto true "Status update" +// @Success 200 {object} response.CommonResponse +// @Failure 400 {object} response.CommonResponse +// @Failure 500 {object} response.CommonResponse +// @Security BearerAuth +// @Router /historian/application/{id}/status [put] +func (m *VerificationController) UpdateVerificationStatus(c fiber.Ctx) error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + dto := &request.UpdateVerificationStatusDto{} + if err := validator.ValidateBodyDto(c, dto); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(response.CommonResponse{ + Status: false, + Message: err.Error(), + }) + } + verificationId := c.Params("id") + res, err := m.service.UpdateStatusVerification(ctx, c.Locals("uid").(string), verificationId, dto) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(response.CommonResponse{ + Status: false, + Message: err.Error(), + }) + } + return c.Status(fiber.StatusOK).JSON(res) +} diff --git a/internal/dtos/request/media.go b/internal/dtos/request/media.go index dcda6a6..e87fa92 100644 --- a/internal/dtos/request/media.go +++ b/internal/dtos/request/media.go @@ -21,5 +21,5 @@ type SearchMediaDto struct { } type MediaBulkDeleteDto struct { - MediaIDs []string `json:"media_ids" validate:"required,dive,uuid"` + MediaIDs []string `json:"media_ids" validate:"required,dive,uuid"` } diff --git a/internal/dtos/request/verification.go b/internal/dtos/request/verification.go index a38e8b4..660c7bd 100644 --- a/internal/dtos/request/verification.go +++ b/internal/dtos/request/verification.go @@ -1,25 +1,31 @@ package request -import "time" +import ( + "time" +) +// SearchUserVerificationDto swagger model type SearchUserVerificationDto struct { PaginationDto - Sort string `json:"sort" query:"sort" validate:"omitempty,oneof=id created_at reviewed_at status"` + Sort string `json:"sort" query:"sort" validate:"omitempty,oneof=id created_at reviewed_at status"` Search string `json:"search" query:"search" validate:"omitempty,min=2,max=200"` UserIDs []string `json:"user_ids" query:"user_ids" validate:"omitempty,dive,uuid"` - VerifyTypes []string `json:"verify_types" query:"verify_types" validate:"omitempty,dive,ascii"` - Statuses []string `json:"statuses" query:"statuses" validate:"omitempty,dive,ascii"` + VerifyTypes []string `json:"verify_types" query:"verify_types" validate:"omitempty,dive,oneof=ID_CARD EDUCATION EXPERT OTHER"` + Statuses []string `json:"statuses" query:"statuses" validate:"omitempty,dive,oneof=PENDING APPROVED REJECTED"` ReviewedBy *string `json:"reviewed_by" query:"reviewed_by" validate:"omitempty,uuid"` - CreatedAfter *time.Time `json:"created_after" query:"created_after" validate:"omitempty"` - CreatedBefore *time.Time `json:"created_before" query:"created_before" validate:"omitempty,gtfield=CreatedAfter"` + CreatedFrom *time.Time `json:"created_from" query:"created_from"` + CreatedTo *time.Time `json:"created_to" query:"created_to"` } +// CreateUserVerificationDto swagger model type CreateUserVerificationDto struct { VerifyType string `json:"verify_type" validate:"required,oneof=ID_CARD EDUCATION EXPERT OTHER"` - Content string `json:"content" validate:"required"` + Content string `json:"content" validate:"required,min=10"` MediaIDs []string `json:"media_ids" validate:"omitempty,dive,uuid"` } +// UpdateVerificationStatusDto swagger model type UpdateVerificationStatusDto struct { Status string `json:"status" validate:"required,oneof=PENDING APPROVED REJECTED"` + ReviewNote string `json:"review_note" validate:"required,min=5,max=3000"` } \ No newline at end of file diff --git a/internal/dtos/response/media.go b/internal/dtos/response/media.go index 7eb2759..c400dd0 100644 --- a/internal/dtos/response/media.go +++ b/internal/dtos/response/media.go @@ -28,5 +28,5 @@ type MediaSimpleResponse struct { MimeType string `json:"mime_type"` Size int64 `json:"size"` FileMetadata []byte `json:"file_metadata"` - CreatedAt time.Time `json:"created_at"` + CreatedAt *time.Time `json:"created_at"` } diff --git a/internal/dtos/response/verification.go b/internal/dtos/response/verification.go index ae5c582..e901585 100644 --- a/internal/dtos/response/verification.go +++ b/internal/dtos/response/verification.go @@ -3,13 +3,14 @@ package response import "time" type UserVerificationResponse struct { - ID string `json:"id"` - UserID string `json:"user_id"` - VerifyType string `json:"verify_type"` - Content string `json:"content"` - Status string `json:"status"` - ReviewedBy *string `json:"reviewed_by"` - ReviewedAt *time.Time `json:"reviewed_at"` - CreatedAt time.Time `json:"created_at"` + ID string `json:"id"` + UserID string `json:"user_id"` + VerifyType string `json:"verify_type"` + Content string `json:"content"` + Status string `json:"status"` + ReviewedBy string `json:"reviewed_by"` + ReviewNote string `json:"review_note"` + ReviewedAt *time.Time `json:"reviewed_at"` + CreatedAt *time.Time `json:"created_at"` Medias []*MediaSimpleResponse `json:"media"` } diff --git a/internal/gen/sqlc/models.go b/internal/gen/sqlc/models.go index 33f0539..eb7605c 100644 --- a/internal/gen/sqlc/models.go +++ b/internal/gen/sqlc/models.go @@ -68,6 +68,7 @@ type UserVerification struct { IsDeleted bool `json:"is_deleted"` Status int16 `json:"status"` ReviewedBy pgtype.UUID `json:"reviewed_by"` + ReviewNote pgtype.Text `json:"review_note"` ReviewedAt pgtype.Timestamptz `json:"reviewed_at"` CreatedAt pgtype.Timestamptz `json:"created_at"` } diff --git a/internal/gen/sqlc/users.sql.go b/internal/gen/sqlc/users.sql.go index dd744ef..2d94a82 100644 --- a/internal/gen/sqlc/users.sql.go +++ b/internal/gen/sqlc/users.sql.go @@ -25,8 +25,8 @@ WHERE ) ) AND ($3::text IS NULL OR u.auth_provider = $3::text) - AND ($4::timestamp IS NULL OR u.created_at >= $4::timestamp) - AND ($5::timestamp IS NULL OR u.created_at <= $5::timestamp) + AND ($4::timestamptz IS NULL OR u.created_at >= $4::timestamptz) + AND ($5::timestamptz IS NULL OR u.created_at <= $5::timestamptz) AND ( $6::text IS NULL OR u.id::text ILIKE '%' || $6::text || '%' OR @@ -43,12 +43,12 @@ WHERE ` type CountUsersParams struct { - IsDeleted pgtype.Bool `json:"is_deleted"` - RoleIds []pgtype.UUID `json:"role_ids"` - AuthProvider pgtype.Text `json:"auth_provider"` - CreatedFrom pgtype.Timestamp `json:"created_from"` - CreatedTo pgtype.Timestamp `json:"created_to"` - SearchText pgtype.Text `json:"search_text"` + IsDeleted pgtype.Bool `json:"is_deleted"` + RoleIds []pgtype.UUID `json:"role_ids"` + AuthProvider pgtype.Text `json:"auth_provider"` + CreatedFrom pgtype.Timestamptz `json:"created_from"` + CreatedTo pgtype.Timestamptz `json:"created_to"` + SearchText pgtype.Text `json:"search_text"` } func (q *Queries) CountUsers(ctx context.Context, arg CountUsersParams) (int64, error) { @@ -399,8 +399,8 @@ WHERE ) ) AND ($3::text IS NULL OR u.auth_provider = $3::text) - AND ($4::timestamp IS NULL OR u.created_at >= $4::timestamp) - AND ($5::timestamp IS NULL OR u.created_at <= $5::timestamp) + AND ($4::timestamptz IS NULL OR u.created_at >= $4::timestamptz) + AND ($5::timestamptz IS NULL OR u.created_at <= $5::timestamptz) AND ( $6::text IS NULL OR u.id::text ILIKE '%' || $6::text || '%' OR @@ -434,16 +434,16 @@ OFFSET $9 ` type SearchUsersParams struct { - IsDeleted pgtype.Bool `json:"is_deleted"` - RoleIds []pgtype.UUID `json:"role_ids"` - AuthProvider pgtype.Text `json:"auth_provider"` - CreatedFrom pgtype.Timestamp `json:"created_from"` - CreatedTo pgtype.Timestamp `json:"created_to"` - SearchText pgtype.Text `json:"search_text"` - Sort interface{} `json:"sort"` - Order interface{} `json:"order"` - Offset int32 `json:"offset"` - Limit int32 `json:"limit"` + IsDeleted pgtype.Bool `json:"is_deleted"` + RoleIds []pgtype.UUID `json:"role_ids"` + AuthProvider pgtype.Text `json:"auth_provider"` + CreatedFrom pgtype.Timestamptz `json:"created_from"` + CreatedTo pgtype.Timestamptz `json:"created_to"` + SearchText pgtype.Text `json:"search_text"` + Sort interface{} `json:"sort"` + Order interface{} `json:"order"` + Offset int32 `json:"offset"` + Limit int32 `json:"limit"` } type SearchUsersRow struct { diff --git a/internal/gen/sqlc/verification.sql.go b/internal/gen/sqlc/verification.sql.go index 65ec182..698aeef 100644 --- a/internal/gen/sqlc/verification.sql.go +++ b/internal/gen/sqlc/verification.sql.go @@ -11,14 +11,30 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) -const bulkDeleteVerificationByMediaId = `-- name: BulkDeleteVerificationByMediaId :exec +const bulkDeleteVerificationMediaByMediaId = `-- name: BulkDeleteVerificationMediaByMediaId :many DELETE FROM verification_medias WHERE media_id = $1 +RETURNING verification_id ` -func (q *Queries) BulkDeleteVerificationByMediaId(ctx context.Context, mediaID pgtype.UUID) error { - _, err := q.db.Exec(ctx, bulkDeleteVerificationByMediaId, mediaID) - return err +func (q *Queries) BulkDeleteVerificationMediaByMediaId(ctx context.Context, mediaID pgtype.UUID) ([]pgtype.UUID, error) { + rows, err := q.db.Query(ctx, bulkDeleteVerificationMediaByMediaId, mediaID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []pgtype.UUID{} + for rows.Next() { + var verification_id pgtype.UUID + if err := rows.Scan(&verification_id); err != nil { + return nil, err + } + items = append(items, verification_id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil } const countUserVerifications = `-- name: CountUserVerifications :one @@ -27,8 +43,14 @@ FROM user_verifications uv WHERE uv.is_deleted = false AND ($1::uuid[] IS NULL OR uv.user_id = ANY($1::uuid[])) - AND ($2::text[] IS NULL OR uv.verify_type = ANY($2::text[])) - AND ($3::text[] IS NULL OR uv.status = ANY($3::text[])) + AND ( + $2::smallint[] IS NULL + OR uv.verify_type = ANY($2::smallint[]) + ) + AND ( + $3::smallint[] IS NULL + OR uv.status = ANY($3::smallint[]) + ) AND ($4::uuid IS NULL OR uv.reviewed_by = $4::uuid) AND ($5::timestamptz IS NULL OR uv.created_at >= $5::timestamptz) AND ($6::timestamptz IS NULL OR uv.created_at <= $6::timestamptz) @@ -40,13 +62,13 @@ WHERE ` type CountUserVerificationsParams struct { - UserIds []pgtype.UUID `json:"user_ids"` - VerifyTypes []string `json:"verify_types"` - Statuses []string `json:"statuses"` - ReviewedBy pgtype.UUID `json:"reviewed_by"` - CreatedAfter pgtype.Timestamptz `json:"created_after"` - CreatedBefore pgtype.Timestamptz `json:"created_before"` - SearchText pgtype.Text `json:"search_text"` + UserIds []pgtype.UUID `json:"user_ids"` + VerifyTypes []int16 `json:"verify_types"` + Statuses []int16 `json:"statuses"` + ReviewedBy pgtype.UUID `json:"reviewed_by"` + CreatedFrom pgtype.Timestamptz `json:"created_from"` + CreatedTo pgtype.Timestamptz `json:"created_to"` + SearchText pgtype.Text `json:"search_text"` } func (q *Queries) CountUserVerifications(ctx context.Context, arg CountUserVerificationsParams) (int64, error) { @@ -55,8 +77,8 @@ func (q *Queries) CountUserVerifications(ctx context.Context, arg CountUserVerif arg.VerifyTypes, arg.Statuses, arg.ReviewedBy, - arg.CreatedAfter, - arg.CreatedBefore, + arg.CreatedFrom, + arg.CreatedTo, arg.SearchText, ) var count int64 @@ -70,7 +92,7 @@ INSERT INTO user_verifications ( ) VALUES ( $1, $2, $3 ) -RETURNING id, user_id, verify_type, content, is_deleted, status, reviewed_by, reviewed_at, created_at +RETURNING id, user_id, verify_type, content, is_deleted, status, reviewed_by, review_note, reviewed_at, created_at ` type CreateUserVerificationParams struct { @@ -90,6 +112,7 @@ func (q *Queries) CreateUserVerification(ctx context.Context, arg CreateUserVeri &i.IsDeleted, &i.Status, &i.ReviewedBy, + &i.ReviewNote, &i.ReviewedAt, &i.CreatedAt, ) @@ -140,21 +163,6 @@ func (q *Queries) DeleteVerificationMedia(ctx context.Context, arg DeleteVerific return err } -const deleteVerificationMedias = `-- name: DeleteVerificationMedias :exec -DELETE FROM verification_medias -WHERE verification_id = $1 AND media_id = ANY($2::uuid[]) -` - -type DeleteVerificationMediasParams struct { - VerificationID pgtype.UUID `json:"verification_id"` - Column2 []pgtype.UUID `json:"column_2"` -} - -func (q *Queries) DeleteVerificationMedias(ctx context.Context, arg DeleteVerificationMediasParams) error { - _, err := q.db.Exec(ctx, deleteVerificationMedias, arg.VerificationID, arg.Column2) - return err -} - const getUserVerificationByID = `-- name: GetUserVerificationByID :one SELECT uv.id, @@ -164,6 +172,7 @@ SELECT uv.is_deleted, uv.status, uv.reviewed_by, + uv.review_note, uv.reviewed_at, uv.created_at, ( @@ -197,6 +206,7 @@ type GetUserVerificationByIDRow struct { IsDeleted bool `json:"is_deleted"` Status int16 `json:"status"` ReviewedBy pgtype.UUID `json:"reviewed_by"` + ReviewNote pgtype.Text `json:"review_note"` ReviewedAt pgtype.Timestamptz `json:"reviewed_at"` CreatedAt pgtype.Timestamptz `json:"created_at"` Medias []byte `json:"medias"` @@ -213,6 +223,7 @@ func (q *Queries) GetUserVerificationByID(ctx context.Context, id pgtype.UUID) ( &i.IsDeleted, &i.Status, &i.ReviewedBy, + &i.ReviewNote, &i.ReviewedAt, &i.CreatedAt, &i.Medias, @@ -229,7 +240,8 @@ SELECT uv.is_deleted, uv.status, uv.reviewed_by, - uv.reviewed_at, + uv.reviewed_at, + uv.review_note, uv.created_at, ( SELECT COALESCE( @@ -264,6 +276,7 @@ type GetUserVerificationsRow struct { Status int16 `json:"status"` ReviewedBy pgtype.UUID `json:"reviewed_by"` ReviewedAt pgtype.Timestamptz `json:"reviewed_at"` + ReviewNote pgtype.Text `json:"review_note"` CreatedAt pgtype.Timestamptz `json:"created_at"` Medias []byte `json:"medias"` } @@ -286,6 +299,7 @@ func (q *Queries) GetUserVerifications(ctx context.Context, userID pgtype.UUID) &i.Status, &i.ReviewedBy, &i.ReviewedAt, + &i.ReviewNote, &i.CreatedAt, &i.Medias, ); err != nil { @@ -307,7 +321,8 @@ SELECT uv.content, uv.is_deleted, uv.status, - uv.reviewed_by, + uv.reviewed_by, + uv.review_note, uv.reviewed_at, uv.created_at, ( @@ -333,8 +348,14 @@ FROM user_verifications uv WHERE uv.is_deleted = false AND ($1::uuid[] IS NULL OR uv.user_id = ANY($1::uuid[])) - AND ($2::text[] IS NULL OR uv.verify_type = ANY($2::text[])) - AND ($3::text[] IS NULL OR uv.status = ANY($3::text[])) + AND ( + $2::smallint[] IS NULL + OR uv.verify_type = ANY($2::smallint[]) + ) + AND ( + $3::smallint[] IS NULL + OR uv.status = ANY($3::smallint[]) + ) AND ($4::uuid IS NULL OR uv.reviewed_by = $4::uuid) AND ($5::timestamptz IS NULL OR uv.created_at >= $5::timestamptz) AND ($6::timestamptz IS NULL OR uv.created_at <= $6::timestamptz) @@ -356,17 +377,17 @@ OFFSET $10 ` type SearchUserVerificationsParams struct { - UserIds []pgtype.UUID `json:"user_ids"` - VerifyTypes []string `json:"verify_types"` - Statuses []string `json:"statuses"` - ReviewedBy pgtype.UUID `json:"reviewed_by"` - CreatedAfter pgtype.Timestamptz `json:"created_after"` - CreatedBefore pgtype.Timestamptz `json:"created_before"` - SearchText pgtype.Text `json:"search_text"` - Sort interface{} `json:"sort"` - Order interface{} `json:"order"` - Offset int32 `json:"offset"` - Limit int32 `json:"limit"` + UserIds []pgtype.UUID `json:"user_ids"` + VerifyTypes []int16 `json:"verify_types"` + Statuses []int16 `json:"statuses"` + ReviewedBy pgtype.UUID `json:"reviewed_by"` + CreatedFrom pgtype.Timestamptz `json:"created_from"` + CreatedTo pgtype.Timestamptz `json:"created_to"` + SearchText pgtype.Text `json:"search_text"` + Sort interface{} `json:"sort"` + Order interface{} `json:"order"` + Offset int32 `json:"offset"` + Limit int32 `json:"limit"` } type SearchUserVerificationsRow struct { @@ -377,6 +398,7 @@ type SearchUserVerificationsRow struct { IsDeleted bool `json:"is_deleted"` Status int16 `json:"status"` ReviewedBy pgtype.UUID `json:"reviewed_by"` + ReviewNote pgtype.Text `json:"review_note"` ReviewedAt pgtype.Timestamptz `json:"reviewed_at"` CreatedAt pgtype.Timestamptz `json:"created_at"` Medias []byte `json:"medias"` @@ -388,8 +410,8 @@ func (q *Queries) SearchUserVerifications(ctx context.Context, arg SearchUserVer arg.VerifyTypes, arg.Statuses, arg.ReviewedBy, - arg.CreatedAfter, - arg.CreatedBefore, + arg.CreatedFrom, + arg.CreatedTo, arg.SearchText, arg.Sort, arg.Order, @@ -411,6 +433,7 @@ func (q *Queries) SearchUserVerifications(ctx context.Context, arg SearchUserVer &i.IsDeleted, &i.Status, &i.ReviewedBy, + &i.ReviewNote, &i.ReviewedAt, &i.CreatedAt, &i.Medias, @@ -429,7 +452,8 @@ const updateUserVerificationStatus = `-- name: UpdateUserVerificationStatus :exe UPDATE user_verifications SET status = $2, - reviewed_by = $3, + review_note = $3, + reviewed_by = $4, reviewed_at = now() WHERE id = $1 AND is_deleted = false ` @@ -437,10 +461,16 @@ WHERE id = $1 AND is_deleted = false type UpdateUserVerificationStatusParams struct { ID pgtype.UUID `json:"id"` Status int16 `json:"status"` + ReviewNote pgtype.Text `json:"review_note"` ReviewedBy pgtype.UUID `json:"reviewed_by"` } func (q *Queries) UpdateUserVerificationStatus(ctx context.Context, arg UpdateUserVerificationStatusParams) error { - _, err := q.db.Exec(ctx, updateUserVerificationStatus, arg.ID, arg.Status, arg.ReviewedBy) + _, err := q.db.Exec(ctx, updateUserVerificationStatus, + arg.ID, + arg.Status, + arg.ReviewNote, + arg.ReviewedBy, + ) return err } diff --git a/internal/middlewares/roleMiddleware.go b/internal/middlewares/roleMiddleware.go index 2576d86..7876b4f 100644 --- a/internal/middlewares/roleMiddleware.go +++ b/internal/middlewares/roleMiddleware.go @@ -60,3 +60,24 @@ func RequireAllRoles(required ...constants.Role) fiber.Handler { return c.Next() } } + +func ForbidRoles(forbidden ...constants.Role) fiber.Handler { + return func(c fiber.Ctx) error { + userRoles, err := getRoles(c) + if err != nil { + return err + } + + if len(userRoles) == 0 { + return c.Next() + } + + for _, ur := range userRoles { + if slices.Contains(forbidden, ur) { + return fiber.ErrForbidden + } + } + + return c.Next() + } +} \ No newline at end of file diff --git a/internal/models/media.go b/internal/models/media.go index 9ea4d7e..99d21fa 100644 --- a/internal/models/media.go +++ b/internal/models/media.go @@ -23,7 +23,7 @@ type MediaSimpleEntity struct { MimeType string `json:"mime_type"` Size int64 `json:"size"` FileMetadata []byte `json:"file_metadata"` - CreatedAt time.Time `json:"created_at"` + CreatedAt *time.Time `json:"created_at"` } @@ -53,6 +53,18 @@ func (e *MediaEntity) ToResponse() *response.MediaResponse { } } +func (e *MediaEntity) ToSimpleEntity() *MediaSimpleEntity { + return &MediaSimpleEntity{ + ID: e.ID, + StorageKey: e.StorageKey, + OriginalName: e.OriginalName, + MimeType: e.MimeType, + Size: e.Size, + FileMetadata: e.FileMetadata, + CreatedAt: e.CreatedAt, + } +} + func MediaEntitiesToResponse(entities []*MediaEntity) []*response.MediaResponse { responses := make([]*response.MediaResponse, len(entities)) for i, entity := range entities { @@ -62,7 +74,7 @@ func MediaEntitiesToResponse(entities []*MediaEntity) []*response.MediaResponse } -func MediaEntitiesToStorageEntitye(entities []*MediaEntity) []*MediaStorageEntity { +func MediaEntitiesToStorageEntity(entities []*MediaEntity) []*MediaStorageEntity { responses := make([]*MediaStorageEntity, len(entities)) for i, entity := range entities { responses[i] = entity.ToStorageEntity() diff --git a/internal/models/verification.go b/internal/models/verification.go index 7cdb141..5006c88 100644 --- a/internal/models/verification.go +++ b/internal/models/verification.go @@ -10,16 +10,23 @@ import ( type UserVerificationEntity struct { ID string `json:"id"` UserID string `json:"user_id"` - VerifyType string `json:"verify_type"` + VerifyType constants.VerifyType `json:"verify_type"` Content string `json:"content"` IsDeleted bool `json:"is_deleted"` - Status constants.VerifyType `json:"status"` - ReviewedBy *string `json:"reviewed_by"` + Status constants.StatusType `json:"status"` + ReviewedBy string `json:"reviewed_by"` + ReviewNote string `json:"review_note"` ReviewedAt *time.Time `json:"reviewed_at"` - CreatedAt time.Time `json:"created_at"` + CreatedAt *time.Time `json:"created_at"` Media []*MediaSimpleEntity `json:"media"` } +type UserVerificationStorageEntity struct { + Email string `json:"email"` + Status constants.StatusType `json:"status"` + ReviewNote string `json:"review_note"` +} + func (u *UserVerificationEntity) ParseMedia(data []byte) error { if len(data) == 0 { u.Media = []*MediaSimpleEntity{} @@ -47,20 +54,26 @@ func (u *UserVerificationEntity) ToResponse() *response.UserVerificationResponse res := &response.UserVerificationResponse{ ID: u.ID, UserID: u.UserID, - VerifyType: u.VerifyType, + VerifyType: u.VerifyType.String(), Content: u.Content, Status: u.Status.String(), + ReviewNote: u.ReviewNote, CreatedAt: u.CreatedAt, Medias: mediaResponses, } - if u.ReviewedBy != nil { - res.ReviewedBy = u.ReviewedBy - } - if u.ReviewedAt != nil { res.ReviewedAt = u.ReviewedAt } return res } + +func UserVerificationsEntitiesToResponse(entities []*UserVerificationEntity) []*response.UserVerificationResponse { + responses := make([]*response.UserVerificationResponse, len(entities)) + for i, entity := range entities { + responses[i] = entity.ToResponse() + } + return responses +} + diff --git a/internal/repositories/mediaRepository.go b/internal/repositories/mediaRepository.go index ebe3fc0..76d482f 100644 --- a/internal/repositories/mediaRepository.go +++ b/internal/repositories/mediaRepository.go @@ -141,8 +141,7 @@ func (r *mediaRepository) Create(ctx context.Context, params sqlc.CreateMediaPar CreatedAt: convert.TimeToPtr(row.CreatedAt), UpdatedAt: convert.TimeToPtr(row.UpdatedAt), } - cacheId := fmt.Sprintf("media:id:%s", media.ID) - _ = r.c.Set(ctx, cacheId, media, constants.NormalCacheDuration) + _ = r.c.Set(ctx, fmt.Sprintf("media:id:%s", media.ID), media, constants.NormalCacheDuration) _ = r.c.Del(ctx, fmt.Sprintf("media:userId:%s", convert.UUIDToString(params.UserID))) return &media, nil } diff --git a/internal/repositories/verificationRepository.go b/internal/repositories/verificationRepository.go index d3aa4ed..cb58a43 100644 --- a/internal/repositories/verificationRepository.go +++ b/internal/repositories/verificationRepository.go @@ -2,9 +2,14 @@ package repositories import ( "context" + "crypto/md5" + "encoding/json" + "fmt" "history-api/internal/gen/sqlc" "history-api/internal/models" "history-api/pkg/cache" + "history-api/pkg/constants" + "history-api/pkg/convert" "github.com/jackc/pgx/v5/pgtype" ) @@ -13,10 +18,13 @@ type VerificationRepository interface { GetByID(ctx context.Context, id pgtype.UUID) (*models.UserVerificationEntity, error) GetByUserID(ctx context.Context, id pgtype.UUID) ([]*models.UserVerificationEntity, error) Count(ctx context.Context, params sqlc.CountUserVerificationsParams) (int64, error) + Create(ctx context.Context, params sqlc.CreateUserVerificationParams) (*models.UserVerificationEntity, error) + UpdateStatus(ctx context.Context, params sqlc.UpdateUserVerificationStatusParams) error Search(ctx context.Context, params sqlc.SearchUserVerificationsParams) ([]*models.UserVerificationEntity, error) Delete(ctx context.Context, id pgtype.UUID) error CreateVerificationMedia(ctx context.Context, params sqlc.CreateVerificationMediaParams) error - DeleteVerificationMedia(ctx context.Context, params sqlc.DeleteVerificationMediasParams) error + DeleteVerificationMedia(ctx context.Context, params sqlc.DeleteVerificationMediaParams) error + BulkVerificationMediaByMediaId(ctx context.Context, mediaId pgtype.UUID) error } type verificationRepository struct { @@ -24,9 +32,297 @@ type verificationRepository struct { c cache.Cache } -// func NewVerificationRepository(db sqlc.DBTX, c cache.Cache) VerificationRepository { -// return &verificationRepository{ -// q: sqlc.New(db), -// c: c, -// } -// } +func NewVerificationRepository(db sqlc.DBTX, c cache.Cache) VerificationRepository { + return &verificationRepository{ + q: sqlc.New(db), + c: c, + } +} + +func (v *verificationRepository) 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 (v *verificationRepository) GetByID(ctx context.Context, id pgtype.UUID) (*models.UserVerificationEntity, error) { + cacheId := fmt.Sprintf("verification:id:%s", convert.UUIDToString(id)) + var verification models.UserVerificationEntity + err := v.c.Get(ctx, cacheId, &verification) + if err == nil { + return &verification, nil + } + + row, err := v.q.GetUserVerificationByID(ctx, id) + if err != nil { + return nil, err + } + + verification = models.UserVerificationEntity{ + ID: convert.UUIDToString(row.ID), + UserID: convert.UUIDToString(row.UserID), + VerifyType: constants.ParseVerifyType(row.VerifyType), + Content: convert.TextToString(row.Content), + IsDeleted: row.IsDeleted, + Status: constants.ParseStatusType(row.Status), + ReviewNote: convert.TextToString(row.ReviewNote), + ReviewedBy: convert.UUIDToString(row.ReviewedBy), + ReviewedAt: convert.TimeToPtr(row.ReviewedAt), + CreatedAt: convert.TimeToPtr(row.CreatedAt), + } + if err := verification.ParseMedia(row.Medias); err != nil { + return nil, err + } + + _ = v.c.Set(ctx, cacheId, verification, constants.NormalCacheDuration) + + return &verification, nil +} + +func (v *verificationRepository) getByIDsWithFallback(ctx context.Context, ids []string) ([]*models.UserVerificationEntity, error) { + if len(ids) == 0 { + return []*models.UserVerificationEntity{}, nil + } + keys := make([]string, len(ids)) + for i, id := range ids { + keys[i] = fmt.Sprintf("verification:id:%s", id) + } + raws := v.c.MGet(ctx, keys...) + + var verification []*models.UserVerificationEntity + missingVerificationToCache := make(map[string]any) + + for i, b := range raws { + if len(b) > 0 { + var u models.UserVerificationEntity + if err := json.Unmarshal(b, &u); err == nil { + verification = append(verification, &u) + } + } else { + pgId := pgtype.UUID{} + err := pgId.Scan(ids[i]) + if err != nil { + continue + } + dbUser, err := v.GetByID(ctx, pgId) + if err == nil && dbUser != nil { + verification = append(verification, dbUser) + missingVerificationToCache[keys[i]] = dbUser + } + } + } + + if len(missingVerificationToCache) > 0 { + _ = v.c.MSet(ctx, missingVerificationToCache, constants.NormalCacheDuration) + } + + return verification, nil +} + +func (v *verificationRepository) Count(ctx context.Context, params sqlc.CountUserVerificationsParams) (int64, error) { + queryKey := v.generateQueryKey("verification:count", params) + var count int64 + if err := v.c.Get(ctx, queryKey, &count); err == nil { + _ = v.c.Set(ctx, queryKey, count, constants.ListCacheDuration) + return count, nil + } + count, err := v.q.CountUserVerifications(ctx, params) + if err != nil { + return 0, err + } + _ = v.c.Set(ctx, queryKey, count, constants.ListCacheDuration) + return count, nil +} + +func (v *verificationRepository) Create(ctx context.Context, params sqlc.CreateUserVerificationParams) (*models.UserVerificationEntity, error) { + row, err := v.q.CreateUserVerification(ctx, params) + if err != nil { + return nil, err + } + + go func() { + bgCtx := context.Background() + _ = v.c.DelByPattern(bgCtx, "verification:search*") + _ = v.c.DelByPattern(bgCtx, "verification:count*") + }() + + verification := models.UserVerificationEntity{ + ID: convert.UUIDToString(row.ID), + UserID: convert.UUIDToString(row.UserID), + VerifyType: constants.ParseVerifyType(row.VerifyType), + Content: convert.TextToString(row.Content), + IsDeleted: row.IsDeleted, + Status: constants.ParseStatusType(row.Status), + ReviewNote: convert.TextToString(row.ReviewNote), + ReviewedBy: convert.UUIDToString(row.ReviewedBy), + ReviewedAt: convert.TimeToPtr(row.ReviewedAt), + CreatedAt: convert.TimeToPtr(row.CreatedAt), + } + _ = v.c.Del(ctx, fmt.Sprintf("verification:userId:%s", convert.UUIDToString(params.UserID))) + return &verification, nil +} + +func (v *verificationRepository) UpdateStatus(ctx context.Context, params sqlc.UpdateUserVerificationStatusParams) error { + err := v.q.UpdateUserVerificationStatus(ctx, params) + return err +} + +func (v *verificationRepository) Delete(ctx context.Context, id pgtype.UUID) error { + err := v.q.DeleteUserVerification(ctx, id) + if err != nil { + return err + } + + cacheId := fmt.Sprintf("verification:id:%s", convert.UUIDToString(id)) + _ = v.c.Del(ctx, cacheId) + + return nil +} + +func (v *verificationRepository) BulkVerificationMediaByMediaId(ctx context.Context, mediaId pgtype.UUID) error { + ids, err := v.q.BulkDeleteVerificationMediaByMediaId(ctx, mediaId) + if err != nil { + return err + } + + listCacheId := make([]string, 0) + for _, it := range ids { + id := convert.UUIDToString(it) + if id == "" { + continue + } + listCacheId = append(listCacheId, fmt.Sprintf("verification:id:%s", id)) + } + + go func() { + bgCtx := context.Background() + _ = v.c.Del(bgCtx, listCacheId...) + }() + + return nil +} + +func (v *verificationRepository) CreateVerificationMedia(ctx context.Context, params sqlc.CreateVerificationMediaParams) error { + err := v.q.CreateVerificationMedia(ctx, params) + return err +} + +func (v *verificationRepository) DeleteVerificationMedia(ctx context.Context, params sqlc.DeleteVerificationMediaParams) error { + err := v.q.DeleteVerificationMedia(ctx, params) + return err +} + +func (v *verificationRepository) GetByUserID(ctx context.Context, userId pgtype.UUID) ([]*models.UserVerificationEntity, error) { + queryKey := fmt.Sprintf("verification:userId:%s", convert.UUIDToString(userId)) + var cachedIDs []string + if err := v.c.Get(ctx, queryKey, &cachedIDs); err == nil && len(cachedIDs) > 0 { + listItem, err := v.getByIDsWithFallback(ctx, cachedIDs) + if err != nil { + return nil, err + } + newCachedIDs := make([]string, len(listItem)) + for i, media := range listItem { + newCachedIDs[i] = media.ID + } + _ = v.c.Set(ctx, queryKey, newCachedIDs, constants.ListCacheDuration) + return listItem, nil + } + + rows, err := v.q.GetUserVerifications(ctx, userId) + if err != nil { + return nil, err + } + var items []*models.UserVerificationEntity + var ids []string + itemToCache := make(map[string]any) + + for _, row := range rows { + verification := &models.UserVerificationEntity{ + ID: convert.UUIDToString(row.ID), + UserID: convert.UUIDToString(row.UserID), + VerifyType: constants.ParseVerifyType(row.VerifyType), + Content: convert.TextToString(row.Content), + IsDeleted: row.IsDeleted, + Status: constants.ParseStatusType(row.Status), + ReviewNote: convert.TextToString(row.ReviewNote), + ReviewedBy: convert.UUIDToString(row.ReviewedBy), + ReviewedAt: convert.TimeToPtr(row.ReviewedAt), + CreatedAt: convert.TimeToPtr(row.CreatedAt), + } + if err := verification.ParseMedia(row.Medias); err != nil { + return nil, err + } + ids = append(ids, verification.ID) + items = append(items, verification) + + itemToCache[fmt.Sprintf("verification:id:%s", verification.ID)] = verification + } + + if len(itemToCache) > 0 { + _ = v.c.MSet(ctx, itemToCache, constants.NormalCacheDuration) + } + + if len(ids) > 0 { + _ = v.c.Set(ctx, queryKey, ids, constants.ListCacheDuration) + } + + return items, nil +} + +func (v *verificationRepository) Search(ctx context.Context, params sqlc.SearchUserVerificationsParams) ([]*models.UserVerificationEntity, error) { + queryKey := v.generateQueryKey("verification:search", params) + var cachedIDs []string + if err := v.c.Get(ctx, queryKey, &cachedIDs); err == nil && len(cachedIDs) > 0 { + listItem, err := v.getByIDsWithFallback(ctx, cachedIDs) + if err != nil { + return nil, err + } + newCachedIDs := make([]string, len(listItem)) + for i, media := range listItem { + newCachedIDs[i] = media.ID + } + _ = v.c.Set(ctx, queryKey, newCachedIDs, constants.ListCacheDuration) + return listItem, err + } + + rows, err := v.q.SearchUserVerifications(ctx, params) + if err != nil { + return nil, err + } + var items []*models.UserVerificationEntity + var ids []string + itemToCache := make(map[string]any) + + for _, row := range rows { + verification := &models.UserVerificationEntity{ + ID: convert.UUIDToString(row.ID), + UserID: convert.UUIDToString(row.UserID), + VerifyType: constants.ParseVerifyType(row.VerifyType), + Content: convert.TextToString(row.Content), + IsDeleted: row.IsDeleted, + Status: constants.ParseStatusType(row.Status), + ReviewNote: convert.TextToString(row.ReviewNote), + ReviewedBy: convert.UUIDToString(row.ReviewedBy), + ReviewedAt: convert.TimeToPtr(row.ReviewedAt), + CreatedAt: convert.TimeToPtr(row.CreatedAt), + } + if err := verification.ParseMedia(row.Medias); err != nil { + return nil, err + } + + ids = append(ids, verification.ID) + items = append(items, verification) + + itemToCache[fmt.Sprintf("verification:id:%s", verification.ID)] = verification + } + + if len(itemToCache) > 0 { + _ = v.c.MSet(ctx, itemToCache, constants.NormalCacheDuration) + } + + if len(ids) > 0 { + _ = v.c.Set(ctx, queryKey, ids, constants.ListCacheDuration) + } + + return items, nil +} diff --git a/internal/routes/userRoute.go b/internal/routes/userRoute.go index e769f5f..1df0036 100644 --- a/internal/routes/userRoute.go +++ b/internal/routes/userRoute.go @@ -58,6 +58,13 @@ func UserRoutes(app *fiber.App, controller *controllers.UserController, userRepo controller.GetMediaByUserID, ) + route.Get( + "/:id/application", + middlewares.JwtAccess(userRepo), + middlewares.RequireAnyRole(constants.ADMIN, constants.MOD), + controller.GetVerificationByUserID, + ) + route.Patch( "/:id/restore", middlewares.JwtAccess(userRepo), diff --git a/internal/routes/verificationRoute.go b/internal/routes/verificationRoute.go new file mode 100644 index 0000000..1cf25a4 --- /dev/null +++ b/internal/routes/verificationRoute.go @@ -0,0 +1,48 @@ +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 VerificationRoutes(app *fiber.App, controller *controllers.VerificationController, userRepo repositories.UserRepository) { + route := app.Group("/historian/application") + route.Get( + "/", + middlewares.JwtAccess(userRepo), + middlewares.RequireAnyRole(constants.ADMIN, constants.MOD), + controller.SearchVerification, + ) + + route.Post( + "/", + middlewares.JwtAccess(userRepo), + middlewares.ForbidRoles(constants.HISTORIAN), + controller.CreateVerification, + ) + + route.Get( + "/:id", + middlewares.JwtAccess(userRepo), + middlewares.RequireAnyRole(constants.ADMIN, constants.MOD), + controller.GetVerificationByID, + ) + + route.Delete( + "/:id", + middlewares.JwtAccess(userRepo), + controller.DeleteVerification, + ) + + route.Put( + "/:id/status", + middlewares.JwtAccess(userRepo), + middlewares.RequireAnyRole(constants.ADMIN, constants.MOD), + controller.UpdateVerificationStatus, + ) + +} diff --git a/internal/services/mediaService.go b/internal/services/mediaService.go index 6911358..007c712 100644 --- a/internal/services/mediaService.go +++ b/internal/services/mediaService.go @@ -229,6 +229,7 @@ func (m *mediaService) SearchMedia(ctx context.Context, dto *request.SearchMedia return response.BuildPaginatedResponse(rows, totalRecords, dto.Page, dto.Limit), nil } + func (m *mediaService) UploadServerSide(ctx context.Context, userId string, fileHeader *multipart.FileHeader) (*response.MediaResponse, error) { userIdUUID, err := convert.StringToUUID(userId) if err != nil { diff --git a/internal/services/userService.go b/internal/services/userService.go index f1a4ece..51c0fb3 100644 --- a/internal/services/userService.go +++ b/internal/services/userService.go @@ -307,11 +307,11 @@ func (m *userService) fillSearchArgs(arg *sqlc.SearchUsersParams, dto *request.S } if dto.CreatedFrom != nil { - arg.CreatedFrom = pgtype.Timestamp{Time: *dto.CreatedFrom, Valid: true} + arg.CreatedFrom = pgtype.Timestamptz{Time: *dto.CreatedFrom, Valid: true} } if dto.CreatedTo != nil { - arg.CreatedTo = pgtype.Timestamp{Time: *dto.CreatedTo, Valid: true} + arg.CreatedTo = pgtype.Timestamptz{Time: *dto.CreatedTo, Valid: true} } if dto.IsDeleted != nil { diff --git a/internal/services/verificationService.go b/internal/services/verificationService.go new file mode 100644 index 0000000..a8cada5 --- /dev/null +++ b/internal/services/verificationService.go @@ -0,0 +1,380 @@ +package services + +import ( + "context" + "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/constants" + "history-api/pkg/convert" + "slices" + + "github.com/gofiber/fiber/v3" + "github.com/jackc/pgx/v5/pgtype" + "golang.org/x/sync/errgroup" +) + +type VerificationService interface { + GetVerificationByID(ctx context.Context, verificationId string) (*response.UserVerificationResponse, error) + GetVerificationByUserID(ctx context.Context, userId string) ([]*response.UserVerificationResponse, error) + SearchVerification(ctx context.Context, dto *request.SearchUserVerificationDto) (*response.PaginatedResponse, error) + DeleteVerification(ctx context.Context, claims *response.JWTClaims, verificationId string) error + CreateVerification(ctx context.Context, userId string, dto *request.CreateUserVerificationDto) (*response.UserVerificationResponse, error) + UpdateStatusVerification(ctx context.Context, userId string, verificationId string, dto *request.UpdateVerificationStatusDto) (*response.UserVerificationResponse, error) +} + +type verificationService struct { + verificationRepo repositories.VerificationRepository + mediaRepo repositories.MediaRepository + userRepo repositories.UserRepository + roleRepo repositories.RoleRepository + c cache.Cache +} + +func NewVerificationService( + verificationRepo repositories.VerificationRepository, + mediaRepo repositories.MediaRepository, + userRepo repositories.UserRepository, + roleRepo repositories.RoleRepository, + c cache.Cache, +) VerificationService { + return &verificationService{ + verificationRepo: verificationRepo, + mediaRepo: mediaRepo, + userRepo: userRepo, + roleRepo: roleRepo, + c: c, + } +} + +func (v *verificationService) CreateVerification(ctx context.Context, userId string, dto *request.CreateUserVerificationDto) (*response.UserVerificationResponse, error) { + verifyType := constants.ParseVerifyTypeText(dto.VerifyType) + if verifyType == constants.VerifyUnknown { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Unknown verify type!") + } + + pgID, err := convert.StringToUUID(userId) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + + mediaList, err := v.mediaRepo.GetByIDs(ctx, dto.MediaIDs) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + + if len(mediaList) != len(dto.MediaIDs) { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Some media IDs are invalid!") + } + + item, err := v.verificationRepo.Create( + ctx, + sqlc.CreateUserVerificationParams{ + VerifyType: verifyType.Int16(), + Content: convert.PtrToText(&dto.Content), + UserID: pgID, + }, + ) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + + itemId, err := convert.StringToUUID(item.ID) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + + mediaIdList := make([]pgtype.UUID, 0) + for _, it := range mediaList { + mediaId, err := convert.StringToUUID(it.ID) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + mediaIdList = append(mediaIdList, mediaId) + item.Media = append(item.Media, it.ToSimpleEntity()) + } + + err = v.verificationRepo.CreateVerificationMedia( + ctx, + sqlc.CreateVerificationMediaParams{ + VerificationID: itemId, + Column2: mediaIdList, + }, + ) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + + return item.ToResponse(), nil +} + +func (v *verificationService) DeleteVerification(ctx context.Context, claims *response.JWTClaims, verificationId string) error { + verificationIdUUID, err := convert.StringToUUID(verificationId) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + + verification, err := v.verificationRepo.GetByID(ctx, verificationIdUUID) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + + shoudDelete := false + if slices.Contains(claims.Roles, constants.ADMIN) || slices.Contains(claims.Roles, constants.MOD) || verification.UserID == claims.UId { + shoudDelete = true + } + + if !shoudDelete { + return fiber.NewError(fiber.StatusForbidden, "You don't have permission to delete this verification") + } + + err = v.mediaRepo.Delete(ctx, verificationIdUUID) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + + return nil +} + +func (v *verificationService) GetVerificationByID(ctx context.Context, verificationId string) (*response.UserVerificationResponse, error) { + verificationUUID, err := convert.StringToUUID(verificationId) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + verification, err := v.verificationRepo.GetByID(ctx, verificationUUID) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + return verification.ToResponse(), nil +} + +func (v *verificationService) GetVerificationByUserID(ctx context.Context, userId string) ([]*response.UserVerificationResponse, error) { + userUUID, err := convert.StringToUUID(userId) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + verifications, err := v.verificationRepo.GetByUserID(ctx, userUUID) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + return models.UserVerificationsEntitiesToResponse(verifications), nil +} + +func (m *verificationService) fillSearchArgs(arg *sqlc.SearchUserVerificationsParams, dto *request.SearchUserVerificationDto) { + if dto.Sort != "" { + arg.Sort = pgtype.Text{String: dto.Sort, Valid: true} + } else { + arg.Sort = pgtype.Text{String: "id", Valid: true} + } + + arg.Order = pgtype.Text{String: "asc", Valid: true} + if dto.Order == "desc" { + arg.Order = pgtype.Text{String: "desc", Valid: true} + } + + if len(dto.Statuses) > 0 { + for _, id := range dto.Statuses { + if u := constants.ParseStatusTypeText(id); u == constants.StatusUnknown { + arg.Statuses = append(arg.Statuses, u.Int16()) + } + } + } + + if len(dto.VerifyTypes) > 0 { + for _, id := range dto.VerifyTypes { + if u := constants.ParseVerifyTypeText(id); u == constants.VerifyUnknown { + arg.VerifyTypes = append(arg.VerifyTypes, u.Int16()) + } + } + } + + if len(dto.UserIDs) > 0 { + for _, id := range dto.UserIDs { + if u, err := convert.StringToUUID(id); err == nil { + arg.UserIds = append(arg.UserIds, u) + } + } + } + + if dto.ReviewedBy != nil { + if rvID, err := convert.StringToUUID(*dto.ReviewedBy); err == nil { + arg.ReviewedBy = rvID + } + } + + if dto.CreatedFrom != nil { + arg.CreatedFrom = pgtype.Timestamptz{Time: *dto.CreatedFrom, Valid: true} + } + + if dto.CreatedTo != nil { + arg.CreatedTo = pgtype.Timestamptz{Time: *dto.CreatedTo, Valid: true} + } + + if dto.Search != "" { + arg.SearchText = pgtype.Text{String: dto.Search, Valid: true} + } +} + +func (v *verificationService) SearchVerification(ctx context.Context, dto *request.SearchUserVerificationDto) (*response.PaginatedResponse, error) { + if dto.Page < 1 { + dto.Page = 1 + } + if dto.Limit == 0 { + dto.Limit = 20 + } + offset := (dto.Page - 1) * dto.Limit + + arg := sqlc.SearchUserVerificationsParams{ + Limit: int32(dto.Limit), + Offset: int32(offset), + } + + v.fillSearchArgs(&arg, dto) + + var rows []*models.UserVerificationEntity + var totalRecords int64 + + g, gCtx := errgroup.WithContext(ctx) + + g.Go(func() error { + var err error + rows, err = v.verificationRepo.Search(gCtx, arg) + return err + }) + + g.Go(func() error { + countArg := sqlc.CountUserVerificationsParams{ + UserIds: arg.UserIds, + Statuses: arg.Statuses, + VerifyTypes: arg.VerifyTypes, + ReviewedBy: arg.ReviewedBy, + CreatedFrom: arg.CreatedFrom, + CreatedTo: arg.CreatedTo, + SearchText: arg.SearchText, + } + var err error + totalRecords, err = v.verificationRepo.Count(gCtx, countArg) + return err + }) + + if err := g.Wait(); err != nil { + return nil, err + } + + return response.BuildPaginatedResponse(rows, totalRecords, dto.Page, dto.Limit), nil +} + +func (v *verificationService) UpdateStatusVerification(ctx context.Context, userId string, verificationId string, dto *request.UpdateVerificationStatusDto) (*response.UserVerificationResponse, error) { + statusType := constants.ParseStatusTypeText(dto.Status) + if statusType == constants.StatusUnknown { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Unknown status type!") + } + verificationUUID, err := convert.StringToUUID(verificationId) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + + userAdminUUID, err := convert.StringToUUID(userId) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + + historianRole, err := v.roleRepo.GetByname(ctx, constants.HISTORIAN.String()) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + + historianRoleID, err := convert.StringToUUID(historianRole.ID) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + + verification, err := v.verificationRepo.GetByID(ctx, verificationUUID) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + + if verification.Status != constants.StatusPending { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid status!") + } + + userVerificationUUID, err := convert.StringToUUID(verification.UserID) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + + userVerification, err := v.userRepo.GetByID(ctx, userVerificationUUID) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + + err = v.verificationRepo.UpdateStatus( + ctx, + sqlc.UpdateUserVerificationStatusParams{ + ID: verificationUUID, + Status: statusType.Int16(), + ReviewedBy: userAdminUUID, + ReviewNote: convert.PtrToText(&dto.ReviewNote), + }, + ) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + + verification.Status = statusType + + data := &models.UserVerificationStorageEntity{ + Email: userVerification.Email, + ReviewNote: dto.ReviewNote, + Status: statusType, + } + + roleIdList := make([]pgtype.UUID, 0) + userVerification.Roles = append(userVerification.Roles, historianRole.ToRoleSimple()) + + roleIdList = append(roleIdList, historianRoleID) + + for _, role := range userVerification.Roles { + roleID, err := convert.StringToUUID(role.ID) + if err != nil { + continue + } + roleIdList = append(roleIdList, roleID) + } + + err = v.roleRepo.BulkDeleteRolesFromUser(ctx, userVerificationUUID) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + + err = v.roleRepo.CreateUserRole(ctx, sqlc.CreateUserRoleParams{ + UserID: userVerificationUUID, + Column2: roleIdList, + }) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + + err = v.userRepo.UpdateTokenVersion(ctx, sqlc.UpdateTokenVersionParams{ + ID: userVerificationUUID, + TokenVersion: userVerification.TokenVersion + 1, + }) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + userVerification.TokenVersion += 1 + + mapCache := map[string]any{ + fmt.Sprintf("user:email:%s", userVerification.Email): userVerification, + fmt.Sprintf("user:id:%s", userVerification.ID): userVerification, + } + _ = v.c.MSet(ctx, mapCache, constants.NormalCacheDuration) + + v.c.PublishTask(ctx, constants.StreamEmailName, constants.TaskTypeNotifyHistorianReview, data) + + return verification.ToResponse(), nil +} diff --git a/pkg/constants/status.go b/pkg/constants/status.go index e35ecaf..da47b80 100644 --- a/pkg/constants/status.go +++ b/pkg/constants/status.go @@ -3,6 +3,7 @@ package constants type StatusType int16 const ( + StatusUnknown StatusType = 0 StatusPending StatusType = 1 StatusApproved StatusType = 2 StatusRejected StatusType = 3 @@ -21,6 +22,10 @@ func (t StatusType) String() string { } } +func (t StatusType) Int16() int16 { + return int16(t) +} + func ParseStatusType(v int16) StatusType { switch v { case 1: @@ -30,6 +35,19 @@ func ParseStatusType(v int16) StatusType { case 3: return StatusRejected default: - return 0 + return StatusUnknown + } +} + +func ParseStatusTypeText(v string) StatusType { + switch v { + case "PENDING": + return StatusPending + case "APPROVED": + return StatusApproved + case "REJECT": + return StatusRejected + default: + return StatusUnknown } } diff --git a/pkg/constants/task.go b/pkg/constants/task.go index 269aac3..a4316de 100644 --- a/pkg/constants/task.go +++ b/pkg/constants/task.go @@ -3,9 +3,10 @@ package constants type TaskType string const ( - TaskTypeSendEmailOTP TaskType = "SEND_EMAIL_OTP" - TaskTypeDeleteMedia TaskType = "DELETE_MEDIA" - TaskTypeBulkDeleteMedia TaskType = "BULK_DELETE_MEDIA" + TaskTypeSendEmailOTP TaskType = "SEND_EMAIL_OTP" + TaskTypeNotifyHistorianReview TaskType = "NOTIFY_HISTORIAN_REVIEW" + TaskTypeDeleteMedia TaskType = "DELETE_MEDIA" + TaskTypeBulkDeleteMedia TaskType = "BULK_DELETE_MEDIA" ) func (t TaskType) String() string { diff --git a/pkg/constants/verify.go b/pkg/constants/verify.go index 8f56ac3..8b120df 100644 --- a/pkg/constants/verify.go +++ b/pkg/constants/verify.go @@ -25,7 +25,26 @@ func (t VerifyType) String() string { } } -func ParseVerifyType(v string) VerifyType { +func (t VerifyType) Int16() int16 { + return int16(t) +} + +func ParseVerifyType(v int16) VerifyType { + switch v { + case 1: + return VerifyIdCard + case 2: + return VerifyEducation + case 3: + return VerifyExpert + case 4: + return VerifyOther + default: + return VerifyUnknown + } +} + +func ParseVerifyTypeText(v string) VerifyType { switch v { case "ID_CARD": return VerifyIdCard diff --git a/pkg/convert/convert.go b/pkg/convert/convert.go index 52f21ea..20d25eb 100644 --- a/pkg/convert/convert.go +++ b/pkg/convert/convert.go @@ -45,7 +45,7 @@ func TimeToPtr(v pgtype.Timestamptz) *time.Time { } func PtrToText(s *string) pgtype.Text { - if s == nil { + if s == nil || *s == "" { return pgtype.Text{Valid: false} } return pgtype.Text{ diff --git a/pkg/email/email.go b/pkg/email/email.go index 24ed645..f5d285c 100644 --- a/pkg/email/email.go +++ b/pkg/email/email.go @@ -3,6 +3,7 @@ package email import ( "fmt" "history-api/assets" + "history-api/internal/models" "history-api/pkg/config" "history-api/pkg/constants" "strings" @@ -10,7 +11,7 @@ import ( "github.com/wneessen/go-mail" ) -func SendMailOTP(toEmail, otpCode string, tokenType constants.TokenType) error { +func SendMail(toEmail, subject, templatePath string, data map[string]string) error { userSmtp, err := config.GetConfig("SMTP_USER") if err != nil { return err @@ -21,36 +22,27 @@ func SendMailOTP(toEmail, otpCode string, tokenType constants.TokenType) error { 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 := htmlTemplate + for k, v := range data { + finalHTML = strings.ReplaceAll(finalHTML, "{{"+k+"}}", v) } - finalHTML := strings.ReplaceAll(htmlTemplate, "{{OTP_CODE}}", otpCode) + message := mail.NewMsg() + if err := message.From(userSmtp); err != nil { + return fmt.Errorf("failed to set From: %s", err) + } + if err := message.To(toEmail); err != nil { + return fmt.Errorf("failed to set To: %s", err) + } message.Subject(subject) message.SetBodyString(mail.TypeTextHTML, finalHTML) + client, err := mail.NewClient( "smtp.gmail.com", mail.WithSMTPAuth(mail.SMTPAuthAutoDiscover), @@ -59,12 +51,56 @@ func SendMailOTP(toEmail, otpCode string, tokenType constants.TokenType) error { mail.WithPassword(passSmtp), ) if err != nil { - return fmt.Errorf("failed to create mail client: %s", err) + return fmt.Errorf("failed to create client: %s", err) } - err = client.DialAndSend(message) - if err != nil { + if err := client.DialAndSend(message); err != nil { return fmt.Errorf("failed to send mail: %s", err) } + return nil } + +func SendMailOTP(dto *models.TokenEntity) error { + var subject string + var templatePath string + + switch dto.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", dto.TokenType) + } + + return SendMail(dto.Email, subject, templatePath, map[string]string{ + "OTP_CODE": dto.Token, + }) +} + +func SendHistorianReviewMail(dto *models.UserVerificationStorageEntity) error { + var subject string + var templatePath string + feUrl := config.GetConfigWithDefault("FRONTEND_URL", "http://localhost:3000") + switch dto.Status { + case constants.StatusApproved: + subject = "Your Historian Application is Approved" + templatePath = "resources/historian_approved.html" + + case constants.StatusRejected: + subject = "Your Historian Application is Rejected" + templatePath = "resources/historian_rejected.html" + + default: + return fmt.Errorf("invalid status: %v", dto.Status) + } + + return SendMail(dto.Email, subject, templatePath, map[string]string{ + "NAME": dto.Email, + "REASON": dto.ReviewNote, + "APP_URL": feUrl, + }) +}