package service import ( "context" "eslogad-be/internal/logger" "time" "eslogad-be/internal/appcontext" "eslogad-be/internal/constant" "eslogad-be/internal/contract" "eslogad-be/internal/entities" "eslogad-be/internal/processor" "eslogad-be/internal/repository" "eslogad-be/internal/transformer" "github.com/google/uuid" ) const ( DefaultIncomingLetterID = "ESLI" ) type LetterProcessor interface { CreateIncomingLetter(ctx context.Context, req *contract.CreateIncomingLetterRequest) (*contract.IncomingLetterResponse, error) GetIncomingLetterByID(ctx context.Context, id uuid.UUID) (*contract.IncomingLetterResponse, error) ListIncomingLetters(ctx context.Context, filter repository.ListIncomingLettersFilter, page, limit int) ([]entities.LetterIncoming, int64, error) GetLetterUnreadCounts(ctx context.Context) (*contract.LetterUnreadCountResponse, error) MarkIncomingLetterAsRead(ctx context.Context, letterID uuid.UUID) (*contract.MarkLetterReadResponse, error) MarkOutgoingLetterAsRead(ctx context.Context, letterID uuid.UUID) (*contract.MarkLetterReadResponse, error) UpdateIncomingLetter(ctx context.Context, id uuid.UUID, req *contract.UpdateIncomingLetterRequest) (*contract.IncomingLetterResponse, error) SoftDeleteIncomingLetter(ctx context.Context, id uuid.UUID) error BulkArchiveIncomingLetters(ctx context.Context, letterIDs []uuid.UUID) (int64, error) BulkArchiveIncomingLettersForUser(ctx context.Context, letterIDs []uuid.UUID, userID uuid.UUID) (int64, error) // Batch loading methods GetBatchAttachments(ctx context.Context, letterIDs []uuid.UUID) (map[uuid.UUID][]entities.LetterIncomingAttachment, error) GetBatchPriorities(ctx context.Context, priorityIDs []uuid.UUID) (map[uuid.UUID]*entities.Priority, error) GetBatchInstitutions(ctx context.Context, institutionIDs []uuid.UUID) (map[uuid.UUID]*entities.Institution, error) GetBatchRecipientsByUser(ctx context.Context, letterIDs []uuid.UUID, userID uuid.UUID) (map[uuid.UUID]*entities.LetterIncomingRecipient, error) CountUnreadByUser(ctx context.Context, userID uuid.UUID) (int, error) CreateDispositions(ctx context.Context, req *contract.CreateLetterDispositionRequest) (*contract.ListDispositionsResponse, error) GetEnhancedDispositionsByLetter(ctx context.Context, letterID uuid.UUID) (*contract.ListEnhancedDispositionsResponse, error) CreateDiscussion(ctx context.Context, letterID uuid.UUID, req *contract.CreateLetterDiscussionRequest) (*contract.LetterDiscussionResponse, error) UpdateDiscussion(ctx context.Context, letterID uuid.UUID, discussionID uuid.UUID, req *contract.UpdateLetterDiscussionRequest) (*contract.LetterDiscussionResponse, error) GetDepartmentDispositionStatus(ctx context.Context, req *contract.GetDepartmentDispositionStatusRequest) (*contract.ListDepartmentDispositionStatusResponse, error) UpdateDispositionStatus(ctx context.Context, req *contract.UpdateDispositionStatusRequest) (*contract.DepartmentDispositionStatusResponse, error) GetLetterCTA(ctx context.Context, letterID uuid.UUID, departmentID uuid.UUID) (*contract.LetterCTAResponse, error) } type LetterServiceImpl struct { processor LetterProcessor txManager *repository.TxManager numberGenerator NumberGenerator recipientProcessor RecipientProcessor activityLogger ActivityLogger letterDispositionProcessor LetterDispositionProcessor notificationProcessor processor.NotificationProcessor } type NumberGenerator interface { GenerateNumber(ctx context.Context, prefixKey, sequenceKey, defaultPrefix string) (string, error) } type RecipientProcessor interface { CreateDefaultRecipients(ctx context.Context, letterID uuid.UUID) ([]entities.LetterIncomingRecipient, error) CreateSingleRecipient(ctx context.Context, recipient *entities.LetterIncomingRecipient) error } type ActivityLogger interface { LogLetterCreated(ctx context.Context, letterID uuid.UUID, userID uuid.UUID, letterNumber string) error LogAttachmentUploaded(ctx context.Context, letterID uuid.UUID, userID uuid.UUID, fileName string, fileType string) error LogLetterDispositionStatusUpdate(ctx context.Context, letterID uuid.UUID, userID uuid.UUID, status string) error } type LetterDispositionProcessor interface { CreateDispositions(ctx context.Context, req *contract.CreateLetterDispositionRequest) (*contract.ListDispositionsResponse, error) } func NewLetterService( processor LetterProcessor, txManager *repository.TxManager, numberGenerator NumberGenerator, recipientProcessor RecipientProcessor, activityLogger ActivityLogger, letterDispositionProcessor LetterDispositionProcessor, notificationProcessor processor.NotificationProcessor, ) *LetterServiceImpl { return &LetterServiceImpl{ processor: processor, txManager: txManager, numberGenerator: numberGenerator, recipientProcessor: recipientProcessor, activityLogger: activityLogger, letterDispositionProcessor: letterDispositionProcessor, notificationProcessor: notificationProcessor, } } func (s *LetterServiceImpl) CreateIncomingLetter(ctx context.Context, req *contract.CreateIncomingLetterRequest) (*contract.IncomingLetterResponse, error) { var result *contract.IncomingLetterResponse var recipients []entities.LetterIncomingRecipient err := s.txManager.WithTransaction(ctx, func(txCtx context.Context) error { letterNumber, err := s.generateLetterNumber(txCtx) if err != nil { return err } req.LetterNumber = letterNumber result, err = s.processor.CreateIncomingLetter(txCtx, req) if err != nil { return err } recipients, err = s.createDefaultRecipients(txCtx, result.ID) if err != nil { return err } if err := s.createDispositionsForRecipients(txCtx, result.ID, recipients); err != nil { return err } s.logLetterCreation(txCtx, result.ID, letterNumber) return nil }) if err != nil { return nil, err } // Send notifications to all recipients after successful creation if s.notificationProcessor != nil && len(recipients) > 0 { go s.sendLetterNotifications(context.Background(), result, recipients) } return result, nil } func (s *LetterServiceImpl) generateLetterNumber(ctx context.Context) (string, error) { return s.numberGenerator.GenerateNumber( ctx, contract.SettingIncomingLetterPrefix, contract.SettingIncomingLetterSequence, DefaultIncomingLetterID, ) } func (s *LetterServiceImpl) createDefaultRecipients(ctx context.Context, letterID uuid.UUID) ([]entities.LetterIncomingRecipient, error) { return s.recipientProcessor.CreateDefaultRecipients(ctx, letterID) } func (s *LetterServiceImpl) createDispositionsForRecipients(ctx context.Context, letterID uuid.UUID, recipients []entities.LetterIncomingRecipient) error { if len(recipients) == 0 || s.letterDispositionProcessor == nil { return nil } departmentIDs := s.extractUniqueDepartmentIDs(recipients) if len(departmentIDs) == 0 { return nil } systemDeptID := constant.SystemDepartmentID systemUserID := constant.SystemUserID dispositionReq := &contract.CreateLetterDispositionRequest{ FromDepartment: systemDeptID, LetterID: letterID, ToDepartmentIDs: departmentIDs, Notes: nil, CreatedBy: systemUserID, } _, err := s.letterDispositionProcessor.CreateDispositions(ctx, dispositionReq) return err } func (s *LetterServiceImpl) extractUniqueDepartmentIDs(recipients []entities.LetterIncomingRecipient) []uuid.UUID { deptMap := make(map[uuid.UUID]bool) var departmentIDs []uuid.UUID for _, recipient := range recipients { if recipient.RecipientDepartmentID != nil && !deptMap[*recipient.RecipientDepartmentID] { deptMap[*recipient.RecipientDepartmentID] = true departmentIDs = append(departmentIDs, *recipient.RecipientDepartmentID) } } return departmentIDs } func (s *LetterServiceImpl) logLetterCreation(ctx context.Context, letterID uuid.UUID, letterNumber string) { if s.activityLogger == nil { return } userID := appcontext.FromGinContext(ctx).UserID err := s.activityLogger.LogLetterCreated(ctx, letterID, userID, letterNumber) if err != nil { logger.FromContext(ctx).Error("error when insert into log", err) } } func (s *LetterServiceImpl) addCreatorAsRecipient(ctx context.Context, letterID uuid.UUID, creatorID uuid.UUID) (*entities.LetterIncomingRecipient, error) { // Check if creator is already a recipient (to avoid duplicates) existingRecipients, err := s.processor.GetBatchRecipientsByUser(ctx, []uuid.UUID{letterID}, creatorID) if err != nil { return nil, err } // If creator is already a recipient, skip if _, exists := existingRecipients[letterID]; exists { return nil, nil } // Create recipient entry for the creator recipient := entities.LetterIncomingRecipient{ ID: uuid.New(), LetterID: letterID, RecipientUserID: &creatorID, Status: entities.RecipientStatusNew, CreatedAt: time.Now(), } // Save the recipient if err := s.recipientProcessor.CreateSingleRecipient(ctx, &recipient); err != nil { // Log error but don't fail the whole operation logger.FromContext(ctx).Error("failed to add creator as recipient", err) return nil, err } return &recipient, nil } func (s *LetterServiceImpl) sendLetterNotifications(ctx context.Context, letter *contract.IncomingLetterResponse, recipients []entities.LetterIncomingRecipient) { for _, recipient := range recipients { // Only send notification to user recipients (not department recipients) // Also exclude the creator from receiving notifications if recipient.RecipientUserID != nil && *recipient.RecipientUserID != letter.CreatedBy { // Use description if available, otherwise use subject err := s.notificationProcessor.SendIncomingLetterNotification( ctx, letter.ID, *recipient.RecipientUserID, "Surat Masuk", letter.Subject) if err != nil { // Log error but don't fail the entire operation logger.FromContext(ctx).Error("failed to send notification", err) } } } } func (s *LetterServiceImpl) GetIncomingLetterByID(ctx context.Context, id uuid.UUID) (*contract.IncomingLetterResponse, error) { return s.processor.GetIncomingLetterByID(ctx, id) } func (s *LetterServiceImpl) ListIncomingLetters(ctx context.Context, req *contract.ListIncomingLettersRequest) (*contract.ListIncomingLettersResponse, error) { appCtx := appcontext.FromGinContext(ctx) userID := appCtx.UserID departmentID := appCtx.DepartmentID filter := repository.ListIncomingLettersFilter{ Status: req.Status, Query: req.Query, DepartmentID: &departmentID, UserID: &userID, IsRead: req.IsRead, PriorityIDs: req.PriorityIDs, IsDispositioned: req.IsDispositioned, IsArchived: req.IsArchived, } letters, total, err := s.processor.ListIncomingLetters(ctx, filter, req.Page, req.Limit) if err != nil { return nil, err } if len(letters) == 0 { return &contract.ListIncomingLettersResponse{ Letters: []contract.IncomingLetterResponse{}, Pagination: transformer.CreatePaginationResponse(int(total), req.Page, req.Limit), TotalUnread: 0, }, nil } letterIDs := make([]uuid.UUID, 0, len(letters)) priorityIDSet := make(map[uuid.UUID]bool) institutionIDSet := make(map[uuid.UUID]bool) for _, letter := range letters { letterIDs = append(letterIDs, letter.ID) if letter.PriorityID != nil { priorityIDSet[*letter.PriorityID] = true } if letter.SenderInstitutionID != nil { institutionIDSet[*letter.SenderInstitutionID] = true } } priorityIDs := make([]uuid.UUID, 0, len(priorityIDSet)) for id := range priorityIDSet { priorityIDs = append(priorityIDs, id) } institutionIDs := make([]uuid.UUID, 0, len(institutionIDSet)) for id := range institutionIDSet { institutionIDs = append(institutionIDs, id) } type batchResult struct { attachments map[uuid.UUID][]entities.LetterIncomingAttachment priorities map[uuid.UUID]*entities.Priority institutions map[uuid.UUID]*entities.Institution recipients map[uuid.UUID]*entities.LetterIncomingRecipient err error } resultChan := make(chan batchResult, 1) go func() { result := batchResult{ attachments: make(map[uuid.UUID][]entities.LetterIncomingAttachment), priorities: make(map[uuid.UUID]*entities.Priority), institutions: make(map[uuid.UUID]*entities.Institution), recipients: make(map[uuid.UUID]*entities.LetterIncomingRecipient), } errChan := make(chan error, 4) go func() { var err error result.attachments, err = s.processor.GetBatchAttachments(ctx, letterIDs) errChan <- err }() go func() { var err error result.priorities, err = s.processor.GetBatchPriorities(ctx, priorityIDs) errChan <- err }() go func() { var err error result.institutions, err = s.processor.GetBatchInstitutions(ctx, institutionIDs) errChan <- err }() go func() { var err error result.recipients, err = s.processor.GetBatchRecipientsByUser(ctx, letterIDs, userID) errChan <- err }() for i := 0; i < 4; i++ { if err := <-errChan; err != nil { logger.FromContext(ctx).Error("batch load error", err) } } resultChan <- result }() batchData := <-resultChan respList := make([]contract.IncomingLetterResponse, 0, len(letters)) for _, letter := range letters { attachments := batchData.attachments[letter.ID] if attachments == nil { attachments = []entities.LetterIncomingAttachment{} } var priority *entities.Priority if letter.PriorityID != nil { priority = batchData.priorities[*letter.PriorityID] } var institution *entities.Institution if letter.SenderInstitutionID != nil { institution = batchData.institutions[*letter.SenderInstitutionID] } isRead := false if recipient, exists := batchData.recipients[letter.ID]; exists && recipient != nil { isRead = recipient.ReadAt != nil } resp := transformer.LetterEntityToContract(&letter, attachments, priority, institution) resp.IsRead = isRead respList = append(respList, *resp) } totalUnread, _ := s.processor.CountUnreadByUser(ctx, userID) return &contract.ListIncomingLettersResponse{ Letters: respList, Pagination: transformer.CreatePaginationResponse(int(total), req.Page, req.Limit), TotalUnread: totalUnread, }, nil } func (s *LetterServiceImpl) GetLetterUnreadCounts(ctx context.Context) (*contract.LetterUnreadCountResponse, error) { return s.processor.GetLetterUnreadCounts(ctx) } func (s *LetterServiceImpl) MarkIncomingLetterAsRead(ctx context.Context, letterID uuid.UUID) (*contract.MarkLetterReadResponse, error) { return s.processor.MarkIncomingLetterAsRead(ctx, letterID) } func (s *LetterServiceImpl) MarkOutgoingLetterAsRead(ctx context.Context, letterID uuid.UUID) (*contract.MarkLetterReadResponse, error) { return s.processor.MarkOutgoingLetterAsRead(ctx, letterID) } func (s *LetterServiceImpl) UpdateIncomingLetter(ctx context.Context, id uuid.UUID, req *contract.UpdateIncomingLetterRequest) (*contract.IncomingLetterResponse, error) { return s.processor.UpdateIncomingLetter(ctx, id, req) } func (s *LetterServiceImpl) SoftDeleteIncomingLetter(ctx context.Context, id uuid.UUID) error { return s.processor.SoftDeleteIncomingLetter(ctx, id) } func (s *LetterServiceImpl) CreateDispositions(ctx context.Context, req *contract.CreateLetterDispositionRequest) (*contract.ListDispositionsResponse, error) { userID := appcontext.FromGinContext(ctx).UserID req.CreatedBy = userID if req.FromDepartment == uuid.Nil { req.FromDepartment = appcontext.FromGinContext(ctx).DepartmentID } var result *contract.ListDispositionsResponse err := s.txManager.WithTransaction(ctx, func(txCtx context.Context) error { var err error result, err = s.processor.CreateDispositions(txCtx, req) return err }) if err != nil { return nil, err } return result, nil } func (s *LetterServiceImpl) GetEnhancedDispositionsByLetter(ctx context.Context, letterID uuid.UUID) (*contract.ListEnhancedDispositionsResponse, error) { return s.processor.GetEnhancedDispositionsByLetter(ctx, letterID) } func (s *LetterServiceImpl) CreateDiscussion(ctx context.Context, letterID uuid.UUID, req *contract.CreateLetterDiscussionRequest) (*contract.LetterDiscussionResponse, error) { return s.processor.CreateDiscussion(ctx, letterID, req) } func (s *LetterServiceImpl) UpdateDiscussion(ctx context.Context, letterID uuid.UUID, discussionID uuid.UUID, req *contract.UpdateLetterDiscussionRequest) (*contract.LetterDiscussionResponse, error) { return s.processor.UpdateDiscussion(ctx, letterID, discussionID, req) } func (s *LetterServiceImpl) GetDepartmentDispositionStatus(ctx context.Context, req *contract.GetDepartmentDispositionStatusRequest) (*contract.ListDepartmentDispositionStatusResponse, error) { return s.processor.GetDepartmentDispositionStatus(ctx, req) } func (s *LetterServiceImpl) UpdateDispositionStatus(ctx context.Context, req *contract.UpdateDispositionStatusRequest) (*contract.DepartmentDispositionStatusResponse, error) { // For now, delegate to the processor which handles this // The processor needs to be refactored to remove context extraction return s.processor.UpdateDispositionStatus(ctx, req) } func (s *LetterServiceImpl) GetLetterCTA(ctx context.Context, letterID uuid.UUID) (*contract.LetterCTAResponse, error) { departmentID := appcontext.FromGinContext(ctx).DepartmentID return s.processor.GetLetterCTA(ctx, letterID, departmentID) } func (s *LetterServiceImpl) BulkArchiveIncomingLetters(ctx context.Context, letterIDs []uuid.UUID) (*contract.BulkArchiveLettersResponse, error) { // Extract user context to archive only for the current user appCtx := appcontext.FromGinContext(ctx) userID := appCtx.UserID // Archive letters only for the current user archivedCount, err := s.processor.BulkArchiveIncomingLettersForUser(ctx, letterIDs, userID) if err != nil { return nil, err } return &contract.BulkArchiveLettersResponse{ Success: true, Message: "Letters archived successfully", ArchivedCount: int(archivedCount), }, nil }