package activity import ( "RenewCMS/api/pkg/apperr" "RenewCMS/api/pkg/claims" validator "RenewCMS/api/pkg/validator" "RenewCMS/internal/domain/audit" "RenewCMS/internal/domain/user" "RenewCMS/internal/infrastructure/useCases" "encoding/json" "errors" "net/http" "net/url" "strconv" "time" ) const ( csvContentType = "text/csv; charset=utf-8" csvContentDisposition = `attachment; filename="activity.csv"` ) type Handler struct { ListUseCase *useCases.ListActivityUseCase SummaryUseCase *useCases.GetActivitySummaryUseCase ExportUseCase *useCases.ExportActivityUseCase } type filterQuery struct { Q string `validate:"max=200"` Actor string `validate:"max=40"` Category string `validate:"omitempty,oneof=articles media users api_keys auth system sites authors"` Outcome string `validate:"omitempty,oneof=all success failed"` Security string `validate:"omitempty,oneof=true"` Range string `validate:"omitempty,oneof=today 7d 30d all custom"` From string `validate:"max=10"` To string `validate:"max=10"` } type listQuery struct { filterQuery Page int `validate:"gte=1"` Size int `validate:"oneof=5 25 50 100"` } type actorResponse struct { Type audit.ActorType `json:"type"` ID *uint32 `json:"id"` Name string `json:"name"` } type targetResponse struct { Type string `json:"type"` ID string `json:"id"` Name string `json:"name"` Exists bool `json:"exists"` } type itemResponse struct { ID uint64 `json:"id"` CreatedAt time.Time `json:"created_at"` Action string `json:"action"` Category audit.Category `json:"category"` Actor actorResponse `json:"actor"` Target *targetResponse `json:"target"` Outcome audit.Outcome `json:"outcome"` FailureReason *string `json:"failure_reason"` Security bool `json:"security"` Source audit.Source `json:"source"` IP *string `json:"ip"` UserAgent *string `json:"user_agent"` Params map[string]any `json:"params"` Changes []audit.Change `json:"changes"` } type listResponse struct { Items []itemResponse `json:"items"` Total int `json:"total"` Page int `json:"page"` Size int `json:"size"` } func NewHandler(list *useCases.ListActivityUseCase, summary *useCases.GetActivitySummaryUseCase, export *useCases.ExportActivityUseCase) *Handler { return &Handler{ListUseCase: list, SummaryUseCase: summary, ExportUseCase: export} } func readFilterQuery(values url.Values) filterQuery { return filterQuery{ Q: values.Get("q"), Actor: values.Get("actor"), Category: values.Get("category"), Outcome: values.Get("outcome"), Security: values.Get("security"), Range: values.Get("range"), From: values.Get("from"), To: values.Get("to"), } } func readListQuery(values url.Values) (listQuery, error) { page, err := intParam(values, "page", 1) if err != nil { return listQuery{}, err } size, err := intParam(values, "size", 25) if err != nil { return listQuery{}, err } return listQuery{filterQuery: readFilterQuery(values), Page: page, Size: size}, nil } func intParam(values url.Values, name string, fallback int) (int, error) { if !values.Has(name) { return fallback, nil } return strconv.Atoi(values.Get(name)) } func toActivityQuery(query filterQuery) useCases.ActivityQuery { return useCases.ActivityQuery{ Q: query.Q, Actor: query.Actor, Category: query.Category, Outcome: query.Outcome, Security: query.Security, Range: query.Range, From: query.From, To: query.To, } } func writeActivityError(w http.ResponseWriter, err error) { switch { case errors.Is(err, audit.ErrInvalidFilter): http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest) case errors.Is(err, audit.ErrActorNotAllowed): http.Error(w, apperr.OwnerOrAdminRequiredErrorMessage, http.StatusForbidden) case errors.Is(err, user.ErrUserNotFound): http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized) default: http.Error(w, "Internal server error", http.StatusInternalServerError) } } func (h *Handler) List(w http.ResponseWriter, r *http.Request) { query, err := readListQuery(r.URL.Query()) if err != nil || validator.Validate.Struct(query) != nil { http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest) return } viewerId, ok := claims.UserId(r) if !ok { http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized) return } activityQuery := toActivityQuery(query.filterQuery) activityQuery.Page, activityQuery.Size = query.Page, query.Size page, err := h.ListUseCase.ListActivity(useCases.ListActivityCommand{ViewerID: viewerId, Query: activityQuery}) if err != nil { writeActivityError(w, err) return } items := make([]itemResponse, 0, len(page.Items)) for _, entry := range page.Items { items = append(items, toItemResponse(entry)) } writeJSON(w, listResponse{Items: items, Total: page.Total, Page: page.Page, Size: page.Size}) } func toItemResponse(entry useCases.ActivityEntry) itemResponse { event := entry.Event item := itemResponse{ ID: event.ID, CreatedAt: event.CreatedAt, Action: event.Action, Category: event.Category, Actor: actorResponse{Type: event.Actor.Type, ID: event.Actor.ID, Name: event.Actor.Name}, Outcome: event.Outcome, FailureReason: event.FailureReason, Security: event.Security, Source: event.Source, IP: event.IP, UserAgent: event.UserAgent, Params: event.Params, Changes: event.Changes, } if item.Params == nil { item.Params = map[string]any{} } if item.Changes == nil { item.Changes = []audit.Change{} } if event.Target.Type != "" { item.Target = &targetResponse{Type: event.Target.Type, ID: event.Target.ID, Name: event.Target.Name, Exists: entry.TargetExists} } return item } func (h *Handler) Summary(w http.ResponseWriter, r *http.Request) { query := readFilterQuery(r.URL.Query()) if validator.Validate.Struct(query) != nil { http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest) return } summary, err := h.SummaryUseCase.GetActivitySummary(toActivityQuery(query)) if err != nil { writeActivityError(w, err) return } writeJSON(w, summary) } func (h *Handler) Export(w http.ResponseWriter, r *http.Request) { query := readFilterQuery(r.URL.Query()) if validator.Validate.Struct(query) != nil { http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest) return } prepared, err := h.ExportUseCase.Prepare(toActivityQuery(query)) if err != nil { writeActivityError(w, err) return } w.Header().Set("Content-Type", csvContentType) w.Header().Set("Content-Disposition", csvContentDisposition) w.Header().Set("Cache-Control", "no-store") _ = prepared.WriteCSV(w) } func writeJSON(w http.ResponseWriter, payload any) { body, err := json.Marshal(payload) if err != nil { http.Error(w, "Internal server error", http.StatusInternalServerError) return } w.Header().Set("Cache-Control", "no-store") _, _ = w.Write(body) }