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 storageService *StorageService uploadService *UploadService cache *CacheService } // NewDocumentService creates a new document service func NewDocumentService(documentRepo *repositories.DocumentRepository, residenceRepo *repositories.ResidenceRepository) *DocumentService { return &DocumentService{ documentRepo: documentRepo, residenceRepo: residenceRepo, } } // SetCacheService wires Redis caching for residence-ID lookups. func (s *DocumentService) SetCacheService(cache *CacheService) { s.cache = cache } // SetStorageService wires the storage service so URLs for presigned uploads // can be generated using the same BaseURL the legacy uploader uses. func (s *DocumentService) SetStorageService(ss *StorageService) { s.storageService = ss } // SetUploadService wires the presigned-URL upload service so CreateDocument // can claim pending_uploads rows by id and convert them into document_image // rows (or, for category=document_file, set the document's main file fields). func (s *DocumentService) SetUploadService(us *UploadService) { s.uploadService = us } // 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 := cachedResidenceIDsForUser(ctx, s.cache, s.residenceRepo, 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 := cachedResidenceIDsForUser(ctx, s.cache, s.residenceRepo, 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, } // Claim presigned uploads BEFORE the document insert. If the client // passed a category=document_file row, lift it onto the document's // FileURL/FileName/FileSize/MimeType fields rather than creating an // image row for it. Image categories produce DocumentImage rows below. var claimedUploads []models.PendingUpload if len(req.UploadIDs) > 0 && s.uploadService != nil { var claimErr error claimedUploads, claimErr = s.uploadService.VerifyAndClaim(ctx, userID, req.UploadIDs) if claimErr != nil { return nil, claimErr } // Lift the (single) document_file upload, if present, onto the // document fields. Multiple document_file claims aren't meaningful; // take the first and ignore extras to keep the surface narrow. for _, pu := range claimedUploads { if pu.Category == models.UploadCategoryDocumentFile { if document.FileURL == "" { document.FileURL = urlForUploadKey(s.storageService, pu.B2Key) } if document.MimeType == "" { document.MimeType = pu.ContentType } if document.FileSize == nil && pu.ActualBytes != nil { b := *pu.ActualBytes document.FileSize = &b } break } } } if err := s.documentRepo.WithContext(ctx).Create(document); err != nil { return nil, apperrors.Internal(err) } // Presigned-URL path — claimed image uploads become DocumentImage rows. // The document_file row (if any) was already lifted onto the document above. for i := range claimedUploads { pu := claimedUploads[i] if pu.Category == models.UploadCategoryDocumentFile { continue } img := &models.DocumentImage{ DocumentID: document.ID, ImageURL: urlForUploadKey(s.storageService, pu.B2Key), PendingUploadID: &pu.ID, } if err := s.documentRepo.WithContext(ctx).CreateDocumentImage(img); err != nil { // Don't fail the whole document for an image insert failure; // matches the legacy ImageURLs behavior. The orphaned upload // row is benign (still claimed, just unreferenced). continue } } // Reload with relations document, err = s.documentRepo.WithContext(ctx).FindByID(document.ID) if err != nil { return nil, apperrors.Internal(err) } invalidateSubStatusForResidence(ctx, s.cache, s.residenceRepo, req.ResidenceID) 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) } invalidateSubStatusForResidence(ctx, s.cache, s.residenceRepo, document.ResidenceID) 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 }