feat: implement system statistics tracking, commit management controllers, and associated database migrations
All checks were successful
Build and Release / release (push) Successful in 1m49s

This commit is contained in:
2026-05-07 11:31:53 +07:00
parent ca05785a24
commit bdaac7ddd8
29 changed files with 1347 additions and 2 deletions

View File

@@ -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,
})
}

View 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,
})
}

View File

@@ -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",
})
}

View 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"`
}

View File

@@ -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"`
}

View 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"`
}

View File

@@ -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"`

View 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
}

View 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,
}
}

View File

@@ -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"`
}

View 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
}

View File

@@ -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),

View 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)
}

View File

@@ -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),

View File

@@ -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
}

View 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
}

View File

@@ -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 {