feat: implement system statistics tracking, commit management controllers, and associated database migrations
All checks were successful
Build and Release / release (push) Successful in 1m49s
All checks were successful
Build and Release / release (push) Successful in 1m49s
This commit is contained in:
39
assets/resources/admin_user_action.html
Normal file
39
assets/resources/admin_user_action.html
Normal file
@@ -0,0 +1,39 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Account Information</title>
|
||||
<style>
|
||||
body { margin: 0; padding: 0; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background-color: #f4f7f6; color: #333333; }
|
||||
.container { max-width: 600px; margin: 40px auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 4px 10px rgba(0,0,0,0.05); overflow: hidden; }
|
||||
.header { background-color: #2563EB; padding: 30px; text-align: center; color: #ffffff; }
|
||||
.header h1 { margin: 0; font-size: 24px; font-weight: 600; }
|
||||
.content { padding: 40px 30px; text-align: left; }
|
||||
.content p { font-size: 16px; line-height: 1.6; color: #555555; margin-bottom: 25px; }
|
||||
.info-box { background-color: #F0F9FF; border: 1px solid #BAE6FD; padding: 20px; border-radius: 6px; margin-bottom: 25px; }
|
||||
.info-item { margin-bottom: 10px; font-size: 16px; }
|
||||
.info-label { font-weight: bold; color: #0369A1; min-width: 100px; display: inline-block; }
|
||||
.footer { background-color: #f9fafb; padding: 20px; text-align: center; font-size: 13px; color: #9ca3af; border-top: 1px solid #eeeeee; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>Account Information</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>Hello,</p>
|
||||
<p>Your account information has been updated by the administrator. Here are your credentials:</p>
|
||||
<div class="info-box">
|
||||
<div class="info-item"><span class="info-label">Email:</span> {{EMAIL}}</div>
|
||||
<div class="info-item"><span class="info-label">Password:</span> {{PASSWORD}}</div>
|
||||
</div>
|
||||
<p>Please log in and change your password as soon as possible to ensure your account security.</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
© 2026 Black Cat Studio. All rights reserved.
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
132
cmd/worker/cron/main.go
Normal file
132
cmd/worker/cron/main.go
Normal file
@@ -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 {}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
1
db/migrations/0000014_system_statistics.down.sql
Normal file
1
db/migrations/0000014_system_statistics.down.sql
Normal file
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS system_statistics CASCADE;
|
||||
28
db/migrations/0000014_system_statistics.up.sql
Normal file
28
db/migrations/0000014_system_statistics.up.sql
Normal file
@@ -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()
|
||||
);
|
||||
62
db/query/statistic.sql
Normal file
62
db/query/statistic.sql
Normal file
@@ -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[]);
|
||||
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
|
||||
3
go.mod
3
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
|
||||
|
||||
8
go.sum
8
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=
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
96
internal/controllers/statisticController.go
Normal file
96
internal/controllers/statisticController.go
Normal file
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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",
|
||||
})
|
||||
}
|
||||
|
||||
6
internal/dtos/request/statistic.go
Normal file
6
internal/dtos/request/statistic.go
Normal file
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
27
internal/dtos/response/statistic.go
Normal file
27
internal/dtos/response/statistic.go
Normal file
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
224
internal/gen/sqlc/statistic.sql.go
Normal file
224
internal/gen/sqlc/statistic.sql.go
Normal file
@@ -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
|
||||
}
|
||||
66
internal/models/statistic.go
Normal file
66
internal/models/statistic.go
Normal file
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
241
internal/repositories/statisticRepo.go
Normal file
241
internal/repositories/statisticRepo.go
Normal file
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
22
internal/routes/statisticRoute.go
Normal file
22
internal/routes/statisticRoute.go
Normal file
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
75
internal/services/statisticService.go
Normal file
75
internal/services/statisticService.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user