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>
This commit is contained in:
@@ -1,13 +1,13 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"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"
|
||||
@@ -39,132 +39,117 @@ func NewMediaHandler(
|
||||
|
||||
// ServeDocument serves a document file with access control
|
||||
// GET /api/media/document/:id
|
||||
func (h *MediaHandler) ServeDocument(c *gin.Context) {
|
||||
user := c.MustGet(middleware.AuthUserKey).(*models.User)
|
||||
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 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"})
|
||||
return
|
||||
return apperrors.BadRequest("error.invalid_id")
|
||||
}
|
||||
|
||||
// Get document
|
||||
doc, err := h.documentRepo.FindByID(uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Document not found"})
|
||||
return
|
||||
return apperrors.NotFound("error.document_not_found")
|
||||
}
|
||||
|
||||
// Check access to residence
|
||||
hasAccess, err := h.residenceRepo.HasAccess(doc.ResidenceID, user.ID)
|
||||
if err != nil || !hasAccess {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
return apperrors.Forbidden("error.access_denied")
|
||||
}
|
||||
|
||||
// Serve the file
|
||||
filePath := h.resolveFilePath(doc.FileURL)
|
||||
if filePath == "" {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "File not found"})
|
||||
return
|
||||
return apperrors.NotFound("error.file_not_found")
|
||||
}
|
||||
|
||||
// Set caching headers (private, 1 hour)
|
||||
c.Header("Cache-Control", "private, max-age=3600")
|
||||
c.File(filePath)
|
||||
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 *gin.Context) {
|
||||
user := c.MustGet(middleware.AuthUserKey).(*models.User)
|
||||
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 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid image ID"})
|
||||
return
|
||||
return apperrors.BadRequest("error.invalid_id")
|
||||
}
|
||||
|
||||
// Get document image
|
||||
img, err := h.documentRepo.FindImageByID(uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Image not found"})
|
||||
return
|
||||
return apperrors.NotFound("error.image_not_found")
|
||||
}
|
||||
|
||||
// Get parent document to check residence access
|
||||
doc, err := h.documentRepo.FindByID(img.DocumentID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Parent document not found"})
|
||||
return
|
||||
return apperrors.NotFound("error.document_not_found")
|
||||
}
|
||||
|
||||
// Check access to residence
|
||||
hasAccess, err := h.residenceRepo.HasAccess(doc.ResidenceID, user.ID)
|
||||
if err != nil || !hasAccess {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
return apperrors.Forbidden("error.access_denied")
|
||||
}
|
||||
|
||||
// Serve the file
|
||||
filePath := h.resolveFilePath(img.ImageURL)
|
||||
if filePath == "" {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "File not found"})
|
||||
return
|
||||
return apperrors.NotFound("error.file_not_found")
|
||||
}
|
||||
|
||||
c.Header("Cache-Control", "private, max-age=3600")
|
||||
c.File(filePath)
|
||||
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 *gin.Context) {
|
||||
user := c.MustGet(middleware.AuthUserKey).(*models.User)
|
||||
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 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid image ID"})
|
||||
return
|
||||
return apperrors.BadRequest("error.invalid_id")
|
||||
}
|
||||
|
||||
// Get completion image
|
||||
img, err := h.taskRepo.FindCompletionImageByID(uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Image not found"})
|
||||
return
|
||||
return apperrors.NotFound("error.image_not_found")
|
||||
}
|
||||
|
||||
// Get the completion to get the task
|
||||
completion, err := h.taskRepo.FindCompletionByID(img.CompletionID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Completion not found"})
|
||||
return
|
||||
return apperrors.NotFound("error.completion_not_found")
|
||||
}
|
||||
|
||||
// Get task to check residence access
|
||||
task, err := h.taskRepo.FindByID(completion.TaskID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Task not found"})
|
||||
return
|
||||
return apperrors.NotFound("error.task_not_found")
|
||||
}
|
||||
|
||||
// Check access to residence
|
||||
hasAccess, err := h.residenceRepo.HasAccess(task.ResidenceID, user.ID)
|
||||
if err != nil || !hasAccess {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
return apperrors.Forbidden("error.access_denied")
|
||||
}
|
||||
|
||||
// Serve the file
|
||||
filePath := h.resolveFilePath(img.ImageURL)
|
||||
if filePath == "" {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "File not found"})
|
||||
return
|
||||
return apperrors.NotFound("error.file_not_found")
|
||||
}
|
||||
|
||||
c.Header("Cache-Control", "private, max-age=3600")
|
||||
c.File(filePath)
|
||||
c.Response().Header().Set("Cache-Control", "private, max-age=3600")
|
||||
return c.File(filePath)
|
||||
}
|
||||
|
||||
// resolveFilePath converts a stored URL to an actual file path
|
||||
|
||||
Reference in New Issue
Block a user