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>
426 lines
12 KiB
Go
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
|
|
}
|