Files
honeyDueAPI/internal/handlers/document_handler.go
T
Trey t bc3da007db
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Wire OpenTelemetry tracing — HTTP, B2, APNs, FCM, asynq, GORM (partial)
Step 1 — OTel SDK: cmd/api and cmd/worker initialize a tracer provider
that exports OTLP/HTTP to obs.88oakapps.com (Jaeger all-in-one). Sampling
is AlwaysSample in dev (DEBUG=true) and TraceIDRatioBased(0.1) in prod,
overridable via OTEL_TRACES_SAMPLER_ARG. Service names are honeydue-api
and honeydue-worker. otelecho.Middleware opens a span per HTTP request.

Step 2 — Manual spans: storage_service.Upload now takes ctx and emits
storage.upload + b2.PutObject spans (size_bytes, key, mime_type, bucket,
result attrs). APNs Send/SendWithCategory and FCM sendOne emit per-token
spans with topic, status_code, reason. Asynq middleware emits
asynq.handle:<task_type> per job with retry/payload attrs and records
asynq_job_duration_seconds.

Step 3 — Database: otelgorm plugin registered in database.Connect, so
any SQL emitted via db.WithContext(ctx) attaches to the request span.
Every repository now exposes WithContext(ctx) *XRepository as the
migration helper. TaskService.ListTasks and GetTasksByResidence are
migrated end-to-end (ctx threaded through handler → service → repo);
remaining services adopt the same pattern incrementally — pre-migration
methods still emit untraced SQL via the unchanged db field.

OBS_TRACES_URL and OBS_INGEST_TOKEN flow from deploy/prod.env →
honeydue-secrets → api+worker Deployments via secretKeyRef (optional).
02-setup-secrets.sh sources them from prod.env on next run; manifests
mark both env vars optional so the deployment rolls without traces if
the secret is absent.

ch15 observability doc now lists what produces spans today vs the
remaining migration work, with the explicit per-method pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 15:28:05 -05:00

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(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(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(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(&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(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(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(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(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(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(uint(documentID), uint(imageID), user.ID)
if err != nil {
return err
}
return c.JSON(http.StatusOK, response)
}