From 2dc8ff01b754acb1994fac95e30ee561e2fa7b75 Mon Sep 17 00:00:00 2001 From: AlexBelyan Date: Sat, 11 Apr 2026 11:51:18 +0300 Subject: [PATCH] Added activities module --- .../000007_create_activity_logs.down.sql | 10 ++ .../000007_create_activity_logs.up.sql | 30 +++++ backend/src/api/docs/docs.go | 107 ++++++++++++++++++ backend/src/api/docs/swagger.json | 107 ++++++++++++++++++ backend/src/api/docs/swagger.yaml | 70 ++++++++++++ backend/src/api/dto/activities.go | 56 +++++++++ backend/src/api/routers/activities.go | 60 ++++++++++ backend/src/api/routers/activities_test.go | 65 +++++++++++ backend/src/api/server.go | 7 +- backend/src/api/services/activities.go | 38 +++++++ backend/src/api/services/transactions.go | 20 +++- backend/src/domain/activity.go | 30 +++++ backend/src/repositories/activities.go | 98 ++++++++++++++++ 13 files changed, 694 insertions(+), 4 deletions(-) create mode 100644 backend/migrations/000007_create_activity_logs.down.sql create mode 100644 backend/migrations/000007_create_activity_logs.up.sql create mode 100644 backend/src/api/dto/activities.go create mode 100644 backend/src/api/routers/activities.go create mode 100644 backend/src/api/routers/activities_test.go create mode 100644 backend/src/api/services/activities.go create mode 100644 backend/src/domain/activity.go create mode 100644 backend/src/repositories/activities.go diff --git a/backend/migrations/000007_create_activity_logs.down.sql b/backend/migrations/000007_create_activity_logs.down.sql new file mode 100644 index 0000000..d6dd2a1 --- /dev/null +++ b/backend/migrations/000007_create_activity_logs.down.sql @@ -0,0 +1,10 @@ +DO +$$ +BEGIN + IF EXISTS (SELECT 1 FROM cron.job WHERE jobname = 'cleanup_activity_logs_hourly') THEN + PERFORM cron.unschedule((SELECT jobid FROM cron.job WHERE jobname = 'cleanup_activity_logs_hourly')); + END IF; +END +$$; + +DROP TABLE IF EXISTS activity_logs; diff --git a/backend/migrations/000007_create_activity_logs.up.sql b/backend/migrations/000007_create_activity_logs.up.sql new file mode 100644 index 0000000..2045059 --- /dev/null +++ b/backend/migrations/000007_create_activity_logs.up.sql @@ -0,0 +1,30 @@ +CREATE UNLOGGED TABLE activity_logs +( + id BIGSERIAL PRIMARY KEY, + family_id BIGINT REFERENCES families (id) ON DELETE CASCADE, + user_id BIGINT NOT NULL REFERENCES users (id) ON DELETE CASCADE, + action TEXT NOT NULL, + entity_type TEXT NOT NULL, + entity_id BIGINT, + description TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_activity_logs_created_at ON activity_logs (created_at DESC); +CREATE INDEX idx_activity_logs_user_id ON activity_logs (user_id); +CREATE INDEX idx_activity_logs_family_id ON activity_logs (family_id); + +DO +$$ +BEGIN + IF EXISTS (SELECT 1 FROM cron.job WHERE jobname = 'cleanup_activity_logs_hourly') THEN + PERFORM cron.unschedule((SELECT jobid FROM cron.job WHERE jobname = 'cleanup_activity_logs_hourly')); + END IF; +END +$$; + +SELECT cron.schedule( + 'cleanup_activity_logs_hourly', + '0 * * * *', + $$DELETE FROM activity_logs WHERE created_at < NOW() - INTERVAL '1 day'$$ +); diff --git a/backend/src/api/docs/docs.go b/backend/src/api/docs/docs.go index 62365e4..ca0b642 100644 --- a/backend/src/api/docs/docs.go +++ b/backend/src/api/docs/docs.go @@ -15,6 +15,67 @@ const docTemplate = `{ "host": "{{.Host}}", "basePath": "{{.BasePath}}", "paths": { + "/activities": { + "get": { + "description": "Возвращает список действий пользователей с пагинацией", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Activities" + ], + "summary": "Получить активность пользователей", + "parameters": [ + { + "type": "integer", + "description": "Family ID", + "name": "family_id", + "in": "query" + }, + { + "type": "integer", + "description": "User ID", + "name": "user_id", + "in": "query" + }, + { + "type": "integer", + "description": "Limit, default 10", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Offset", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.ActivityListResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/dto.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/dto.ErrorResponse" + } + } + } + } + }, "/families": { "post": { "description": "Создает новую семью", @@ -1158,6 +1219,52 @@ const docTemplate = `{ } } }, + "dto.ActivityListResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/dto.ActivityResponse" + } + }, + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + } + } + }, + "dto.ActivityResponse": { + "type": "object", + "properties": { + "action": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "description": { + "type": "string" + }, + "entity_id": { + "type": "integer" + }, + "entity_type": { + "type": "string" + }, + "family_id": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "user_id": { + "type": "integer" + } + } + }, "dto.CreateTransactionRequest": { "type": "object", "required": [ diff --git a/backend/src/api/docs/swagger.json b/backend/src/api/docs/swagger.json index 1853e2f..97ebc73 100644 --- a/backend/src/api/docs/swagger.json +++ b/backend/src/api/docs/swagger.json @@ -4,6 +4,67 @@ "contact": {} }, "paths": { + "/activities": { + "get": { + "description": "Возвращает список действий пользователей с пагинацией", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Activities" + ], + "summary": "Получить активность пользователей", + "parameters": [ + { + "type": "integer", + "description": "Family ID", + "name": "family_id", + "in": "query" + }, + { + "type": "integer", + "description": "User ID", + "name": "user_id", + "in": "query" + }, + { + "type": "integer", + "description": "Limit, default 10", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Offset", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.ActivityListResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/dto.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/dto.ErrorResponse" + } + } + } + } + }, "/families": { "post": { "description": "Создает новую семью", @@ -1147,6 +1208,52 @@ } } }, + "dto.ActivityListResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/dto.ActivityResponse" + } + }, + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + } + } + }, + "dto.ActivityResponse": { + "type": "object", + "properties": { + "action": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "description": { + "type": "string" + }, + "entity_id": { + "type": "integer" + }, + "entity_type": { + "type": "string" + }, + "family_id": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "user_id": { + "type": "integer" + } + } + }, "dto.CreateTransactionRequest": { "type": "object", "required": [ diff --git a/backend/src/api/docs/swagger.yaml b/backend/src/api/docs/swagger.yaml index d787e13..d83e14c 100644 --- a/backend/src/api/docs/swagger.yaml +++ b/backend/src/api/docs/swagger.yaml @@ -120,6 +120,36 @@ definitions: username: type: string type: object + dto.ActivityListResponse: + properties: + items: + items: + $ref: '#/definitions/dto.ActivityResponse' + type: array + limit: + type: integer + offset: + type: integer + type: object + dto.ActivityResponse: + properties: + action: + type: string + created_at: + type: string + description: + type: string + entity_id: + type: integer + entity_type: + type: string + family_id: + type: integer + id: + type: integer + user_id: + type: integer + type: object dto.CreateTransactionRequest: properties: amount: @@ -210,6 +240,46 @@ definitions: info: contact: {} paths: + /activities: + get: + consumes: + - application/json + description: Возвращает список действий пользователей с пагинацией + parameters: + - description: Family ID + in: query + name: family_id + type: integer + - description: User ID + in: query + name: user_id + type: integer + - description: Limit, default 10 + in: query + name: limit + type: integer + - description: Offset + in: query + name: offset + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/dto.ActivityListResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/dto.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/dto.ErrorResponse' + summary: Получить активность пользователей + tags: + - Activities /families: post: consumes: diff --git a/backend/src/api/dto/activities.go b/backend/src/api/dto/activities.go new file mode 100644 index 0000000..bbdc791 --- /dev/null +++ b/backend/src/api/dto/activities.go @@ -0,0 +1,56 @@ +package dto + +import ( + "FamilyHub/src/domain" + "time" +) + +type ActivityListQuery struct { + FamilyID *int64 `form:"family_id"` + UserID *int64 `form:"user_id"` + Limit int `form:"limit"` + Offset int `form:"offset"` +} + +type ActivityResponse struct { + ID int64 `json:"id"` + FamilyID *int64 `json:"family_id"` + UserID int64 `json:"user_id"` + Action string `json:"action"` + EntityType string `json:"entity_type"` + EntityID *int64 `json:"entity_id"` + Description string `json:"description"` + CreatedAt string `json:"created_at"` +} + +type ActivityListResponse struct { + Items []ActivityResponse `json:"items"` + Limit int `json:"limit"` + Offset int `json:"offset"` +} + +func ActivityToResponse(activity *domain.ActivityLog) ActivityResponse { + return ActivityResponse{ + ID: activity.ID, + FamilyID: activity.FamilyID, + UserID: activity.UserID, + Action: activity.Action, + EntityType: activity.EntityType, + EntityID: activity.EntityID, + Description: activity.Description, + CreatedAt: activity.CreatedAt.Format(time.RFC3339), + } +} + +func ActivitiesToListResponse(activities []*domain.ActivityLog, limit, offset int) ActivityListResponse { + items := make([]ActivityResponse, 0, len(activities)) + for _, activity := range activities { + items = append(items, ActivityToResponse(activity)) + } + + return ActivityListResponse{ + Items: items, + Limit: limit, + Offset: offset, + } +} diff --git a/backend/src/api/routers/activities.go b/backend/src/api/routers/activities.go new file mode 100644 index 0000000..240241b --- /dev/null +++ b/backend/src/api/routers/activities.go @@ -0,0 +1,60 @@ +package routers + +import ( + "FamilyHub/src/api/dto" + "FamilyHub/src/api/services" + "FamilyHub/src/domain" + "net/http" + + "github.com/gin-gonic/gin" +) + +type ActivitiesRouter struct { + service services.ActivityService +} + +func NewActivitiesRouter(s services.ActivityService) *ActivitiesRouter { + return &ActivitiesRouter{service: s} +} + +func (router *ActivitiesRouter) RegisterRoutes(r *gin.RouterGroup) { + activities := r.Group("/activities") + { + activities.GET("", router.List) + } +} + +// List GoDoc +// @Summary Получить активность пользователей +// @Description Возвращает список действий пользователей с пагинацией +// @Tags Activities +// @Accept json +// @Produce json +// @Param family_id query int false "Family ID" +// @Param user_id query int false "User ID" +// @Param limit query int false "Limit, default 10" +// @Param offset query int false "Offset" +// @Success 200 {object} dto.ActivityListResponse +// @Failure 400 {object} dto.ErrorResponse +// @Failure 500 {object} dto.ErrorResponse +// @Router /activities [get] +func (router *ActivitiesRouter) List(c *gin.Context) { + var query dto.ActivityListQuery + if err := c.ShouldBindQuery(&query); err != nil { + c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()}) + return + } + + activities, filter, err := router.service.List(c.Request.Context(), domain.ActivityLogListFilter{ + FamilyID: query.FamilyID, + UserID: query.UserID, + Limit: query.Limit, + Offset: query.Offset, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, dto.ErrorResponse{Message: "internal server error"}) + return + } + + c.JSON(http.StatusOK, dto.ActivitiesToListResponse(activities, filter.Limit, filter.Offset)) +} diff --git a/backend/src/api/routers/activities_test.go b/backend/src/api/routers/activities_test.go new file mode 100644 index 0000000..9e9a035 --- /dev/null +++ b/backend/src/api/routers/activities_test.go @@ -0,0 +1,65 @@ +package routers + +import ( + "FamilyHub/src/api/services" + "FamilyHub/src/domain" + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type activityServiceMock struct { + listFn func(ctx context.Context, filter domain.ActivityLogListFilter) ([]*domain.ActivityLog, domain.ActivityLogListFilter, error) +} + +func (m *activityServiceMock) List(ctx context.Context, filter domain.ActivityLogListFilter) ([]*domain.ActivityLog, domain.ActivityLogListFilter, error) { + if m.listFn != nil { + return m.listFn(ctx, filter) + } + return nil, filter, errors.New("mock list is not configured") +} + +func setupActivitiesRouter(mock services.ActivityService) *gin.Engine { + gin.SetMode(gin.TestMode) + r := gin.New() + apiV1 := r.Group("/api/v1") + router := NewActivitiesRouter(mock) + router.RegisterRoutes(apiV1) + return r +} + +func TestActivitiesRouter_List(t *testing.T) { + t.Run("uses default pagination", func(t *testing.T) { + r := setupActivitiesRouter(&activityServiceMock{listFn: func(ctx context.Context, filter domain.ActivityLogListFilter) ([]*domain.ActivityLog, domain.ActivityLogListFilter, error) { + assert.Equal(t, 0, filter.Limit) + assert.Equal(t, 0, filter.Offset) + + activity := &domain.ActivityLog{ + ID: 1, + UserID: 2, + Action: "create", + EntityType: "transaction", + Description: "Created transaction 1", + CreatedAt: time.Date(2026, time.April, 11, 12, 0, 0, 0, time.UTC), + } + filter.Limit = 10 + return []*domain.ActivityLog{activity}, filter, nil + }}) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/activities", nil) + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), "\"limit\":10") + assert.Contains(t, w.Body.String(), "Created transaction 1") + }) +} diff --git a/backend/src/api/server.go b/backend/src/api/server.go index 15f8473..5ff22c9 100644 --- a/backend/src/api/server.go +++ b/backend/src/api/server.go @@ -90,6 +90,7 @@ func NewServer(cfg config.Config) *Server { apiV1 := router.Group("/api/v1") transactionRepo := repositories.NewTransactionsSQLRepository(dbConn) + activityRepo := repositories.NewActivitySQLRepository(dbConn) ocrSvc, err := ocr.NewGoogleOCR(context.Background()) if err != nil { @@ -101,10 +102,14 @@ func NewServer(cfg config.Config) *Server { receiptRouter := routers.NewReceiptRouter(receiptService_, ocrSvc) receiptRouter.RegisterRoutes(apiV1) - transactionService := services.NewTransactionService(transactionRepo) + transactionService := services.NewTransactionService(transactionRepo, activityRepo) transactionRouter := routers.NewTransactionsRouter(transactionService) transactionRouter.RegisterRoutes(apiV1) + activityService := services.NewActivityService(activityRepo) + activityRouter := routers.NewActivitiesRouter(activityService) + activityRouter.RegisterRoutes(apiV1) + usersRepo := repositories.NewUsersSQLRepository(dbConn) usersService := services.NewUserService(usersRepo) usersRouter := routers.NewUsersRouter(usersService) diff --git a/backend/src/api/services/activities.go b/backend/src/api/services/activities.go new file mode 100644 index 0000000..213f828 --- /dev/null +++ b/backend/src/api/services/activities.go @@ -0,0 +1,38 @@ +package services + +import ( + "FamilyHub/src/domain" + "FamilyHub/src/repositories" + "context" +) + +type ActivityService interface { + List(ctx context.Context, filter domain.ActivityLogListFilter) ([]*domain.ActivityLog, domain.ActivityLogListFilter, error) +} + +type activityService struct { + repo repositories.ActivityRepository +} + +func NewActivityService(repo repositories.ActivityRepository) ActivityService { + return &activityService{repo: repo} +} + +func (s *activityService) List(ctx context.Context, filter domain.ActivityLogListFilter) ([]*domain.ActivityLog, domain.ActivityLogListFilter, error) { + if filter.Limit <= 0 { + filter.Limit = 10 + } + if filter.Limit > 100 { + filter.Limit = 100 + } + if filter.Offset < 0 { + filter.Offset = 0 + } + + activities, err := s.repo.List(ctx, filter) + if err != nil { + return nil, filter, err + } + + return activities, filter, nil +} diff --git a/backend/src/api/services/transactions.go b/backend/src/api/services/transactions.go index 0cfea2a..cb99c8f 100644 --- a/backend/src/api/services/transactions.go +++ b/backend/src/api/services/transactions.go @@ -6,6 +6,7 @@ import ( "context" "database/sql" "errors" + "fmt" "strings" ) @@ -19,11 +20,12 @@ type TransactionService interface { } type transactionService struct { - repo repositories.TransactionRepository + repo repositories.TransactionRepository + activityRepo repositories.ActivityRepository } -func NewTransactionService(repo repositories.TransactionRepository) TransactionService { - return &transactionService{repo: repo} +func NewTransactionService(repo repositories.TransactionRepository, activityRepo repositories.ActivityRepository) TransactionService { + return &transactionService{repo: repo, activityRepo: activityRepo} } var ( @@ -58,6 +60,18 @@ func (s *transactionService) Create(ctx context.Context, req domain.CreateTransa return nil, err } + if s.activityRepo != nil { + description := fmt.Sprintf("Created transaction %d", transaction.ID) + _ = s.activityRepo.Create(ctx, &domain.ActivityLog{ + FamilyID: &transaction.FamilyID, + UserID: transaction.CreatedBy, + Action: "create", + EntityType: "transaction", + EntityID: &transaction.ID, + Description: description, + }) + } + return transaction, nil } diff --git a/backend/src/domain/activity.go b/backend/src/domain/activity.go new file mode 100644 index 0000000..d663d6a --- /dev/null +++ b/backend/src/domain/activity.go @@ -0,0 +1,30 @@ +package domain + +import "time" + +type ActivityLog struct { + ID int64 + FamilyID *int64 + UserID int64 + Action string + EntityType string + EntityID *int64 + Description string + CreatedAt time.Time +} + +type ActivityLogCreateRequest struct { + FamilyID *int64 + UserID int64 + Action string + EntityType string + EntityID *int64 + Description string +} + +type ActivityLogListFilter struct { + FamilyID *int64 + UserID *int64 + Limit int + Offset int +} diff --git a/backend/src/repositories/activities.go b/backend/src/repositories/activities.go new file mode 100644 index 0000000..29f9d0d --- /dev/null +++ b/backend/src/repositories/activities.go @@ -0,0 +1,98 @@ +package repositories + +import ( + "FamilyHub/src/domain" + "context" + "database/sql" + "fmt" + "strings" +) + +type ActivityRepository interface { + Create(ctx context.Context, activity *domain.ActivityLog) error + List(ctx context.Context, filter domain.ActivityLogListFilter) ([]*domain.ActivityLog, error) +} + +type ActivitySQLRepository struct { + db *sql.DB +} + +func NewActivitySQLRepository(db *sql.DB) *ActivitySQLRepository { + return &ActivitySQLRepository{db: db} +} + +func (r *ActivitySQLRepository) Create(ctx context.Context, activity *domain.ActivityLog) error { + query := ` + INSERT INTO activity_logs (family_id, user_id, action, entity_type, entity_id, description) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id, created_at + ` + + return r.db.QueryRowContext( + ctx, + query, + activity.FamilyID, + activity.UserID, + activity.Action, + activity.EntityType, + activity.EntityID, + activity.Description, + ).Scan(&activity.ID, &activity.CreatedAt) +} + +func (r *ActivitySQLRepository) List(ctx context.Context, filter domain.ActivityLogListFilter) ([]*domain.ActivityLog, error) { + var ( + whereClauses []string + args []any + ) + + appendFilter := func(condition string, value any) { + args = append(args, value) + whereClauses = append(whereClauses, fmt.Sprintf(condition, len(args))) + } + + query := ` + SELECT id, family_id, user_id, action, entity_type, entity_id, description, created_at + FROM activity_logs + ` + + if filter.FamilyID != nil { + appendFilter("family_id = $%d", *filter.FamilyID) + } + if filter.UserID != nil { + appendFilter("user_id = $%d", *filter.UserID) + } + + if len(whereClauses) > 0 { + query += " WHERE " + strings.Join(whereClauses, " AND ") + } + + args = append(args, filter.Limit, filter.Offset) + query += fmt.Sprintf(" ORDER BY created_at DESC LIMIT $%d OFFSET $%d", len(args)-1, len(args)) + + rows, err := r.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var activities []*domain.ActivityLog + for rows.Next() { + var activity domain.ActivityLog + if err := rows.Scan( + &activity.ID, + &activity.FamilyID, + &activity.UserID, + &activity.Action, + &activity.EntityType, + &activity.EntityID, + &activity.Description, + &activity.CreatedAt, + ); err != nil { + return nil, err + } + activities = append(activities, &activity) + } + + return activities, rows.Err() +}