From bdaac7ddd8cc0853b3b12f0d159fc545fd41f2fe Mon Sep 17 00:00:00 2001 From: AzenKain Date: Thu, 7 May 2026 11:31:53 +0700 Subject: [PATCH] feat: implement system statistics tracking, commit management controllers, and associated database migrations --- assets/resources/admin_user_action.html | 39 +++ cmd/api/server.go | 4 + cmd/worker/cron/main.go | 132 ++++++++++ cmd/worker/email/main.go | 20 ++ .../0000014_system_statistics.down.sql | 1 + .../0000014_system_statistics.up.sql | 28 ++ db/query/statistic.sql | 62 +++++ db/schema.sql | 26 ++ go.mod | 3 + go.sum | 8 + internal/controllers/commitController.go | 32 +++ internal/controllers/statisticController.go | 96 +++++++ internal/controllers/userController.go | 77 ++++++ internal/dtos/request/statistic.go | 6 + internal/dtos/request/user.go | 5 + internal/dtos/response/statistic.go | 27 ++ internal/gen/sqlc/models.go | 24 ++ internal/gen/sqlc/statistic.sql.go | 224 ++++++++++++++++ internal/models/statistic.go | 66 +++++ internal/models/user.go | 6 + internal/repositories/statisticRepo.go | 241 ++++++++++++++++++ internal/routes/projectRoute.go | 7 + internal/routes/statisticRoute.go | 22 ++ internal/routes/userRoute.go | 16 +- internal/services/commitService.go | 15 ++ internal/services/statisticService.go | 75 ++++++ internal/services/userService.go | 66 ++++- pkg/constants/task.go | 1 + pkg/email/email.go | 20 ++ 29 files changed, 1347 insertions(+), 2 deletions(-) create mode 100644 assets/resources/admin_user_action.html create mode 100644 cmd/worker/cron/main.go create mode 100644 db/migrations/0000014_system_statistics.down.sql create mode 100644 db/migrations/0000014_system_statistics.up.sql create mode 100644 db/query/statistic.sql create mode 100644 internal/controllers/statisticController.go create mode 100644 internal/dtos/request/statistic.go create mode 100644 internal/dtos/response/statistic.go create mode 100644 internal/gen/sqlc/statistic.sql.go create mode 100644 internal/models/statistic.go create mode 100644 internal/repositories/statisticRepo.go create mode 100644 internal/routes/statisticRoute.go create mode 100644 internal/services/statisticService.go diff --git a/assets/resources/admin_user_action.html b/assets/resources/admin_user_action.html new file mode 100644 index 0000000..8235970 --- /dev/null +++ b/assets/resources/admin_user_action.html @@ -0,0 +1,39 @@ + + + + + + Account Information + + + +
+
+

Account Information

+
+
+

Hello,

+

Your account information has been updated by the administrator. Here are your credentials:

+
+
Email: {{EMAIL}}
+
Password: {{PASSWORD}}
+
+

Please log in and change your password as soon as possible to ensure your account security.

