Added activities module

This commit is contained in:
2026-04-11 11:51:18 +03:00
parent 8e074db55f
commit 2dc8ff01b7
13 changed files with 694 additions and 4 deletions
@@ -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;
@@ -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'$$
);
+107
View File
@@ -15,6 +15,67 @@ const docTemplate = `{
"host": "{{.Host}}", "host": "{{.Host}}",
"basePath": "{{.BasePath}}", "basePath": "{{.BasePath}}",
"paths": { "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": { "/families": {
"post": { "post": {
"description": "Создает новую семью", "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": { "dto.CreateTransactionRequest": {
"type": "object", "type": "object",
"required": [ "required": [
+107
View File
@@ -4,6 +4,67 @@
"contact": {} "contact": {}
}, },
"paths": { "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": { "/families": {
"post": { "post": {
"description": "Создает новую семью", "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": { "dto.CreateTransactionRequest": {
"type": "object", "type": "object",
"required": [ "required": [
+70
View File
@@ -120,6 +120,36 @@ definitions:
username: username:
type: string type: string
type: object 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: dto.CreateTransactionRequest:
properties: properties:
amount: amount:
@@ -210,6 +240,46 @@ definitions:
info: info:
contact: {} contact: {}
paths: 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: /families:
post: post:
consumes: consumes:
+56
View File
@@ -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,
}
}
+60
View File
@@ -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))
}
@@ -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")
})
}
+6 -1
View File
@@ -90,6 +90,7 @@ func NewServer(cfg config.Config) *Server {
apiV1 := router.Group("/api/v1") apiV1 := router.Group("/api/v1")
transactionRepo := repositories.NewTransactionsSQLRepository(dbConn) transactionRepo := repositories.NewTransactionsSQLRepository(dbConn)
activityRepo := repositories.NewActivitySQLRepository(dbConn)
ocrSvc, err := ocr.NewGoogleOCR(context.Background()) ocrSvc, err := ocr.NewGoogleOCR(context.Background())
if err != nil { if err != nil {
@@ -101,10 +102,14 @@ func NewServer(cfg config.Config) *Server {
receiptRouter := routers.NewReceiptRouter(receiptService_, ocrSvc) receiptRouter := routers.NewReceiptRouter(receiptService_, ocrSvc)
receiptRouter.RegisterRoutes(apiV1) receiptRouter.RegisterRoutes(apiV1)
transactionService := services.NewTransactionService(transactionRepo) transactionService := services.NewTransactionService(transactionRepo, activityRepo)
transactionRouter := routers.NewTransactionsRouter(transactionService) transactionRouter := routers.NewTransactionsRouter(transactionService)
transactionRouter.RegisterRoutes(apiV1) transactionRouter.RegisterRoutes(apiV1)
activityService := services.NewActivityService(activityRepo)
activityRouter := routers.NewActivitiesRouter(activityService)
activityRouter.RegisterRoutes(apiV1)
usersRepo := repositories.NewUsersSQLRepository(dbConn) usersRepo := repositories.NewUsersSQLRepository(dbConn)
usersService := services.NewUserService(usersRepo) usersService := services.NewUserService(usersRepo)
usersRouter := routers.NewUsersRouter(usersService) usersRouter := routers.NewUsersRouter(usersService)
+38
View File
@@ -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
}
+16 -2
View File
@@ -6,6 +6,7 @@ import (
"context" "context"
"database/sql" "database/sql"
"errors" "errors"
"fmt"
"strings" "strings"
) )
@@ -20,10 +21,11 @@ type TransactionService interface {
type transactionService struct { type transactionService struct {
repo repositories.TransactionRepository repo repositories.TransactionRepository
activityRepo repositories.ActivityRepository
} }
func NewTransactionService(repo repositories.TransactionRepository) TransactionService { func NewTransactionService(repo repositories.TransactionRepository, activityRepo repositories.ActivityRepository) TransactionService {
return &transactionService{repo: repo} return &transactionService{repo: repo, activityRepo: activityRepo}
} }
var ( var (
@@ -58,6 +60,18 @@ func (s *transactionService) Create(ctx context.Context, req domain.CreateTransa
return nil, err 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 return transaction, nil
} }
+30
View File
@@ -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
}
+98
View File
@@ -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()
}