diff --git a/main_dc/yalarba/api_yal/internal/domain/appeal/dto.go b/main_dc/yalarba/api_yal/internal/domain/appeal/dto.go index c65c4f6..b6d4bae 100644 --- a/main_dc/yalarba/api_yal/internal/domain/appeal/dto.go +++ b/main_dc/yalarba/api_yal/internal/domain/appeal/dto.go @@ -1,4 +1,219 @@ package appeal -import () +import ( + "time" + "api_yal/internal/models" +) +// CreateAppealRequest DTO для создания обращения +type CreateAppealRequest struct { + Type string `json:"type" validate:"required,oneof=complaint suggestion wish question other"` + Title string `json:"title" validate:"required,min=3,max=255"` + Message string `json:"message" validate:"required,min=10"` + Priority string `json:"priority" validate:"omitempty,oneof=low medium high critical"` + ObjectID *uint `json:"object_id,omitempty"` + FeedbackID *uint `json:"feedback_id,omitempty"` + CommentID *uint `json:"comment_id,omitempty"` + ContactName string `json:"contact_name,omitempty"` + ContactEmail string `json:"contact_email,omitempty" validate:"omitempty,email"` + ContactPhone string `json:"contact_phone,omitempty"` + Attachments []string `json:"attachments,omitempty"` + Category string `json:"category,omitempty"` + Labels []string `json:"labels,omitempty"` + CustomData map[string]interface{} `json:"custom_data,omitempty"` + IPAddress string `json:"ip_address,omitempty"` + UserAgent string `json:"user_agent,omitempty"` +} + +// UpdateAppealRequest DTO для обновления обращения +type UpdateAppealRequest struct { + Title *string `json:"title,omitempty" validate:"omitempty,min=3,max=255"` + Message *string `json:"message,omitempty" validate:"omitempty,min=10"` + Priority *string `json:"priority,omitempty" validate:"omitempty,oneof=low medium high critical"` + Category *string `json:"category,omitempty"` + Labels []string `json:"labels,omitempty"` + Attachments []string `json:"attachments,omitempty"` + CustomData map[string]interface{} `json:"custom_data,omitempty"` +} + +// UpdateStatusRequest DTO для обновления статуса +type UpdateStatusRequest struct { + Status string `json:"status" validate:"required,oneof=new in_progress resolved rejected closed"` + Comment string `json:"comment,omitempty"` +} + +// AssignRequest DTO для назначения ответственного +type AssignRequest struct { + AssignedToID *uint `json:"assigned_to_id"` +} + +// ResolveRequest DTO для решения обращения +type ResolveRequest struct { + Resolution string `json:"resolution" validate:"required,min=5"` +} + +// AppealResponse DTO для ответа +type AppealResponse struct { + ID uint `json:"id"` + Type string `json:"type"` + Status string `json:"status"` + Priority string `json:"priority"` + Title string `json:"title"` + Message string `json:"message"` + AuthorID *uint `json:"author_id,omitempty"` + Author *AuthorInfo `json:"author,omitempty"` + ObjectID *uint `json:"object_id,omitempty"` + Object *ObjectInfo `json:"object,omitempty"` + FeedbackID *uint `json:"feedback_id,omitempty"` + CommentID *uint `json:"comment_id,omitempty"` + ContactName string `json:"contact_name,omitempty"` + ContactEmail string `json:"contact_email,omitempty"` + ContactPhone string `json:"contact_phone,omitempty"` + Attachments []string `json:"attachments,omitempty"` + Category string `json:"category,omitempty"` + Labels []string `json:"labels,omitempty"` + AssignedToID *uint `json:"assigned_to_id,omitempty"` + AssignedTo *AuthorInfo `json:"assigned_to,omitempty"` + ResolvedAt *time.Time `json:"resolved_at,omitempty"` + ResolvedBy *uint `json:"resolved_by,omitempty"` + Resolution string `json:"resolution,omitempty"` + CustomData map[string]interface{} `json:"custom_data,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// AuthorInfo информация об авторе +type AuthorInfo struct { + ID uint `json:"id"` + Email string `json:"email"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` +} + +// ObjectInfo информация об объекте +type ObjectInfo struct { + ID uint `json:"id"` + Name string `json:"name"` + Description string `json:"description"` +} + +// ListAppealsResponse DTO для списка обращений +type ListAppealsResponse struct { + Data []AppealResponse `json:"data"` + Total int64 `json:"total"` + Offset int `json:"offset"` + Limit int `json:"limit"` + TotalPages int `json:"total_pages"` +} + +// AppealHistoryResponse DTO для истории +type AppealHistoryResponse struct { + ID uint `json:"id"` + AppealID uint `json:"appeal_id"` + UserID *uint `json:"user_id,omitempty"` + User *AuthorInfo `json:"user,omitempty"` + OldStatus string `json:"old_status"` + NewStatus string `json:"new_status"` + Comment string `json:"comment,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// ListHistoryResponse DTO для списка истории +type ListHistoryResponse struct { + Data []AppealHistoryResponse `json:"data"` + Total int64 `json:"total"` + Offset int `json:"offset"` + Limit int `json:"limit"` + TotalPages int `json:"total_pages"` +} + +// AppealStatisticsResponse DTO для статистики +type AppealStatisticsResponse struct { + Total int64 `json:"total"` + ByStatus map[string]int64 `json:"by_status"` + ByType map[string]int64 `json:"by_type"` + ByPriority map[string]int64 `json:"by_priority"` + ByCategory map[string]int64 `json:"by_category,omitempty"` + AvgResolveTime float64 `json:"avg_resolve_time_hours"` +} + +// NewAppealResponse создает AppealResponse из модели +func NewAppealResponse(appeal *models.Appeal) AppealResponse { + resp := AppealResponse{ + ID: appeal.ID, + Type: string(appeal.Type), + Status: string(appeal.Status), + Priority: string(appeal.Priority), + Title: appeal.Title, + Message: appeal.Message, + AuthorID: appeal.AuthorID, + ObjectID: appeal.ObjectID, + FeedbackID: appeal.FeedbackID, + CommentID: appeal.CommentID, + ContactName: appeal.ContactName, + ContactEmail: appeal.ContactEmail, + ContactPhone: appeal.ContactPhone, + Attachments: appeal.Attachments, + Category: appeal.Category, + Labels: appeal.Labels, + AssignedToID: appeal.AssignedToID, + ResolvedAt: appeal.ResolvedAt, + ResolvedBy: appeal.ResolvedBy, + Resolution: appeal.Resolution, + CustomData: appeal.CustomData, + CreatedAt: appeal.CreatedAt, + UpdatedAt: appeal.UpdatedAt, + } + + if appeal.Author != nil { + resp.Author = &AuthorInfo{ + ID: appeal.Author.ID, + Email: appeal.Author.Email, + FirstName: appeal.Author.FirstName, + LastName: appeal.Author.LastName, + } + } + + if appeal.Object != nil { + resp.Object = &ObjectInfo{ + ID: appeal.Object.ID, + Name: appeal.Object.ShortName, + Description: appeal.Object.Description, + } + } + + if appeal.AssignedTo != nil { + resp.AssignedTo = &AuthorInfo{ + ID: appeal.AssignedTo.ID, + Email: appeal.AssignedTo.Email, + FirstName: appeal.AssignedTo.FirstName, + LastName: appeal.AssignedTo.LastName, + } + } + + return resp +} + +// NewAppealHistoryResponse создает AppealHistoryResponse из модели +func NewAppealHistoryResponse(history *models.AppealHistory) AppealHistoryResponse { + resp := AppealHistoryResponse{ + ID: history.ID, + AppealID: history.AppealID, + UserID: history.UserID, + OldStatus: string(history.OldStatus), + NewStatus: string(history.NewStatus), + Comment: history.Comment, + CreatedAt: history.CreatedAt, + } + + if history.User != nil { + resp.User = &AuthorInfo{ + ID: history.User.ID, + Email: history.User.Email, + FirstName: history.User.FirstName, + LastName: history.User.LastName, + } + } + + return resp +} \ No newline at end of file diff --git a/main_dc/yalarba/api_yal/internal/domain/appeal/handler.go b/main_dc/yalarba/api_yal/internal/domain/appeal/handler.go new file mode 100644 index 0000000..cf6b2b0 --- /dev/null +++ b/main_dc/yalarba/api_yal/internal/domain/appeal/handler.go @@ -0,0 +1,580 @@ +package appeal + +import ( + "encoding/json" + "net/http" + "strconv" + "strings" + + "api_yal/internal/logger" + "api_yal/internal/middleware" + "api_yal/internal/models" + + "github.com/go-chi/chi/v5" + "go.uber.org/zap" +) + +// Handler HTTP обработчик для обращений +type Handler struct { + service AppealService + logger *zap.Logger +} + +// NewHandler создает новый экземпляр обработчика +func NewHandler(service AppealService) *Handler { + return &Handler{ + service: service, + logger: logger.Get(), + } +} + +// CreateAppeal создает новое обращение +// @Summary Создать обращение +// @Description Создает новое обращение (жалобу, предложение и т.д.) +// @Tags appeals +// @Accept json +// @Produce json +// @Param request body CreateAppealRequest true "Данные обращения" +// @Success 201 {object} AppealResponse +// @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Router /appeals [post] +func (h *Handler) CreateAppeal(w http.ResponseWriter, r *http.Request) { + var req CreateAppealRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + h.logger.Error("Failed to decode request", zap.Error(err)) + respondWithError(w, http.StatusBadRequest, "Invalid request body") + return + } + + // Получаем ID автора из контекста (если авторизован) + authorID, isAuth := middleware.GetUserID(r.Context()) + var authorIDPtr *uint + if isAuth { + authorIDPtr = &authorID + } + + // Получаем IP и User-Agent + ipAddress := r.RemoteAddr + if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" { + ipAddress = strings.Split(forwarded, ",")[0] + } + userAgent := r.Header.Get("User-Agent") + + appeal, err := h.service.Create(&req, authorIDPtr, ipAddress, userAgent) + if err != nil { + h.logger.Error("Failed to create appeal", zap.Error(err)) + respondWithError(w, http.StatusBadRequest, err.Error()) + return + } + + respondWithJSON(w, http.StatusCreated, NewAppealResponse(appeal)) +} + +// GetAppeal возвращает обращение по ID +// @Summary Получить обращение +// @Description Возвращает обращение по ID +// @Tags appeals +// @Produce json +// @Param id path int true "ID обращения" +// @Success 200 {object} AppealResponse +// @Failure 404 {object} map[string]string +// @Router /appeals/{id} [get] +func (h *Handler) GetAppeal(w http.ResponseWriter, r *http.Request) { + id, err := parseIDParam(r, "id") + if err != nil { + respondWithError(w, http.StatusBadRequest, "Invalid appeal ID") + return + } + + appeal, err := h.service.GetByID(id) + if err != nil { + h.logger.Error("Failed to get appeal", zap.Error(err), zap.Uint("id", id)) + respondWithError(w, http.StatusNotFound, "Appeal not found") + return + } + + // Проверка прав доступа + userID, _ := middleware.GetUserID(r.Context()) + userRole, _ := middleware.GetUserRole(r.Context()) + if !canAccessAppeal(appeal, userID, userRole) { + respondWithError(w, http.StatusForbidden, "Access denied") + return + } + + respondWithJSON(w, http.StatusOK, NewAppealResponse(appeal)) +} + +// UpdateAppeal обновляет обращение +// @Summary Обновить обращение +// @Description Обновляет существующее обращение +// @Tags appeals +// @Accept json +// @Produce json +// @Param id path int true "ID обращения" +// @Param request body UpdateAppealRequest true "Данные для обновления" +// @Success 200 {object} AppealResponse +// @Failure 400 {object} map[string]string +// @Failure 403 {object} map[string]string +// @Router /appeals/{id} [put] +func (h *Handler) UpdateAppeal(w http.ResponseWriter, r *http.Request) { + id, err := parseIDParam(r, "id") + if err != nil { + respondWithError(w, http.StatusBadRequest, "Invalid appeal ID") + return + } + + var req UpdateAppealRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + respondWithError(w, http.StatusBadRequest, "Invalid request body") + return + } + + userID, _ := middleware.GetUserID(r.Context()) + userRole, _ := middleware.GetUserRole(r.Context()) + isAdmin := userRole == "admin" + + appeal, err := h.service.Update(id, &req, userID, isAdmin) + if err != nil { + h.logger.Error("Failed to update appeal", zap.Error(err), zap.Uint("id", id)) + if err.Error() == "permission denied" { + respondWithError(w, http.StatusForbidden, err.Error()) + return + } + respondWithError(w, http.StatusBadRequest, err.Error()) + return + } + + respondWithJSON(w, http.StatusOK, NewAppealResponse(appeal)) +} + +// DeleteAppeal удаляет обращение +// @Summary Удалить обращение +// @Description Мягкое удаление обращения +// @Tags appeals +// @Param id path int true "ID обращения" +// @Success 204 "No Content" +// @Failure 403 {object} map[string]string +// @Router /appeals/{id} [delete] +func (h *Handler) DeleteAppeal(w http.ResponseWriter, r *http.Request) { + id, err := parseIDParam(r, "id") + if err != nil { + respondWithError(w, http.StatusBadRequest, "Invalid appeal ID") + return + } + + userID, _ := middleware.GetUserID(r.Context()) + userRole, _ := middleware.GetUserRole(r.Context()) + isAdmin := userRole == "admin" + + if err := h.service.Delete(id, userID, isAdmin); err != nil { + h.logger.Error("Failed to delete appeal", zap.Error(err), zap.Uint("id", id)) + if err.Error() == "permission denied" { + respondWithError(w, http.StatusForbidden, err.Error()) + return + } + respondWithError(w, http.StatusNotFound, err.Error()) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// ListAppeals возвращает список обращений +// @Summary Список обращений +// @Description Возвращает список обращений с пагинацией и фильтрацией +// @Tags appeals +// @Produce json +// @Param offset query int false "Смещение" default(0) +// @Param limit query int false "Лимит" default(20) +// @Param status query string false "Статус" Enums(new, in_progress, resolved, rejected, closed) +// @Param type query string false "Тип" Enums(complaint, suggestion, wish, question, other) +// @Param priority query string false "Приоритет" Enums(low, medium, high, critical) +// @Param search query string false "Поиск по заголовку/сообщению" +// @Success 200 {object} ListAppealsResponse +// @Router /appeals [get] +func (h *Handler) ListAppeals(w http.ResponseWriter, r *http.Request) { + offset, limit := parsePagination(r) + + // Собираем фильтры + filters := make(map[string]interface{}) + if status := r.URL.Query().Get("status"); status != "" { + filters["status"] = status + } + if appealType := r.URL.Query().Get("type"); appealType != "" { + filters["type"] = appealType + } + if priority := r.URL.Query().Get("priority"); priority != "" { + filters["priority"] = priority + } + if search := r.URL.Query().Get("search"); search != "" { + filters["search"] = search + } + + appeals, total, err := h.service.List(offset, limit, filters) + if err != nil { + h.logger.Error("Failed to list appeals", zap.Error(err)) + respondWithError(w, http.StatusInternalServerError, "Failed to retrieve appeals") + return + } + + data := make([]AppealResponse, len(appeals)) + for i, appeal := range appeals { + data[i] = NewAppealResponse(&appeal) + } + + totalPages := int(total) / limit + if int(total)%limit > 0 { + totalPages++ + } + + respondWithJSON(w, http.StatusOK, ListAppealsResponse{ + Data: data, + Total: total, + Offset: offset, + Limit: limit, + TotalPages: totalPages, + }) +} + +// UpdateAppealStatus обновляет статус обращения +// @Summary Обновить статус +// @Description Обновляет статус обращения (требует прав администратора или модератора) +// @Tags appeals +// @Accept json +// @Produce json +// @Param id path int true "ID обращения" +// @Param request body UpdateStatusRequest true "Новый статус" +// @Success 200 {object} map[string]string +// @Failure 400 {object} map[string]string +// @Router /appeals/{id}/status [patch] +func (h *Handler) UpdateAppealStatus(w http.ResponseWriter, r *http.Request) { + id, err := parseIDParam(r, "id") + if err != nil { + respondWithError(w, http.StatusBadRequest, "Invalid appeal ID") + return + } + + var req UpdateStatusRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + respondWithError(w, http.StatusBadRequest, "Invalid request body") + return + } + + userID, _ := middleware.GetUserID(r.Context()) + userRole, _ := middleware.GetUserRole(r.Context()) + + // Только администраторы и модераторы могут менять статус + if userRole != "admin" && userRole != "moderator" { + respondWithError(w, http.StatusForbidden, "Permission denied") + return + } + + if err := h.service.UpdateStatus(id, &req, userID); err != nil { + h.logger.Error("Failed to update status", zap.Error(err), zap.Uint("id", id)) + respondWithError(w, http.StatusBadRequest, err.Error()) + return + } + + respondWithJSON(w, http.StatusOK, map[string]string{"message": "Status updated successfully"}) +} + +// AssignAppeal назначает ответственного +// @Summary Назначить ответственного +// @Description Назначает ответственного за обработку обращения +// @Tags appeals +// @Accept json +// @Produce json +// @Param id path int true "ID обращения" +// @Param request body AssignRequest true "ID ответственного" +// @Success 200 {object} map[string]string +// @Router /appeals/{id}/assign [post] +func (h *Handler) AssignAppeal(w http.ResponseWriter, r *http.Request) { + id, err := parseIDParam(r, "id") + if err != nil { + respondWithError(w, http.StatusBadRequest, "Invalid appeal ID") + return + } + + var req AssignRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + respondWithError(w, http.StatusBadRequest, "Invalid request body") + return + } + + userID, _ := middleware.GetUserID(r.Context()) + userRole, _ := middleware.GetUserRole(r.Context()) + + if userRole != "admin" && userRole != "moderator" { + respondWithError(w, http.StatusForbidden, "Permission denied") + return + } + + if err := h.service.AssignTo(id, &req, userID); err != nil { + h.logger.Error("Failed to assign appeal", zap.Error(err), zap.Uint("id", id)) + respondWithError(w, http.StatusBadRequest, err.Error()) + return + } + + respondWithJSON(w, http.StatusOK, map[string]string{"message": "Assigned successfully"}) +} + +// ResolveAppeal решает обращение +// @Summary Решить обращение +// @Description Отмечает обращение как решенное с указанием решения +// @Tags appeals +// @Accept json +// @Produce json +// @Param id path int true "ID обращения" +// @Param request body ResolveRequest true "Решение" +// @Success 200 {object} map[string]string +// @Router /appeals/{id}/resolve [post] +func (h *Handler) ResolveAppeal(w http.ResponseWriter, r *http.Request) { + id, err := parseIDParam(r, "id") + if err != nil { + respondWithError(w, http.StatusBadRequest, "Invalid appeal ID") + return + } + + var req ResolveRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + respondWithError(w, http.StatusBadRequest, "Invalid request body") + return + } + + userID, _ := middleware.GetUserID(r.Context()) + userRole, _ := middleware.GetUserRole(r.Context()) + + if userRole != "admin" && userRole != "moderator" { + respondWithError(w, http.StatusForbidden, "Permission denied") + return + } + + if err := h.service.Resolve(id, &req, userID); err != nil { + h.logger.Error("Failed to resolve appeal", zap.Error(err), zap.Uint("id", id)) + respondWithError(w, http.StatusBadRequest, err.Error()) + return + } + + respondWithJSON(w, http.StatusOK, map[string]string{"message": "Appeal resolved successfully"}) +} + +// GetAppealHistory возвращает историю изменений обращения +// @Summary История изменений +// @Description Возвращает историю изменений статуса обращения +// @Tags appeals +// @Produce json +// @Param id path int true "ID обращения" +// @Param offset query int false "Смещение" default(0) +// @Param limit query int false "Лимит" default(20) +// @Success 200 {object} ListHistoryResponse +// @Router /appeals/{id}/history [get] +func (h *Handler) GetAppealHistory(w http.ResponseWriter, r *http.Request) { + id, err := parseIDParam(r, "id") + if err != nil { + respondWithError(w, http.StatusBadRequest, "Invalid appeal ID") + return + } + + offset, limit := parsePagination(r) + + histories, total, err := h.service.GetHistory(id, offset, limit) + if err != nil { + h.logger.Error("Failed to get history", zap.Error(err), zap.Uint("id", id)) + respondWithError(w, http.StatusInternalServerError, "Failed to retrieve history") + return + } + + data := make([]AppealHistoryResponse, len(histories)) + for i, history := range histories { + data[i] = NewAppealHistoryResponse(&history) + } + + totalPages := int(total) / limit + if int(total)%limit > 0 { + totalPages++ + } + + respondWithJSON(w, http.StatusOK, ListHistoryResponse{ + Data: data, + Total: total, + Offset: offset, + Limit: limit, + TotalPages: totalPages, + }) +} + +// GetAppealStatistics возвращает статистику по обращениям +// @Summary Статистика обращений +// @Description Возвращает статистику по обращениям (требует прав администратора) +// @Tags appeals +// @Produce json +// @Success 200 {object} AppealStatisticsResponse +// @Router /appeals/statistics [get] +func (h *Handler) GetAppealStatistics(w http.ResponseWriter, r *http.Request) { + userRole, _ := middleware.GetUserRole(r.Context()) + if userRole != "admin" { + respondWithError(w, http.StatusForbidden, "Admin access required") + return + } + + stats, err := h.service.GetStatistics() + if err != nil { + h.logger.Error("Failed to get statistics", zap.Error(err)) + respondWithError(w, http.StatusInternalServerError, "Failed to retrieve statistics") + return + } + + respondWithJSON(w, http.StatusOK, stats) +} + +// GetMyAppeals возвращает обращения текущего пользователя +// @Summary Мои обращения +// @Description Возвращает обращения текущего авторизованного пользователя +// @Tags appeals +// @Produce json +// @Param offset query int false "Смещение" default(0) +// @Param limit query int false "Лимит" default(20) +// @Success 200 {object} ListAppealsResponse +// @Router /appeals/me [get] +func (h *Handler) GetMyAppeals(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserID(r.Context()) + if !ok { + respondWithError(w, http.StatusUnauthorized, "User not authenticated") + return + } + + offset, limit := parsePagination(r) + + appeals, total, err := h.service.GetMyAppeals(userID, offset, limit) + if err != nil { + h.logger.Error("Failed to get my appeals", zap.Error(err), zap.Uint("user_id", userID)) + respondWithError(w, http.StatusInternalServerError, "Failed to retrieve appeals") + return + } + + data := make([]AppealResponse, len(appeals)) + for i, appeal := range appeals { + data[i] = NewAppealResponse(&appeal) + } + + totalPages := int(total) / limit + if int(total)%limit > 0 { + totalPages++ + } + + respondWithJSON(w, http.StatusOK, ListAppealsResponse{ + Data: data, + Total: total, + Offset: offset, + Limit: limit, + TotalPages: totalPages, + }) +} + +// GetAppealsByAuthor возвращает обращения пользователя по ID +// @Summary Обращения пользователя +// @Description Возвращает обращения указанного пользователя (админ доступ) +// @Tags appeals +// @Produce json +// @Param userID path int true "ID пользователя" +// @Param offset query int false "Смещение" default(0) +// @Param limit query int false "Лимит" default(20) +// @Success 200 {object} ListAppealsResponse +// @Router /appeals/user/{userID} [get] +func (h *Handler) GetAppealsByAuthor(w http.ResponseWriter, r *http.Request) { + userRole, _ := middleware.GetUserRole(r.Context()) + if userRole != "admin" { + respondWithError(w, http.StatusForbidden, "Admin access required") + return + } + + authorID, err := parseIDParam(r, "userID") + if err != nil { + respondWithError(w, http.StatusBadRequest, "Invalid user ID") + return + } + + offset, limit := parsePagination(r) + + appeals, total, err := h.service.GetAppealsByAuthor(authorID, offset, limit) + if err != nil { + h.logger.Error("Failed to get appeals by author", zap.Error(err), zap.Uint("author_id", authorID)) + respondWithError(w, http.StatusInternalServerError, "Failed to retrieve appeals") + return + } + + data := make([]AppealResponse, len(appeals)) + for i, appeal := range appeals { + data[i] = NewAppealResponse(&appeal) + } + + totalPages := int(total) / limit + if int(total)%limit > 0 { + totalPages++ + } + + respondWithJSON(w, http.StatusOK, ListAppealsResponse{ + Data: data, + Total: total, + Offset: offset, + Limit: limit, + TotalPages: totalPages, + }) +} + +// Вспомогательные функции + +func parseIDParam(r *http.Request, param string) (uint, error) { + idStr := chi.URLParam(r, param) + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + return 0, err + } + return uint(id), nil +} + +func parsePagination(r *http.Request) (offset, limit int) { + offsetStr := r.URL.Query().Get("offset") + limitStr := r.URL.Query().Get("limit") + + offset, _ = strconv.Atoi(offsetStr) + limit, _ = strconv.Atoi(limitStr) + + if offset < 0 { + offset = 0 + } + if limit <= 0 || limit > 100 { + limit = 20 + } + return +} + +func respondWithJSON(w http.ResponseWriter, status int, data interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(data); err != nil { + logger.Get().Error("Failed to encode JSON response", zap.Error(err)) + } +} + +func respondWithError(w http.ResponseWriter, status int, message string) { + respondWithJSON(w, status, map[string]string{"error": message}) +} + +func canAccessAppeal(appeal *models.Appeal, userID uint, userRole string) bool { + // Администратор может видеть все + if userRole == "admin" { + return true + } + // Модератор может видеть все + if userRole == "moderator" { + return true + } + // Обычный пользователь может видеть только свои обращения + if appeal.AuthorID != nil && *appeal.AuthorID == userID { + return true + } + // Анонимные обращения могут видеть только админы и модераторы + return false +} \ No newline at end of file diff --git a/main_dc/yalarba/api_yal/internal/domain/appeal/router.go b/main_dc/yalarba/api_yal/internal/domain/appeal/router.go index 8b50161..7bf87d9 100644 --- a/main_dc/yalarba/api_yal/internal/domain/appeal/router.go +++ b/main_dc/yalarba/api_yal/internal/domain/appeal/router.go @@ -1,3 +1,60 @@ package appeal -import () +import ( + "api_yal/internal/logger" + "api_yal/internal/middleware" + "api_yal/internal/repository" + + "github.com/go-chi/chi/v5" + "gorm.io/gorm" +) + +// RegisterRoutes регистрирует маршруты для работы с обращениями +func RegisterRoutes(r chi.Router, db *gorm.DB, jwtSecret string) { + l := logger.Get() + l.Debug("Регистрация маршрутов обращений") + + // Создаем репозиторий и сервис + appealRepo := repository.NewAppealRepository(db) + appealService := NewAppealService(appealRepo) + appealHandler := NewHandler(appealService) + + // Публичные маршруты (не требуют аутентификации) + r.Post("/appeals", appealHandler.CreateAppeal) + r.Get("/appeals/{id}", appealHandler.GetAppeal) + + // Защищенные маршруты (требуют аутентификации) + r.Group(func(r chi.Router) { + r.Use(middleware.AuthMiddleware(jwtSecret)) + + // Личные обращения пользователя + r.Get("/appeals/me", appealHandler.GetMyAppeals) + + // Обновление и удаление своих обращений + r.Put("/appeals/{id}", appealHandler.UpdateAppeal) + r.Delete("/appeals/{id}", appealHandler.DeleteAppeal) + }) + + // Административные маршруты (требуют прав администратора или модератора) + r.Group(func(r chi.Router) { + r.Use(middleware.AuthMiddleware(jwtSecret)) + r.Use(middleware.AdminOnlyMiddleware) // Или создайте ModeratorOnlyMiddleware + + // Полный список обращений (с фильтрацией) + r.Get("/appeals", appealHandler.ListAppeals) + + // Управление статусами + r.Patch("/appeals/{id}/status", appealHandler.UpdateAppealStatus) + r.Post("/appeals/{id}/assign", appealHandler.AssignAppeal) + r.Post("/appeals/{id}/resolve", appealHandler.ResolveAppeal) + + // История и статистика + r.Get("/appeals/{id}/history", appealHandler.GetAppealHistory) + r.Get("/appeals/statistics", appealHandler.GetAppealStatistics) + + // Просмотр обращений конкретного пользователя + r.Get("/appeals/user/{userID}", appealHandler.GetAppealsByAuthor) + }) + + l.Info("Маршруты обращений зарегистрированы") +} \ No newline at end of file diff --git a/main_dc/yalarba/api_yal/internal/domain/appeal/service.go b/main_dc/yalarba/api_yal/internal/domain/appeal/service.go index f2f2f0f..d45082a 100644 --- a/main_dc/yalarba/api_yal/internal/domain/appeal/service.go +++ b/main_dc/yalarba/api_yal/internal/domain/appeal/service.go @@ -1,17 +1,452 @@ package appeal -import "api_yal/internal/repository" +import ( + "errors" + "time" + "api_yal/internal/models" + "api_yal/internal/repository" +) +// AppealService интерфейс сервиса обращений type AppealService interface { - + // Create создает новое обращение + Create(req *CreateAppealRequest, authorID *uint, ipAddress, userAgent string) (*models.Appeal, error) + + // GetByID возвращает обращение по ID + GetByID(id uint) (*models.Appeal, error) + + // Update обновляет обращение + Update(id uint, req *UpdateAppealRequest, userID uint, isAdmin bool) (*models.Appeal, error) + + // Delete удаляет обращение (мягкое удаление) + Delete(id uint, userID uint, isAdmin bool) error + + // List возвращает список обращений + List(offset, limit int, filters map[string]interface{}) ([]models.Appeal, int64, error) + + // UpdateStatus обновляет статус обращения + UpdateStatus(id uint, req *UpdateStatusRequest, userID uint) error + + // AssignTo назначает ответственного + AssignTo(id uint, req *AssignRequest, userID uint) error + + // Resolve решает обращение + Resolve(id uint, req *ResolveRequest, userID uint) error + + // GetHistory возвращает историю изменений + GetHistory(appealID uint, offset, limit int) ([]models.AppealHistory, int64, error) + + // GetStatistics возвращает статистику по обращениям + GetStatistics() (*AppealStatisticsResponse, error) + + // GetMyAppeals возвращает обращения текущего пользователя + GetMyAppeals(userID uint, offset, limit int) ([]models.Appeal, int64, error) + + // GetAppealsByAuthor возвращает обращения автора + GetAppealsByAuthor(authorID uint, offset, limit int) ([]models.Appeal, int64, error) } type appealServiceImpl struct { - appelRepository repository.AppealRepository + appealRepo repository.AppealRepository } +// NewAppealService создает новый экземпляр сервиса обращений func NewAppealService(appealRepo repository.AppealRepository) AppealService { return &appealServiceImpl{ - appelRepository: appealRepo, + appealRepo: appealRepo, } +} + +// Create создает новое обращение +func (s *appealServiceImpl) Create(req *CreateAppealRequest, authorID *uint, ipAddress, userAgent string) (*models.Appeal, error) { + // Валидация + if req.Title == "" { + return nil, errors.New("title is required") + } + if req.Message == "" { + return nil, errors.New("message is required") + } + + // Конвертация типов + appealType := models.AppealType(req.Type) + if !isValidAppealType(appealType) { + return nil, errors.New("invalid appeal type") + } + + priority := models.AppealPriority(req.Priority) + if req.Priority == "" { + priority = models.AppealPriorityMedium + } + if !isValidAppealPriority(priority) { + return nil, errors.New("invalid appeal priority") + } + + appeal := &models.Appeal{ + Type: appealType, + Status: models.AppealStatusNew, + Priority: priority, + Title: req.Title, + Message: req.Message, + AuthorID: authorID, + ObjectID: req.ObjectID, + FeedbackID: req.FeedbackID, + CommentID: req.CommentID, + ContactName: req.ContactName, + ContactEmail: req.ContactEmail, + ContactPhone: req.ContactPhone, + Attachments: req.Attachments, + Category: req.Category, + Labels: req.Labels, + CustomData: req.CustomData, + IPAddress: ipAddress, + UserAgent: userAgent, + } + + if err := s.appealRepo.Create(appeal); err != nil { + return nil, err + } + + // Создаем запись в истории + history := &models.AppealHistory{ + AppealID: appeal.ID, + UserID: authorID, + OldStatus: "", + NewStatus: models.AppealStatusNew, + Comment: "Обращение создано", + } + _ = s.appealRepo.CreateHistory(history) + + return appeal, nil +} + +// GetByID возвращает обращение по ID +func (s *appealServiceImpl) GetByID(id uint) (*models.Appeal, error) { + return s.appealRepo.GetByID(id) +} + +// Update обновляет обращение +func (s *appealServiceImpl) Update(id uint, req *UpdateAppealRequest, userID uint, isAdmin bool) (*models.Appeal, error) { + appeal, err := s.appealRepo.GetByID(id) + if err != nil { + return nil, err + } + + // Проверка прав: только автор или админ может редактировать + if !isAdmin && (appeal.AuthorID == nil || *appeal.AuthorID != userID) { + return nil, errors.New("permission denied") + } + + // Только новые обращения можно редактировать + if !isAdmin && appeal.Status != models.AppealStatusNew { + return nil, errors.New("only new appeals can be edited") + } + + if req.Title != nil { + appeal.Title = *req.Title + } + if req.Message != nil { + appeal.Message = *req.Message + } + if req.Priority != nil { + priority := models.AppealPriority(*req.Priority) + if isValidAppealPriority(priority) { + appeal.Priority = priority + } + } + if req.Category != nil { + appeal.Category = *req.Category + } + if req.Labels != nil { + appeal.Labels = req.Labels + } + if req.Attachments != nil { + appeal.Attachments = req.Attachments + } + if req.CustomData != nil { + appeal.CustomData = req.CustomData + } + + if err := s.appealRepo.Update(appeal); err != nil { + return nil, err + } + + return appeal, nil +} + +// Delete удаляет обращение +func (s *appealServiceImpl) Delete(id uint, userID uint, isAdmin bool) error { + appeal, err := s.appealRepo.GetByID(id) + if err != nil { + return err + } + + // Проверка прав: только автор или админ может удалить + if !isAdmin && (appeal.AuthorID == nil || *appeal.AuthorID != userID) { + return errors.New("permission denied") + } + + return s.appealRepo.Delete(id) +} + +// List возвращает список обращений +func (s *appealServiceImpl) List(offset, limit int, filters map[string]interface{}) ([]models.Appeal, int64, error) { + if offset < 0 { + offset = 0 + } + if limit <= 0 || limit > 100 { + limit = 20 + } + + var appeals []models.Appeal + var total int64 + var err error + + // Применяем фильтры + if status, ok := filters["status"].(string); ok { + appeals, err = s.appealRepo.ListByStatus(models.AppealStatus(status), offset, limit) + if err == nil { + total, _ = s.appealRepo.Count() + } + } else if appealType, ok := filters["type"].(string); ok { + appeals, err = s.appealRepo.ListByType(models.AppealType(appealType), offset, limit) + if err == nil { + total, _ = s.appealRepo.Count() + } + } else if priority, ok := filters["priority"].(string); ok { + appeals, err = s.appealRepo.ListByPriority(models.AppealPriority(priority), offset, limit) + if err == nil { + total, _ = s.appealRepo.Count() + } + } else if query, ok := filters["search"].(string); ok && query != "" { + appeals, err = s.appealRepo.Search(query, offset, limit) + if err == nil { + total = int64(len(appeals)) + } + } else { + appeals, err = s.appealRepo.List(offset, limit) + if err == nil { + total, _ = s.appealRepo.Count() + } + } + + if err != nil { + return nil, 0, err + } + + return appeals, total, nil +} + +// UpdateStatus обновляет статус обращения +func (s *appealServiceImpl) UpdateStatus(id uint, req *UpdateStatusRequest, userID uint) error { + appeal, err := s.appealRepo.GetByID(id) + if err != nil { + return err + } + + oldStatus := appeal.Status + newStatus := models.AppealStatus(req.Status) + + if !isValidAppealStatus(newStatus) { + return errors.New("invalid appeal status") + } + + if err := s.appealRepo.UpdateStatus(id, newStatus); err != nil { + return err + } + + // Создаем запись в истории + history := &models.AppealHistory{ + AppealID: id, + UserID: &userID, + OldStatus: oldStatus, + NewStatus: newStatus, + Comment: req.Comment, + } + if err := s.appealRepo.CreateHistory(history); err != nil { + return err + } + + return nil +} + +// AssignTo назначает ответственного +func (s *appealServiceImpl) AssignTo(id uint, req *AssignRequest, userID uint) error { + appeal, err := s.appealRepo.GetByID(id) + if err != nil { + return err + } + + if err := s.appealRepo.AssignTo(id, req.AssignedToID); err != nil { + return err + } + + // Создаем запись в истории + comment := "Назначен ответственный" + if req.AssignedToID == nil { + comment = "Ответственный снят" + } + history := &models.AppealHistory{ + AppealID: id, + UserID: &userID, + OldStatus: appeal.Status, + NewStatus: appeal.Status, + Comment: comment, + } + _ = s.appealRepo.CreateHistory(history) + + return nil +} + +// Resolve решает обращение +func (s *appealServiceImpl) Resolve(id uint, req *ResolveRequest, userID uint) error { + appeal, err := s.appealRepo.GetByID(id) + if err != nil { + return err + } + + now := time.Now() + appeal.Status = models.AppealStatusResolved + appeal.ResolvedAt = &now + appeal.ResolvedBy = &userID + appeal.Resolution = req.Resolution + + if err := s.appealRepo.Update(appeal); err != nil { + return err + } + + // Создаем запись в истории + history := &models.AppealHistory{ + AppealID: id, + UserID: &userID, + OldStatus: appeal.Status, + NewStatus: models.AppealStatusResolved, + Comment: "Обращение решено: " + req.Resolution, + } + if err := s.appealRepo.CreateHistory(history); err != nil { + return err + } + + return nil +} + +// GetHistory возвращает историю изменений +func (s *appealServiceImpl) GetHistory(appealID uint, offset, limit int) ([]models.AppealHistory, int64, error) { + if offset < 0 { + offset = 0 + } + if limit <= 0 || limit > 100 { + limit = 20 + } + + histories, err := s.appealRepo.ListHistory(appealID, offset, limit) + if err != nil { + return nil, 0, err + } + + // Для подсчета общего количества нужно реализовать отдельный метод + total := int64(len(histories)) + + return histories, total, nil +} + +// GetStatistics возвращает статистику по обращениям +func (s *appealServiceImpl) GetStatistics() (*AppealStatisticsResponse, error) { + total, err := s.appealRepo.Count() + if err != nil { + return nil, err + } + + // Здесь нужно добавить агрегационные запросы для статистики + // Для простоты возвращаем базовую структуру + stats := &AppealStatisticsResponse{ + Total: total, + ByStatus: make(map[string]int64), + ByType: make(map[string]int64), + ByPriority: make(map[string]int64), + AvgResolveTime: 0, + } + + // TODO: Реализовать подсчет статистики через raw SQL запросы + + return stats, nil +} + +// GetMyAppeals возвращает обращения текущего пользователя +func (s *appealServiceImpl) GetMyAppeals(userID uint, offset, limit int) ([]models.Appeal, int64, error) { + // Получаем все обращения и фильтруем по автору + allAppeals, err := s.appealRepo.List(0, 1000) + if err != nil { + return nil, 0, err + } + + var myAppeals []models.Appeal + for _, appeal := range allAppeals { + if appeal.AuthorID != nil && *appeal.AuthorID == userID { + myAppeals = append(myAppeals, appeal) + } + } + + total := int64(len(myAppeals)) + start := offset + end := offset + limit + if start > len(myAppeals) { + return []models.Appeal{}, total, nil + } + if end > len(myAppeals) { + end = len(myAppeals) + } + + return myAppeals[start:end], total, nil +} + +// GetAppealsByAuthor возвращает обращения автора +func (s *appealServiceImpl) GetAppealsByAuthor(authorID uint, offset, limit int) ([]models.Appeal, int64, error) { + allAppeals, err := s.appealRepo.List(0, 1000) + if err != nil { + return nil, 0, err + } + + var authorAppeals []models.Appeal + for _, appeal := range allAppeals { + if appeal.AuthorID != nil && *appeal.AuthorID == authorID { + authorAppeals = append(authorAppeals, appeal) + } + } + + total := int64(len(authorAppeals)) + start := offset + end := offset + limit + if start > len(authorAppeals) { + return []models.Appeal{}, total, nil + } + if end > len(authorAppeals) { + end = len(authorAppeals) + } + + return authorAppeals[start:end], total, nil +} + +// Вспомогательные функции валидации +func isValidAppealType(t models.AppealType) bool { + switch t { + case models.AppealTypeComplaint, models.AppealTypeSuggestion, models.AppealTypeWish, models.AppealTypeQuestion, models.AppealTypeOther: + return true + } + return false +} + +func isValidAppealStatus(s models.AppealStatus) bool { + switch s { + case models.AppealStatusNew, models.AppealStatusInProgress, models.AppealStatusResolved, models.AppealStatusRejected, models.AppealStatusClosed: + return true + } + return false +} + +func isValidAppealPriority(p models.AppealPriority) bool { + switch p { + case models.AppealPriorityLow, models.AppealPriorityMedium, models.AppealPriorityHigh, models.AppealPriorityCritical: + return true + } + return false } \ No newline at end of file diff --git a/main_dc/yalarba/api_yal/internal/models/appeal.go b/main_dc/yalarba/api_yal/internal/models/appeal.go index eac9fec..a557225 100644 --- a/main_dc/yalarba/api_yal/internal/models/appeal.go +++ b/main_dc/yalarba/api_yal/internal/models/appeal.go @@ -106,7 +106,7 @@ type Appeal struct { // AppealHistory записывает историю изменений статуса обращения type AppealHistory struct { - Base Base `gorm:"embedded"` + Base `gorm:"embedded"` AppealID uint `gorm:"not null;index" json:"appeal_id"` Appeal Appeal `gorm:"foreignKey:AppealID;references:ID" json:"appeal,omitempty"` diff --git a/main_dc/yalarba/api_yal/internal/router/router.go b/main_dc/yalarba/api_yal/internal/router/router.go index 9883e5e..d1747ac 100644 --- a/main_dc/yalarba/api_yal/internal/router/router.go +++ b/main_dc/yalarba/api_yal/internal/router/router.go @@ -3,6 +3,7 @@ package router import ( "api_yal/internal/config" "api_yal/internal/domain/account" + "api_yal/internal/domain/appeal" "api_yal/internal/domain/auth" "api_yal/internal/domain/comment" "api_yal/internal/domain/feetback" @@ -60,17 +61,19 @@ func SetupRouter(db *gorm.DB, config *config.Config) http.Handler { // Регистрируем маршурты обьектов object.RegisterRoutes(r, db, config.JWTSecret) - + // Регистрируем маршруты отзывов feetback.RegisterRoutes(r, db, config.JWTSecret) - + // Регистрация маршрутов для комментариев comment.RegisterRoutes(r, db, config.JWTSecret) // Регистрация маршрутов для райтинга rating.RegisterRoutes(r, db, config.JWTSecret) - + // Регистрируем маршруты обращений + appeal.RegisterRoutes(r, db, config.JWTSecret) + }) zapLogger.Info("Настройка маршрутов завершена")