+
+ +
+ + diff --git a/cmd/api/server.go b/cmd/api/server.go index c4e3068..50e9af5 100644 --- a/cmd/api/server.go +++ b/cmd/api/server.go @@ -96,6 +96,7 @@ func (s *FiberServer) SetupServer( raguRepo := repositories.NewRagRepository(poolPg, redis) usageRepo := repositories.NewUsageRepository(redis) + statisticRepo := repositories.NewStatisticRepository(poolPg, redis) // service setup authService := services.NewAuthService(userRepo, roleRepo, tokenRepo, redis, poolPg) @@ -116,6 +117,7 @@ func (s *FiberServer) SetupServer( raguRepo, raguUtils, poolPg, redis, ) chatbotService := services.NewChatbotService(raguRepo, usageRepo, raguUtils) + statisticService := services.NewStatisticService(statisticRepo) // controller setup authController := controllers.NewAuthController(authService, oauth) @@ -132,6 +134,7 @@ func (s *FiberServer) SetupServer( commitController := controllers.NewCommitController(commitService) submissionController := controllers.NewSubmissionController(submissionService) chatbotController := controllers.NewChatbotController(chatbotService) + statisticController := controllers.NewStatisticController(statisticService) // route setup routes.AuthRoutes(s.App, authController, userRepo) @@ -147,5 +150,6 @@ func (s *FiberServer) SetupServer( routes.ProjectRoutes(s.App, projectController, commitController, userRepo) routes.SubmissionRoutes(s.App, submissionController, userRepo) routes.ChatbotRoutes(s.App, chatbotController, userRepo) + routes.StatisticRoutes(s.App, statisticController, userRepo) routes.NotFoundRoute(s.App) } diff --git a/cmd/worker/cron/main.go b/cmd/worker/cron/main.go new file mode 100644 index 0000000..70c39f5 --- /dev/null +++ b/cmd/worker/cron/main.go @@ -0,0 +1,132 @@ +package main + +import ( + "context" + "fmt" + "history-api/internal/repositories" + "history-api/pkg/cache" + "history-api/pkg/config" + "history-api/pkg/storage" + "os" + "os/exec" + "path/filepath" + "time" + + "github.com/go-co-op/gocron/v2" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/rs/zerolog/log" +) + +func runStatistics(ctx context.Context, repo repositories.StatisticRepository) { + log.Info().Msg("Running daily statistics...") + today := time.Now().UTC().Truncate(24 * time.Hour) + _, err := repo.Upsert(ctx, today) + if err != nil { + log.Error().Err(err).Msg("Failed to upsert system statistics") + } else { + log.Info().Msg("Successfully updated daily statistics and cleared cache") + } +} + +func runBackup(ctx context.Context, s3 storage.Storage, dbURI string) { + log.Info().Msg("Running weekly database backup...") + + tmpDir := os.TempDir() + fileName := fmt.Sprintf("db_backup_%s.sql", time.Now().Format("2006-01-02_15-04-05")) + filePath := filepath.Join(tmpDir, fileName) + + // Run pg_dump + cmd := exec.Command("pg_dump", dbURI, "-F", "c", "-f", filePath) + if err := cmd.Run(); err != nil { + log.Error().Err(err).Msg("Failed to execute pg_dump. Make sure pg_dump is installed.") + return + } + defer os.Remove(filePath) + + file, err := os.Open(filePath) + if err != nil { + log.Error().Err(err).Msg("Failed to open backup file") + return + } + defer file.Close() + + stat, err := file.Stat() + if err != nil { + log.Error().Err(err).Msg("Failed to stat backup file") + return + } + + key := fmt.Sprintf("backups/%s", fileName) + err = s3.Upload(ctx, key, file, stat.Size(), storage.UploadOptions{ + ContentType: "application/octet-stream", + }) + + if err != nil { + log.Error().Err(err).Msg("Failed to upload backup to S3") + } else { + log.Info().Str("key", key).Msg("Successfully uploaded backup to S3") + } +} + +func main() { + config.LoadEnv() + + dbURI, err := config.GetConfig("DATABASE_URI") + if err != nil { + log.Fatal().Err(err).Msg("DATABASE_URI not set") + } + + dbPool, err := pgxpool.New(context.Background(), dbURI) + if err != nil { + log.Fatal().Err(err).Msg("Failed to connect to DB") + } + defer dbPool.Close() + + redis, err := cache.NewRedisClient() + if err != nil { + log.Fatal().Err(err).Msg("Failed to connect to Redis") + } + + statisticRepo := repositories.NewStatisticRepository(dbPool, redis) + + s3Store, err := storage.NewS3Storage() + if err != nil { + log.Fatal().Err(err).Msg("Failed to init S3 storage") + } + + log.Info().Msg("Cron worker started") + + // Run initially on startup + runStatistics(context.Background(), statisticRepo) + + s, err := gocron.NewScheduler() + if err != nil { + log.Fatal().Err(err).Msg("Failed to create scheduler") + } + + // Run statistics every day at midnight (00:00) + _, err = s.NewJob( + gocron.DailyJob(1, gocron.NewAtTimes(gocron.NewAtTime(0, 0, 0))), + gocron.NewTask(func() { + runStatistics(context.Background(), statisticRepo) + }), + ) + if err != nil { + log.Fatal().Err(err).Msg("Failed to schedule daily statistics") + } + + // Run backup every Sunday at 01:00 AM + _, err = s.NewJob( + gocron.WeeklyJob(1, gocron.NewWeekdays(time.Sunday), gocron.NewAtTimes(gocron.NewAtTime(1, 0, 0))), + gocron.NewTask(func() { + runBackup(context.Background(), s3Store, dbURI) + }), + ) + if err != nil { + log.Fatal().Err(err).Msg("Failed to schedule weekly backup") + } + + s.Start() + + select {} +} diff --git a/cmd/worker/email/main.go b/cmd/worker/email/main.go index 33862ab..6edfd97 100644 --- a/cmd/worker/email/main.go +++ b/cmd/worker/email/main.go @@ -87,6 +87,26 @@ func runSingleWorker(ctx context.Context, rdb *redis.Client, consumerID int) { continue } } + + if taskType == constants.TaskTypeAdminUserAction.String() { + var data models.AdminUserActionPayload + 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). + Str("action", data.Action). + Msg("Processing admin user action email task") + + errSend := email.SendAdminUserActionMail(&data) + if errSend != nil { + log.Error().Err(errSend).Str("email", data.Email).Msg("Failed to send email") + continue + } + } rdb.XAck(ctx, constants.StreamEmailName, constants.GroupEmailName, message.ID) log.Info().Str("msg_id", message.ID).Msg("Task acknowledged") diff --git a/db/migrations/0000014_system_statistics.down.sql b/db/migrations/0000014_system_statistics.down.sql new file mode 100644 index 0000000..032ab29 --- /dev/null +++ b/db/migrations/0000014_system_statistics.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS system_statistics CASCADE; diff --git a/db/migrations/0000014_system_statistics.up.sql b/db/migrations/0000014_system_statistics.up.sql new file mode 100644 index 0000000..62ba59a --- /dev/null +++ b/db/migrations/0000014_system_statistics.up.sql @@ -0,0 +1,28 @@ +CREATE TABLE IF NOT EXISTS system_statistics ( + id UUID PRIMARY KEY DEFAULT uuidv7(), + date DATE UNIQUE NOT NULL, + + -- Cumulative stats + total_users INT NOT NULL DEFAULT 0, + total_projects INT NOT NULL DEFAULT 0, + total_commits INT NOT NULL DEFAULT 0, + total_submissions INT NOT NULL DEFAULT 0, + total_medias INT NOT NULL DEFAULT 0, + total_wikis INT NOT NULL DEFAULT 0, + total_entities INT NOT NULL DEFAULT 0, + total_geometries INT NOT NULL DEFAULT 0, + total_storage_bytes BIGINT NOT NULL DEFAULT 0, + + -- Daily stats + new_users INT NOT NULL DEFAULT 0, + new_projects INT NOT NULL DEFAULT 0, + new_commits INT NOT NULL DEFAULT 0, + new_submissions INT NOT NULL DEFAULT 0, + new_medias INT NOT NULL DEFAULT 0, + new_wikis INT NOT NULL DEFAULT 0, + new_entities INT NOT NULL DEFAULT 0, + new_geometries INT NOT NULL DEFAULT 0, + new_storage_bytes BIGINT NOT NULL DEFAULT 0, + + created_at TIMESTAMPTZ DEFAULT now() +); diff --git a/db/query/statistic.sql b/db/query/statistic.sql new file mode 100644 index 0000000..56734e8 --- /dev/null +++ b/db/query/statistic.sql @@ -0,0 +1,62 @@ +-- name: UpsertSystemStatistics :one +INSERT INTO system_statistics ( + date, + total_users, total_projects, total_commits, total_submissions, total_medias, total_wikis, total_entities, total_geometries, total_storage_bytes, + new_users, new_projects, new_commits, new_submissions, new_medias, new_wikis, new_entities, new_geometries, new_storage_bytes +) VALUES ( + $1, + (SELECT COUNT(*)::INT FROM users), + (SELECT COUNT(*)::INT FROM projects), + (SELECT COUNT(*)::INT FROM commits), + (SELECT COUNT(*)::INT FROM submissions), + (SELECT COUNT(*)::INT FROM medias), + (SELECT COUNT(*)::INT FROM wikis), + (SELECT COUNT(*)::INT FROM entities), + (SELECT COUNT(*)::INT FROM geometries), + COALESCE((SELECT SUM(size)::BIGINT FROM medias), 0), + + (SELECT COUNT(*)::INT FROM users WHERE created_at >= $1::DATE AND created_at < $1::DATE + INTERVAL '1 day'), + (SELECT COUNT(*)::INT FROM projects WHERE created_at >= $1::DATE AND created_at < $1::DATE + INTERVAL '1 day'), + (SELECT COUNT(*)::INT FROM commits WHERE created_at >= $1::DATE AND created_at < $1::DATE + INTERVAL '1 day'), + (SELECT COUNT(*)::INT FROM submissions WHERE created_at >= $1::DATE AND created_at < $1::DATE + INTERVAL '1 day'), + (SELECT COUNT(*)::INT FROM medias WHERE created_at >= $1::DATE AND created_at < $1::DATE + INTERVAL '1 day'), + (SELECT COUNT(*)::INT FROM wikis WHERE created_at >= $1::DATE AND created_at < $1::DATE + INTERVAL '1 day'), + (SELECT COUNT(*)::INT FROM entities WHERE created_at >= $1::DATE AND created_at < $1::DATE + INTERVAL '1 day'), + (SELECT COUNT(*)::INT FROM geometries WHERE created_at >= $1::DATE AND created_at < $1::DATE + INTERVAL '1 day'), + COALESCE((SELECT SUM(size)::BIGINT FROM medias WHERE created_at >= $1::DATE AND created_at < $1::DATE + INTERVAL '1 day'), 0) +) +ON CONFLICT (date) DO UPDATE SET + total_users = EXCLUDED.total_users, + total_projects = EXCLUDED.total_projects, + total_commits = EXCLUDED.total_commits, + total_submissions = EXCLUDED.total_submissions, + total_medias = EXCLUDED.total_medias, + total_wikis = EXCLUDED.total_wikis, + total_entities = EXCLUDED.total_entities, + total_geometries = EXCLUDED.total_geometries, + total_storage_bytes = EXCLUDED.total_storage_bytes, + new_users = EXCLUDED.new_users, + new_projects = EXCLUDED.new_projects, + new_commits = EXCLUDED.new_commits, + new_submissions = EXCLUDED.new_submissions, + new_medias = EXCLUDED.new_medias, + new_wikis = EXCLUDED.new_wikis, + new_entities = EXCLUDED.new_entities, + new_geometries = EXCLUDED.new_geometries, + new_storage_bytes = EXCLUDED.new_storage_bytes +RETURNING *; + +-- name: SearchSystemStatistics :many +SELECT * FROM system_statistics +WHERE + (sqlc.narg('start_date')::DATE IS NULL OR date >= sqlc.narg('start_date')::DATE) AND + (sqlc.narg('end_date')::DATE IS NULL OR date <= sqlc.narg('end_date')::DATE) +ORDER BY date DESC; + +-- name: GetSystemStatisticsByDate :one +SELECT * FROM system_statistics WHERE date = $1 LIMIT 1; + +-- name: GetSystemStatisticsByIDs :many +SELECT * FROM system_statistics WHERE id = ANY($1::UUID[]); + + diff --git a/db/schema.sql b/db/schema.sql index fd7bab0..1c6ef07 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -182,3 +182,29 @@ CREATE TABLE IF NOT EXISTS rag_chunks ( updated_at TIMESTAMPTZ DEFAULT now() ); +CREATE TABLE IF NOT EXISTS system_statistics ( + id UUID PRIMARY KEY DEFAULT uuidv7(), + date DATE UNIQUE NOT NULL, + + total_users INT NOT NULL DEFAULT 0, + total_projects INT NOT NULL DEFAULT 0, + total_commits INT NOT NULL DEFAULT 0, + total_submissions INT NOT NULL DEFAULT 0, + total_medias INT NOT NULL DEFAULT 0, + total_wikis INT NOT NULL DEFAULT 0, + total_entities INT NOT NULL DEFAULT 0, + total_geometries INT NOT NULL DEFAULT 0, + total_storage_bytes BIGINT NOT NULL DEFAULT 0, + + new_users INT NOT NULL DEFAULT 0, + new_projects INT NOT NULL DEFAULT 0, + new_commits INT NOT NULL DEFAULT 0, + new_submissions INT NOT NULL DEFAULT 0, + new_medias INT NOT NULL DEFAULT 0, + new_wikis INT NOT NULL DEFAULT 0, + new_entities INT NOT NULL DEFAULT 0, + new_geometries INT NOT NULL DEFAULT 0, + new_storage_bytes BIGINT NOT NULL DEFAULT 0, + + created_at TIMESTAMPTZ DEFAULT now() +); diff --git a/go.mod b/go.mod index 50f79df..5307626 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/aws/aws-sdk-go-v2/credentials v1.19.13 github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 github.com/glebarez/go-sqlite v1.22.0 + github.com/go-co-op/gocron/v2 v2.21.1 github.com/go-playground/validator/v10 v10.30.1 github.com/gofiber/contrib/v3/jwt v1.1.0 github.com/gofiber/contrib/v3/swaggerui v1.0.1 @@ -95,6 +96,7 @@ require ( github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jonboulle/clockwork v0.5.0 // indirect github.com/klauspost/compress v1.18.5 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect @@ -103,6 +105,7 @@ require ( github.com/philhofer/fwd v1.2.0 // indirect github.com/pkoukk/tiktoken-go v0.1.6 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/robfig/cron/v3 v3.0.1 // indirect github.com/tinylib/msgp v1.6.3 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.69.0 // indirect diff --git a/go.sum b/go.sum index e399099..0e1698f 100644 --- a/go.sum +++ b/go.sum @@ -93,6 +93,8 @@ github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCK github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ= github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc= +github.com/go-co-op/gocron/v2 v2.21.1 h1:QYOK6iOQVCut+jDcs4zRdWRTBHRxRCEeeFi1TnAmgbU= +github.com/go-co-op/gocron/v2 v2.21.1/go.mod h1:5lEiCKk1oVJV39Zg7/YG10OnaVrDAV5GGR6O0663k6U= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -202,6 +204,8 @@ github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= +github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY= @@ -239,6 +243,8 @@ github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfS github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= @@ -321,6 +327,8 @@ go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4Len go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= diff --git a/internal/controllers/commitController.go b/internal/controllers/commitController.go index af5756e..0aa4514 100644 --- a/internal/controllers/commitController.go +++ b/internal/controllers/commitController.go @@ -136,3 +136,35 @@ func (h *CommitController) GetProjectCommits(c fiber.Ctx) error { Data: res, }) } + +// GetCommitByID godoc +// @Summary Get commit by ID +// @Description Retrieve a specific commit by its ID +// @Tags Commits +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param commitId path string true "Commit ID" +// @Success 200 {object} response.CommonResponse +// @Failure 400 {object} response.CommonResponse +// @Failure 404 {object} response.CommonResponse +// @Failure 500 {object} response.CommonResponse +// @Router /projects/commits/{commitId} [get] +func (h *CommitController) GetCommitByID(c fiber.Ctx) error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + commitID := c.Params("commitId") + res, err := h.service.GetCommitByID(ctx, commitID) + if err != nil { + return c.Status(err.Code).JSON(response.CommonResponse{ + Status: false, + Message: err.Message, + }) + } + + return c.Status(fiber.StatusOK).JSON(response.CommonResponse{ + Status: true, + Data: res, + }) +} diff --git a/internal/controllers/statisticController.go b/internal/controllers/statisticController.go new file mode 100644 index 0000000..902ca15 --- /dev/null +++ b/internal/controllers/statisticController.go @@ -0,0 +1,96 @@ +package controllers + +import ( + "history-api/internal/dtos/request" + "history-api/internal/dtos/response" + "history-api/internal/services" + "history-api/pkg/validator" + + "github.com/gofiber/fiber/v3" +) + +type StatisticController struct { + statService services.StatisticService +} + +func NewStatisticController(statService services.StatisticService) *StatisticController { + return &StatisticController{ + statService: statService, + } +} + +// SearchStatistics godoc +// @Summary Search system statistics +// @Description Fetch daily system statistics with optional date range filtering +// @Tags Statistics +// @Accept json +// @Produce json +// @Param start_date query string false "Start date in YYYY-MM-DD format" +// @Param end_date query string false "End date in YYYY-MM-DD format" +// @Success 200 {object} response.CommonResponse{data=[]response.StatisticResponse} +// @Failure 400 {object} response.CommonResponse +// @Failure 401 {object} response.CommonResponse +// @Failure 403 {object} response.CommonResponse +// @Failure 500 {object} response.CommonResponse +// @Router /statistics [get] +// @Security BearerAuth +func (c *StatisticController) SearchStatistics(ctx fiber.Ctx) error { + dto := new(request.SearchStatisticDto) + if err := validator.ValidateQueryDto(ctx, dto); err != nil { + return ctx.Status(fiber.StatusBadRequest).JSON(response.CommonResponse{ + Status: false, + Errors: err, + }) + } + + res, err := c.statService.Search(ctx.Context(), dto) + if err != nil { + return ctx.Status(err.Code).JSON(response.CommonResponse{ + Status: false, + Message: err.Message, + }) + } + + return ctx.Status(fiber.StatusOK).JSON(response.CommonResponse{ + Status: true, + Data: res, + }) +} + +// GetStatisticByDate godoc +// @Summary Get system statistics by date +// @Description Fetch system statistics for a specific date +// @Tags Statistics +// @Accept json +// @Produce json +// @Param date path string true "Date in YYYY-MM-DD format" +// @Success 200 {object} response.CommonResponse{data=response.StatisticResponse} +// @Failure 400 {object} response.CommonResponse +// @Failure 401 {object} response.CommonResponse +// @Failure 403 {object} response.CommonResponse +// @Failure 404 {object} response.CommonResponse +// @Failure 500 {object} response.CommonResponse +// @Router /statistics/{date} [get] +// @Security BearerAuth +func (c *StatisticController) GetStatisticByDate(ctx fiber.Ctx) error { + dateStr := ctx.Params("date") + if dateStr == "" { + return ctx.Status(fiber.StatusBadRequest).JSON(response.CommonResponse{ + Status: false, + Message: "Date parameter is required", + }) + } + + res, err := c.statService.GetByDate(ctx.Context(), dateStr) + if err != nil { + return ctx.Status(err.Code).JSON(response.CommonResponse{ + Status: false, + Message: err.Message, + }) + } + + return ctx.Status(fiber.StatusOK).JSON(response.CommonResponse{ + Status: true, + Data: res, + }) +} diff --git a/internal/controllers/userController.go b/internal/controllers/userController.go index 63b84dc..d55590a 100644 --- a/internal/controllers/userController.go +++ b/internal/controllers/userController.go @@ -526,3 +526,80 @@ func (h *UserController) GetProjectByUserID(c fiber.Ctx) error { Data: res, }) } + +// AdminUpdateProfile godoc +// @Summary Update user profile (Admin/Mod only) +// @Description Update the profile details of any user +// @Tags Users +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path string true "User ID" +// @Param request body request.UpdateProfileDto true "Update Profile request" +// @Success 200 {object} response.CommonResponse +// @Failure 400 {object} response.CommonResponse +// @Failure 500 {object} response.CommonResponse +// @Router /users/{id} [put] +func (h *UserController) AdminUpdateProfile(c fiber.Ctx) error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + userId := c.Params("id") + dto := &request.UpdateProfileDto{} + if err := validator.ValidateBodyDto(c, dto); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(response.CommonResponse{ + Status: false, + Errors: err, + }) + } + + res, err := h.service.UpdateProfile(ctx, userId, dto) + if err != nil { + return c.Status(err.Code).JSON(response.CommonResponse{ + Status: false, + Message: err.Message, + }) + } + return c.Status(fiber.StatusOK).JSON(response.CommonResponse{ + Status: true, + Data: res, + }) +} + +// AdminResetPassword godoc +// @Summary Reset user password (Admin/Mod only) +// @Description Reset the password for any user without requiring the old password +// @Tags Users +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path string true "User ID" +// @Param request body request.ResetPasswordDto true "Reset Password request" +// @Success 200 {object} response.CommonResponse +// @Failure 400 {object} response.CommonResponse +// @Failure 500 {object} response.CommonResponse +// @Router /users/{id}/password [patch] +func (h *UserController) AdminResetPassword(c fiber.Ctx) error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + userId := c.Params("id") + dto := &request.ResetPasswordDto{} + if err := validator.ValidateBodyDto(c, dto); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(response.CommonResponse{ + Status: false, + Errors: err, + }) + } + err := h.service.AdminResetPassword(ctx, userId, dto) + if err != nil { + return c.Status(err.Code).JSON(response.CommonResponse{ + Status: false, + Message: err.Message, + }) + } + return c.Status(fiber.StatusOK).JSON(response.CommonResponse{ + Status: true, + Message: "Password reset successfully", + }) +} diff --git a/internal/dtos/request/statistic.go b/internal/dtos/request/statistic.go new file mode 100644 index 0000000..fb15ecc --- /dev/null +++ b/internal/dtos/request/statistic.go @@ -0,0 +1,6 @@ +package request + +type SearchStatisticDto struct { + StartDate string `query:"start_date" validate:"omitempty,datetime=2006-01-02"` + EndDate string `query:"end_date" validate:"omitempty,datetime=2006-01-02"` +} diff --git a/internal/dtos/request/user.go b/internal/dtos/request/user.go index c784498..450c7a9 100644 --- a/internal/dtos/request/user.go +++ b/internal/dtos/request/user.go @@ -45,3 +45,8 @@ type CreateUserDto struct { DisplayName string `json:"display_name" validate:"required,min=2,max=50"` Roles []string `json:"role_ids" validate:"required,min=1,dive,required,uuid"` } + +type ResetPasswordDto struct { + NewPassword string `json:"new_password" validate:"required,min=8,max=64"` + IsSendEmail bool `json:"is_send_email"` +} diff --git a/internal/dtos/response/statistic.go b/internal/dtos/response/statistic.go new file mode 100644 index 0000000..b2b5066 --- /dev/null +++ b/internal/dtos/response/statistic.go @@ -0,0 +1,27 @@ +package response + +import "time" + +type StatisticResponse struct { + ID string `json:"id"` + Date string `json:"date"` + TotalUsers int32 `json:"total_users"` + TotalProjects int32 `json:"total_projects"` + TotalCommits int32 `json:"total_commits"` + TotalSubmissions int32 `json:"total_submissions"` + TotalMedias int32 `json:"total_medias"` + TotalWikis int32 `json:"total_wikis"` + TotalEntities int32 `json:"total_entities"` + TotalGeometries int32 `json:"total_geometries"` + TotalStorageBytes int64 `json:"total_storage_bytes"` + NewUsers int32 `json:"new_users"` + NewProjects int32 `json:"new_projects"` + NewCommits int32 `json:"new_commits"` + NewSubmissions int32 `json:"new_submissions"` + NewMedias int32 `json:"new_medias"` + NewWikis int32 `json:"new_wikis"` + NewEntities int32 `json:"new_entities"` + NewGeometries int32 `json:"new_geometries"` + NewStorageBytes int64 `json:"new_storage_bytes"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/internal/gen/sqlc/models.go b/internal/gen/sqlc/models.go index 86e7a60..df6dd5b 100644 --- a/internal/gen/sqlc/models.go +++ b/internal/gen/sqlc/models.go @@ -129,6 +129,30 @@ type Submission struct { CreatedAt pgtype.Timestamptz `json:"created_at"` } +type SystemStatistic struct { + ID pgtype.UUID `json:"id"` + Date pgtype.Date `json:"date"` + TotalUsers int32 `json:"total_users"` + TotalProjects int32 `json:"total_projects"` + TotalCommits int32 `json:"total_commits"` + TotalSubmissions int32 `json:"total_submissions"` + TotalMedias int32 `json:"total_medias"` + TotalWikis int32 `json:"total_wikis"` + TotalEntities int32 `json:"total_entities"` + TotalGeometries int32 `json:"total_geometries"` + TotalStorageBytes int64 `json:"total_storage_bytes"` + NewUsers int32 `json:"new_users"` + NewProjects int32 `json:"new_projects"` + NewCommits int32 `json:"new_commits"` + NewSubmissions int32 `json:"new_submissions"` + NewMedias int32 `json:"new_medias"` + NewWikis int32 `json:"new_wikis"` + NewEntities int32 `json:"new_entities"` + NewGeometries int32 `json:"new_geometries"` + NewStorageBytes int64 `json:"new_storage_bytes"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type User struct { ID pgtype.UUID `json:"id"` Email string `json:"email"` diff --git a/internal/gen/sqlc/statistic.sql.go b/internal/gen/sqlc/statistic.sql.go new file mode 100644 index 0000000..75656df --- /dev/null +++ b/internal/gen/sqlc/statistic.sql.go @@ -0,0 +1,224 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: statistic.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const getSystemStatisticsByDate = `-- name: GetSystemStatisticsByDate :one +SELECT id, date, total_users, total_projects, total_commits, total_submissions, total_medias, total_wikis, total_entities, total_geometries, total_storage_bytes, new_users, new_projects, new_commits, new_submissions, new_medias, new_wikis, new_entities, new_geometries, new_storage_bytes, created_at FROM system_statistics WHERE date = $1 LIMIT 1 +` + +func (q *Queries) GetSystemStatisticsByDate(ctx context.Context, date pgtype.Date) (SystemStatistic, error) { + row := q.db.QueryRow(ctx, getSystemStatisticsByDate, date) + var i SystemStatistic + err := row.Scan( + &i.ID, + &i.Date, + &i.TotalUsers, + &i.TotalProjects, + &i.TotalCommits, + &i.TotalSubmissions, + &i.TotalMedias, + &i.TotalWikis, + &i.TotalEntities, + &i.TotalGeometries, + &i.TotalStorageBytes, + &i.NewUsers, + &i.NewProjects, + &i.NewCommits, + &i.NewSubmissions, + &i.NewMedias, + &i.NewWikis, + &i.NewEntities, + &i.NewGeometries, + &i.NewStorageBytes, + &i.CreatedAt, + ) + return i, err +} + +const getSystemStatisticsByIDs = `-- name: GetSystemStatisticsByIDs :many +SELECT id, date, total_users, total_projects, total_commits, total_submissions, total_medias, total_wikis, total_entities, total_geometries, total_storage_bytes, new_users, new_projects, new_commits, new_submissions, new_medias, new_wikis, new_entities, new_geometries, new_storage_bytes, created_at FROM system_statistics WHERE id = ANY($1::UUID[]) +` + +func (q *Queries) GetSystemStatisticsByIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([]SystemStatistic, error) { + rows, err := q.db.Query(ctx, getSystemStatisticsByIDs, dollar_1) + if err != nil { + return nil, err + } + defer rows.Close() + items := []SystemStatistic{} + for rows.Next() { + var i SystemStatistic + if err := rows.Scan( + &i.ID, + &i.Date, + &i.TotalUsers, + &i.TotalProjects, + &i.TotalCommits, + &i.TotalSubmissions, + &i.TotalMedias, + &i.TotalWikis, + &i.TotalEntities, + &i.TotalGeometries, + &i.TotalStorageBytes, + &i.NewUsers, + &i.NewProjects, + &i.NewCommits, + &i.NewSubmissions, + &i.NewMedias, + &i.NewWikis, + &i.NewEntities, + &i.NewGeometries, + &i.NewStorageBytes, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const searchSystemStatistics = `-- name: SearchSystemStatistics :many +SELECT id, date, total_users, total_projects, total_commits, total_submissions, total_medias, total_wikis, total_entities, total_geometries, total_storage_bytes, new_users, new_projects, new_commits, new_submissions, new_medias, new_wikis, new_entities, new_geometries, new_storage_bytes, created_at FROM system_statistics +WHERE + ($1::DATE IS NULL OR date >= $1::DATE) AND + ($2::DATE IS NULL OR date <= $2::DATE) +ORDER BY date DESC +` + +type SearchSystemStatisticsParams struct { + StartDate pgtype.Date `json:"start_date"` + EndDate pgtype.Date `json:"end_date"` +} + +func (q *Queries) SearchSystemStatistics(ctx context.Context, arg SearchSystemStatisticsParams) ([]SystemStatistic, error) { + rows, err := q.db.Query(ctx, searchSystemStatistics, arg.StartDate, arg.EndDate) + if err != nil { + return nil, err + } + defer rows.Close() + items := []SystemStatistic{} + for rows.Next() { + var i SystemStatistic + if err := rows.Scan( + &i.ID, + &i.Date, + &i.TotalUsers, + &i.TotalProjects, + &i.TotalCommits, + &i.TotalSubmissions, + &i.TotalMedias, + &i.TotalWikis, + &i.TotalEntities, + &i.TotalGeometries, + &i.TotalStorageBytes, + &i.NewUsers, + &i.NewProjects, + &i.NewCommits, + &i.NewSubmissions, + &i.NewMedias, + &i.NewWikis, + &i.NewEntities, + &i.NewGeometries, + &i.NewStorageBytes, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const upsertSystemStatistics = `-- name: UpsertSystemStatistics :one +INSERT INTO system_statistics ( + date, + total_users, total_projects, total_commits, total_submissions, total_medias, total_wikis, total_entities, total_geometries, total_storage_bytes, + new_users, new_projects, new_commits, new_submissions, new_medias, new_wikis, new_entities, new_geometries, new_storage_bytes +) VALUES ( + $1, + (SELECT COUNT(*)::INT FROM users), + (SELECT COUNT(*)::INT FROM projects), + (SELECT COUNT(*)::INT FROM commits), + (SELECT COUNT(*)::INT FROM submissions), + (SELECT COUNT(*)::INT FROM medias), + (SELECT COUNT(*)::INT FROM wikis), + (SELECT COUNT(*)::INT FROM entities), + (SELECT COUNT(*)::INT FROM geometries), + COALESCE((SELECT SUM(size)::BIGINT FROM medias), 0), + + (SELECT COUNT(*)::INT FROM users WHERE created_at >= $1::DATE AND created_at < $1::DATE + INTERVAL '1 day'), + (SELECT COUNT(*)::INT FROM projects WHERE created_at >= $1::DATE AND created_at < $1::DATE + INTERVAL '1 day'), + (SELECT COUNT(*)::INT FROM commits WHERE created_at >= $1::DATE AND created_at < $1::DATE + INTERVAL '1 day'), + (SELECT COUNT(*)::INT FROM submissions WHERE created_at >= $1::DATE AND created_at < $1::DATE + INTERVAL '1 day'), + (SELECT COUNT(*)::INT FROM medias WHERE created_at >= $1::DATE AND created_at < $1::DATE + INTERVAL '1 day'), + (SELECT COUNT(*)::INT FROM wikis WHERE created_at >= $1::DATE AND created_at < $1::DATE + INTERVAL '1 day'), + (SELECT COUNT(*)::INT FROM entities WHERE created_at >= $1::DATE AND created_at < $1::DATE + INTERVAL '1 day'), + (SELECT COUNT(*)::INT FROM geometries WHERE created_at >= $1::DATE AND created_at < $1::DATE + INTERVAL '1 day'), + COALESCE((SELECT SUM(size)::BIGINT FROM medias WHERE created_at >= $1::DATE AND created_at < $1::DATE + INTERVAL '1 day'), 0) +) +ON CONFLICT (date) DO UPDATE SET + total_users = EXCLUDED.total_users, + total_projects = EXCLUDED.total_projects, + total_commits = EXCLUDED.total_commits, + total_submissions = EXCLUDED.total_submissions, + total_medias = EXCLUDED.total_medias, + total_wikis = EXCLUDED.total_wikis, + total_entities = EXCLUDED.total_entities, + total_geometries = EXCLUDED.total_geometries, + total_storage_bytes = EXCLUDED.total_storage_bytes, + new_users = EXCLUDED.new_users, + new_projects = EXCLUDED.new_projects, + new_commits = EXCLUDED.new_commits, + new_submissions = EXCLUDED.new_submissions, + new_medias = EXCLUDED.new_medias, + new_wikis = EXCLUDED.new_wikis, + new_entities = EXCLUDED.new_entities, + new_geometries = EXCLUDED.new_geometries, + new_storage_bytes = EXCLUDED.new_storage_bytes +RETURNING id, date, total_users, total_projects, total_commits, total_submissions, total_medias, total_wikis, total_entities, total_geometries, total_storage_bytes, new_users, new_projects, new_commits, new_submissions, new_medias, new_wikis, new_entities, new_geometries, new_storage_bytes, created_at +` + +func (q *Queries) UpsertSystemStatistics(ctx context.Context, date pgtype.Date) (SystemStatistic, error) { + row := q.db.QueryRow(ctx, upsertSystemStatistics, date) + var i SystemStatistic + err := row.Scan( + &i.ID, + &i.Date, + &i.TotalUsers, + &i.TotalProjects, + &i.TotalCommits, + &i.TotalSubmissions, + &i.TotalMedias, + &i.TotalWikis, + &i.TotalEntities, + &i.TotalGeometries, + &i.TotalStorageBytes, + &i.NewUsers, + &i.NewProjects, + &i.NewCommits, + &i.NewSubmissions, + &i.NewMedias, + &i.NewWikis, + &i.NewEntities, + &i.NewGeometries, + &i.NewStorageBytes, + &i.CreatedAt, + ) + return i, err +} diff --git a/internal/models/statistic.go b/internal/models/statistic.go new file mode 100644 index 0000000..57859a5 --- /dev/null +++ b/internal/models/statistic.go @@ -0,0 +1,66 @@ +package models + +import ( + "history-api/internal/dtos/response" + "time" +) + +type StatisticEntity struct { + ID string `json:"id"` + Date time.Time `json:"date"` + TotalUsers int32 `json:"total_users"` + TotalProjects int32 `json:"total_projects"` + TotalCommits int32 `json:"total_commits"` + TotalSubmissions int32 `json:"total_submissions"` + TotalMedias int32 `json:"total_medias"` + TotalWikis int32 `json:"total_wikis"` + TotalEntities int32 `json:"total_entities"` + TotalGeometries int32 `json:"total_geometries"` + TotalStorageBytes int64 `json:"total_storage_bytes"` + NewUsers int32 `json:"new_users"` + NewProjects int32 `json:"new_projects"` + NewCommits int32 `json:"new_commits"` + NewSubmissions int32 `json:"new_submissions"` + NewMedias int32 `json:"new_medias"` + NewWikis int32 `json:"new_wikis"` + NewEntities int32 `json:"new_entities"` + NewGeometries int32 `json:"new_geometries"` + NewStorageBytes int64 `json:"new_storage_bytes"` + CreatedAt *time.Time `json:"created_at"` +} + +func (e *StatisticEntity) ToResponse() *response.StatisticResponse { + if e == nil { + return nil + } + + dateStr := e.Date.Format("2006-01-02") + var createdAt time.Time + if e.CreatedAt != nil { + createdAt = *e.CreatedAt + } + + return &response.StatisticResponse{ + ID: e.ID, + Date: dateStr, + TotalUsers: e.TotalUsers, + TotalProjects: e.TotalProjects, + TotalCommits: e.TotalCommits, + TotalSubmissions: e.TotalSubmissions, + TotalMedias: e.TotalMedias, + TotalWikis: e.TotalWikis, + TotalEntities: e.TotalEntities, + TotalGeometries: e.TotalGeometries, + TotalStorageBytes: e.TotalStorageBytes, + NewUsers: e.NewUsers, + NewProjects: e.NewProjects, + NewCommits: e.NewCommits, + NewSubmissions: e.NewSubmissions, + NewMedias: e.NewMedias, + NewWikis: e.NewWikis, + NewEntities: e.NewEntities, + NewGeometries: e.NewGeometries, + NewStorageBytes: e.NewStorageBytes, + CreatedAt: createdAt, + } +} diff --git a/internal/models/user.go b/internal/models/user.go index 4908814..7c06e0d 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -88,3 +88,9 @@ func UsersEntityToResponse(users []*UserEntity) []*response.UserResponse { } return out } + +type AdminUserActionPayload struct { + Email string `json:"email"` + Password string `json:"password"` + Action string `json:"action"` +} diff --git a/internal/repositories/statisticRepo.go b/internal/repositories/statisticRepo.go new file mode 100644 index 0000000..62419e8 --- /dev/null +++ b/internal/repositories/statisticRepo.go @@ -0,0 +1,241 @@ +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" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +type StatisticRepository interface { + Search(ctx context.Context, params sqlc.SearchSystemStatisticsParams) ([]*models.StatisticEntity, error) + GetByDate(ctx context.Context, date time.Time) (*models.StatisticEntity, error) + GetByID(ctx context.Context, id pgtype.UUID) (*models.StatisticEntity, error) + Upsert(ctx context.Context, date time.Time) (*models.StatisticEntity, error) + WithTx(tx pgx.Tx) StatisticRepository +} + +type statisticRepository struct { + q *sqlc.Queries + c cache.Cache + db sqlc.DBTX +} + +func NewStatisticRepository(db sqlc.DBTX, c cache.Cache) StatisticRepository { + return &statisticRepository{ + q: sqlc.New(db), + c: c, + db: db, + } +} + +func (r *statisticRepository) WithTx(tx pgx.Tx) StatisticRepository { + return &statisticRepository{ + q: r.q.WithTx(tx), + c: r.c, + db: tx, + } +} + +func (r *statisticRepository) 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 mapToEntity(row sqlc.SystemStatistic) *models.StatisticEntity { + return &models.StatisticEntity{ + ID: convert.UUIDToString(row.ID), + Date: row.Date.Time, + TotalUsers: row.TotalUsers, + TotalProjects: row.TotalProjects, + TotalCommits: row.TotalCommits, + TotalSubmissions: row.TotalSubmissions, + TotalMedias: row.TotalMedias, + TotalWikis: row.TotalWikis, + TotalEntities: row.TotalEntities, + TotalGeometries: row.TotalGeometries, + TotalStorageBytes: row.TotalStorageBytes, + NewUsers: row.NewUsers, + NewProjects: row.NewProjects, + NewCommits: row.NewCommits, + NewSubmissions: row.NewSubmissions, + NewMedias: row.NewMedias, + NewWikis: row.NewWikis, + NewEntities: row.NewEntities, + NewGeometries: row.NewGeometries, + NewStorageBytes: row.NewStorageBytes, + CreatedAt: convert.TimeToPtr(row.CreatedAt), + } +} + +func (r *statisticRepository) getByIDsWithFallback(ctx context.Context, ids []string) ([]*models.StatisticEntity, error) { + if len(ids) == 0 { + return []*models.StatisticEntity{}, nil + } + keys := make([]string, len(ids)) + for i, id := range ids { + keys[i] = fmt.Sprintf("statistic:id:%s", id) + } + raws := r.c.MGet(ctx, keys...) + + var stats []*models.StatisticEntity + missingStatsToCache := make(map[string]any) + + var missingPgIds []pgtype.UUID + for i, b := range raws { + if len(b) == 0 { + pgId := pgtype.UUID{} + err := pgId.Scan(ids[i]) + if err == nil { + missingPgIds = append(missingPgIds, pgId) + } + } + } + + dbMap := make(map[string]*models.StatisticEntity) + if len(missingPgIds) > 0 { + dbRows, err := r.q.GetSystemStatisticsByIDs(ctx, missingPgIds) + if err == nil { + for _, row := range dbRows { + entity := mapToEntity(row) + dbMap[entity.ID] = entity + } + } + } + + for i, b := range raws { + if len(b) > 0 { + var s models.StatisticEntity + if err := json.Unmarshal(b, &s); err == nil { + stats = append(stats, &s) + } + } else { + if item, ok := dbMap[ids[i]]; ok { + stats = append(stats, item) + missingStatsToCache[keys[i]] = item + } + } + } + + if len(missingStatsToCache) > 0 { + _ = r.c.MSet(ctx, missingStatsToCache, constants.NormalCacheDuration) + } + + return stats, nil +} + +func (r *statisticRepository) Search(ctx context.Context, params sqlc.SearchSystemStatisticsParams) ([]*models.StatisticEntity, error) { + queryKey := r.generateQueryKey("statistic:search", params) + + var cachedIDs []string + if err := r.c.Get(ctx, queryKey, &cachedIDs); err == nil && len(cachedIDs) > 0 { + return r.getByIDsWithFallback(ctx, cachedIDs) + } + + rows, err := r.q.SearchSystemStatistics(ctx, params) + if err != nil { + return nil, err + } + + var ids []string + statsToCache := make(map[string]any) + var stats []*models.StatisticEntity + + for _, row := range rows { + entity := mapToEntity(row) + ids = append(ids, entity.ID) + stats = append(stats, entity) + statsToCache[fmt.Sprintf("statistic:id:%s", entity.ID)] = entity + } + + if len(statsToCache) > 0 { + _ = r.c.MSet(ctx, statsToCache, constants.NormalCacheDuration) + } + if len(ids) > 0 { + _ = r.c.Set(ctx, queryKey, ids, constants.ListCacheDuration) + } + + return stats, nil +} + +func (r *statisticRepository) GetByID(ctx context.Context, id pgtype.UUID) (*models.StatisticEntity, error) { + cacheId := fmt.Sprintf("statistic:id:%s", convert.UUIDToString(id)) + var stat models.StatisticEntity + err := r.c.Get(ctx, cacheId, &stat) + if err == nil { + return &stat, nil + } + + rows, err := r.q.GetSystemStatisticsByIDs(ctx, []pgtype.UUID{id}) + if err != nil { + return nil, err + } + if len(rows) == 0 { + return nil, nil + } + + entity := mapToEntity(rows[0]) + _ = r.c.Set(ctx, cacheId, entity, constants.NormalCacheDuration) + + return entity, nil +} + +func (r *statisticRepository) GetByDate(ctx context.Context, date time.Time) (*models.StatisticEntity, error) { + dateStr := date.Format("2006-01-02") + cacheId := fmt.Sprintf("statistic:date:%s", dateStr) + + var stat models.StatisticEntity + err := r.c.Get(ctx, cacheId, &stat) + if err == nil { + return &stat, nil + } + + row, err := r.q.GetSystemStatisticsByDate(ctx, pgtype.Date{Time: date, Valid: true}) + if err != nil { + if err == pgx.ErrNoRows { + return nil, nil + } + return nil, err + } + + entity := mapToEntity(row) + + _ = r.c.Set(ctx, cacheId, entity, constants.NormalCacheDuration) + _ = r.c.Set(ctx, fmt.Sprintf("statistic:id:%s", entity.ID), entity, constants.NormalCacheDuration) + + return entity, nil +} + +func (r *statisticRepository) Upsert(ctx context.Context, date time.Time) (*models.StatisticEntity, error) { + row, err := r.q.UpsertSystemStatistics(ctx, pgtype.Date{Time: date, Valid: true}) + if err != nil { + return nil, err + } + + entity := mapToEntity(row) + + // Clear search cache and the specific date cache + go func() { + bgCtx := context.Background() + _ = r.c.DelByPattern(bgCtx, "statistic:search*") + _ = r.c.Del( + bgCtx, + fmt.Sprintf("statistic:id:%s", entity.ID), + fmt.Sprintf("statistic:date:%s", date.Format("2006-01-02")), + ) + }() + + return entity, nil +} + + diff --git a/internal/routes/projectRoute.go b/internal/routes/projectRoute.go index d6e4812..3b52df6 100644 --- a/internal/routes/projectRoute.go +++ b/internal/routes/projectRoute.go @@ -16,6 +16,12 @@ func ProjectRoutes( userRepo repositories.UserRepository, ) { route := app.Group("/projects") + + route.Get( + "/commits/:commitId", + middlewares.JwtAccess(userRepo), + commitController.GetCommitByID, + ) route.Post( "/:id/commits", @@ -35,6 +41,7 @@ func ProjectRoutes( commitController.GetProjectCommits, ) + route.Post( "/:id/members", middlewares.JwtAccess(userRepo), diff --git a/internal/routes/statisticRoute.go b/internal/routes/statisticRoute.go new file mode 100644 index 0000000..ff61879 --- /dev/null +++ b/internal/routes/statisticRoute.go @@ -0,0 +1,22 @@ +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 StatisticRoutes(app *fiber.App, statController *controllers.StatisticController, userRepo repositories.UserRepository) { + statGroup := app.Group( + "/statistics", + middlewares.JwtAccess(userRepo), + middlewares.RequireAnyRole(constants.RoleTypeAdmin, constants.RoleTypeMod), + ) + + statGroup.Get("/", statController.SearchStatistics) + statGroup.Get("/:date", statController.GetStatisticByDate) +} + diff --git a/internal/routes/userRoute.go b/internal/routes/userRoute.go index 58dd2d1..ec5132d 100644 --- a/internal/routes/userRoute.go +++ b/internal/routes/userRoute.go @@ -17,7 +17,7 @@ func UserRoutes(app *fiber.App, controller *controllers.UserController, userRepo middlewares.JwtAccess(userRepo), controller.GetUserCurrent, ) - + route.Put( "/current", middlewares.JwtAccess(userRepo), @@ -61,6 +61,13 @@ func UserRoutes(app *fiber.App, controller *controllers.UserController, userRepo middlewares.RequireAnyRole(constants.RoleTypeAdmin, constants.RoleTypeMod), controller.DeleteUser, ) + + route.Put( + "/:id", + middlewares.JwtAccess(userRepo), + middlewares.RequireAnyRole(constants.RoleTypeAdmin, constants.RoleTypeMod), + controller.AdminUpdateProfile, + ) route.Get( "/:id/media", @@ -90,6 +97,13 @@ func UserRoutes(app *fiber.App, controller *controllers.UserController, userRepo controller.RestoreUser, ) + route.Patch( + "/:id/password", + middlewares.JwtAccess(userRepo), + middlewares.RequireAnyRole(constants.RoleTypeAdmin, constants.RoleTypeMod), + controller.AdminResetPassword, + ) + route.Patch( "/:id/role", middlewares.JwtAccess(userRepo), diff --git a/internal/services/commitService.go b/internal/services/commitService.go index f189eb6..1014c58 100644 --- a/internal/services/commitService.go +++ b/internal/services/commitService.go @@ -22,6 +22,7 @@ type CommitService interface { CreateCommit(ctx context.Context, userID string, projectID string, dto *request.CreateCommitDto) (*response.CommitResponse, *fiber.Error) RestoreCommit(ctx context.Context, userID string, projectID string, dto *request.RestoreCommitDto) *fiber.Error GetProjectCommits(ctx context.Context, projectID string) ([]*response.CommitResponse, *fiber.Error) + GetCommitByID(ctx context.Context, commitID string) (*response.CommitResponse, *fiber.Error) } type commitService struct { @@ -188,3 +189,17 @@ func (s *commitService) GetProjectCommits(ctx context.Context, projectID string) return models.CommitsEntityToResponse(commits), nil } + +func (s *commitService) GetCommitByID(ctx context.Context, commitID string) (*response.CommitResponse, *fiber.Error) { + commitUUID, err := convert.StringToUUID(commitID) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid commit ID") + } + + commit, err := s.commitRepo.GetByID(ctx, commitUUID) + if err != nil { + return nil, fiber.NewError(fiber.StatusNotFound, "Commit not found") + } + + return commit.ToResponse(), nil +} diff --git a/internal/services/statisticService.go b/internal/services/statisticService.go new file mode 100644 index 0000000..51426ef --- /dev/null +++ b/internal/services/statisticService.go @@ -0,0 +1,75 @@ +package services + +import ( + "context" + "history-api/internal/dtos/request" + "history-api/internal/dtos/response" + "history-api/internal/gen/sqlc" + "history-api/internal/repositories" + "time" + + "github.com/gofiber/fiber/v3" + "github.com/jackc/pgx/v5/pgtype" +) + +type StatisticService interface { + Search(ctx context.Context, dto *request.SearchStatisticDto) ([]*response.StatisticResponse, *fiber.Error) + GetByDate(ctx context.Context, dateStr string) (*response.StatisticResponse, *fiber.Error) +} + +type statisticService struct { + repo repositories.StatisticRepository +} + +func NewStatisticService(repo repositories.StatisticRepository) StatisticService { + return &statisticService{repo: repo} +} + +func (s *statisticService) Search(ctx context.Context, dto *request.SearchStatisticDto) ([]*response.StatisticResponse, *fiber.Error) { + params := sqlc.SearchSystemStatisticsParams{} + + if dto.StartDate != "" { + parsedDate, err := time.Parse("2006-01-02", dto.StartDate) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid start_date format, expected YYYY-MM-DD") + } + params.StartDate = pgtype.Date{Time: parsedDate, Valid: true} + } + + if dto.EndDate != "" { + parsedDate, err := time.Parse("2006-01-02", dto.EndDate) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid end_date format, expected YYYY-MM-DD") + } + params.EndDate = pgtype.Date{Time: parsedDate, Valid: true} + } + + stats, err := s.repo.Search(ctx, params) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to search statistics") + } + + responses := make([]*response.StatisticResponse, 0, len(stats)) + for _, stat := range stats { + responses = append(responses, stat.ToResponse()) + } + + return responses, nil +} + +func (s *statisticService) GetByDate(ctx context.Context, dateStr string) (*response.StatisticResponse, *fiber.Error) { + parsedDate, err := time.Parse("2006-01-02", dateStr) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid date format, expected YYYY-MM-DD") + } + + stat, err := s.repo.GetByDate(ctx, parsedDate) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get statistics") + } + if stat == nil { + return nil, fiber.NewError(fiber.StatusNotFound, "Statistics not found for the given date") + } + + return stat.ToResponse(), nil +} diff --git a/internal/services/userService.go b/internal/services/userService.go index 86311d1..32f597b 100644 --- a/internal/services/userService.go +++ b/internal/services/userService.go @@ -34,6 +34,7 @@ type UserService interface { RestoreUser(ctx context.Context, userId string) (*response.UserResponse, *fiber.Error) GetUserByID(ctx context.Context, userId string) (*response.UserResponse, *fiber.Error) SearchUser(ctx context.Context, dto *request.SearchUserDto) (*response.PaginatedResponse, *fiber.Error) + AdminResetPassword(ctx context.Context, userId string, dto *request.ResetPasswordDto) *fiber.Error } type userService struct { @@ -121,7 +122,13 @@ func (u *userService) CreateUser(ctx context.Context, dto *request.CreateUserDto if err := tx.Commit(ctx); err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to commit transaction") } - + + _ = u.c.PublishTask(ctx, constants.StreamEmailName, constants.TaskTypeAdminUserAction, models.AdminUserActionPayload{ + Email: dto.Email, + Password: dto.Password, + Action: "create", + }) + finalUser, err := u.userRepo.GetByID(ctx, userUUID) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch created user") @@ -189,6 +196,63 @@ func (u *userService) ChangePassword(ctx context.Context, userId string, dto *re return nil } +func (u *userService) AdminResetPassword(ctx context.Context, userId string, dto *request.ResetPasswordDto) *fiber.Error { + tx, err := u.db.Begin(ctx) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to start transaction") + } + defer tx.Rollback(ctx) + + uRepo := u.userRepo.WithTx(tx) + + pgID, err := convert.StringToUUID(userId) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Invalid user ID") + } + user, err := u.userRepo.GetByID(ctx, pgID) + if err != nil { + return fiber.NewError(fiber.StatusNotFound, "Failed to fetch user") + } + if user == nil { + return fiber.NewError(fiber.StatusNotFound, "User not found") + } + + hashPassword, err := bcrypt.GenerateFromPassword([]byte(dto.NewPassword), bcrypt.DefaultCost) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to hash new password") + } + + err = uRepo.UpdatePassword(ctx, sqlc.UpdateUserPasswordParams{ + ID: pgID, + PasswordHash: pgtype.Text{String: string(hashPassword), Valid: true}, + }) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to update password") + } + + err = uRepo.UpdateTokenVersion(ctx, sqlc.UpdateTokenVersionParams{ + ID: pgID, + TokenVersion: user.TokenVersion + 1, + }) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to update token version") + } + + if err := tx.Commit(ctx); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to commit transaction") + } + + if dto.IsSendEmail { + _ = u.c.PublishTask(ctx, constants.StreamEmailName, constants.TaskTypeAdminUserAction, models.AdminUserActionPayload{ + Email: user.Email, + Password: dto.NewPassword, + Action: "reset", + }) + } + + return nil +} + func (u *userService) ChangeRoleUser(ctx context.Context, userId string, claims *response.JWTClaims, dto *request.ChangeRoleDto) (*response.UserResponse, *fiber.Error) { tx, err := u.db.Begin(ctx) if err != nil { diff --git a/pkg/constants/task.go b/pkg/constants/task.go index ecee177..da6b16f 100644 --- a/pkg/constants/task.go +++ b/pkg/constants/task.go @@ -8,6 +8,7 @@ const ( TaskTypeDeleteMedia TaskType = "DELETE_MEDIA" TaskTypeBulkDeleteMedia TaskType = "BULK_DELETE_MEDIA" TaskTypeRagIndexSubmission TaskType = "RAG_INDEX_SUBMISSION" + TaskTypeAdminUserAction TaskType = "ADMIN_USER_ACTION" ) func (t TaskType) String() string { diff --git a/pkg/email/email.go b/pkg/email/email.go index ff4e90e..90f9e26 100644 --- a/pkg/email/email.go +++ b/pkg/email/email.go @@ -104,3 +104,23 @@ func SendHistorianReviewMail(dto *models.UserVerificationStorageEntity) error { "APP_URL": feUrl, }) } + +func SendAdminUserActionMail(payload *models.AdminUserActionPayload) error { + var subject string + templatePath := "resources/admin_user_action.html" + + switch payload.Action { + case "create": + subject = "Your account has been created" + case "reset": + subject = "Your password has been reset" + default: + return fmt.Errorf("invalid action: %s", payload.Action) + } + + return SendMail(payload.Email, subject, templatePath, map[string]string{ + "EMAIL": payload.Email, + "PASSWORD": payload.Password, + "ACTION": payload.Action, + }) +}