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 departmentRepo *repository.DepartmentRepository userDeptRepo *repository.UserDepartmentRepository priorityRepo *repository.PriorityRepository institutionRepo *repository.InstitutionRepository dispActionRepo *repository.DispositionActionRepository } 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, departmentRepo *repository.DepartmentRepository, userDeptRepo *repository.UserDepartmentRepository, priorityRepo *repository.PriorityRepository, institutionRepo *repository.InstitutionRepository, dispActionRepo *repository.DispositionActionRepository) *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, departmentRepo: departmentRepo, userDeptRepo: userDeptRepo, priorityRepo: priorityRepo, institutionRepo: institutionRepo, dispActionRepo: dispActionRepo} } func (p *LetterProcessorImpl) CreateIncomingLetter(ctx context.Context, req *contract.CreateIncomingLetterRequest) (*contract.IncomingLetterResponse, error) { var result *contract.IncomingLetterResponse err := p.txManager.WithTransaction(ctx, func(txCtx context.Context) error { userID := appcontext.FromGinContext(txCtx).UserID prefix := "ESLI" seq := 0 if s, err := p.settingRepo.Get(txCtx, contract.SettingIncomingLetterPrefix); err == nil { if v, ok := s.Value["value"].(string); ok && v != "" { prefix = v } } if s, err := p.settingRepo.Get(txCtx, contract.SettingIncomingLetterSequence); err == nil { if v, ok := s.Value["value"].(float64); ok { seq = int(v) } } seq = seq + 1 letterNumber := fmt.Sprintf("%s%04d", prefix, seq) entity := &entities.LetterIncoming{ ReferenceNumber: req.ReferenceNumber, Subject: req.Subject, Description: req.Description, PriorityID: req.PriorityID, SenderInstitutionID: req.SenderInstitutionID, ReceivedDate: req.ReceivedDate, DueDate: req.DueDate, Status: entities.LetterIncomingStatusNew, CreatedBy: userID, } entity.LetterNumber = letterNumber if err := p.letterRepo.Create(txCtx, entity); err != nil { return err } _ = p.settingRepo.Upsert(txCtx, contract.SettingIncomingLetterSequence, entities.JSONB{"value": seq}) defaultDeptCodes := []string{} if s, err := p.settingRepo.Get(txCtx, contract.SettingIncomingLetterRecipients); err == nil { if arr, ok := s.Value["department_codes"].([]interface{}); ok { for _, it := range arr { if str, ok := it.(string); ok { defaultDeptCodes = append(defaultDeptCodes, str) } } } } depIDs := make([]uuid.UUID, 0, len(defaultDeptCodes)) for _, code := range defaultDeptCodes { dep, err := p.departmentRepo.GetByCode(txCtx, code) if err != nil { continue } depIDs = append(depIDs, dep.ID) } userMemberships, _ := p.userDeptRepo.ListActiveByDepartmentIDs(txCtx, depIDs) var recipients []entities.LetterIncomingRecipient mapsUsers := map[string]bool{} for _, row := range userMemberships { uid := row.UserID if _, ok := mapsUsers[uid.String()]; !ok { recipients = append(recipients, entities.LetterIncomingRecipient{LetterID: entity.ID, RecipientUserID: &uid, RecipientDepartmentID: &row.DepartmentID, Status: entities.RecipientStatusNew}) } mapsUsers[uid.String()] = true } if len(recipients) > 0 { if err := p.recipientRepo.CreateBulk(txCtx, recipients); err != nil { return err } } if p.activity != nil { action := "letter.created" if err := p.activity.Log(txCtx, entity.ID, action, &userID, nil, nil, nil, nil, nil, map[string]interface{}{"letter_number": letterNumber}); err != nil { return err } } attachments := make([]entities.LetterIncomingAttachment, 0, len(req.Attachments)) for _, a := range req.Attachments { attachments = append(attachments, entities.LetterIncomingAttachment{LetterID: entity.ID, FileURL: a.FileURL, FileName: a.FileName, FileType: a.FileType, UploadedBy: &userID}) } if len(attachments) > 0 { if err := p.attachRepo.CreateBulk(txCtx, attachments); err != nil { return err } if p.activity != nil { action := "attachment.uploaded" for _, a := range attachments { ctxMap := map[string]interface{}{"file_name": a.FileName, "file_type": a.FileType} if err := p.activity.Log(txCtx, entity.ID, action, &userID, nil, nil, nil, nil, nil, ctxMap); err != nil { return err } } } } savedAttachments, _ := p.attachRepo.ListByLetter(txCtx, entity.ID) var pr *entities.Priority if entity.PriorityID != nil { if p.priorityRepo != nil { if got, err := p.priorityRepo.Get(txCtx, *entity.PriorityID); err == nil { pr = got } } } var inst *entities.Institution if entity.SenderInstitutionID != nil { if p.institutionRepo != nil { if got, err := p.institutionRepo.Get(txCtx, *entity.SenderInstitutionID); err == nil { inst = got } } } result = transformer.LetterEntityToContract(entity, savedAttachments, pr, inst) return nil }) if err != nil { return nil, err } return result, nil } func (p *LetterProcessorImpl) GetIncomingLetterByID(ctx context.Context, id uuid.UUID) (*contract.IncomingLetterResponse, error) { entity, err := p.letterRepo.Get(ctx, id) if err != nil { return nil, err } atts, _ := p.attachRepo.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 } } return transformer.LetterEntityToContract(entity, atts, pr, inst), nil } func (p *LetterProcessorImpl) ListIncomingLetters(ctx context.Context, req *contract.ListIncomingLettersRequest) (*contract.ListIncomingLettersResponse, error) { page, limit := req.Page, req.Limit if page <= 0 { page = 1 } if limit <= 0 { limit = 10 } filter := repository.ListIncomingLettersFilter{Status: req.Status, Query: req.Query} list, total, err := p.letterRepo.List(ctx, filter, limit, (page-1)*limit) if err != nil { return nil, err } respList := make([]contract.IncomingLetterResponse, 0, len(list)) for _, e := range list { atts, _ := p.attachRepo.ListByLetter(ctx, e.ID) var pr *entities.Priority if e.PriorityID != nil && p.priorityRepo != nil { if got, err := p.priorityRepo.Get(ctx, *e.PriorityID); err == nil { pr = got } } var inst *entities.Institution if e.SenderInstitutionID != nil && p.institutionRepo != nil { if got, err := p.institutionRepo.Get(ctx, *e.SenderInstitutionID); err == nil { inst = got } } resp := transformer.LetterEntityToContract(&e, atts, pr, inst) respList = append(respList, *resp) } return &contract.ListIncomingLettersResponse{Letters: respList, Pagination: transformer.CreatePaginationResponse(int(total), page, limit)}, nil } 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.ReceivedDate != nil { entity.ReceivedDate = *req.ReceivedDate } if req.DueDate != nil { entity.DueDate = req.DueDate } 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) 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, 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) CreateDispositions(ctx context.Context, req *contract.CreateLetterDispositionRequest) (*contract.ListDispositionsResponse, error) { var out *contract.ListDispositionsResponse err := p.txManager.WithTransaction(ctx, func(txCtx context.Context) error { userID := appcontext.FromGinContext(txCtx).UserID disp := entities.LetterIncomingDisposition{ LetterID: req.LetterID, DepartmentID: &req.FromDepartment, Notes: req.Notes, CreatedBy: userID, } if err := p.dispositionRepo.Create(txCtx, &disp); err != nil { return err } var dispDepartments []entities.LetterIncomingDispositionDepartment for _, toDept := range req.ToDepartmentIDs { dispDepartments = append(dispDepartments, entities.LetterIncomingDispositionDepartment{ LetterIncomingDispositionID: disp.ID, DepartmentID: toDept, }) } if err := p.dispositionDeptRepo.CreateBulk(txCtx, dispDepartments); err != nil { return err } if len(req.SelectedActions) > 0 { selections := make([]entities.LetterDispositionActionSelection, 0, len(req.SelectedActions)) for _, sel := range req.SelectedActions { selections = append(selections, entities.LetterDispositionActionSelection{ DispositionID: disp.ID, ActionID: sel.ActionID, Note: sel.Note, CreatedBy: userID, }) } if err := p.dispositionActionSelRepo.CreateBulk(txCtx, selections); err != nil { return err } } if p.activity != nil { action := "disposition.created" ctxMap := map[string]interface{}{"to_department_id": dispDepartments} if err := p.activity.Log(txCtx, req.LetterID, action, &userID, nil, nil, &disp.ID, nil, nil, ctxMap); err != nil { return err } } out = &contract.ListDispositionsResponse{Dispositions: []contract.DispositionResponse{transformer.DispoToContract(disp)}} return nil }) if err != nil { return nil, err } return out, nil } 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) { var out *contract.LetterDiscussionResponse err := p.txManager.WithTransaction(ctx, func(txCtx context.Context) error { userID := appcontext.FromGinContext(txCtx).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(txCtx, disc); err != nil { return err } if p.activity != nil { action := "reference_numberdiscussion.created" tgt := "discussion" ctxMap := map[string]interface{}{"message": req.Message, "parent_id": req.ParentID} if err := p.activity.Log(txCtx, letterID, action, &userID, nil, &tgt, &disc.ID, nil, nil, ctxMap); err != nil { return err } } out = transformer.DiscussionEntityToContract(disc) return nil }) if err != nil { return nil, err } return out, nil } func (p *LetterProcessorImpl) UpdateDiscussion(ctx context.Context, letterID uuid.UUID, discussionID uuid.UUID, req *contract.UpdateLetterDiscussionRequest) (*contract.LetterDiscussionResponse, error) { var out *contract.LetterDiscussionResponse err := p.txManager.WithTransaction(ctx, func(txCtx context.Context) error { disc, err := p.discussionRepo.Get(txCtx, discussionID) if err != nil { return err } oldMessage := disc.Message 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(txCtx, disc); err != nil { return err } if p.activity != nil { userID := appcontext.FromGinContext(txCtx).UserID action := "discussion.updated" tgt := "discussion" ctxMap := map[string]interface{}{"old_message": oldMessage, "new_message": req.Message} if err := p.activity.Log(txCtx, letterID, action, &userID, nil, &tgt, &disc.ID, nil, nil, ctxMap); err != nil { return err } } out = transformer.DiscussionEntityToContract(disc) return nil }) if err != nil { return nil, err } return out, nil }