package processor import ( "context" "fmt" "time" "eslogad-be/internal/appcontext" "eslogad-be/internal/contract" "eslogad-be/internal/entities" "eslogad-be/internal/repository" "eslogad-be/internal/transformer" "github.com/google/uuid" ) type LetterProcessorImpl struct { letterRepo *repository.LetterIncomingRepository attachRepo *repository.LetterIncomingAttachmentRepository txManager *repository.TxManager activity *ActivityLogProcessorImpl dispositionRepo *repository.LetterIncomingDispositionRepository dispositionDeptRepo *repository.LetterIncomingDispositionDepartmentRepository dispositionActionSelRepo *repository.LetterDispositionActionSelectionRepository dispositionNoteRepo *repository.DispositionNoteRepository discussionRepo *repository.LetterDiscussionRepository settingRepo *repository.AppSettingRepository recipientRepo *repository.LetterIncomingRecipientRepository outgoingRecipientRepo *repository.LetterOutgoingRecipientRepository departmentRepo *repository.DepartmentRepository userDeptRepo *repository.UserDepartmentRepository priorityRepo *repository.PriorityRepository institutionRepo *repository.InstitutionRepository dispActionRepo *repository.DispositionActionRepository dispoRoutes *repository.DispositionRouteRepository numberGenerator *LetterNumberGeneratorImpl } func NewLetterProcessor(letterRepo *repository.LetterIncomingRepository, attachRepo *repository.LetterIncomingAttachmentRepository, txManager *repository.TxManager, activity *ActivityLogProcessorImpl, dispRepo *repository.LetterIncomingDispositionRepository, dispDeptRepo *repository.LetterIncomingDispositionDepartmentRepository, dispSelRepo *repository.LetterDispositionActionSelectionRepository, noteRepo *repository.DispositionNoteRepository, discussionRepo *repository.LetterDiscussionRepository, settingRepo *repository.AppSettingRepository, recipientRepo *repository.LetterIncomingRecipientRepository, outgoingRecipientRepo *repository.LetterOutgoingRecipientRepository, departmentRepo *repository.DepartmentRepository, userDeptRepo *repository.UserDepartmentRepository, priorityRepo *repository.PriorityRepository, institutionRepo *repository.InstitutionRepository, dispActionRepo *repository.DispositionActionRepository, numberGenerator *LetterNumberGeneratorImpl, dispoRoutes *repository.DispositionRouteRepository) *LetterProcessorImpl { return &LetterProcessorImpl{letterRepo: letterRepo, attachRepo: attachRepo, txManager: txManager, activity: activity, dispositionRepo: dispRepo, dispositionDeptRepo: dispDeptRepo, dispositionActionSelRepo: dispSelRepo, dispositionNoteRepo: noteRepo, discussionRepo: discussionRepo, settingRepo: settingRepo, recipientRepo: recipientRepo, outgoingRecipientRepo: outgoingRecipientRepo, departmentRepo: departmentRepo, userDeptRepo: userDeptRepo, priorityRepo: priorityRepo, institutionRepo: institutionRepo, dispActionRepo: dispActionRepo, numberGenerator: numberGenerator, dispoRoutes: dispoRoutes} } func (p *LetterProcessorImpl) CreateIncomingLetter(ctx context.Context, req *contract.CreateIncomingLetterRequest) (*contract.IncomingLetterResponse, error) { userID := appcontext.FromGinContext(ctx).UserID existingIncoming, err := p.letterRepo.GetByReferenceNumber(ctx, req.ReferenceNumber) if err == nil && existingIncoming != nil { return nil, fmt.Errorf("surat dengan nomor %s sudah ada", *req.ReferenceNumber) } letterType := entities.LetterIncomingTypeUtama if req.Type == "TEMBUSAN" { letterType = entities.LetterIncomingTypeTembusan } entity := &entities.LetterIncoming{ LetterNumber: req.LetterNumber, ReferenceNumber: req.ReferenceNumber, Subject: req.Subject, Description: req.Description, PriorityID: req.PriorityID, SenderInstitutionID: req.SenderInstitutionID, SenderName: req.SenderName, Addressee: req.Addressee, ReceivedDate: req.ReceivedDate, DueDate: req.DueDate, Type: letterType, Status: entities.LetterIncomingStatusNew, CreatedBy: userID, } if err := p.letterRepo.Create(ctx, entity); err != nil { return nil, err } if err := p.createAttachments(ctx, entity.ID, req.Attachments, userID); err != nil { return nil, err } return p.buildLetterResponse(ctx, entity) } func (p *LetterProcessorImpl) GetIncomingLetterByID(ctx context.Context, id uuid.UUID) (*contract.IncomingLetterResponse, error) { // Get current user ID from context userID := appcontext.FromGinContext(ctx).UserID entity, err := p.letterRepo.Get(ctx, id) if err != nil { return nil, err } atts, _ := p.attachRepo.ListByLetter(ctx, id) dispo, _ := p.dispositionRepo.ListByLetter(ctx, id) var pr *entities.Priority if entity.PriorityID != nil && p.priorityRepo != nil { if got, err := p.priorityRepo.Get(ctx, *entity.PriorityID); err == nil { pr = got } } var inst *entities.Institution if entity.SenderInstitutionID != nil && p.institutionRepo != nil { if got, err := p.institutionRepo.Get(ctx, *entity.SenderInstitutionID); err == nil { inst = got } } // Check if letter is read by current user isRead := false if p.recipientRepo != nil { if recipient, err := p.recipientRepo.GetByLetterAndUser(ctx, id, userID); err == nil { isRead = recipient.ReadAt != nil fmt.Printf("Recipient: %+v\n", recipient) } } resp := transformer.LetterEntityToContract(entity, atts, dispo, pr, inst) resp.IsRead = isRead // Include created_by if the current user is the creator if entity.CreatedBy == userID { resp.CreatedBy = entity.CreatedBy } return resp, nil } func (p *LetterProcessorImpl) GetLetterUnreadCounts(ctx context.Context) (*contract.LetterUnreadCountResponse, error) { userID := appcontext.FromGinContext(ctx).UserID incomingUnread := 0 if p.recipientRepo != nil { if count, err := p.recipientRepo.CountUnreadByUser(ctx, userID); err == nil { incomingUnread = count } } outgoingUnread := 0 if p.outgoingRecipientRepo != nil { if count, err := p.outgoingRecipientRepo.CountUnreadByUser(ctx, userID); err == nil { outgoingUnread = count } } response := &contract.LetterUnreadCountResponse{} response.IncomingLetter.Unread = incomingUnread response.OutgoingLetter.Unread = outgoingUnread return response, nil } func (p *LetterProcessorImpl) MarkIncomingLetterAsRead(ctx context.Context, letterID uuid.UUID) (*contract.MarkLetterReadResponse, error) { // Get current user ID from context userID := appcontext.FromGinContext(ctx).UserID // Mark the letter as read for the current user if p.recipientRepo != nil { if err := p.recipientRepo.MarkAsRead(ctx, letterID, userID); err != nil { return &contract.MarkLetterReadResponse{ Success: false, Message: "Failed to mark letter as read", }, err } } return &contract.MarkLetterReadResponse{ Success: true, Message: "Letter marked as read successfully", }, nil } func (p *LetterProcessorImpl) MarkOutgoingLetterAsRead(ctx context.Context, letterID uuid.UUID) (*contract.MarkLetterReadResponse, error) { // Get current user ID from context userID := appcontext.FromGinContext(ctx).UserID // Mark the letter as read for the current user if p.outgoingRecipientRepo != nil { if err := p.outgoingRecipientRepo.MarkAsRead(ctx, letterID, userID); err != nil { return &contract.MarkLetterReadResponse{ Success: false, Message: "Failed to mark letter as read", }, err } } return &contract.MarkLetterReadResponse{ Success: true, Message: "Letter marked as read successfully", }, nil } func (p *LetterProcessorImpl) ListIncomingLetters(ctx context.Context, filter repository.ListIncomingLettersFilter, page, limit int) ([]entities.LetterIncoming, int64, error) { // Just fetch the raw data appCtx := appcontext.FromGinContext(ctx) fmt.Printf("Checked User Role: %s\n", appCtx.UserRole) fmt.Printf("Checked User ID: %s\n", appCtx.UserID) if appCtx.IsSuperAdmin() { fmt.Println("Checked Role: super admin") return p.letterRepo.ListAll(ctx, filter, page, limit) } return p.letterRepo.List(ctx, filter, limit, (page-1)*limit) } func (p *LetterProcessorImpl) GetBatchAttachments(ctx context.Context, letterIDs []uuid.UUID) (map[uuid.UUID][]entities.LetterIncomingAttachment, error) { if p.attachRepo == nil || len(letterIDs) == 0 { return make(map[uuid.UUID][]entities.LetterIncomingAttachment), nil } return p.attachRepo.ListByLetterIDs(ctx, letterIDs) } func (p *LetterProcessorImpl) GetBatchDispositions(ctx context.Context, letterIDs []uuid.UUID) (map[uuid.UUID][]entities.LetterIncomingDisposition, error) { if p.dispositionRepo == nil || len(letterIDs) == 0 { return make(map[uuid.UUID][]entities.LetterIncomingDisposition), nil } return p.dispositionRepo.ListByLetterIDs(ctx, letterIDs) } func (p *LetterProcessorImpl) GetBatchPriorities(ctx context.Context, priorityIDs []uuid.UUID) (map[uuid.UUID]*entities.Priority, error) { if p.priorityRepo == nil || len(priorityIDs) == 0 { return make(map[uuid.UUID]*entities.Priority), nil } return p.priorityRepo.GetByIDs(ctx, priorityIDs) } func (p *LetterProcessorImpl) GetBatchInstitutions(ctx context.Context, institutionIDs []uuid.UUID) (map[uuid.UUID]*entities.Institution, error) { if p.institutionRepo == nil || len(institutionIDs) == 0 { return make(map[uuid.UUID]*entities.Institution), nil } return p.institutionRepo.GetByIDs(ctx, institutionIDs) } func (p *LetterProcessorImpl) GetBatchRecipientsByUser(ctx context.Context, letterIDs []uuid.UUID, userID uuid.UUID) (map[uuid.UUID]*entities.LetterIncomingRecipient, error) { if p.recipientRepo == nil || len(letterIDs) == 0 { return make(map[uuid.UUID]*entities.LetterIncomingRecipient), nil } return p.recipientRepo.GetByLetterIDsAndUser(ctx, letterIDs, userID) } func (p *LetterProcessorImpl) CountUnreadByUser(ctx context.Context, userID uuid.UUID) (int, error) { if p.recipientRepo == nil { return 0, nil } return p.recipientRepo.CountUnreadByUser(ctx, userID) } func (p *LetterProcessorImpl) UpdateIncomingLetter(ctx context.Context, id uuid.UUID, req *contract.UpdateIncomingLetterRequest) (*contract.IncomingLetterResponse, error) { var out *contract.IncomingLetterResponse err := p.txManager.WithTransaction(ctx, func(txCtx context.Context) error { entity, err := p.letterRepo.Get(txCtx, id) if err != nil { return err } fromStatus := string(entity.Status) if req.ReferenceNumber != nil { entity.ReferenceNumber = req.ReferenceNumber } if req.Subject != nil { entity.Subject = *req.Subject } if req.Description != nil { entity.Description = req.Description } if req.PriorityID != nil { entity.PriorityID = req.PriorityID } if req.SenderInstitutionID != nil { entity.SenderInstitutionID = req.SenderInstitutionID } if req.SenderName != nil { entity.SenderName = req.SenderName } if req.Addressee != nil { entity.Addressee = req.Addressee } if req.ReceivedDate != nil { entity.ReceivedDate = *req.ReceivedDate } if req.DueDate != nil { entity.DueDate = req.DueDate } if req.Type != nil { entity.Type = entities.LetterIncomingType(*req.Type) } if req.Status != nil { entity.Status = entities.LetterIncomingStatus(*req.Status) } if err := p.letterRepo.Update(txCtx, entity); err != nil { return err } toStatus := string(entity.Status) if p.activity != nil && fromStatus != toStatus { userID := appcontext.FromGinContext(txCtx).UserID action := "status.changed" if err := p.activity.Log(txCtx, id, action, &userID, nil, nil, nil, &fromStatus, &toStatus, map[string]interface{}{}); err != nil { return err } } atts, _ := p.attachRepo.ListByLetter(txCtx, id) dispo, _ := p.dispositionRepo.ListByLetter(txCtx, id) var pr *entities.Priority if entity.PriorityID != nil && p.priorityRepo != nil { if got, err := p.priorityRepo.Get(txCtx, *entity.PriorityID); err == nil { pr = got } } var inst *entities.Institution if entity.SenderInstitutionID != nil && p.institutionRepo != nil { if got, err := p.institutionRepo.Get(txCtx, *entity.SenderInstitutionID); err == nil { inst = got } } out = transformer.LetterEntityToContract(entity, atts, dispo, pr, inst) return nil }) if err != nil { return nil, err } return out, nil } func (p *LetterProcessorImpl) SoftDeleteIncomingLetter(ctx context.Context, id uuid.UUID) error { return p.txManager.WithTransaction(ctx, func(txCtx context.Context) error { if err := p.letterRepo.SoftDelete(txCtx, id); err != nil { return err } if p.activity != nil { userID := appcontext.FromGinContext(txCtx).UserID action := "letter.deleted" if err := p.activity.Log(txCtx, id, action, &userID, nil, nil, nil, nil, nil, map[string]interface{}{}); err != nil { return err } } return nil }) } func (p *LetterProcessorImpl) BulkSoftDeleteIncomingLetters(ctx context.Context, ids []uuid.UUID) error { if len(ids) == 0 { return nil } return p.txManager.WithTransaction(ctx, func(txCtx context.Context) error { if err := p.letterRepo.BulkSoftDelete(txCtx, ids); err != nil { return err } if p.activity != nil { userID := appcontext.FromGinContext(txCtx).UserID action := "letter.bulk_deleted" // Log activity untuk setiap letter yang dihapus for _, id := range ids { if err := p.activity.Log(txCtx, id, action, &userID, nil, nil, nil, nil, nil, map[string]interface{}{}); err != nil { return err } } } return nil }) } // CreateDispositions creates a new disposition with modular helper functions func (p *LetterProcessorImpl) CreateDispositions(ctx context.Context, req *contract.CreateLetterDispositionRequest) (*contract.ListDispositionsResponse, error) { // Transaction should be handled at service layer // The context passed here should already contain the transaction if needed // Step 1: Update existing disposition departments if err := p.updateExistingDispositionDepartments(ctx, req.LetterID, req.FromDepartment); err != nil { return nil, err } // Step 2: Create the main disposition disp, err := p.createMainDisposition(ctx, req) if err != nil { return nil, err } // Step 3: Create disposition departments for target departments dispDepartments, err := p.createDispositionDepartments(ctx, disp.ID, req.LetterID, req.ToDepartmentIDs) if err != nil { return nil, err } // Step 4: Create action selections if provided if err := p.createActionSelections(ctx, disp.ID, req.SelectedActions, req.CreatedBy); err != nil { return nil, err } // Step 5: Build and return the response return p.buildDispositionResponse(disp, dispDepartments, req.ToDepartmentIDs), nil } // updateExistingDispositionDepartments updates the status of existing disposition departments func (p *LetterProcessorImpl) updateExistingDispositionDepartments(ctx context.Context, letterID uuid.UUID, fromDepartment uuid.UUID) error { existingDispDepts, err := p.dispositionDeptRepo.GetByLetterAndDepartment(ctx, letterID, fromDepartment) if err != nil { // If no existing departments found, that's ok return nil } for _, existingDispDept := range existingDispDepts { if existingDispDept.Status == entities.DispositionDepartmentStatusPending { existingDispDept.Status = entities.DispositionDepartmentStatusDispositioned if err := p.dispositionDeptRepo.Update(ctx, &existingDispDept); err != nil { return fmt.Errorf("failed to update existing disposition department: %w", err) } } } return nil } // createMainDisposition creates the primary disposition record func (p *LetterProcessorImpl) createMainDisposition(ctx context.Context, req *contract.CreateLetterDispositionRequest) (*entities.LetterIncomingDisposition, error) { disp := &entities.LetterIncomingDisposition{ LetterID: req.LetterID, DepartmentID: &req.FromDepartment, Notes: req.Notes, CreatedBy: req.CreatedBy, // Should be set by service layer } if err := p.dispositionRepo.Create(ctx, disp); err != nil { return nil, fmt.Errorf("failed to create disposition: %w", err) } return disp, nil } // createDispositionDepartments creates disposition department records for target departments func (p *LetterProcessorImpl) createDispositionDepartments(ctx context.Context, dispositionID, letterID uuid.UUID, toDepartmentIDs []uuid.UUID) ([]entities.LetterIncomingDispositionDepartment, error) { if len(toDepartmentIDs) == 0 { return nil, nil } dispDepartments := make([]entities.LetterIncomingDispositionDepartment, 0, len(toDepartmentIDs)) for _, toDept := range toDepartmentIDs { dispDepartments = append(dispDepartments, entities.LetterIncomingDispositionDepartment{ LetterIncomingDispositionID: dispositionID, LetterIncomingID: letterID, DepartmentID: toDept, Status: entities.DispositionDepartmentStatusPending, }) } if err := p.dispositionDeptRepo.CreateBulk(ctx, dispDepartments); err != nil { return nil, fmt.Errorf("failed to create disposition departments: %w", err) } return dispDepartments, nil } // createActionSelections creates action selection records for the disposition func (p *LetterProcessorImpl) createActionSelections(ctx context.Context, dispositionID uuid.UUID, selectedActions []contract.CreateDispositionActionSelection, createdBy uuid.UUID) error { if len(selectedActions) == 0 { return nil } selections := make([]entities.LetterDispositionActionSelection, 0, len(selectedActions)) for _, sel := range selectedActions { selections = append(selections, entities.LetterDispositionActionSelection{ DispositionID: dispositionID, ActionID: sel.ActionID, Note: sel.Note, CreatedBy: createdBy, }) } if err := p.dispositionActionSelRepo.CreateBulk(ctx, selections); err != nil { return fmt.Errorf("failed to create action selections: %w", err) } return nil } // buildDispositionResponse builds the response for the created disposition func (p *LetterProcessorImpl) buildDispositionResponse(disp *entities.LetterIncomingDisposition, dispDepartments []entities.LetterIncomingDispositionDepartment, toDepartmentIDs []uuid.UUID) *contract.ListDispositionsResponse { response := &contract.ListDispositionsResponse{ Dispositions: []contract.DispositionResponse{transformer.DispoToContract(*disp)}, } // The toDepartmentIDs are available in the dispDepartments for service layer logging // No need to store them in the response as DispositionResponse doesn't have this field return response } func (p *LetterProcessorImpl) ListDispositionsByLetter(ctx context.Context, letterID uuid.UUID) (*contract.ListDispositionsResponse, error) { list, err := p.dispositionRepo.ListByLetter(ctx, letterID) if err != nil { return nil, err } return &contract.ListDispositionsResponse{Dispositions: transformer.DispositionsToContract(list)}, nil } func (p *LetterProcessorImpl) GetEnhancedDispositionsByLetter(ctx context.Context, letterID uuid.UUID) (*contract.ListEnhancedDispositionsResponse, error) { // Get dispositions with all related data preloaded in a single query dispositions, err := p.dispositionRepo.ListByLetter(ctx, letterID) if err != nil { return nil, err } // Get discussions with preloaded user profiles discussions, err := p.discussionRepo.ListByLetter(ctx, letterID) if err != nil { return nil, err } // Extract all mentioned user IDs from discussions for efficient batch fetching var mentionedUserIDs []uuid.UUID mentionedUserIDsMap := make(map[uuid.UUID]bool) for _, discussion := range discussions { if discussion.Mentions != nil { mentions := map[string]interface{}(discussion.Mentions) if userIDs, ok := mentions["user_ids"]; ok { if userIDList, ok := userIDs.([]interface{}); ok { for _, userID := range userIDList { if userIDStr, ok := userID.(string); ok { if userUUID, err := uuid.Parse(userIDStr); err == nil { if !mentionedUserIDsMap[userUUID] { mentionedUserIDsMap[userUUID] = true mentionedUserIDs = append(mentionedUserIDs, userUUID) } } } } } } } } // Fetch all mentioned users in a single batch query var mentionedUsers []entities.User if len(mentionedUserIDs) > 0 { mentionedUsers, err = p.discussionRepo.GetUsersByIDs(ctx, mentionedUserIDs) if err != nil { return nil, err } } // Transform dispositions enhancedDispositions := transformer.EnhancedDispositionsWithPreloadedDataToContract(dispositions) // Transform discussions with mentioned users enhancedDiscussions := transformer.DiscussionsWithPreloadedDataToContract(discussions, mentionedUsers) return &contract.ListEnhancedDispositionsResponse{ Dispositions: enhancedDispositions, Discussions: enhancedDiscussions, }, nil } func (p *LetterProcessorImpl) CreateDiscussion(ctx context.Context, letterID uuid.UUID, req *contract.CreateLetterDiscussionRequest) (*contract.LetterDiscussionResponse, error) { userID := appcontext.FromGinContext(ctx).UserID mentions := entities.JSONB(nil) if req.Mentions != nil { mentions = entities.JSONB(req.Mentions) } disc := &entities.LetterDiscussion{ ID: uuid.New(), LetterID: letterID, ParentID: req.ParentID, UserID: userID, Message: req.Message, Mentions: mentions, } if err := p.discussionRepo.Create(ctx, disc); err != nil { return nil, fmt.Errorf("failed to create discussion: %w", err) } // Activity logging should be handled at service layer return transformer.DiscussionEntityToContract(disc), nil } func (p *LetterProcessorImpl) UpdateDiscussion(ctx context.Context, letterID uuid.UUID, discussionID uuid.UUID, req *contract.UpdateLetterDiscussionRequest) (*contract.LetterDiscussionResponse, string, error) { // Transaction should be handled at service layer disc, err := p.discussionRepo.Get(ctx, discussionID) if err != nil { return nil, "", fmt.Errorf("failed to get discussion: %w", err) } // Store old message for activity logging oldMessage := disc.Message // Update discussion fields disc.Message = req.Message if req.Mentions != nil { disc.Mentions = entities.JSONB(req.Mentions) } now := time.Now() disc.EditedAt = &now if err := p.discussionRepo.Update(ctx, disc); err != nil { return nil, "", fmt.Errorf("failed to update discussion: %w", err) } // Return both the updated discussion and old message for service layer logging return transformer.DiscussionEntityToContract(disc), oldMessage, nil } func (p *LetterProcessorImpl) createAttachments(ctx context.Context, letterID uuid.UUID, attachments []contract.CreateIncomingLetterAttachment, userID uuid.UUID) error { if len(attachments) == 0 { return nil } attachmentEntities := make([]entities.LetterIncomingAttachment, 0, len(attachments)) for _, a := range attachments { attachmentEntities = append(attachmentEntities, entities.LetterIncomingAttachment{ LetterID: letterID, FileURL: a.FileURL, FileName: a.FileName, FileType: a.FileType, UploadedBy: &userID, }) } if err := p.attachRepo.CreateBulk(ctx, attachmentEntities); err != nil { return err } // Attachment logging will be handled by service layer return nil } func (p *LetterProcessorImpl) SearchIncomingLetters(ctx context.Context, filters map[string]interface{}, page, limit int, sortBy, sortOrder string) ([]entities.LetterIncoming, int64, error) { offset := (page - 1) * limit return p.letterRepo.Search(ctx, filters, limit, offset, sortBy, sortOrder) } func (p *LetterProcessorImpl) buildLetterResponse(ctx context.Context, entity *entities.LetterIncoming) (*contract.IncomingLetterResponse, error) { savedAttachments, _ := p.attachRepo.ListByLetter(ctx, entity.ID) dispo, _ := p.dispositionRepo.ListByLetter(ctx, entity.ID) var pr *entities.Priority if entity.PriorityID != nil && p.priorityRepo != nil { if got, err := p.priorityRepo.Get(ctx, *entity.PriorityID); err == nil { pr = got } } var inst *entities.Institution if entity.SenderInstitutionID != nil && p.institutionRepo != nil { if got, err := p.institutionRepo.Get(ctx, *entity.SenderInstitutionID); err == nil { inst = got } } return transformer.LetterEntityToContract(entity, savedAttachments, dispo, pr, inst), nil } func (p *LetterProcessorImpl) BulkArchiveIncomingLetters(ctx context.Context, letterIDs []uuid.UUID) (int64, error) { return p.letterRepo.BulkArchive(ctx, letterIDs) } func (p *LetterProcessorImpl) ArchiveIncomingLetter(ctx context.Context, letterID uuid.UUID) error { return p.letterRepo.Archive(ctx, letterID) } // BulkArchiveIncomingLettersForUser archives letters for a specific user only func (p *LetterProcessorImpl) BulkArchiveIncomingLettersForUser(ctx context.Context, letterIDs []uuid.UUID, userID uuid.UUID) (int64, error) { return p.letterRepo.BulkArchiveForUser(ctx, letterIDs, userID) }