Comprehensive security hardening from audit findings: - Add validation tags to all DTO request structs (max lengths, ranges, enums) - Replace unsafe type assertions with MustGetAuthUser helper across all handlers - Remove query-param token auth from admin middleware (prevents URL token leakage) - Add request validation calls in handlers that were missing c.Validate() - Remove goroutines in handlers (timezone update now synchronous) - Add sanitize middleware and path traversal protection (path_utils) - Stop resetting admin passwords on migration restart - Warn on well-known default SECRET_KEY - Add ~30 new test files covering security regressions, auth safety, repos, and services - Add deploy/ config, audit digests, and AUDIT_FINDINGS documentation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
270 lines
9.1 KiB
Go
270 lines
9.1 KiB
Go
package handlers
|
|
|
|
import (
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/labstack/echo/v4"
|
|
"github.com/shopspring/decimal"
|
|
"gorm.io/gorm"
|
|
|
|
"github.com/treytartt/casera-api/internal/admin/dto"
|
|
"github.com/treytartt/casera-api/internal/models"
|
|
)
|
|
|
|
// AdminCompletionHandler handles admin task completion management endpoints
|
|
type AdminCompletionHandler struct {
|
|
db *gorm.DB
|
|
}
|
|
|
|
// NewAdminCompletionHandler creates a new admin completion handler
|
|
func NewAdminCompletionHandler(db *gorm.DB) *AdminCompletionHandler {
|
|
return &AdminCompletionHandler{db: db}
|
|
}
|
|
|
|
// CompletionImageResponse represents an image in a completion
|
|
type CompletionImageResponse struct {
|
|
ID uint `json:"id"`
|
|
ImageURL string `json:"image_url"`
|
|
Caption string `json:"caption"`
|
|
}
|
|
|
|
// CompletionResponse represents a task completion in API responses
|
|
type CompletionResponse struct {
|
|
ID uint `json:"id"`
|
|
TaskID uint `json:"task_id"`
|
|
TaskTitle string `json:"task_title"`
|
|
ResidenceID uint `json:"residence_id"`
|
|
ResidenceName string `json:"residence_name"`
|
|
CompletedByID uint `json:"completed_by_id"`
|
|
CompletedBy string `json:"completed_by"`
|
|
CompletedAt string `json:"completed_at"`
|
|
Notes string `json:"notes"`
|
|
ActualCost *string `json:"actual_cost"`
|
|
Rating *int `json:"rating"`
|
|
Images []CompletionImageResponse `json:"images"`
|
|
CreatedAt string `json:"created_at"`
|
|
}
|
|
|
|
// CompletionFilters extends PaginationParams with completion-specific filters
|
|
type CompletionFilters struct {
|
|
dto.PaginationParams
|
|
TaskID *uint `form:"task_id"`
|
|
ResidenceID *uint `form:"residence_id"`
|
|
UserID *uint `form:"user_id"`
|
|
}
|
|
|
|
// List handles GET /api/admin/completions
|
|
func (h *AdminCompletionHandler) List(c echo.Context) error {
|
|
var filters CompletionFilters
|
|
if err := c.Bind(&filters); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
|
|
}
|
|
|
|
var completions []models.TaskCompletion
|
|
var total int64
|
|
|
|
query := h.db.Model(&models.TaskCompletion{}).
|
|
Preload("Task").
|
|
Preload("Task.Residence").
|
|
Preload("CompletedBy").
|
|
Preload("Images")
|
|
|
|
// Apply search
|
|
if filters.Search != "" {
|
|
search := "%" + filters.Search + "%"
|
|
query = query.Joins("JOIN task_task ON task_task.id = task_taskcompletion.task_id").
|
|
Joins("JOIN auth_user ON auth_user.id = task_taskcompletion.completed_by_id").
|
|
Where(
|
|
"task_task.title ILIKE ? OR auth_user.username ILIKE ? OR task_taskcompletion.notes ILIKE ?",
|
|
search, search, search,
|
|
)
|
|
}
|
|
|
|
// Apply filters
|
|
if filters.TaskID != nil {
|
|
query = query.Where("task_id = ?", *filters.TaskID)
|
|
}
|
|
if filters.ResidenceID != nil {
|
|
query = query.Joins("JOIN task_task t ON t.id = task_taskcompletion.task_id").
|
|
Where("t.residence_id = ?", *filters.ResidenceID)
|
|
}
|
|
if filters.UserID != nil {
|
|
query = query.Where("completed_by_id = ?", *filters.UserID)
|
|
}
|
|
|
|
// Get total count
|
|
query.Count(&total)
|
|
|
|
// Apply sorting (allowlist prevents SQL injection via sort_by parameter)
|
|
sortBy := filters.GetSafeSortBy([]string{
|
|
"id", "task_id", "completed_by_id", "completed_at",
|
|
"created_at", "notes", "actual_cost", "rating",
|
|
}, "completed_at")
|
|
sortDir := "DESC"
|
|
if filters.SortDir != "" {
|
|
sortDir = filters.GetSortDir()
|
|
}
|
|
query = query.Order(sortBy + " " + sortDir)
|
|
|
|
// Apply pagination
|
|
query = query.Offset(filters.GetOffset()).Limit(filters.GetPerPage())
|
|
|
|
if err := query.Find(&completions).Error; err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch completions"})
|
|
}
|
|
|
|
// Build response
|
|
responses := make([]CompletionResponse, len(completions))
|
|
for i, completion := range completions {
|
|
responses[i] = h.toCompletionResponse(&completion)
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
|
|
}
|
|
|
|
// Get handles GET /api/admin/completions/:id
|
|
func (h *AdminCompletionHandler) Get(c echo.Context) error {
|
|
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid completion ID"})
|
|
}
|
|
|
|
var completion models.TaskCompletion
|
|
if err := h.db.Preload("Task").Preload("Task.Residence").Preload("CompletedBy").Preload("Images").First(&completion, id).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Completion not found"})
|
|
}
|
|
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch completion"})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, h.toCompletionResponse(&completion))
|
|
}
|
|
|
|
// Delete handles DELETE /api/admin/completions/:id
|
|
func (h *AdminCompletionHandler) Delete(c echo.Context) error {
|
|
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid completion ID"})
|
|
}
|
|
|
|
var completion models.TaskCompletion
|
|
if err := h.db.First(&completion, id).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Completion not found"})
|
|
}
|
|
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch completion"})
|
|
}
|
|
|
|
if err := h.db.Delete(&completion).Error; err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete completion"})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Completion deleted successfully"})
|
|
}
|
|
|
|
// BulkDelete handles DELETE /api/admin/completions/bulk
|
|
func (h *AdminCompletionHandler) BulkDelete(c echo.Context) error {
|
|
var req dto.BulkDeleteRequest
|
|
if err := c.Bind(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
|
|
}
|
|
|
|
result := h.db.Where("id IN ?", req.IDs).Delete(&models.TaskCompletion{})
|
|
if result.Error != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete completions"})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Completions deleted successfully", "count": result.RowsAffected})
|
|
}
|
|
|
|
// UpdateCompletionRequest represents the request to update a completion
|
|
type UpdateCompletionRequest struct {
|
|
Notes *string `json:"notes"`
|
|
ActualCost *string `json:"actual_cost"`
|
|
}
|
|
|
|
// Update handles PUT /api/admin/completions/:id
|
|
func (h *AdminCompletionHandler) Update(c echo.Context) error {
|
|
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid completion ID"})
|
|
}
|
|
|
|
var completion models.TaskCompletion
|
|
if err := h.db.First(&completion, id).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Completion not found"})
|
|
}
|
|
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch completion"})
|
|
}
|
|
|
|
var req UpdateCompletionRequest
|
|
if err := c.Bind(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
|
|
}
|
|
|
|
if req.Notes != nil {
|
|
completion.Notes = *req.Notes
|
|
}
|
|
if req.ActualCost != nil {
|
|
if *req.ActualCost == "" {
|
|
completion.ActualCost = nil
|
|
} else {
|
|
cost, err := decimal.NewFromString(*req.ActualCost)
|
|
if err == nil {
|
|
completion.ActualCost = &cost
|
|
}
|
|
}
|
|
}
|
|
|
|
if err := h.db.Save(&completion).Error; err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to update completion"})
|
|
}
|
|
|
|
h.db.Preload("Task").Preload("Task.Residence").Preload("CompletedBy").Preload("Images").First(&completion, id)
|
|
return c.JSON(http.StatusOK, h.toCompletionResponse(&completion))
|
|
}
|
|
|
|
// toCompletionResponse converts a TaskCompletion model to CompletionResponse
|
|
func (h *AdminCompletionHandler) toCompletionResponse(completion *models.TaskCompletion) CompletionResponse {
|
|
response := CompletionResponse{
|
|
ID: completion.ID,
|
|
TaskID: completion.TaskID,
|
|
CompletedByID: completion.CompletedByID,
|
|
CompletedAt: completion.CompletedAt.Format("2006-01-02T15:04:05Z"),
|
|
Notes: completion.Notes,
|
|
Rating: completion.Rating,
|
|
Images: make([]CompletionImageResponse, 0),
|
|
CreatedAt: completion.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
|
}
|
|
|
|
if completion.Task.ID != 0 {
|
|
response.TaskTitle = completion.Task.Title
|
|
response.ResidenceID = completion.Task.ResidenceID
|
|
if completion.Task.Residence.ID != 0 {
|
|
response.ResidenceName = completion.Task.Residence.Name
|
|
}
|
|
}
|
|
|
|
if completion.CompletedBy.ID != 0 {
|
|
response.CompletedBy = completion.CompletedBy.Username
|
|
}
|
|
|
|
if completion.ActualCost != nil {
|
|
cost := completion.ActualCost.String()
|
|
response.ActualCost = &cost
|
|
}
|
|
|
|
// Convert images
|
|
for _, img := range completion.Images {
|
|
response.Images = append(response.Images, CompletionImageResponse{
|
|
ID: img.ID,
|
|
ImageURL: img.ImageURL,
|
|
Caption: img.Caption,
|
|
})
|
|
}
|
|
|
|
return response
|
|
}
|