add sender and receiver

This commit is contained in:
Aditya Siregar 2025-09-21 20:13:12 +07:00
parent 90da195a2e
commit 24e1d265d3
23 changed files with 854 additions and 0 deletions

Binary file not shown.

Binary file not shown.

View File

@ -6,6 +6,29 @@ import (
"github.com/google/uuid"
)
type SearchIncomingLettersRequest struct {
Query string `json:"query" form:"query"`
LetterNumber string `json:"letter_number" form:"letter_number"`
Subject string `json:"subject" form:"subject"`
Status string `json:"status" form:"status"`
PriorityID *uuid.UUID `json:"priority_id" form:"priority_id"`
InstitutionID *uuid.UUID `json:"institution_id" form:"institution_id"`
CreatedBy *uuid.UUID `json:"created_by" form:"created_by"`
DateFrom *time.Time `json:"date_from" form:"date_from"`
DateTo *time.Time `json:"date_to" form:"date_to"`
Page int `json:"page" form:"page"`
Limit int `json:"limit" form:"limit"`
SortBy string `json:"sort_by" form:"sort_by"`
SortOrder string `json:"sort_order" form:"sort_order"`
}
type SearchIncomingLettersResponse struct {
Letters []IncomingLetterResponse `json:"letters"`
TotalCount int64 `json:"total_count"`
Page int `json:"page"`
Limit int `json:"limit"`
}
type CreateIncomingLetterAttachment struct {
FileURL string `json:"file_url"`
FileName string `json:"file_name"`
@ -19,6 +42,7 @@ type CreateIncomingLetterRequest struct {
Description *string `json:"description,omitempty"`
PriorityID *uuid.UUID `json:"priority_id,omitempty"`
SenderInstitutionID *uuid.UUID `json:"sender_institution_id,omitempty"`
SenderName *string `json:"sender_name,omitempty"`
ReceivedDate time.Time `json:"received_date"`
DueDate *time.Time `json:"due_date,omitempty"`
Attachments []CreateIncomingLetterAttachment `json:"attachments,omitempty"`
@ -40,6 +64,7 @@ type IncomingLetterResponse struct {
Description *string `json:"description,omitempty"`
Priority *PriorityResponse `json:"priority,omitempty"`
SenderInstitution *InstitutionResponse `json:"sender_institution,omitempty"`
SenderName *string `json:"sender_name,omitempty"`
ReceivedDate time.Time `json:"received_date"`
DueDate *time.Time `json:"due_date,omitempty"`
Status string `json:"status"`
@ -56,6 +81,7 @@ type UpdateIncomingLetterRequest struct {
Description *string `json:"description,omitempty"`
PriorityID *uuid.UUID `json:"priority_id,omitempty"`
SenderInstitutionID *uuid.UUID `json:"sender_institution_id,omitempty"`
SenderName *string `json:"sender_name,omitempty"`
ReceivedDate *time.Time `json:"received_date,omitempty"`
DueDate *time.Time `json:"due_date,omitempty"`
Status *string `json:"status,omitempty"`

View File

@ -6,6 +6,29 @@ import (
"github.com/google/uuid"
)
type SearchOutgoingLettersRequest struct {
Query string `json:"query" form:"query"`
LetterNumber string `json:"letter_number" form:"letter_number"`
Subject string `json:"subject" form:"subject"`
Status string `json:"status" form:"status"`
PriorityID *uuid.UUID `json:"priority_id" form:"priority_id"`
InstitutionID *uuid.UUID `json:"institution_id" form:"institution_id"`
CreatedBy *uuid.UUID `json:"created_by" form:"created_by"`
DateFrom *time.Time `json:"date_from" form:"date_from"`
DateTo *time.Time `json:"date_to" form:"date_to"`
Page int `json:"page" form:"page"`
Limit int `json:"limit" form:"limit"`
SortBy string `json:"sort_by" form:"sort_by"`
SortOrder string `json:"sort_order" form:"sort_order"`
}
type SearchOutgoingLettersResponse struct {
Letters []OutgoingLetterResponse `json:"letters"`
TotalCount int64 `json:"total_count"`
Page int `json:"page"`
Limit int `json:"limit"`
}
type CreateOutgoingLetterRecipient struct {
LetterID uuid.UUID `json:"letter_id"`
UserID *uuid.UUID `json:"user_id,omitempty"`
@ -28,6 +51,7 @@ type CreateOutgoingLetterRequest struct {
Description *string `json:"description,omitempty"`
PriorityID *uuid.UUID `json:"priority_id,omitempty"`
ReceiverInstitutionID *uuid.UUID `json:"receiver_institution_id,omitempty"`
ReceiverName *string `json:"receiver_name,omitempty"`
IssueDate time.Time `json:"issue_date" validate:"required"`
Attachments []CreateOutgoingLetterAttachment `json:"attachments,omitempty"`
UserID uuid.UUID
@ -78,6 +102,7 @@ type OutgoingLetterResponse struct {
Priority *PriorityResponse `json:"priority,omitempty"`
ReceiverInstitutionID *uuid.UUID `json:"receiver_institution_id,omitempty"`
ReceiverInstitution *InstitutionResponse `json:"receiver_institution,omitempty"`
ReceiverName *string `json:"receiver_name,omitempty"`
IssueDate time.Time `json:"issue_date"`
Status string `json:"status"`
ApprovalFlowID *uuid.UUID `json:"approval_flow_id,omitempty"`
@ -95,6 +120,7 @@ type UpdateOutgoingLetterRequest struct {
Description *string `json:"description,omitempty"`
PriorityID *uuid.UUID `json:"priority_id,omitempty"`
ReceiverInstitutionID *uuid.UUID `json:"receiver_institution_id,omitempty"`
ReceiverName *string `json:"receiver_name,omitempty"`
IssueDate *time.Time `json:"issue_date,omitempty"`
}

View File

@ -22,6 +22,7 @@ type LetterIncoming struct {
Description *string `json:"description,omitempty"`
PriorityID *uuid.UUID `json:"priority_id,omitempty"`
SenderInstitutionID *uuid.UUID `json:"sender_institution_id,omitempty"`
SenderName *string `json:"sender_name,omitempty"`
ReceivedDate time.Time `json:"received_date"`
DueDate *time.Time `json:"due_date,omitempty"`
Status LetterIncomingStatus `gorm:"not null;default:'new'" json:"status"`

View File

@ -24,6 +24,7 @@ type LetterOutgoing struct {
Description *string `json:"description,omitempty"`
PriorityID *uuid.UUID `json:"priority_id,omitempty"`
ReceiverInstitutionID *uuid.UUID `json:"receiver_institution_id,omitempty"`
ReceiverName *string `json:"receiver_name,omitempty"`
IssueDate time.Time `json:"issue_date"`
Status LetterOutgoingStatus `gorm:"not null;default:'draft'" json:"status"`
ApprovalFlowID *uuid.UUID `json:"approval_flow_id,omitempty"`

View File

@ -17,6 +17,7 @@ type LetterService 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, req *contract.ListIncomingLettersRequest) (*contract.ListIncomingLettersResponse, error)
SearchIncomingLetters(ctx context.Context, req *contract.SearchIncomingLettersRequest) (*contract.SearchIncomingLettersResponse, 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)
@ -358,6 +359,34 @@ func (h *LetterHandler) UpdateDiscussion(c *gin.Context) {
h.respondSuccess(c, http.StatusOK, resp)
}
func (h *LetterHandler) SearchIncomingLetters(c *gin.Context) {
var req contract.SearchIncomingLettersRequest
if !h.bindQuery(c, &req) {
return
}
if req.Page <= 0 {
req.Page = 1
}
if req.Limit <= 0 {
req.Limit = 10
}
if req.SortOrder == "" {
req.SortOrder = "desc"
}
if req.SortBy == "" {
req.SortBy = "created_at"
}
resp, err := h.svc.SearchIncomingLetters(c.Request.Context(), &req)
if err != nil {
h.handleServiceError(c, err)
return
}
h.respondSuccess(c, http.StatusOK, resp)
}
func (h *LetterHandler) GetDepartmentDispositionStatus(c *gin.Context) {
letterID, ok := h.parseUUID(c, "letter_id")
if !ok {

View File

@ -15,6 +15,7 @@ type LetterOutgoingService interface {
CreateOutgoingLetter(ctx context.Context, req *contract.CreateOutgoingLetterRequest) (*contract.OutgoingLetterResponse, error)
GetOutgoingLetterByID(ctx context.Context, id uuid.UUID) (*contract.OutgoingLetterResponse, error)
ListOutgoingLetters(ctx context.Context, req *contract.ListOutgoingLettersRequest) (*contract.ListOutgoingLettersResponse, error)
SearchOutgoingLetters(ctx context.Context, req *contract.SearchOutgoingLettersRequest) (*contract.SearchOutgoingLettersResponse, error)
UpdateOutgoingLetter(ctx context.Context, id uuid.UUID, req *contract.UpdateOutgoingLetterRequest) (*contract.OutgoingLetterResponse, error)
DeleteOutgoingLetter(ctx context.Context, id uuid.UUID) error
@ -402,6 +403,35 @@ func (h *LetterOutgoingHandler) DeleteDiscussion(c *gin.Context) {
c.JSON(http.StatusOK, &contract.SuccessResponse{Message: "discussion deleted"})
}
func (h *LetterOutgoingHandler) SearchOutgoingLetters(c *gin.Context) {
var req contract.SearchOutgoingLettersRequest
if err := c.ShouldBindQuery(&req); err != nil {
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid query parameters", Code: http.StatusBadRequest})
return
}
if req.Page <= 0 {
req.Page = 1
}
if req.Limit <= 0 {
req.Limit = 10
}
if req.SortOrder == "" {
req.SortOrder = "desc"
}
if req.SortBy == "" {
req.SortBy = "created_at"
}
resp, err := h.svc.SearchOutgoingLetters(c.Request.Context(), &req)
if err != nil {
c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError})
return
}
c.JSON(http.StatusOK, contract.BuildSuccessResponse(resp))
}
func (h *LetterOutgoingHandler) GetLetterApprovalInfo(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {

View File

@ -16,6 +16,7 @@ type LetterOutgoingProcessor interface {
CreateOutgoingLetter(ctx context.Context, letter *entities.LetterOutgoing, attachments []entities.LetterOutgoingAttachment, userID, departmentID uuid.UUID) error
GetOutgoingLetterByID(ctx context.Context, id uuid.UUID) (*entities.LetterOutgoing, error)
ListOutgoingLetters(ctx context.Context, filter repository.ListOutgoingLettersFilter, limit, offset int) ([]entities.LetterOutgoing, int64, error)
SearchOutgoingLetters(ctx context.Context, filters map[string]interface{}, page, limit int, sortBy, sortOrder string) ([]entities.LetterOutgoing, int64, error)
UpdateOutgoingLetter(ctx context.Context, letter *entities.LetterOutgoing, userID uuid.UUID) error
DeleteOutgoingLetter(ctx context.Context, id uuid.UUID, userID uuid.UUID) error
@ -780,6 +781,11 @@ func (p *LetterOutgoingProcessorImpl) GetUsersByIDs(ctx context.Context, userIDs
return users, nil
}
func (p *LetterOutgoingProcessorImpl) SearchOutgoingLetters(ctx context.Context, filters map[string]interface{}, page, limit int, sortBy, sortOrder string) ([]entities.LetterOutgoing, int64, error) {
offset := (page - 1) * limit
return p.letterRepo.Search(ctx, filters, limit, offset, sortBy, sortOrder)
}
func (p *LetterOutgoingProcessorImpl) BulkArchiveOutgoingLetters(ctx context.Context, letterIDs []uuid.UUID) (int64, error) {
return p.letterRepo.BulkArchive(ctx, letterIDs)
}

View File

@ -67,6 +67,7 @@ func (p *LetterProcessorImpl) CreateIncomingLetter(ctx context.Context, req *con
Description: req.Description,
PriorityID: req.PriorityID,
SenderInstitutionID: req.SenderInstitutionID,
SenderName: req.SenderName,
ReceivedDate: req.ReceivedDate,
DueDate: req.DueDate,
Status: entities.LetterIncomingStatusNew,
@ -252,6 +253,9 @@ func (p *LetterProcessorImpl) UpdateIncomingLetter(ctx context.Context, id uuid.
if req.SenderInstitutionID != nil {
entity.SenderInstitutionID = req.SenderInstitutionID
}
if req.SenderName != nil {
entity.SenderName = req.SenderName
}
if req.ReceivedDate != nil {
entity.ReceivedDate = *req.ReceivedDate
}
@ -577,6 +581,11 @@ func (p *LetterProcessorImpl) createAttachments(ctx context.Context, letterID uu
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)

View File

@ -188,6 +188,109 @@ func (r *LetterOutgoingRepository) List(ctx context.Context, filter ListOutgoing
return list, total, nil
}
func (r *LetterOutgoingRepository) Search(ctx context.Context, filters map[string]interface{}, limit, offset int, sortBy, sortOrder string) ([]entities.LetterOutgoing, int64, error) {
db := DBFromContext(ctx, r.db)
query := db.WithContext(ctx).Model(&entities.LetterOutgoing{}).Where("deleted_at IS NULL")
// Apply search filters
if q, ok := filters["query"]; ok && q != "" {
searchTerm := "%" + q.(string) + "%"
query = query.Where("subject ILIKE ? OR reference_number ILIKE ? OR letter_number ILIKE ? OR description ILIKE ? OR receiver_name ILIKE ?", searchTerm, searchTerm, searchTerm, searchTerm, searchTerm)
}
if letterNumber, ok := filters["letter_number"]; ok && letterNumber != "" {
query = query.Where("letter_number ILIKE ?", "%"+letterNumber.(string)+"%")
}
if subject, ok := filters["subject"]; ok && subject != "" {
query = query.Where("subject ILIKE ?", "%"+subject.(string)+"%")
}
if status, ok := filters["status"]; ok && status != "" {
query = query.Where("status = ?", status)
}
if priorityID, ok := filters["priority_id"]; ok {
query = query.Where("priority_id = ?", priorityID)
}
if institutionID, ok := filters["receiver_institution_id"]; ok {
query = query.Where("receiver_institution_id = ?", institutionID)
}
if createdBy, ok := filters["created_by"]; ok {
query = query.Where("created_by = ?", createdBy)
}
if dateFrom, ok := filters["date_from"]; ok {
query = query.Where("issue_date >= ?", dateFrom)
}
if dateTo, ok := filters["date_to"]; ok {
query = query.Where("issue_date <= ?", dateTo)
}
// Apply user context filters if present
if userContext, ok := filters["user_context"]; ok {
if ctx, ok := userContext.(map[string]interface{}); ok {
if userID, ok := ctx["user_id"]; ok {
// User can see: letters created by them OR letters where they are recipients
subQuery := db.Model(&entities.LetterOutgoingRecipient{}).Select("letter_id").Where("user_id = ?", userID)
query = query.Where("created_by = ? OR id IN (?)", userID, subQuery)
}
}
}
// Count total results
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
// Apply sorting
if sortBy == "" {
sortBy = "created_at"
}
if sortOrder == "" {
sortOrder = "desc"
}
validSortFields := map[string]bool{
"letter_number": true,
"subject": true,
"issue_date": true,
"status": true,
"created_at": true,
"updated_at": true,
}
if !validSortFields[sortBy] {
sortBy = "created_at"
}
if sortOrder != "asc" && sortOrder != "desc" {
sortOrder = "desc"
}
orderBy := sortBy + " " + sortOrder
// Execute query with preloads
var letters []entities.LetterOutgoing
if err := query.
Preload("Priority").
Preload("ReceiverInstitution").
Preload("Creator").
Preload("Creator.Profile").
Order(orderBy).
Limit(limit).
Offset(offset).
Find(&letters).Error; err != nil {
return nil, 0, err
}
return letters, total, nil
}
func (r *LetterOutgoingRepository) UpdateStatus(ctx context.Context, id uuid.UUID, status entities.LetterOutgoingStatus) error {
db := DBFromContext(ctx, r.db)
return db.WithContext(ctx).Model(&entities.LetterOutgoing{}).Where("id = ? AND deleted_at IS NULL", id).Update("status", status).Error

View File

@ -210,6 +210,139 @@ func (r *LetterIncomingRepository) List(ctx context.Context, filter ListIncoming
return list, total, nil
}
func (r *LetterIncomingRepository) Search(ctx context.Context, filters map[string]interface{}, limit, offset int, sortBy, sortOrder string) ([]entities.LetterIncoming, int64, error) {
db := DBFromContext(ctx, r.db)
query := db.WithContext(ctx).Model(&entities.LetterIncoming{}).Where("deleted_at IS NULL")
joinedRecipients := false
needsGroupBy := false
// Apply search filters
if q, ok := filters["query"]; ok && q != "" {
searchTerm := "%" + q.(string) + "%"
query = query.Where("subject ILIKE ? OR reference_number ILIKE ? OR letter_number ILIKE ? OR description ILIKE ? OR sender_name ILIKE ?", searchTerm, searchTerm, searchTerm, searchTerm, searchTerm)
}
if letterNumber, ok := filters["letter_number"]; ok && letterNumber != "" {
query = query.Where("letter_number ILIKE ?", "%"+letterNumber.(string)+"%")
}
if subject, ok := filters["subject"]; ok && subject != "" {
query = query.Where("subject ILIKE ?", "%"+subject.(string)+"%")
}
if status, ok := filters["status"]; ok && status != "" {
query = query.Where("status = ?", status)
}
if priorityID, ok := filters["priority_id"]; ok {
query = query.Where("priority_id = ?", priorityID)
}
if institutionID, ok := filters["sender_institution_id"]; ok {
query = query.Where("sender_institution_id = ?", institutionID)
}
if createdBy, ok := filters["created_by"]; ok {
query = query.Where("created_by = ?", createdBy)
}
if dateFrom, ok := filters["date_from"]; ok {
query = query.Where("received_date >= ?", dateFrom)
}
if dateTo, ok := filters["date_to"]; ok {
query = query.Where("received_date <= ?", dateTo)
}
// Apply user context filters if present
if userContext, ok := filters["user_context"]; ok {
if ctx, ok := userContext.(map[string]interface{}); ok {
if userID, ok := ctx["user_id"]; ok {
// User can see letters where they are recipients
query = query.Joins("JOIN letter_incoming_recipients ON letter_incoming_recipients.letter_id = letters_incoming.id").
Where("letter_incoming_recipients.recipient_user_id = ?", userID)
joinedRecipients = true
needsGroupBy = true
}
if departmentID, ok := ctx["department_id"]; ok {
// Also include letters for user's department
if !joinedRecipients {
query = query.Joins("JOIN letter_incoming_recipients ON letter_incoming_recipients.letter_id = letters_incoming.id")
joinedRecipients = true
needsGroupBy = true
}
query = query.Where("letter_incoming_recipients.recipient_department_id = ?", departmentID)
}
}
}
// Count total results
var total int64
if needsGroupBy {
// For grouped queries, count distinct letter IDs
if err := db.WithContext(ctx).Model(&entities.LetterIncoming{}).
Joins("JOIN letter_incoming_recipients ON letter_incoming_recipients.letter_id = letters_incoming.id").
Where("letters_incoming.deleted_at IS NULL").
Distinct("letters_incoming.id").
Count(&total).Error; err != nil {
return nil, 0, err
}
} else {
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
}
// Apply sorting
if sortBy == "" {
sortBy = "created_at"
}
if sortOrder == "" {
sortOrder = "desc"
}
validSortFields := map[string]bool{
"letter_number": true,
"subject": true,
"received_date": true,
"status": true,
"created_at": true,
"updated_at": true,
}
if !validSortFields[sortBy] {
sortBy = "created_at"
}
if sortOrder != "asc" && sortOrder != "desc" {
sortOrder = "desc"
}
orderBy := "letters_incoming." + sortBy + " " + sortOrder
// Apply grouping if necessary
if needsGroupBy {
query = query.Group("letters_incoming.id, letters_incoming.letter_number, letters_incoming.reference_number, " +
"letters_incoming.subject, letters_incoming.description, letters_incoming.priority_id, " +
"letters_incoming.sender_institution_id, letters_incoming.received_date, letters_incoming.due_date, " +
"letters_incoming.status, letters_incoming.created_by, letters_incoming.created_at, " +
"letters_incoming.updated_at, letters_incoming.deleted_at")
}
// Execute query
var letters []entities.LetterIncoming
if err := query.
Order(orderBy).
Limit(limit).
Offset(offset).
Find(&letters).Error; err != nil {
return nil, 0, err
}
return letters, total, nil
}
type LetterIncomingAttachmentRepository struct{ db *gorm.DB }
func NewLetterIncomingAttachmentRepository(db *gorm.DB) *LetterIncomingAttachmentRepository {

View File

@ -74,6 +74,7 @@ type LetterHandler interface {
CreateIncomingLetter(c *gin.Context)
GetIncomingLetter(c *gin.Context)
ListIncomingLetters(c *gin.Context)
SearchIncomingLetters(c *gin.Context)
GetLetterUnreadCounts(c *gin.Context)
MarkIncomingLetterAsRead(c *gin.Context)
MarkOutgoingLetterAsRead(c *gin.Context)
@ -95,6 +96,7 @@ type LetterOutgoingHandler interface {
CreateOutgoingLetter(c *gin.Context)
GetOutgoingLetter(c *gin.Context)
ListOutgoingLetters(c *gin.Context)
SearchOutgoingLetters(c *gin.Context)
UpdateOutgoingLetter(c *gin.Context)
DeleteOutgoingLetter(c *gin.Context)

View File

@ -169,6 +169,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
lettersch.GET("/unread-counts", r.letterHandler.GetLetterUnreadCounts)
lettersch.GET("/incoming", r.letterHandler.ListIncomingLetters)
lettersch.GET("/incoming/search", r.letterHandler.SearchIncomingLetters)
lettersch.POST("/incoming", r.letterHandler.CreateIncomingLetter)
lettersch.GET("/incoming/cta/:letter_id", r.letterHandler.GetLetterCTA)
lettersch.GET("/incoming/:id", r.letterHandler.GetIncomingLetter)
@ -178,8 +179,10 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
lettersch.POST("/incoming/archive", r.letterHandler.BulkArchiveIncomingLetters)
lettersch.POST("/outgoing", r.letterOutgoingHandler.CreateOutgoingLetter)
lettersch.GET("/outgoing/search", r.letterOutgoingHandler.SearchOutgoingLetters)
lettersch.GET("/outgoing/:id", r.letterOutgoingHandler.GetOutgoingLetter)
lettersch.GET("/outgoing", r.letterOutgoingHandler.ListOutgoingLetters)
lettersch.PUT("/outgoing/:id", r.letterOutgoingHandler.UpdateOutgoingLetter)
lettersch.PUT("/outgoing/:id/read", r.letterHandler.MarkOutgoingLetterAsRead)
lettersch.DELETE("/outgoing/:id", r.letterOutgoingHandler.DeleteOutgoingLetter)

View File

@ -21,6 +21,7 @@ type LetterOutgoingService interface {
CreateOutgoingLetter(ctx context.Context, req *contract.CreateOutgoingLetterRequest) (*contract.OutgoingLetterResponse, error)
GetOutgoingLetterByID(ctx context.Context, id uuid.UUID) (*contract.OutgoingLetterResponse, error)
ListOutgoingLetters(ctx context.Context, req *contract.ListOutgoingLettersRequest) (*contract.ListOutgoingLettersResponse, error)
SearchOutgoingLetters(ctx context.Context, req *contract.SearchOutgoingLettersRequest) (*contract.SearchOutgoingLettersResponse, error)
UpdateOutgoingLetter(ctx context.Context, id uuid.UUID, req *contract.UpdateOutgoingLetterRequest) (*contract.OutgoingLetterResponse, error)
DeleteOutgoingLetter(ctx context.Context, id uuid.UUID) error
@ -99,6 +100,7 @@ func (s *LetterOutgoingServiceImpl) CreateOutgoingLetter(ctx context.Context, re
Description: req.Description,
PriorityID: req.PriorityID,
ReceiverInstitutionID: req.ReceiverInstitutionID,
ReceiverName: req.ReceiverName,
IssueDate: req.IssueDate,
CreatedBy: userID,
}
@ -352,6 +354,159 @@ func (s *LetterOutgoingServiceImpl) ListOutgoingLetters(ctx context.Context, req
}, nil
}
func (s *LetterOutgoingServiceImpl) SearchOutgoingLetters(ctx context.Context, req *contract.SearchOutgoingLettersRequest) (*contract.SearchOutgoingLettersResponse, error) {
userID := getUserIDFromContext(ctx)
departmentID := getDepartmentIDFromContext(ctx)
// Build search filters
filters := buildOutgoingSearchFilters(req, userID, departmentID)
// Execute search with pagination
letters, total, err := s.processor.SearchOutgoingLetters(ctx, filters, req.Page, req.Limit, req.SortBy, req.SortOrder)
if err != nil {
return nil, err
}
// Collect IDs for batch loading
letterIDs := make([]uuid.UUID, len(letters))
priorityIDMap := make(map[uuid.UUID]bool)
institutionIDMap := make(map[uuid.UUID]bool)
for i, letter := range letters {
letterIDs[i] = letter.ID
if letter.PriorityID != nil {
priorityIDMap[*letter.PriorityID] = true
}
if letter.ReceiverInstitutionID != nil {
institutionIDMap[*letter.ReceiverInstitutionID] = true
}
}
// Convert maps to slices
priorityIDSlice := make([]uuid.UUID, 0, len(priorityIDMap))
for id := range priorityIDMap {
priorityIDSlice = append(priorityIDSlice, id)
}
institutionIDSlice := make([]uuid.UUID, 0, len(institutionIDMap))
for id := range institutionIDMap {
institutionIDSlice = append(institutionIDSlice, id)
}
// Parallel batch loading
type batchLoadResult struct {
attachments map[uuid.UUID][]entities.LetterOutgoingAttachment
recipients map[uuid.UUID][]entities.LetterOutgoingRecipient
priorities map[uuid.UUID]*entities.Priority
institutions map[uuid.UUID]*entities.Institution
}
var result batchLoadResult
errChan := make(chan error, 4)
// Load attachments
go func() {
result.attachments, err = s.processor.GetBatchAttachments(ctx, letterIDs)
errChan <- err
}()
// Load recipients
go func() {
result.recipients, err = s.processor.GetBatchRecipients(ctx, letterIDs)
errChan <- err
}()
// Load priorities
go func() {
result.priorities, err = s.processor.GetBatchPriorities(ctx, priorityIDSlice)
errChan <- err
}()
// Load institutions
go func() {
result.institutions, err = s.processor.GetBatchInstitutions(ctx, institutionIDSlice)
errChan <- err
}()
// Wait for all goroutines and check for errors
for i := 0; i < 4; i++ {
if err := <-errChan; err != nil {
return nil, err
}
}
// Transform letters with batch loaded data
items := make([]contract.OutgoingLetterResponse, len(letters))
for i, letter := range letters {
// Attach batch loaded data to letter
if attachments, ok := result.attachments[letter.ID]; ok {
letter.Attachments = attachments
}
if recipients, ok := result.recipients[letter.ID]; ok {
letter.Recipients = recipients
}
if letter.PriorityID != nil {
if priority, ok := result.priorities[*letter.PriorityID]; ok {
letter.Priority = priority
}
}
if letter.ReceiverInstitutionID != nil {
if institution, ok := result.institutions[*letter.ReceiverInstitutionID]; ok {
letter.ReceiverInstitution = institution
}
}
items[i] = *transformLetterToResponse(&letter)
}
return &contract.SearchOutgoingLettersResponse{
Letters: items,
TotalCount: total,
Page: req.Page,
Limit: req.Limit,
}, nil
}
func buildOutgoingSearchFilters(req *contract.SearchOutgoingLettersRequest, userID, departmentID uuid.UUID) map[string]interface{} {
filters := make(map[string]interface{})
if req.Query != "" {
filters["query"] = req.Query
}
if req.LetterNumber != "" {
filters["letter_number"] = req.LetterNumber
}
if req.Subject != "" {
filters["subject"] = req.Subject
}
if req.Status != "" {
filters["status"] = req.Status
}
if req.PriorityID != nil {
filters["priority_id"] = *req.PriorityID
}
if req.InstitutionID != nil {
filters["receiver_institution_id"] = *req.InstitutionID
}
if req.CreatedBy != nil {
filters["created_by"] = *req.CreatedBy
}
if req.DateFrom != nil {
filters["date_from"] = *req.DateFrom
}
if req.DateTo != nil {
filters["date_to"] = *req.DateTo
}
// Add user/department context filters
filters["user_context"] = map[string]interface{}{
"user_id": userID,
"department_id": departmentID,
}
return filters
}
func (s *LetterOutgoingServiceImpl) UpdateOutgoingLetter(ctx context.Context, id uuid.UUID, req *contract.UpdateOutgoingLetterRequest) (*contract.OutgoingLetterResponse, error) {
userID := getUserIDFromContext(ctx)
@ -382,6 +537,9 @@ func (s *LetterOutgoingServiceImpl) UpdateOutgoingLetter(ctx context.Context, id
if req.ReferenceNumber != nil {
letter.ReferenceNumber = req.ReferenceNumber
}
if req.ReceiverName != nil {
letter.ReceiverName = req.ReceiverName
}
err = s.processor.UpdateOutgoingLetter(ctx, letter, userID)
if err != nil {
@ -1245,6 +1403,7 @@ func transformLetterToResponse(letter *entities.LetterOutgoing) *contract.Outgoi
Description: letter.Description,
PriorityID: letter.PriorityID,
ReceiverInstitutionID: letter.ReceiverInstitutionID,
ReceiverName: letter.ReceiverName,
IssueDate: letter.IssueDate,
Status: string(letter.Status),
ApprovalFlowID: letter.ApprovalFlowID,

View File

@ -25,6 +25,7 @@ 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)
SearchIncomingLetters(ctx context.Context, filters map[string]interface{}, page, limit int, sortBy, sortOrder string) ([]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)
@ -448,6 +449,208 @@ func (s *LetterServiceImpl) SoftDeleteIncomingLetter(ctx context.Context, id uui
return s.processor.SoftDeleteIncomingLetter(ctx, id)
}
func (s *LetterServiceImpl) SearchIncomingLetters(ctx context.Context, req *contract.SearchIncomingLettersRequest) (*contract.SearchIncomingLettersResponse, error) {
appCtx := appcontext.FromGinContext(ctx)
userID := appCtx.UserID
departmentID := appCtx.DepartmentID
// Build search filters
filters := buildIncomingSearchFilters(req, userID, departmentID)
// Execute search with pagination
letters, total, err := s.processor.SearchIncomingLetters(ctx, filters, req.Page, req.Limit, req.SortBy, req.SortOrder)
if err != nil {
return nil, err
}
// Collect IDs for batch loading
letterIDs := make([]uuid.UUID, len(letters))
priorityIDMap := make(map[uuid.UUID]bool)
institutionIDMap := make(map[uuid.UUID]bool)
for i, letter := range letters {
letterIDs[i] = letter.ID
if letter.PriorityID != nil {
priorityIDMap[*letter.PriorityID] = true
}
if letter.SenderInstitutionID != nil {
institutionIDMap[*letter.SenderInstitutionID] = true
}
}
// Convert maps to slices
priorityIDSlice := make([]uuid.UUID, 0, len(priorityIDMap))
for id := range priorityIDMap {
priorityIDSlice = append(priorityIDSlice, id)
}
institutionIDSlice := make([]uuid.UUID, 0, len(institutionIDMap))
for id := range institutionIDMap {
institutionIDSlice = append(institutionIDSlice, id)
}
// Parallel batch loading
type batchLoadResult struct {
attachments map[uuid.UUID][]entities.LetterIncomingAttachment
recipients map[uuid.UUID]*entities.LetterIncomingRecipient
priorities map[uuid.UUID]*entities.Priority
institutions map[uuid.UUID]*entities.Institution
}
var result batchLoadResult
errChan := make(chan error, 4)
// Load attachments
go func() {
result.attachments, err = s.processor.GetBatchAttachments(ctx, letterIDs)
errChan <- err
}()
// Load recipients for user
go func() {
result.recipients, err = s.processor.GetBatchRecipientsByUser(ctx, letterIDs, userID)
errChan <- err
}()
// Load priorities
go func() {
result.priorities, err = s.processor.GetBatchPriorities(ctx, priorityIDSlice)
errChan <- err
}()
// Load institutions
go func() {
result.institutions, err = s.processor.GetBatchInstitutions(ctx, institutionIDSlice)
errChan <- err
}()
// Wait for all goroutines and check for errors
for i := 0; i < 4; i++ {
if err := <-errChan; err != nil {
return nil, err
}
}
// Transform letters with batch loaded data
items := make([]contract.IncomingLetterResponse, len(letters))
for i, letter := range letters {
// Attach batch loaded data
attachmentResponses := []contract.IncomingLetterAttachmentResponse{}
if attachments, ok := result.attachments[letter.ID]; ok {
for _, att := range attachments {
attachmentResponses = append(attachmentResponses, contract.IncomingLetterAttachmentResponse{
ID: att.ID,
FileURL: att.FileURL,
FileName: att.FileName,
FileType: att.FileType,
UploadedAt: att.UploadedAt,
})
}
}
var priorityResp *contract.PriorityResponse
if letter.PriorityID != nil {
if priority, ok := result.priorities[*letter.PriorityID]; ok {
priorityResp = &contract.PriorityResponse{
ID: priority.ID.String(),
Name: priority.Name,
Level: priority.Level,
CreatedAt: priority.CreatedAt,
UpdatedAt: priority.UpdatedAt,
}
}
}
var institutionResp *contract.InstitutionResponse
if letter.SenderInstitutionID != nil {
if institution, ok := result.institutions[*letter.SenderInstitutionID]; ok {
institutionResp = &contract.InstitutionResponse{
ID: institution.ID.String(),
Name: institution.Name,
Type: string(institution.Type),
Address: institution.Address,
ContactPerson: institution.ContactPerson,
Phone: institution.Phone,
Email: institution.Email,
CreatedAt: institution.CreatedAt,
UpdatedAt: institution.UpdatedAt,
}
}
}
isRead := false
if recipient, ok := result.recipients[letter.ID]; ok && recipient.ReadAt != nil {
isRead = true
}
items[i] = contract.IncomingLetterResponse{
ID: letter.ID,
LetterNumber: letter.LetterNumber,
ReferenceNumber: letter.ReferenceNumber,
Subject: letter.Subject,
Description: letter.Description,
Priority: priorityResp,
SenderInstitution: institutionResp,
SenderName: letter.SenderName,
ReceivedDate: letter.ReceivedDate,
DueDate: letter.DueDate,
Status: string(letter.Status),
CreatedBy: letter.CreatedBy,
CreatedAt: letter.CreatedAt,
UpdatedAt: letter.UpdatedAt,
Attachments: attachmentResponses,
IsRead: isRead,
}
}
return &contract.SearchIncomingLettersResponse{
Letters: items,
TotalCount: total,
Page: req.Page,
Limit: req.Limit,
}, nil
}
func buildIncomingSearchFilters(req *contract.SearchIncomingLettersRequest, userID, departmentID uuid.UUID) map[string]interface{} {
filters := make(map[string]interface{})
if req.Query != "" {
filters["query"] = req.Query
}
if req.LetterNumber != "" {
filters["letter_number"] = req.LetterNumber
}
if req.Subject != "" {
filters["subject"] = req.Subject
}
if req.Status != "" {
filters["status"] = req.Status
}
if req.PriorityID != nil {
filters["priority_id"] = *req.PriorityID
}
if req.InstitutionID != nil {
filters["sender_institution_id"] = *req.InstitutionID
}
if req.CreatedBy != nil {
filters["created_by"] = *req.CreatedBy
}
if req.DateFrom != nil {
filters["date_from"] = *req.DateFrom
}
if req.DateTo != nil {
filters["date_to"] = *req.DateTo
}
// Add user/department context filters
filters["user_context"] = map[string]interface{}{
"user_id": userID,
"department_id": departmentID,
}
return filters
}
func (s *LetterServiceImpl) CreateDispositions(ctx context.Context, req *contract.CreateLetterDispositionRequest) (*contract.ListDispositionsResponse, error) {
log.Printf("[DEBUG] CreateDispositions START - LetterID: %s\n", req.LetterID.String())
userID := appcontext.FromGinContext(ctx).UserID

View File

@ -14,6 +14,7 @@ func LetterEntityToContract(e *entities.LetterIncoming, attachments []entities.L
ReferenceNumber: e.ReferenceNumber,
Subject: e.Subject,
Description: e.Description,
SenderName: e.SenderName,
ReceivedDate: e.ReceivedDate,
DueDate: e.DueDate,
Status: string(e.Status),
@ -75,6 +76,7 @@ func LetterIncomingEntityToContract(e *entities.LetterIncoming) *contract.Incomi
ReferenceNumber: e.ReferenceNumber,
Subject: e.Subject,
Description: e.Description,
SenderName: e.SenderName,
ReceivedDate: e.ReceivedDate,
DueDate: e.DueDate,
Status: string(e.Status),

BIN
main

Binary file not shown.

View File

@ -0,0 +1,31 @@
-- Drop functions
DROP FUNCTION IF EXISTS search_outgoing_letters(text);
DROP FUNCTION IF EXISTS search_incoming_letters(text);
-- Drop indexes for outgoing letters
DROP INDEX IF EXISTS idx_letters_outgoing_letter_number_text;
DROP INDEX IF EXISTS idx_letters_outgoing_subject_text;
DROP INDEX IF EXISTS idx_letters_outgoing_description_text;
DROP INDEX IF EXISTS idx_letters_outgoing_reference_number_text;
DROP INDEX IF EXISTS idx_letters_outgoing_status_created;
DROP INDEX IF EXISTS idx_letters_outgoing_priority_created;
DROP INDEX IF EXISTS idx_letters_outgoing_institution_created;
DROP INDEX IF EXISTS idx_letters_outgoing_created_by;
DROP INDEX IF EXISTS idx_letters_outgoing_issue_date;
-- Drop indexes for incoming letters
DROP INDEX IF EXISTS idx_letters_incoming_letter_number_text;
DROP INDEX IF EXISTS idx_letters_incoming_subject_text;
DROP INDEX IF EXISTS idx_letters_incoming_description_text;
DROP INDEX IF EXISTS idx_letters_incoming_reference_number_text;
DROP INDEX IF EXISTS idx_letters_incoming_status_created;
DROP INDEX IF EXISTS idx_letters_incoming_priority_created;
DROP INDEX IF EXISTS idx_letters_incoming_institution_created;
DROP INDEX IF EXISTS idx_letters_incoming_created_by;
DROP INDEX IF EXISTS idx_letters_incoming_received_date;
-- Drop recipient indexes
DROP INDEX IF EXISTS idx_letter_outgoing_recipients_user;
DROP INDEX IF EXISTS idx_letter_outgoing_recipients_dept;
DROP INDEX IF EXISTS idx_letter_incoming_recipients_user;
DROP INDEX IF EXISTS idx_letter_incoming_recipients_dept;

View File

@ -0,0 +1,63 @@
-- Add indexes for optimized search on outgoing letters
CREATE INDEX IF NOT EXISTS idx_letters_outgoing_letter_number_text ON letters_outgoing USING gin(to_tsvector('simple', letter_number));
CREATE INDEX IF NOT EXISTS idx_letters_outgoing_subject_text ON letters_outgoing USING gin(to_tsvector('simple', subject));
CREATE INDEX IF NOT EXISTS idx_letters_outgoing_description_text ON letters_outgoing USING gin(to_tsvector('simple', COALESCE(description, '')));
CREATE INDEX IF NOT EXISTS idx_letters_outgoing_reference_number_text ON letters_outgoing USING gin(to_tsvector('simple', COALESCE(reference_number, '')));
-- Composite indexes for common query patterns on outgoing letters
CREATE INDEX IF NOT EXISTS idx_letters_outgoing_status_created ON letters_outgoing(status, created_at DESC) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_letters_outgoing_priority_created ON letters_outgoing(priority_id, created_at DESC) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_letters_outgoing_institution_created ON letters_outgoing(receiver_institution_id, created_at DESC) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_letters_outgoing_created_by ON letters_outgoing(created_by, created_at DESC) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_letters_outgoing_issue_date ON letters_outgoing(issue_date DESC) WHERE deleted_at IS NULL;
-- Add indexes for optimized search on incoming letters
CREATE INDEX IF NOT EXISTS idx_letters_incoming_letter_number_text ON letters_incoming USING gin(to_tsvector('simple', letter_number));
CREATE INDEX IF NOT EXISTS idx_letters_incoming_subject_text ON letters_incoming USING gin(to_tsvector('simple', subject));
CREATE INDEX IF NOT EXISTS idx_letters_incoming_description_text ON letters_incoming USING gin(to_tsvector('simple', COALESCE(description, '')));
CREATE INDEX IF NOT EXISTS idx_letters_incoming_reference_number_text ON letters_incoming USING gin(to_tsvector('simple', COALESCE(reference_number, '')));
-- Composite indexes for common query patterns on incoming letters
CREATE INDEX IF NOT EXISTS idx_letters_incoming_status_created ON letters_incoming(status, created_at DESC) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_letters_incoming_priority_created ON letters_incoming(priority_id, created_at DESC) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_letters_incoming_institution_created ON letters_incoming(sender_institution_id, created_at DESC) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_letters_incoming_created_by ON letters_incoming(created_by, created_at DESC) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_letters_incoming_received_date ON letters_incoming(received_date DESC) WHERE deleted_at IS NULL;
-- Indexes for recipient lookups
CREATE INDEX IF NOT EXISTS idx_letter_outgoing_recipients_user ON letter_outgoing_recipients(user_id, letter_id);
CREATE INDEX IF NOT EXISTS idx_letter_outgoing_recipients_dept ON letter_outgoing_recipients(department_id, letter_id);
CREATE INDEX IF NOT EXISTS idx_letter_incoming_recipients_user ON letter_incoming_recipients(recipient_user_id, letter_id);
CREATE INDEX IF NOT EXISTS idx_letter_incoming_recipients_dept ON letter_incoming_recipients(recipient_department_id, letter_id);
-- Create a function for full-text search on outgoing letters
CREATE OR REPLACE FUNCTION search_outgoing_letters(search_query text)
RETURNS SETOF letters_outgoing AS $$
BEGIN
RETURN QUERY
SELECT * FROM letters_outgoing
WHERE deleted_at IS NULL
AND (
to_tsvector('simple', letter_number) @@ plainto_tsquery('simple', search_query)
OR to_tsvector('simple', subject) @@ plainto_tsquery('simple', search_query)
OR to_tsvector('simple', COALESCE(description, '')) @@ plainto_tsquery('simple', search_query)
OR to_tsvector('simple', COALESCE(reference_number, '')) @@ plainto_tsquery('simple', search_query)
);
END;
$$ LANGUAGE plpgsql;
-- Create a function for full-text search on incoming letters
CREATE OR REPLACE FUNCTION search_incoming_letters(search_query text)
RETURNS SETOF letters_incoming AS $$
BEGIN
RETURN QUERY
SELECT * FROM letters_incoming
WHERE deleted_at IS NULL
AND (
to_tsvector('simple', letter_number) @@ plainto_tsquery('simple', search_query)
OR to_tsvector('simple', subject) @@ plainto_tsquery('simple', search_query)
OR to_tsvector('simple', COALESCE(description, '')) @@ plainto_tsquery('simple', search_query)
OR to_tsvector('simple', COALESCE(reference_number, '')) @@ plainto_tsquery('simple', search_query)
);
END;
$$ LANGUAGE plpgsql;

View File

@ -0,0 +1,12 @@
-- Drop indexes
DROP INDEX IF EXISTS idx_letters_incoming_sender_name_text;
DROP INDEX IF EXISTS idx_letters_outgoing_receiver_name_text;
DROP INDEX IF EXISTS idx_letters_incoming_sender_name;
DROP INDEX IF EXISTS idx_letters_outgoing_receiver_name;
-- Remove columns
ALTER TABLE letters_incoming
DROP COLUMN IF EXISTS sender_name;
ALTER TABLE letters_outgoing
DROP COLUMN IF EXISTS receiver_name;

View File

@ -0,0 +1,15 @@
-- Add sender_name to incoming letters
ALTER TABLE letters_incoming
ADD COLUMN IF NOT EXISTS sender_name VARCHAR(255);
-- Add receiver_name to outgoing letters
ALTER TABLE letters_outgoing
ADD COLUMN IF NOT EXISTS receiver_name VARCHAR(255);
-- Add indexes for the new fields to support searching
CREATE INDEX IF NOT EXISTS idx_letters_incoming_sender_name ON letters_incoming(sender_name) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_letters_outgoing_receiver_name ON letters_outgoing(receiver_name) WHERE deleted_at IS NULL;
-- Add GIN indexes for full-text search
CREATE INDEX IF NOT EXISTS idx_letters_incoming_sender_name_text ON letters_incoming USING gin(to_tsvector('simple', COALESCE(sender_name, '')));
CREATE INDEX IF NOT EXISTS idx_letters_outgoing_receiver_name_text ON letters_outgoing USING gin(to_tsvector('simple', COALESCE(receiver_name, '')));

BIN
server

Binary file not shown.