e881d37de0
Every public method on these five services now takes ctx context.Context as the first arg and routes its repo calls through .WithContext(ctx). With TaskService and ResidenceService already migrated, this means every in-process service that touches Postgres now produces a flame graph in Jaeger where the SQL spans nest under the parent HTTP request span. Endpoints now fully traced (HTTP → service → SQL): - /api/auth/login, /register, /logout, /me, /verify-email, /resend-verification - /api/auth/forgot-password, /verify-reset, /reset-password, /update-profile - /api/contractors/* (CRUD + favorite + by-residence + tasks) - /api/documents/* (CRUD + activate/deactivate + image upload/delete) - /api/notifications/* (list, count, mark-read, prefs, devices) - /api/subscription/* (status, purchase, cancel, triggers, promotions) - All previously-migrated /api/tasks/* and /api/residences/* paths Internal helpers also threaded: - TaskService.sendTaskCompletedNotification → forwards ctx - TaskService.UpdateUserTimezone → forwards ctx to NotificationService - ResidenceService.CreateResidence → forwards ctx to SubscriptionService.CheckLimit - NotificationService.registerAPNSDevice / registerGCMDevice → both take ctx ~75 method signatures, ~120 handler/test call sites updated. Tests pass green; the only failure is the pre-existing flaky TaskHandler_QuickComplete SQLite race that fails ~60% of runs on master. Step 3 of the observability plan is now genuinely complete: every API endpoint backed by a Go service emits a per-request flame graph with HTTP → service → SQL spans, plus B2/APNs/FCM/asynq spans where applicable. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
381 lines
11 KiB
Go
381 lines
11 KiB
Go
package handlers
|
|
|
|
import (
|
|
"mime/multipart"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/labstack/echo/v4"
|
|
"github.com/shopspring/decimal"
|
|
|
|
"github.com/treytartt/honeydue-api/internal/apperrors"
|
|
"github.com/treytartt/honeydue-api/internal/dto/requests"
|
|
"github.com/treytartt/honeydue-api/internal/dto/responses"
|
|
"github.com/treytartt/honeydue-api/internal/i18n"
|
|
"github.com/treytartt/honeydue-api/internal/middleware"
|
|
"github.com/treytartt/honeydue-api/internal/models"
|
|
"github.com/treytartt/honeydue-api/internal/repositories"
|
|
"github.com/treytartt/honeydue-api/internal/services"
|
|
)
|
|
|
|
// DocumentHandler handles document-related HTTP requests
|
|
type DocumentHandler struct {
|
|
documentService *services.DocumentService
|
|
storageService *services.StorageService
|
|
}
|
|
|
|
// NewDocumentHandler creates a new document handler
|
|
func NewDocumentHandler(documentService *services.DocumentService, storageService *services.StorageService) *DocumentHandler {
|
|
return &DocumentHandler{
|
|
documentService: documentService,
|
|
storageService: storageService,
|
|
}
|
|
}
|
|
|
|
// ListDocuments handles GET /api/documents/
|
|
func (h *DocumentHandler) ListDocuments(c echo.Context) error {
|
|
user, err := middleware.MustGetAuthUser(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Build filter from supported query params.
|
|
var filter *repositories.DocumentFilter
|
|
if c.QueryParam("residence") != "" || c.QueryParam("document_type") != "" ||
|
|
c.QueryParam("is_active") != "" || c.QueryParam("expiring_soon") != "" ||
|
|
c.QueryParam("search") != "" {
|
|
filter = &repositories.DocumentFilter{
|
|
DocumentType: c.QueryParam("document_type"),
|
|
Search: c.QueryParam("search"),
|
|
}
|
|
if rid := c.QueryParam("residence"); rid != "" {
|
|
if parsed, err := strconv.ParseUint(rid, 10, 32); err == nil {
|
|
residenceID := uint(parsed)
|
|
filter.ResidenceID = &residenceID
|
|
}
|
|
}
|
|
if ia := c.QueryParam("is_active"); ia != "" {
|
|
isActive := ia == "true" || ia == "1"
|
|
filter.IsActive = &isActive
|
|
}
|
|
if es := c.QueryParam("expiring_soon"); es != "" {
|
|
if parsed, err := strconv.Atoi(es); err == nil {
|
|
if parsed < 1 || parsed > 3650 {
|
|
return apperrors.BadRequest("error.days_out_of_range")
|
|
}
|
|
filter.ExpiringSoon = &parsed
|
|
}
|
|
}
|
|
}
|
|
|
|
response, err := h.documentService.ListDocuments(c.Request().Context(), user.ID, filter)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.JSON(http.StatusOK, response)
|
|
}
|
|
|
|
// GetDocument handles GET /api/documents/:id/
|
|
func (h *DocumentHandler) GetDocument(c echo.Context) error {
|
|
user, err := middleware.MustGetAuthUser(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
documentID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
|
if err != nil {
|
|
return apperrors.BadRequest("error.invalid_document_id")
|
|
}
|
|
|
|
response, err := h.documentService.GetDocument(c.Request().Context(), uint(documentID), user.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.JSON(http.StatusOK, response)
|
|
}
|
|
|
|
// ListWarranties handles GET /api/documents/warranties/
|
|
func (h *DocumentHandler) ListWarranties(c echo.Context) error {
|
|
user, err := middleware.MustGetAuthUser(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
response, err := h.documentService.ListWarranties(c.Request().Context(), user.ID)
|
|
if err != nil {
|
|
return apperrors.Internal(err)
|
|
}
|
|
return c.JSON(http.StatusOK, response)
|
|
}
|
|
|
|
// CreateDocument handles POST /api/documents/
|
|
// Supports both JSON and multipart form data (for file uploads)
|
|
func (h *DocumentHandler) CreateDocument(c echo.Context) error {
|
|
user, err := middleware.MustGetAuthUser(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var req requests.CreateDocumentRequest
|
|
|
|
contentType := c.Request().Header.Get("Content-Type")
|
|
|
|
// Check if this is a multipart form request (file upload)
|
|
if strings.HasPrefix(contentType, "multipart/form-data") {
|
|
// Parse multipart form
|
|
if err := c.Request().ParseMultipartForm(32 << 20); err != nil { // 32MB max
|
|
return apperrors.BadRequest("error.failed_to_parse_form")
|
|
}
|
|
|
|
// Parse residence_id (required)
|
|
residenceIDStr := c.FormValue("residence_id")
|
|
if residenceIDStr == "" {
|
|
return apperrors.BadRequest("error.residence_id_required")
|
|
}
|
|
residenceID, err := strconv.ParseUint(residenceIDStr, 10, 32)
|
|
if err != nil {
|
|
return apperrors.BadRequest("error.invalid_residence_id")
|
|
}
|
|
req.ResidenceID = uint(residenceID)
|
|
|
|
// Parse title (required)
|
|
req.Title = c.FormValue("title")
|
|
if req.Title == "" {
|
|
return apperrors.BadRequest("error.title_required")
|
|
}
|
|
|
|
// Parse optional fields
|
|
req.Description = c.FormValue("description")
|
|
req.Vendor = c.FormValue("vendor")
|
|
req.SerialNumber = c.FormValue("serial_number")
|
|
req.ModelNumber = c.FormValue("model_number")
|
|
|
|
// Parse document_type
|
|
if docType := c.FormValue("document_type"); docType != "" {
|
|
dt := models.DocumentType(docType)
|
|
req.DocumentType = dt
|
|
}
|
|
|
|
// Parse task_id (optional)
|
|
if taskIDStr := c.FormValue("task_id"); taskIDStr != "" {
|
|
if taskID, err := strconv.ParseUint(taskIDStr, 10, 32); err == nil {
|
|
tid := uint(taskID)
|
|
req.TaskID = &tid
|
|
}
|
|
}
|
|
|
|
// Parse purchase_price (optional)
|
|
if priceStr := c.FormValue("purchase_price"); priceStr != "" {
|
|
if price, err := decimal.NewFromString(priceStr); err == nil {
|
|
req.PurchasePrice = &price
|
|
}
|
|
}
|
|
|
|
// Parse purchase_date (optional)
|
|
if dateStr := c.FormValue("purchase_date"); dateStr != "" {
|
|
if t, err := time.Parse(time.RFC3339, dateStr); err == nil {
|
|
req.PurchaseDate = &t
|
|
} else if t, err := time.Parse("2006-01-02", dateStr); err == nil {
|
|
req.PurchaseDate = &t
|
|
}
|
|
}
|
|
|
|
// Parse expiry_date (optional)
|
|
if dateStr := c.FormValue("expiry_date"); dateStr != "" {
|
|
if t, err := time.Parse(time.RFC3339, dateStr); err == nil {
|
|
req.ExpiryDate = &t
|
|
} else if t, err := time.Parse("2006-01-02", dateStr); err == nil {
|
|
req.ExpiryDate = &t
|
|
}
|
|
}
|
|
|
|
// Handle file upload (look for "file", "document", or "image" field)
|
|
var uploadedFile *multipart.FileHeader
|
|
for _, fieldName := range []string{"file", "document", "image", "images"} {
|
|
if file, err := c.FormFile(fieldName); err == nil {
|
|
uploadedFile = file
|
|
break
|
|
}
|
|
}
|
|
|
|
if uploadedFile != nil {
|
|
if h.storageService == nil {
|
|
return apperrors.Internal(nil)
|
|
}
|
|
result, err := h.storageService.Upload(c.Request().Context(), uploadedFile, "documents")
|
|
if err != nil {
|
|
return apperrors.BadRequest("error.failed_to_upload_file")
|
|
}
|
|
req.FileURL = result.URL
|
|
req.FileName = result.FileName
|
|
req.MimeType = result.MimeType
|
|
fileSize := result.FileSize
|
|
req.FileSize = &fileSize
|
|
}
|
|
} else {
|
|
// Standard JSON request
|
|
if err := c.Bind(&req); err != nil {
|
|
return apperrors.BadRequest("error.invalid_request")
|
|
}
|
|
}
|
|
|
|
if err := c.Validate(&req); err != nil {
|
|
return err
|
|
}
|
|
|
|
response, err := h.documentService.CreateDocument(c.Request().Context(), &req, user.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.JSON(http.StatusCreated, response)
|
|
}
|
|
|
|
// UpdateDocument handles PUT/PATCH /api/documents/:id/
|
|
func (h *DocumentHandler) UpdateDocument(c echo.Context) error {
|
|
user, err := middleware.MustGetAuthUser(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
documentID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
|
if err != nil {
|
|
return apperrors.BadRequest("error.invalid_document_id")
|
|
}
|
|
|
|
var req requests.UpdateDocumentRequest
|
|
if err := c.Bind(&req); err != nil {
|
|
return apperrors.BadRequest("error.invalid_request")
|
|
}
|
|
if err := c.Validate(&req); err != nil {
|
|
return err
|
|
}
|
|
|
|
response, err := h.documentService.UpdateDocument(c.Request().Context(), uint(documentID), user.ID, &req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.JSON(http.StatusOK, response)
|
|
}
|
|
|
|
// DeleteDocument handles DELETE /api/documents/:id/
|
|
func (h *DocumentHandler) DeleteDocument(c echo.Context) error {
|
|
user, err := middleware.MustGetAuthUser(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
documentID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
|
if err != nil {
|
|
return apperrors.BadRequest("error.invalid_document_id")
|
|
}
|
|
|
|
err = h.documentService.DeleteDocument(c.Request().Context(), uint(documentID), user.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.JSON(http.StatusOK, responses.MessageResponse{Message: i18n.LocalizedMessage(c, "message.document_deleted")})
|
|
}
|
|
|
|
// ActivateDocument handles POST /api/documents/:id/activate/
|
|
func (h *DocumentHandler) ActivateDocument(c echo.Context) error {
|
|
user, err := middleware.MustGetAuthUser(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
documentID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
|
if err != nil {
|
|
return apperrors.BadRequest("error.invalid_document_id")
|
|
}
|
|
|
|
response, err := h.documentService.ActivateDocument(c.Request().Context(), uint(documentID), user.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.JSON(http.StatusOK, response)
|
|
}
|
|
|
|
// DeactivateDocument handles POST /api/documents/:id/deactivate/
|
|
func (h *DocumentHandler) DeactivateDocument(c echo.Context) error {
|
|
user, err := middleware.MustGetAuthUser(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
documentID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
|
if err != nil {
|
|
return apperrors.BadRequest("error.invalid_document_id")
|
|
}
|
|
|
|
response, err := h.documentService.DeactivateDocument(c.Request().Context(), uint(documentID), user.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.JSON(http.StatusOK, response)
|
|
}
|
|
|
|
// UploadDocumentImage handles POST /api/documents/:id/images/
|
|
func (h *DocumentHandler) UploadDocumentImage(c echo.Context) error {
|
|
user, err := middleware.MustGetAuthUser(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
documentID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
|
if err != nil {
|
|
return apperrors.BadRequest("error.invalid_document_id")
|
|
}
|
|
|
|
// Parse multipart form
|
|
if err := c.Request().ParseMultipartForm(32 << 20); err != nil {
|
|
return apperrors.BadRequest("error.failed_to_parse_form")
|
|
}
|
|
|
|
// Look for file in common field names
|
|
var uploadedFile *multipart.FileHeader
|
|
for _, fieldName := range []string{"image", "file"} {
|
|
if file, err := c.FormFile(fieldName); err == nil {
|
|
uploadedFile = file
|
|
break
|
|
}
|
|
}
|
|
|
|
if uploadedFile == nil {
|
|
return apperrors.BadRequest("error.no_file_provided")
|
|
}
|
|
|
|
if h.storageService == nil {
|
|
return apperrors.Internal(nil)
|
|
}
|
|
|
|
result, err := h.storageService.Upload(c.Request().Context(), uploadedFile, "images")
|
|
if err != nil {
|
|
return apperrors.BadRequest("error.failed_to_upload_file")
|
|
}
|
|
|
|
caption := c.FormValue("caption")
|
|
|
|
response, err := h.documentService.UploadDocumentImage(c.Request().Context(), uint(documentID), user.ID, result.URL, caption)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.JSON(http.StatusCreated, response)
|
|
}
|
|
|
|
// DeleteDocumentImage handles DELETE /api/documents/:id/images/:imageId/
|
|
func (h *DocumentHandler) DeleteDocumentImage(c echo.Context) error {
|
|
user, err := middleware.MustGetAuthUser(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
documentID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
|
if err != nil {
|
|
return apperrors.BadRequest("error.invalid_document_id")
|
|
}
|
|
|
|
imageID, err := strconv.ParseUint(c.Param("imageId"), 10, 32)
|
|
if err != nil {
|
|
return apperrors.BadRequest("error.invalid_image_id")
|
|
}
|
|
|
|
response, err := h.documentService.DeleteDocumentImage(c.Request().Context(), uint(documentID), uint(imageID), user.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.JSON(http.StatusOK, response)
|
|
}
|