Files
honeyDueAPI/internal/services/document_service.go
T
Trey t e881d37de0
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
Migrate Auth/Contractor/Document/Notification/Subscription services to ctx
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>
2026-04-25 16:26:21 -05:00

426 lines
12 KiB
Go

package services
import (
"context"
"errors"
"gorm.io/gorm"
"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/models"
"github.com/treytartt/honeydue-api/internal/repositories"
)
// Document-related errors
// DEPRECATED: These constants are deprecated. Use apperrors package instead.
// var (
// ErrDocumentNotFound = errors.New("document not found")
// ErrDocumentAccessDenied = errors.New("you do not have access to this document")
// )
// DocumentService handles document business logic
type DocumentService struct {
documentRepo *repositories.DocumentRepository
residenceRepo *repositories.ResidenceRepository
}
// NewDocumentService creates a new document service
func NewDocumentService(documentRepo *repositories.DocumentRepository, residenceRepo *repositories.ResidenceRepository) *DocumentService {
return &DocumentService{
documentRepo: documentRepo,
residenceRepo: residenceRepo,
}
}
// GetDocument gets a document by ID with access check
func (s *DocumentService) GetDocument(ctx context.Context, documentID, userID uint) (*responses.DocumentResponse, error) {
document, err := s.documentRepo.WithContext(ctx).FindByID(documentID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, apperrors.NotFound("error.document_not_found")
}
return nil, apperrors.Internal(err)
}
// Check access via residence
hasAccess, err := s.residenceRepo.WithContext(ctx).HasAccess(document.ResidenceID, userID)
if err != nil {
return nil, apperrors.Internal(err)
}
if !hasAccess {
return nil, apperrors.Forbidden("error.document_access_denied")
}
resp := responses.NewDocumentResponse(document)
return &resp, nil
}
// ListDocuments lists all documents accessible to a user, with optional filters.
func (s *DocumentService) ListDocuments(ctx context.Context, userID uint, filter *repositories.DocumentFilter) ([]responses.DocumentResponse, error) {
// Get residence IDs (lightweight - no preloads)
residenceIDs, err := s.residenceRepo.WithContext(ctx).FindResidenceIDsByUser(userID)
if err != nil {
return nil, apperrors.Internal(err)
}
if len(residenceIDs) == 0 {
return []responses.DocumentResponse{}, nil
}
// If a specific residence filter is set, narrow to that single residence (if user has access)
if filter != nil && filter.ResidenceID != nil {
found := false
for _, rid := range residenceIDs {
if rid == *filter.ResidenceID {
found = true
break
}
}
if !found {
return nil, apperrors.Forbidden("error.residence_access_denied")
}
residenceIDs = []uint{*filter.ResidenceID}
}
documents, err := s.documentRepo.WithContext(ctx).FindByUserFiltered(residenceIDs, filter)
if err != nil {
return nil, apperrors.Internal(err)
}
return responses.NewDocumentListResponse(documents), nil
}
// ListWarranties lists all warranty documents
func (s *DocumentService) ListWarranties(ctx context.Context, userID uint) ([]responses.DocumentResponse, error) {
// Get residence IDs (lightweight - no preloads)
residenceIDs, err := s.residenceRepo.WithContext(ctx).FindResidenceIDsByUser(userID)
if err != nil {
return nil, apperrors.Internal(err)
}
if len(residenceIDs) == 0 {
return []responses.DocumentResponse{}, nil
}
documents, err := s.documentRepo.WithContext(ctx).FindWarranties(residenceIDs)
if err != nil {
return nil, apperrors.Internal(err)
}
return responses.NewDocumentListResponse(documents), nil
}
// CreateDocument creates a new document
func (s *DocumentService) CreateDocument(ctx context.Context, req *requests.CreateDocumentRequest, userID uint) (*responses.DocumentResponse, error) {
// Check residence access
hasAccess, err := s.residenceRepo.WithContext(ctx).HasAccess(req.ResidenceID, userID)
if err != nil {
return nil, apperrors.Internal(err)
}
if !hasAccess {
return nil, apperrors.Forbidden("error.residence_access_denied")
}
documentType := req.DocumentType
if documentType == "" {
documentType = models.DocumentTypeGeneral
}
document := &models.Document{
ResidenceID: req.ResidenceID,
CreatedByID: userID,
Title: req.Title,
Description: req.Description,
DocumentType: documentType,
FileURL: req.FileURL,
FileName: req.FileName,
FileSize: req.FileSize,
MimeType: req.MimeType,
PurchaseDate: req.PurchaseDate,
ExpiryDate: req.ExpiryDate,
PurchasePrice: req.PurchasePrice,
Vendor: req.Vendor,
SerialNumber: req.SerialNumber,
ModelNumber: req.ModelNumber,
TaskID: req.TaskID,
IsActive: true,
}
if err := s.documentRepo.WithContext(ctx).Create(document); err != nil {
return nil, apperrors.Internal(err)
}
// Create images if provided
for _, imageURL := range req.ImageURLs {
if imageURL != "" {
img := &models.DocumentImage{
DocumentID: document.ID,
ImageURL: imageURL,
}
if err := s.documentRepo.WithContext(ctx).CreateDocumentImage(img); err != nil {
// Log but don't fail the whole operation
continue
}
}
}
// Reload with relations
document, err = s.documentRepo.WithContext(ctx).FindByID(document.ID)
if err != nil {
return nil, apperrors.Internal(err)
}
resp := responses.NewDocumentResponse(document)
return &resp, nil
}
// UpdateDocument updates a document
func (s *DocumentService) UpdateDocument(ctx context.Context, documentID, userID uint, req *requests.UpdateDocumentRequest) (*responses.DocumentResponse, error) {
document, err := s.documentRepo.WithContext(ctx).FindByID(documentID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, apperrors.NotFound("error.document_not_found")
}
return nil, apperrors.Internal(err)
}
// Check access
hasAccess, err := s.residenceRepo.WithContext(ctx).HasAccess(document.ResidenceID, userID)
if err != nil {
return nil, apperrors.Internal(err)
}
if !hasAccess {
return nil, apperrors.Forbidden("error.document_access_denied")
}
// Apply updates
if req.Title != nil {
document.Title = *req.Title
}
if req.Description != nil {
document.Description = *req.Description
}
if req.DocumentType != nil {
document.DocumentType = *req.DocumentType
}
if req.FileURL != nil {
document.FileURL = *req.FileURL
}
if req.FileName != nil {
document.FileName = *req.FileName
}
if req.FileSize != nil {
document.FileSize = req.FileSize
}
if req.MimeType != nil {
document.MimeType = *req.MimeType
}
if req.PurchaseDate != nil {
document.PurchaseDate = req.PurchaseDate
}
if req.ExpiryDate != nil {
document.ExpiryDate = req.ExpiryDate
}
if req.PurchasePrice != nil {
document.PurchasePrice = req.PurchasePrice
}
if req.Vendor != nil {
document.Vendor = *req.Vendor
}
if req.SerialNumber != nil {
document.SerialNumber = *req.SerialNumber
}
if req.ModelNumber != nil {
document.ModelNumber = *req.ModelNumber
}
if req.TaskID != nil {
document.TaskID = req.TaskID
}
if err := s.documentRepo.WithContext(ctx).Update(document); err != nil {
return nil, apperrors.Internal(err)
}
// Reload
document, err = s.documentRepo.WithContext(ctx).FindByID(documentID)
if err != nil {
return nil, apperrors.Internal(err)
}
resp := responses.NewDocumentResponse(document)
return &resp, nil
}
// DeleteDocument soft-deletes a document
func (s *DocumentService) DeleteDocument(ctx context.Context, documentID, userID uint) error {
document, err := s.documentRepo.WithContext(ctx).FindByID(documentID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return apperrors.NotFound("error.document_not_found")
}
return apperrors.Internal(err)
}
// Check access
hasAccess, err := s.residenceRepo.WithContext(ctx).HasAccess(document.ResidenceID, userID)
if err != nil {
return apperrors.Internal(err)
}
if !hasAccess {
return apperrors.Forbidden("error.document_access_denied")
}
if err := s.documentRepo.WithContext(ctx).Delete(documentID); err != nil {
return apperrors.Internal(err)
}
return nil
}
// ActivateDocument activates a document
func (s *DocumentService) ActivateDocument(ctx context.Context, documentID, userID uint) (*responses.DocumentResponse, error) {
// First check if document exists (even if inactive)
var document models.Document
if err := s.documentRepo.WithContext(ctx).FindByIDIncludingInactive(documentID, &document); err != nil {
return nil, apperrors.NotFound("error.document_not_found")
}
// Check access
hasAccess, err := s.residenceRepo.WithContext(ctx).HasAccess(document.ResidenceID, userID)
if err != nil {
return nil, apperrors.Internal(err)
}
if !hasAccess {
return nil, apperrors.Forbidden("error.document_access_denied")
}
if err := s.documentRepo.WithContext(ctx).Activate(documentID); err != nil {
return nil, apperrors.Internal(err)
}
// Reload
doc, err := s.documentRepo.WithContext(ctx).FindByID(documentID)
if err != nil {
return nil, apperrors.Internal(err)
}
resp := responses.NewDocumentResponse(doc)
return &resp, nil
}
// DeactivateDocument deactivates a document
func (s *DocumentService) DeactivateDocument(ctx context.Context, documentID, userID uint) (*responses.DocumentResponse, error) {
document, err := s.documentRepo.WithContext(ctx).FindByID(documentID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, apperrors.NotFound("error.document_not_found")
}
return nil, apperrors.Internal(err)
}
// Check access
hasAccess, err := s.residenceRepo.WithContext(ctx).HasAccess(document.ResidenceID, userID)
if err != nil {
return nil, apperrors.Internal(err)
}
if !hasAccess {
return nil, apperrors.Forbidden("error.document_access_denied")
}
if err := s.documentRepo.WithContext(ctx).Deactivate(documentID); err != nil {
return nil, apperrors.Internal(err)
}
document.IsActive = false
resp := responses.NewDocumentResponse(document)
return &resp, nil
}
// UploadDocumentImage adds an image to an existing document
func (s *DocumentService) UploadDocumentImage(ctx context.Context, documentID, userID uint, imageURL, caption string) (*responses.DocumentResponse, error) {
document, err := s.documentRepo.WithContext(ctx).FindByID(documentID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, apperrors.NotFound("error.document_not_found")
}
return nil, apperrors.Internal(err)
}
// Check access via residence
hasAccess, err := s.residenceRepo.WithContext(ctx).HasAccess(document.ResidenceID, userID)
if err != nil {
return nil, apperrors.Internal(err)
}
if !hasAccess {
return nil, apperrors.Forbidden("error.document_access_denied")
}
img := &models.DocumentImage{
DocumentID: documentID,
ImageURL: imageURL,
Caption: caption,
}
if err := s.documentRepo.WithContext(ctx).CreateDocumentImage(img); err != nil {
return nil, apperrors.Internal(err)
}
// Reload with relations
document, err = s.documentRepo.WithContext(ctx).FindByID(documentID)
if err != nil {
return nil, apperrors.Internal(err)
}
resp := responses.NewDocumentResponse(document)
return &resp, nil
}
// DeleteDocumentImage removes an image from a document
func (s *DocumentService) DeleteDocumentImage(ctx context.Context, documentID, imageID, userID uint) (*responses.DocumentResponse, error) {
// Find the image first
image, err := s.documentRepo.WithContext(ctx).FindImageByID(imageID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, apperrors.NotFound("error.document_image_not_found")
}
return nil, apperrors.Internal(err)
}
// Verify image belongs to the specified document
if image.DocumentID != documentID {
return nil, apperrors.NotFound("error.document_image_not_found")
}
// Find parent document to check access
document, err := s.documentRepo.WithContext(ctx).FindByID(documentID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, apperrors.NotFound("error.document_not_found")
}
return nil, apperrors.Internal(err)
}
// Check access via residence
hasAccess, err := s.residenceRepo.WithContext(ctx).HasAccess(document.ResidenceID, userID)
if err != nil {
return nil, apperrors.Internal(err)
}
if !hasAccess {
return nil, apperrors.Forbidden("error.document_access_denied")
}
if err := s.documentRepo.WithContext(ctx).DeleteDocumentImage(imageID); err != nil {
return nil, apperrors.Internal(err)
}
// Reload with relations
document, err = s.documentRepo.WithContext(ctx).FindByID(documentID)
if err != nil {
return nil, apperrors.Internal(err)
}
resp := responses.NewDocumentResponse(document)
return &resp, nil
}