Files
honeyDueAPI/internal/handlers/media_handler.go
Trey t 6dac34e373 Migrate from Gin to Echo framework and add comprehensive integration tests
Major changes:
- Migrate all handlers from Gin to Echo framework
- Add new apperrors, echohelpers, and validator packages
- Update middleware for Echo compatibility
- Add ArchivedHandler to task categorization chain (archived tasks go to cancelled_tasks column)
- Add 6 new integration tests:
  - RecurringTaskLifecycle: NextDueDate advancement for weekly/monthly tasks
  - MultiUserSharing: Complex sharing with user removal
  - TaskStateTransitions: All state transitions and kanban column changes
  - DateBoundaryEdgeCases: Threshold boundary testing
  - CascadeOperations: Residence deletion cascade effects
  - MultiUserOperations: Shared residence collaboration
- Add single-purpose repository functions for kanban columns (GetOverdueTasks, GetDueSoonTasks, etc.)
- Fix RemoveUser route param mismatch (userId -> user_id)
- Fix determineExpectedColumn helper to correctly prioritize in_progress over overdue

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 13:52:08 -06:00

172 lines
4.8 KiB
Go

package handlers
import (
"path/filepath"
"strconv"
"strings"
"github.com/labstack/echo/v4"
"github.com/treytartt/casera-api/internal/apperrors"
"github.com/treytartt/casera-api/internal/middleware"
"github.com/treytartt/casera-api/internal/models"
"github.com/treytartt/casera-api/internal/repositories"
"github.com/treytartt/casera-api/internal/services"
)
// MediaHandler handles authenticated media serving
type MediaHandler struct {
documentRepo *repositories.DocumentRepository
taskRepo *repositories.TaskRepository
residenceRepo *repositories.ResidenceRepository
storageSvc *services.StorageService
}
// NewMediaHandler creates a new media handler
func NewMediaHandler(
documentRepo *repositories.DocumentRepository,
taskRepo *repositories.TaskRepository,
residenceRepo *repositories.ResidenceRepository,
storageSvc *services.StorageService,
) *MediaHandler {
return &MediaHandler{
documentRepo: documentRepo,
taskRepo: taskRepo,
residenceRepo: residenceRepo,
storageSvc: storageSvc,
}
}
// ServeDocument serves a document file with access control
// GET /api/media/document/:id
func (h *MediaHandler) ServeDocument(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
return apperrors.BadRequest("error.invalid_id")
}
// Get document
doc, err := h.documentRepo.FindByID(uint(id))
if err != nil {
return apperrors.NotFound("error.document_not_found")
}
// Check access to residence
hasAccess, err := h.residenceRepo.HasAccess(doc.ResidenceID, user.ID)
if err != nil || !hasAccess {
return apperrors.Forbidden("error.access_denied")
}
// Serve the file
filePath := h.resolveFilePath(doc.FileURL)
if filePath == "" {
return apperrors.NotFound("error.file_not_found")
}
// Set caching headers (private, 1 hour)
c.Response().Header().Set("Cache-Control", "private, max-age=3600")
return c.File(filePath)
}
// ServeDocumentImage serves a document image with access control
// GET /api/media/document-image/:id
func (h *MediaHandler) ServeDocumentImage(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
return apperrors.BadRequest("error.invalid_id")
}
// Get document image
img, err := h.documentRepo.FindImageByID(uint(id))
if err != nil {
return apperrors.NotFound("error.image_not_found")
}
// Get parent document to check residence access
doc, err := h.documentRepo.FindByID(img.DocumentID)
if err != nil {
return apperrors.NotFound("error.document_not_found")
}
// Check access to residence
hasAccess, err := h.residenceRepo.HasAccess(doc.ResidenceID, user.ID)
if err != nil || !hasAccess {
return apperrors.Forbidden("error.access_denied")
}
// Serve the file
filePath := h.resolveFilePath(img.ImageURL)
if filePath == "" {
return apperrors.NotFound("error.file_not_found")
}
c.Response().Header().Set("Cache-Control", "private, max-age=3600")
return c.File(filePath)
}
// ServeCompletionImage serves a task completion image with access control
// GET /api/media/completion-image/:id
func (h *MediaHandler) ServeCompletionImage(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
return apperrors.BadRequest("error.invalid_id")
}
// Get completion image
img, err := h.taskRepo.FindCompletionImageByID(uint(id))
if err != nil {
return apperrors.NotFound("error.image_not_found")
}
// Get the completion to get the task
completion, err := h.taskRepo.FindCompletionByID(img.CompletionID)
if err != nil {
return apperrors.NotFound("error.completion_not_found")
}
// Get task to check residence access
task, err := h.taskRepo.FindByID(completion.TaskID)
if err != nil {
return apperrors.NotFound("error.task_not_found")
}
// Check access to residence
hasAccess, err := h.residenceRepo.HasAccess(task.ResidenceID, user.ID)
if err != nil || !hasAccess {
return apperrors.Forbidden("error.access_denied")
}
// Serve the file
filePath := h.resolveFilePath(img.ImageURL)
if filePath == "" {
return apperrors.NotFound("error.file_not_found")
}
c.Response().Header().Set("Cache-Control", "private, max-age=3600")
return c.File(filePath)
}
// resolveFilePath converts a stored URL to an actual file path
func (h *MediaHandler) resolveFilePath(storedURL string) string {
if storedURL == "" {
return ""
}
uploadDir := h.storageSvc.GetUploadDir()
// Handle legacy /uploads/... URLs
if strings.HasPrefix(storedURL, "/uploads/") {
relativePath := strings.TrimPrefix(storedURL, "/uploads/")
return filepath.Join(uploadDir, relativePath)
}
// Handle relative paths (new format)
return filepath.Join(uploadDir, storedURL)
}